diff --git a/.github/scripts/pr-labeler.cjs b/.github/scripts/pr-labeler.cjs new file mode 100644 index 0000000000..c57dce0d2f --- /dev/null +++ b/.github/scripts/pr-labeler.cjs @@ -0,0 +1,126 @@ +"use strict"; + +/** + * PR title → GitHub type label for PR Labeler. + * Accepts conventional commits (`fix(scope): …`) and sentence-case fallbacks + * (`Fix Console Go …`) for LLM-authored PRs that skip the prefix colon. + * Kept as a pure module so override behavior can be unit-tested without Actions. + */ + +const PREFIX_TO_LABEL = Object.freeze({ + feat: "enhancement", + feature: "enhancement", + fix: "bug", + bugfix: "bug", + hotfix: "bug", + docs: "documentation", + doc: "documentation", + chore: "chore", + refactor: "chore", + style: "chore", + test: "chore", + tests: "chore", + ci: "chore", + build: "chore", + perf: "enhancement", + revert: "chore", +}); + +const TYPE_LABELS = new Set(Object.values(PREFIX_TO_LABEL)); + +/** Actors whose type-label mutations are treated as bot-owned (may be overwritten). */ +const BOT_ACTORS = new Set(["github-actions[bot]"]); + +function labelForTitlePrefix(prefix) { + const key = String(prefix || "").toLowerCase(); + if (!Object.prototype.hasOwnProperty.call(PREFIX_TO_LABEL, key)) return null; + return PREFIX_TO_LABEL[key]; +} + +/** + * Map a PR title to a managed type label. + * @param {string} title + * @returns {string|null} + */ +function detectTypeLabelFromTitle(title) { + const text = String(title || ""); + + const conventional = text.match(/^([a-zA-Z]+)(?:\([^)]*\))?[!]?\s*:/); + if (conventional) return labelForTitlePrefix(conventional[1]); + + // Sentence-case fallback (e.g. PR #524: "Fix Console Go tool schema sanitization"). + const sentence = text.match(/^([A-Za-z]+)\s+\S/); + if (sentence) return labelForTitlePrefix(sentence[1]); + + return null; +} + +/** + * True when a human (any non-bot actor) has ever labeled or unlabeled a managed + * type label on this PR. Mirrors issue-quality's sticky maintainerOverride: + * once a person changes the bot's choice, later synchronize/edited runs must + * not revert it. + * + * @param {Array<{ event?: string, label?: { name?: string }, actor?: { login?: string } }>} events + * @param {Set} [typeLabels] + * @param {Set} [botActors] + * @returns {boolean} + */ +function hasHumanTypeLabelOverride(events, typeLabels = TYPE_LABELS, botActors = BOT_ACTORS) { + if (!Array.isArray(events)) return false; + for (const event of events) { + if (event?.event !== "labeled" && event?.event !== "unlabeled") continue; + const name = event.label?.name; + if (!name || !typeLabels.has(name)) continue; + const actor = event.actor?.login; + if (actor && !botActors.has(actor)) return true; + } + return false; +} + +/** + * Plan type-label add/remove mutations for a PR. + * + * @param {{ + * title: string, + * currentLabels: string[], + * events: Array<{ event?: string, label?: { name?: string }, actor?: { login?: string } }>, + * }} input + * @returns {{ + * skip: true, + * reason: "human-override" | "no-prefix", + * } | { + * skip: false, + * detected: string, + * add: string|null, + * remove: string[], + * }} + */ +function planTypeLabelSync(input) { + const title = input?.title ?? ""; + const currentLabels = Array.isArray(input?.currentLabels) ? input.currentLabels : []; + const events = Array.isArray(input?.events) ? input.events : []; + + if (hasHumanTypeLabelOverride(events)) { + return { skip: true, reason: "human-override" }; + } + + const detected = detectTypeLabelFromTitle(title); + if (!detected) { + return { skip: true, reason: "no-prefix" }; + } + + const current = new Set(currentLabels); + const remove = [...TYPE_LABELS].filter((label) => current.has(label) && label !== detected); + const add = current.has(detected) ? null : detected; + return { skip: false, detected, add, remove }; +} + +module.exports = { + PREFIX_TO_LABEL, + TYPE_LABELS, + BOT_ACTORS, + detectTypeLabelFromTitle, + hasHumanTypeLabelOverride, + planTypeLabelSync, +}; diff --git a/.github/scripts/pr-labeler.test.cjs b/.github/scripts/pr-labeler.test.cjs new file mode 100644 index 0000000000..71afd7836c --- /dev/null +++ b/.github/scripts/pr-labeler.test.cjs @@ -0,0 +1,172 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { describe, it } = require("node:test"); +const assert = require("node:assert/strict"); +const { + detectTypeLabelFromTitle, + hasHumanTypeLabelOverride, + planTypeLabelSync, + TYPE_LABELS, +} = require("./pr-labeler.cjs"); + +describe("detectTypeLabelFromTitle", () => { + it("maps conventional prefixes to type labels", () => { + assert.equal(detectTypeLabelFromTitle("fix(codex): warn after sync"), "bug"); + assert.equal(detectTypeLabelFromTitle("feat(images): add bridge"), "enhancement"); + assert.equal(detectTypeLabelFromTitle("docs: update guide"), "documentation"); + assert.equal(detectTypeLabelFromTitle("chore!: drop legacy"), "chore"); + }); + + it("maps sentence-case prefixes when no conventional colon is present", () => { + assert.equal( + detectTypeLabelFromTitle("Fix Console Go tool schema sanitization"), + "bug", + ); + assert.equal(detectTypeLabelFromTitle("Feat add Grok image bridge"), "enhancement"); + assert.equal(detectTypeLabelFromTitle("Docs update setup guide"), "documentation"); + }); + + it("returns null without a recognized prefix", () => { + assert.equal(detectTypeLabelFromTitle("Warn or restart stale app-server"), null); + assert.equal(detectTypeLabelFromTitle(""), null); + assert.equal(detectTypeLabelFromTitle("constructor: drop legacy"), null); + assert.equal(detectTypeLabelFromTitle("Fixed Console Go tool schema"), null); + assert.equal(detectTypeLabelFromTitle("Fix"), null); + }); +}); + +describe("hasHumanTypeLabelOverride", () => { + it("is false when only the Actions bot touched type labels", () => { + const events = [ + { event: "labeled", label: { name: "bug" }, actor: { login: "github-actions[bot]" } }, + ]; + assert.equal(hasHumanTypeLabelOverride(events), false); + }); + + it("is true after a human replaces the bot type label (PR #518)", () => { + const events = [ + { event: "labeled", label: { name: "bug" }, actor: { login: "github-actions[bot]" } }, + { event: "unlabeled", label: { name: "bug" }, actor: { login: "Wibias" } }, + { event: "labeled", label: { name: "enhancement" }, actor: { login: "Wibias" } }, + ]; + assert.equal(hasHumanTypeLabelOverride(events), true); + }); + + it("stays true even if the bot later reverts the human choice", () => { + const events = [ + { event: "labeled", label: { name: "bug" }, actor: { login: "github-actions[bot]" } }, + { event: "unlabeled", label: { name: "bug" }, actor: { login: "Wibias" } }, + { event: "labeled", label: { name: "enhancement" }, actor: { login: "Wibias" } }, + { event: "unlabeled", label: { name: "enhancement" }, actor: { login: "github-actions[bot]" } }, + { event: "labeled", label: { name: "bug" }, actor: { login: "github-actions[bot]" } }, + ]; + assert.equal(hasHumanTypeLabelOverride(events), true); + }); + + it("ignores non-type labels from humans", () => { + const events = [ + { event: "labeled", label: { name: "bug" }, actor: { login: "github-actions[bot]" } }, + { event: "labeled", label: { name: "needs-triage" }, actor: { login: "Wibias" } }, + ]; + assert.equal(hasHumanTypeLabelOverride(events), false); + }); +}); + +describe("planTypeLabelSync", () => { + it("adds the detected label and removes other type labels when bot-owned", () => { + const plan = planTypeLabelSync({ + title: "fix(codex): warn after sync", + currentLabels: ["enhancement", "needs-triage"], + events: [ + { event: "labeled", label: { name: "enhancement" }, actor: { login: "github-actions[bot]" } }, + ], + }); + assert.deepEqual(plan, { + skip: false, + detected: "bug", + add: "bug", + remove: ["enhancement"], + }); + assert.ok(TYPE_LABELS.has("bug")); + }); + + it("is a no-op add when the detected label is already present", () => { + const plan = planTypeLabelSync({ + title: "fix(codex): warn after sync", + currentLabels: ["bug"], + events: [ + { event: "labeled", label: { name: "bug" }, actor: { login: "github-actions[bot]" } }, + ], + }); + assert.deepEqual(plan, { + skip: false, + detected: "bug", + add: null, + remove: [], + }); + }); + + it("skips when a human has overridden the type label", () => { + const plan = planTypeLabelSync({ + title: "fix(codex): warn after sync", + currentLabels: ["enhancement"], + events: [ + { event: "labeled", label: { name: "bug" }, actor: { login: "github-actions[bot]" } }, + { event: "unlabeled", label: { name: "bug" }, actor: { login: "Wibias" } }, + { event: "labeled", label: { name: "enhancement" }, actor: { login: "Wibias" } }, + ], + }); + assert.deepEqual(plan, { skip: true, reason: "human-override" }); + }); + + it("labels sentence-case bug-fix titles (PR #524)", () => { + const plan = planTypeLabelSync({ + title: "Fix Console Go tool schema sanitization", + currentLabels: [], + events: [], + }); + assert.deepEqual(plan, { + skip: false, + detected: "bug", + add: "bug", + remove: [], + }); + }); + + it("skips titles without a recognized prefix", () => { + const plan = planTypeLabelSync({ + title: "Warn or restart stale app-server", + currentLabels: [], + events: [], + }); + assert.deepEqual(plan, { skip: true, reason: "no-prefix" }); + }); +}); + +describe("pr-labeler workflow", () => { + const workflowPath = path.join(__dirname, "../workflows/pr-labeler.yml"); + const workflow = fs.readFileSync(workflowPath, "utf8"); + + function pullRequestTargetTypes() { + const match = workflow.match(/pull_request_target:\s*\n(?:[ \t].*\n)*?[ \t]+types:\s*\[([^\]]+)\]/); + assert.ok(match, "expected pull_request_target types array in pr-labeler.yml"); + return match[1].split(",").map((type) => type.trim()); + } + + it("listens for labeled and unlabeled so human overrides cancel stale sync runs", () => { + const types = pullRequestTargetTypes(); + assert.ok(types.includes("labeled"), "missing pull_request_target type: labeled"); + assert.ok(types.includes("unlabeled"), "missing pull_request_target type: unlabeled"); + assert.ok(types.includes("synchronize"), "missing pull_request_target type: synchronize"); + }); + + it("keeps trusted default-branch checkout, concurrency cancel, and minimal permissions", () => { + assert.match(workflow, /ref:\s*\$\{\{\s*github\.event\.repository\.default_branch\s*\}\}/); + assert.match(workflow, /cancel-in-progress:\s*true/); + assert.match(workflow, /pull-requests:\s*read/); + assert.match(workflow, /issues:\s*write/); + assert.doesNotMatch(workflow, /pull-requests:\s*write/); + }); +}); diff --git a/.github/workflows/issue-quality-tests.yml b/.github/workflows/issue-quality-tests.yml index 5455bb3828..76d7e97ad0 100644 --- a/.github/workflows/issue-quality-tests.yml +++ b/.github/workflows/issue-quality-tests.yml @@ -6,6 +6,8 @@ on: - ".github/ISSUE_TEMPLATE/**" - ".github/scripts/issue-quality.cjs" - ".github/scripts/issue-quality.test.cjs" + - ".github/scripts/pr-labeler.cjs" + - ".github/scripts/pr-labeler.test.cjs" - ".github/scripts/issue-translation.cjs" - ".github/scripts/issue-translation.test.cjs" - ".github/scripts/issue-triage.cjs" @@ -13,6 +15,7 @@ on: - ".github/scripts/parse-issue-translation-response.cjs" - ".github/scripts/parse-issue-translation-response.test.cjs" - ".github/workflows/enforce-issue-quality.yml" + - ".github/workflows/pr-labeler.yml" - ".github/workflows/issue-triage.yml" - ".github/workflows/issue-quality-tests.yml" push: @@ -20,6 +23,8 @@ on: - ".github/ISSUE_TEMPLATE/**" - ".github/scripts/issue-quality.cjs" - ".github/scripts/issue-quality.test.cjs" + - ".github/scripts/pr-labeler.cjs" + - ".github/scripts/pr-labeler.test.cjs" - ".github/scripts/issue-translation.cjs" - ".github/scripts/issue-translation.test.cjs" - ".github/scripts/issue-triage.cjs" @@ -27,6 +32,7 @@ on: - ".github/scripts/parse-issue-translation-response.cjs" - ".github/scripts/parse-issue-translation-response.test.cjs" - ".github/workflows/enforce-issue-quality.yml" + - ".github/workflows/pr-labeler.yml" - ".github/workflows/issue-triage.yml" - ".github/workflows/issue-quality-tests.yml" @@ -46,6 +52,7 @@ jobs: - name: Run validator tests run: | node --test .github/scripts/issue-quality.test.cjs + node --test .github/scripts/pr-labeler.test.cjs node --test .github/scripts/issue-translation.test.cjs node --test .github/scripts/issue-triage.test.cjs node --test .github/scripts/parse-issue-translation-response.test.cjs diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml index eec22febec..8bc529b51f 100644 --- a/.github/workflows/pr-labeler.yml +++ b/.github/workflows/pr-labeler.yml @@ -1,85 +1,115 @@ name: PR Labeler +# pull_request_target always loads this workflow from the repository DEFAULT +# branch (currently `main`), not from `dev`. Landing here on `dev` alone does +# not change live labeler behavior until the change is also on that default +# branch — same promotion model as enforce-issue-quality.yml. on: pull_request_target: - types: [opened, edited, synchronize] + # labeled/unlabeled let a human type-label change enqueue a fresher run in the + # per-PR concurrency group, cancelling any in-flight title sync that started + # before the override (PR #518 race). + types: [opened, edited, synchronize, labeled, unlabeled] concurrency: group: pr-labeler-${{ github.event.pull_request.number }} cancel-in-progress: true permissions: - pull-requests: write + contents: read + # pulls.get only needs read; label mutations use the issues API. + pull-requests: read + issues: write jobs: label: runs-on: ubuntu-latest steps: + - name: Checkout labeler script (default-branch trusted code) + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + - name: Apply type label from PR title uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 with: script: | + const { + planTypeLabelSync, + } = require('./.github/scripts/pr-labeler.cjs'); + const title = context.payload.pull_request.title || ''; const pr = context.payload.pull_request.number; - - const PREFIX_TO_LABEL = { - 'feat': 'enhancement', 'feature': 'enhancement', - 'fix': 'bug', 'bugfix': 'bug', 'hotfix': 'bug', - 'docs': 'documentation', 'doc': 'documentation', - 'chore': 'chore', 'refactor': 'chore', 'style': 'chore', - 'test': 'chore', 'tests': 'chore', 'ci': 'chore', - 'build': 'chore', 'perf': 'enhancement', 'revert': 'chore', - }; - const TYPE_LABELS = new Set(Object.values(PREFIX_TO_LABEL)); + const owner = context.repo.owner; + const repo = context.repo.repo; // Refetch live PR title to avoid race with title edits. const { data: livePr } = await github.rest.pulls.get({ - owner: context.repo.owner, repo: context.repo.repo, - pull_number: pr, + owner, repo, pull_number: pr, }); const liveTitle = livePr.title || title; - const match = liveTitle.match(/^([a-zA-Z]+)(?:\([^)]*\))?[!]?\s*:/); - const detected = match ? PREFIX_TO_LABEL[match[1].toLowerCase()] : null; - if (!detected) { - core.info(`No conventional-commit prefix in: "${liveTitle}" — skipping`); + const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ + owner, repo, issue_number: pr, + }); + + // Issue event timeline (same number space as PRs). Used to detect a + // sticky human override — once someone other than github-actions[bot] + // changes a managed type label, we never overwrite that choice again + // (mirrors issue-quality maintainerOverride / no re-close after reopen). + const events = await github.paginate(github.rest.issues.listEvents, { + owner, repo, issue_number: pr, per_page: 100, + }); + + const plan = planTypeLabelSync({ + title: liveTitle, + currentLabels: currentLabels.map((label) => label.name), + events, + }); + + if (plan.skip) { + core.info(`Skipping type-label sync for PR #${pr}: ${plan.reason}`); return; } // Ensure the target label exists (create if missing). try { - await github.rest.issues.getLabel({ - owner: context.repo.owner, repo: context.repo.repo, name: detected, - }); + await github.rest.issues.getLabel({ owner, repo, name: plan.detected }); } catch (err) { if (err.status === 404) { - const colors = { enhancement: '0075ca', bug: 'd73a4a', documentation: '0075ca', chore: 'e4e669' }; - await github.rest.issues.createLabel({ - owner: context.repo.owner, repo: context.repo.repo, - name: detected, color: colors[detected] || 'ededed', - }); - core.info(`Created missing label "${detected}"`); + const colors = { + enhancement: '0075ca', + bug: 'd73a4a', + documentation: '0075ca', + chore: 'e4e669', + }; + try { + await github.rest.issues.createLabel({ + owner, repo, + name: plan.detected, + color: colors[plan.detected] || 'ededed', + }); + core.info(`Created missing label "${plan.detected}"`); + } catch (createErr) { + if (createErr.status !== 422) throw createErr; + core.info(`Label "${plan.detected}" was created concurrently; continuing.`); + } + } else { + throw err; } } - const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ - owner: context.repo.owner, repo: context.repo.repo, issue_number: pr, - }); - const current = new Set(currentLabels.map(l => l.name)); - - for (const label of TYPE_LABELS) { - if (current.has(label) && label !== detected) { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, repo: context.repo.repo, - issue_number: pr, name: label, - }); - } + for (const label of plan.remove) { + await github.rest.issues.removeLabel({ + owner, repo, issue_number: pr, name: label, + }); + core.info(`Removed stale type label "${label}" from PR #${pr}`); } - if (!current.has(detected)) { + if (plan.add) { await github.rest.issues.addLabels({ - owner: context.repo.owner, repo: context.repo.repo, - issue_number: pr, labels: [detected], + owner, repo, issue_number: pr, labels: [plan.add], }); - core.info(`Applied label "${detected}" to PR #${pr}`); + core.info(`Applied label "${plan.add}" to PR #${pr}`); }