From 90e23bfd090b5608625f610219042de8394b3afa Mon Sep 17 00:00:00 2001 From: Xinmin Zeng <135568692+fancyboi999@users.noreply.github.com> Date: Tue, 9 Jun 2026 11:14:19 +0800 Subject: [PATCH] fix(ci): consolidate PR/issue labeling and fix reviewing-job crash + label thrash (#3455) * fix(ci): consolidate PR/issue labeling into one triage.yml; fix reviewing crash & label thrash - Replace pr-labeler + pr-triage + issue-triage with a single triage.yml; drop actions/labeler. Its sync-labels removed labels outside its config (clobbered size/risk/needs-validation and could clobber maintainer labels). Area is now computed in-script and reconciled only within owned namespaces (area:/size//risk:/needs-validation); first-time/reviewing are add-only. - reviewing: gate on author_association in {OWNER,MEMBER,COLLABORATOR} + user.type==='User' instead of getCollaboratorPermissionLevel, which 404'd on bot reviewers ('Copilot is not a user') and crashed the job. Excludes all review bots with no denylist and no API call. - Read live state (listFiles + listLabelsOnIssue) not the stale event payload, so rapid synchronize events converge instead of thrashing. Size churn excludes lockfiles/snapshots. * fix(ci): read labels live via paginate in reviewing & issue-triage jobs Address review feedback on #3455: - reviewing: listLabelsOnIssue now paginates (per_page:100) instead of the default 30, matching pr-labels, so a 'reviewing' label is never missed on PRs with many labels. - issue-triage: read live labels via the API instead of the event payload, consistent with the live-state reads documented in the header. --- .github/labeler.yml | 72 ---------- .github/workflows/issue-triage.yml | 44 ------ .github/workflows/pr-labeler.yml | 28 ---- .github/workflows/pr-triage.yml | 164 --------------------- .github/workflows/triage.yml | 223 +++++++++++++++++++++++++++++ 5 files changed, 223 insertions(+), 308 deletions(-) delete mode 100644 .github/labeler.yml delete mode 100644 .github/workflows/issue-triage.yml delete mode 100644 .github/workflows/pr-labeler.yml delete mode 100644 .github/workflows/pr-triage.yml create mode 100644 .github/workflows/triage.yml diff --git a/.github/labeler.yml b/.github/labeler.yml deleted file mode 100644 index 343b87496..000000000 --- a/.github/labeler.yml +++ /dev/null @@ -1,72 +0,0 @@ -# Path-based PR auto-labeling config for actions/labeler@v5. -# Each key is a label (must exist — see .github/labels.yml); the globs decide -# when it is applied. A PR can match several areas, which is expected. - -"area:frontend": - - changed-files: - - any-glob-to-any-file: - - "frontend/**" - -"area:backend": - - changed-files: - - any-glob-to-any-file: - - "backend/app/**" - - "backend/packages/harness/deerflow/runtime/**" - - "backend/packages/harness/deerflow/persistence/**" - - "backend/packages/harness/deerflow/config/**" - - "backend/packages/harness/deerflow/tools/**" - - "backend/packages/harness/deerflow/guardrails/**" - - "backend/packages/harness/deerflow/tracing/**" - - "backend/packages/harness/deerflow/models/**" - - "backend/packages/harness/deerflow/utils/**" - - "backend/packages/harness/deerflow/uploads/**" - -"area:agents": - - changed-files: - - any-glob-to-any-file: - - "backend/packages/harness/deerflow/agents/**" - - "backend/packages/harness/deerflow/subagents/**" - - "backend/packages/harness/deerflow/reflection/**" - - "backend/langgraph.json" - - "backend/**/prompts/**" - -"area:sandbox": - - changed-files: - - any-glob-to-any-file: - - "docker/**" - - "backend/packages/harness/deerflow/sandbox/**" - - "backend/Dockerfile" - - "frontend/Dockerfile" - -"area:skills": - - changed-files: - - any-glob-to-any-file: - - "skills/**" - - "backend/packages/harness/deerflow/skills/**" - - "frontend/src/core/skills/**" - -"area:mcp": - - changed-files: - - any-glob-to-any-file: - - "backend/packages/harness/deerflow/mcp/**" - - "frontend/src/core/mcp/**" - -"area:ci": - - changed-files: - - any-glob-to-any-file: - - ".github/**" - - "scripts/**" - -"area:docs": - - changed-files: - - any-glob-to-any-file: - - "docs/**" - - "**/*.md" - -"area:deps": - - changed-files: - - any-glob-to-any-file: - - "backend/pyproject.toml" - - "backend/uv.lock" - - "frontend/package.json" - - "frontend/pnpm-lock.yaml" diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml deleted file mode 100644 index 927561be7..000000000 --- a/.github/workflows/issue-triage.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Issue Triage - -# Ensures every newly opened issue carries `needs-triage`, even blank or -# API-created ones that bypass the issue templates. Creates the label if it is -# somehow missing, so the workflow is self-healing. - -on: - issues: - types: [opened] - -permissions: - issues: write - -jobs: - needs-triage: - runs-on: ubuntu-latest - steps: - - name: Add needs-triage label - uses: actions/github-script@v7 - with: - script: | - const { owner, repo } = context.repo; - const issue_number = context.payload.issue.number; - - const current = (context.payload.issue.labels || []).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}.`); diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml deleted file mode 100644 index 6f4e9e279..000000000 --- a/.github/workflows/pr-labeler.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: PR Labeler - -# Applies area:* labels based on which files a PR changes (see .github/labeler.yml). -# Uses pull_request_target so it also works on fork PRs. SAFE: actions/labeler -# only reads the changed-file list via the API — it never checks out or runs PR code. - -on: - pull_request_target: - types: [opened, synchronize, reopened, ready_for_review] - -permissions: - contents: read - pull-requests: write - -concurrency: - group: pr-labeler-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - label: - if: github.event.pull_request.draft == false - runs-on: ubuntu-latest - steps: - - name: Apply area labels - uses: actions/labeler@v5 - with: - configuration-path: .github/labeler.yml - sync-labels: true diff --git a/.github/workflows/pr-triage.yml b/.github/workflows/pr-triage.yml deleted file mode 100644 index c139e995e..000000000 --- a/.github/workflows/pr-triage.yml +++ /dev/null @@ -1,164 +0,0 @@ -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; - } diff --git a/.github/workflows/triage.yml b/.github/workflows/triage.yml new file mode 100644 index 000000000..6adfdfb52 --- /dev/null +++ b/.github/workflows/triage.yml @@ -0,0 +1,223 @@ +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}.`);