name: PR Triage # Two responsibilities, both pure-metadata (no PR code is checked out or run): # 1. On open/sync: apply size/* + risk:* labels, and needs-validation when the # PR touches the front/back contract surface (backend API, SSE, agents, or # the frontend streaming client). A `skip-validation` label opts out. # 2. On maintainer review: apply the `reviewing` label. # # All labels are managed within their own namespace — labels outside size/*, # risk:*, needs-validation and reviewing are never touched here. on: pull_request_target: types: [opened, synchronize, reopened, ready_for_review] pull_request_review: types: [submitted] permissions: contents: read pull-requests: write concurrency: group: pr-triage-${{ github.event.pull_request.number }} cancel-in-progress: false jobs: size-and-risk: if: github.event_name == 'pull_request_target' && github.event.pull_request.draft == false runs-on: ubuntu-latest steps: - name: Label size, risk and validation need uses: actions/github-script@v7 with: script: | const pr = context.payload.pull_request; const { owner, repo } = context.repo; const prNumber = pr.number; // ---- size, from additions + deletions ---- const churn = (pr.additions || 0) + (pr.deletions || 0); const sizeLabel = churn < 20 ? 'size/XS' : churn < 100 ? 'size/S' : churn < 300 ? 'size/M' : churn < 700 ? 'size/L' : 'size/XL'; // ---- changed paths ---- const files = await github.paginate(github.rest.pulls.listFiles, { owner, repo, pull_number: prNumber, per_page: 100, }); const paths = files.map(f => f.filename); const matches = (re) => paths.some(p => re.test(p)); 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 = matches( /^backend\/app\/gateway\//) || matches( /^backend\/packages\/harness\/deerflow\/(agents|subagents|sandbox)\//) || matches( /(^|\/)langgraph\.json$/) || matches( /(^|\/)(auth|authz|security)/i) || matches( /(pyproject\.toml|uv\.lock|package\.json|pnpm-lock\.yaml)$/) || matches( /^docker\//) || matches( /^\.github\/workflows\//); const riskLabel = docsOnly ? 'risk:low' : (highRisk ? 'risk:high' : 'risk:medium'); // needs-validation: front/back contract surface const contractSurface = matches(/^backend\/app\/gateway\//) || matches(/^backend\/packages\/harness\/deerflow\/(agents|subagents)\//) || matches(/(^|\/)langgraph\.json$/) || matches(/^frontend\/src\/core\/(api|threads|messages)\//); const current = (pr.labels || []).map(l => l.name); const hasSkip = current.includes('skip-validation'); const desired = [sizeLabel, riskLabel]; if (contractSurface && !hasSkip) desired.push('needs-validation'); const managed = (name) => name.startsWith('size/') || name.startsWith('risk:') || name === 'needs-validation'; const toRemove = current.filter(l => managed(l) && !desired.includes(l)); const toAdd = desired.filter(l => !current.includes(l)); for (const name of toRemove) { try { await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name }); } catch (e) { if (e.status !== 404) throw e; } } if (toAdd.length) { await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: toAdd }); } core.info(`size=${sizeLabel} risk=${riskLabel} churn=${churn} ` + `validation=${desired.includes('needs-validation')} ` + `(+${toAdd.join(',') || '-'} / -${toRemove.join(',') || '-'})`); first-time: if: github.event_name == 'pull_request_target' && github.event.action == 'opened' runs-on: ubuntu-latest steps: - name: Label first-time contributors uses: actions/github-script@v7 with: script: | const pr = context.payload.pull_request; const { owner, repo } = context.repo; const assoc = pr.author_association; const isBot = pr.user.type === 'Bot'; core.info(`author=${pr.user.login} association=${assoc} bot=${isBot}`); // FIRST_TIME_CONTRIBUTOR = no prior merged commit to this repo; // FIRST_TIMER = no prior commit anywhere on GitHub. Either counts. if (isBot || !['FIRST_TIME_CONTRIBUTOR', 'FIRST_TIMER'].includes(assoc)) { core.info('Not a first-time contributor; skipping.'); return; } await github.rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: ['first-time-contributor'], }); core.info(`Added first-time-contributor to #${pr.number}.`); reviewing: if: github.event_name == 'pull_request_review' runs-on: ubuntu-latest steps: - name: Add reviewing label for maintainer reviews uses: actions/github-script@v7 with: script: | const { owner, repo } = context.repo; const prNumber = context.payload.pull_request.number; const reviewer = context.payload.review.user.login; const { data: perm } = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username: reviewer, }); if (!['admin', 'write', 'maintain'].includes(perm.permission)) { core.info(`Reviewer ${reviewer} (${perm.permission}) is not a maintainer; skipping.`); return; } const { data: labels } = await github.rest.issues.listLabelsOnIssue({ owner, repo, issue_number: prNumber, }); if (labels.some(l => l.name === 'reviewing')) { core.info('Already labeled reviewing; skipping.'); return; } try { await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: ['reviewing'], }); core.info(`Added "reviewing" (reviewer ${reviewer}).`); } catch (e) { // 403 is expected for review events on some fork PR contexts. if (e.status === 403) core.info('No permission to label (expected on some fork PRs).'); else throw e; }