name: Triage # One workflow for all event-driven PR/issue labeling. Replaces the former # pr-labeler / pr-triage / issue-triage workflows (and drops actions/labeler). # # Design notes: # * All jobs are pure-metadata: they read changed-file lists / PR fields / the # review payload via the API and write labels. PR code is NEVER checked out # or executed, so pull_request_target is safe here. # * Each job only reconciles labels in namespaces IT owns # (area:* / size/* / risk:* / needs-validation). It never touches labels # applied by maintainers or other tools (bug, priority, etc.). first-time- # contributor and reviewing are add-only. # * State is read LIVE (listFiles + listLabelsOnIssue) at run time, not from # the (stale) event payload, so rapid synchronize events converge instead # of thrashing. on: pull_request_target: types: [opened, synchronize, reopened, ready_for_review] pull_request_review: types: [submitted] issues: types: [opened] permissions: contents: read pull-requests: write issues: write jobs: # ── PR: area / size / risk / needs-validation / first-time ───────────────── pr-labels: if: github.event_name == 'pull_request_target' && github.event.pull_request.draft == false runs-on: ubuntu-latest concurrency: group: triage-pr-${{ github.event.pull_request.number }} cancel-in-progress: true steps: - name: Apply PR labels from live state uses: actions/github-script@v8 with: script: | const pr = context.payload.pull_request; const { owner, repo } = context.repo; const num = pr.number; // ---- live changed files ---- const files = await github.paginate(github.rest.pulls.listFiles, { owner, repo, pull_number: num, per_page: 100, }); const paths = files.map(f => f.filename); const m = (re) => paths.some(p => re.test(p)); // ---- area: replaces .github/labeler.yml (path -> area) ---- const AREA_RULES = [ ['area:frontend', [/^frontend\//]], ['area:backend', [/^backend\/app\//, /^backend\/packages\/harness\/deerflow\/(runtime|persistence|config|tools|guardrails|tracing|models|utils|uploads)\//]], ['area:agents', [/^backend\/packages\/harness\/deerflow\/(agents|subagents|reflection)\//, /(^|\/)langgraph\.json$/, /^backend\/.*\/prompts\//]], ['area:sandbox', [/^docker\//, /^backend\/packages\/harness\/deerflow\/sandbox\//, /(^|\/)Dockerfile$/]], ['area:skills', [/^skills\//, /^backend\/packages\/harness\/deerflow\/skills\//, /^frontend\/src\/core\/skills\//]], ['area:mcp', [/^backend\/packages\/harness\/deerflow\/mcp\//, /^frontend\/src\/core\/mcp\//]], ['area:ci', [/^\.github\//, /^scripts\//]], ['area:docs', [/^docs\//, /\.mdx?$/]], ['area:deps', [/(^|\/)(pyproject\.toml|uv\.lock|package\.json|pnpm-lock\.yaml)$/]], ]; const areaLabels = AREA_RULES .filter(([, res]) => res.some(re => m(re))) .map(([label]) => label); // ---- size: additions+deletions, excluding lockfiles/snapshots ---- const EXCLUDE_SIZE = /(^|\/)(uv\.lock|pnpm-lock\.yaml|package-lock\.json)$|\.snap$/; const churn = files .filter(f => !EXCLUDE_SIZE.test(f.filename)) .reduce((s, f) => s + (f.additions || 0) + (f.deletions || 0), 0); const sizeLabel = churn < 20 ? 'size/XS' : churn < 100 ? 'size/S' : churn < 300 ? 'size/M' : churn < 700 ? 'size/L' : 'size/XL'; // ---- risk ---- const docsOnly = paths.length > 0 && paths.every(p => /\.(md|mdx|txt)$/i.test(p) || p.startsWith('docs/') || /\.(png|jpe?g|gif|svg|webp|ico)$/i.test(p)); const highRisk = m(/^backend\/app\/gateway\//) || m(/^backend\/packages\/harness\/deerflow\/(agents|subagents|sandbox)\//) || m(/(^|\/)langgraph\.json$/) || m(/(^|\/)(auth|authz|security)/i) || m(/(pyproject\.toml|uv\.lock|package\.json|pnpm-lock\.yaml)$/) || m(/^docker\//) || m(/^\.github\/workflows\//); const riskLabel = docsOnly ? 'risk:low' : (highRisk ? 'risk:high' : 'risk:medium'); // ---- needs-validation: front/back contract surface ---- const contract = m(/^backend\/app\/gateway\//) || m(/^backend\/packages\/harness\/deerflow\/(agents|subagents)\//) || m(/(^|\/)langgraph\.json$/) || m(/^frontend\/src\/core\/(api|threads|messages)\//); // ---- live current labels (NOT the stale event payload) ---- const current = (await github.paginate(github.rest.issues.listLabelsOnIssue, { owner, repo, issue_number: num, per_page: 100, })).map(l => l.name); const hasSkip = current.includes('skip-validation'); // Reconcile ONLY namespaces we own; never touch others. const owned = (n) => n.startsWith('area:') || n.startsWith('size/') || n.startsWith('risk:') || n === 'needs-validation'; const desired = new Set([...areaLabels, sizeLabel, riskLabel]); if (contract && !hasSkip) desired.add('needs-validation'); const toRemove = current.filter(n => owned(n) && !desired.has(n)); const toAdd = [...desired].filter(n => !current.includes(n)); // first-time-contributor: add-only, on opened, real users only. if (context.payload.action === 'opened' && pr.user.type === 'User' && ['FIRST_TIME_CONTRIBUTOR', 'FIRST_TIMER'].includes(pr.author_association) && !current.includes('first-time-contributor')) { toAdd.push('first-time-contributor'); } for (const name of toRemove) { try { await github.rest.issues.removeLabel({ owner, repo, issue_number: num, name }); } catch (e) { if (e.status !== 404) throw e; } } if (toAdd.length) { await github.rest.issues.addLabels({ owner, repo, issue_number: num, labels: toAdd }); } core.info(`area=[${areaLabels.join(',')}] ${sizeLabel} ${riskLabel} churn=${churn} ` + `validation=${desired.has('needs-validation')} ` + `(+${toAdd.join(',') || '-'} / -${toRemove.join(',') || '-'})`); # ── PR: reviewing label on a maintainer's human review ───────────────────── reviewing: if: github.event_name == 'pull_request_review' runs-on: ubuntu-latest concurrency: group: triage-review-${{ github.event.pull_request.number }} cancel-in-progress: false steps: - name: Add reviewing label for maintainer reviews uses: actions/github-script@v8 with: script: | const { owner, repo } = context.repo; const num = context.payload.pull_request.number; const review = context.payload.review; const assoc = review.author_association; // payload field; no API call const type = review.user && review.user.type; // author_association is NONE for every automated reviewer // (Copilot, CodeRabbit, Codex, Sourcery, ...), so this allowlist // drops them all without a denylist — and never calls the // collaborators API that 404s on "Copilot is not a user". // user.type === 'User' guards the rare bot-added-as-collaborator case. if (!['OWNER', 'MEMBER', 'COLLABORATOR'].includes(assoc) || type !== 'User') { core.info(`reviewer ${review.user && review.user.login} assoc=${assoc} type=${type}; skipping.`); return; } const labels = (await github.paginate(github.rest.issues.listLabelsOnIssue, { owner, repo, issue_number: num, per_page: 100, })).map(l => l.name); if (labels.includes('reviewing')) { core.info('Already labeled reviewing; skipping.'); return; } try { await github.rest.issues.addLabels({ owner, repo, issue_number: num, labels: ['reviewing'], }); core.info('Added "reviewing".'); } catch (e) { if (e.status === 403) core.info('No permission to label (expected on some fork PRs).'); else throw e; } # ── Issue: needs-triage on every new issue ──────────────────────────────── issue-triage: if: github.event_name == 'issues' runs-on: ubuntu-latest concurrency: group: triage-issue-${{ github.event.issue.number }} cancel-in-progress: false steps: - name: Add needs-triage label uses: actions/github-script@v8 with: script: | const { owner, repo } = context.repo; const issue_number = context.payload.issue.number; // Read live labels (not the event payload) so labels added at creation // time via the API or by another automation are seen — consistent with // the live-state reads in the PR jobs above. const current = (await github.paginate(github.rest.issues.listLabelsOnIssue, { owner, repo, issue_number, per_page: 100, })).map(l => l.name); if (current.includes('needs-triage')) { core.info('Issue already has needs-triage; nothing to do.'); return; } // Self-heal: create the label if it does not exist yet. try { await github.rest.issues.createLabel({ owner, repo, name: 'needs-triage', color: 'fef2c0', description: 'Awaiting maintainer triage', }); } catch (e) { if (e.status !== 422) throw e; // 422 = already exists } await github.rest.issues.addLabels({ owner, repo, issue_number, labels: ['needs-triage'], }); core.info(`Added needs-triage to #${issue_number}.`);