From 9a43d78971cc07950636157684630e8be9ff0aed Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:06:15 +0200 Subject: [PATCH 1/4] fix(ci): stop PR labeler from reverting human type labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once a non-bot actor changes a managed type label, keep that choice sticky across synchronize/edited runs — same override model as issue-quality not re-closing maintainer reopens. --- .github/scripts/pr-labeler.cjs | 111 ++++++++++++++++++++ .github/scripts/pr-labeler.test.cjs | 118 ++++++++++++++++++++++ .github/workflows/issue-quality-tests.yml | 7 ++ .github/workflows/pr-labeler.yml | 98 +++++++++++------- 4 files changed, 295 insertions(+), 39 deletions(-) create mode 100644 .github/scripts/pr-labeler.cjs create mode 100644 .github/scripts/pr-labeler.test.cjs diff --git a/.github/scripts/pr-labeler.cjs b/.github/scripts/pr-labeler.cjs new file mode 100644 index 0000000000..1c7c1e5ce2 --- /dev/null +++ b/.github/scripts/pr-labeler.cjs @@ -0,0 +1,111 @@ +"use strict"; + +/** + * Conventional-commit title → GitHub type label for PR Labeler. + * 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]"]); + +/** + * Map a conventional-commit PR title to a managed type label. + * @param {string} title + * @returns {string|null} + */ +function detectTypeLabelFromTitle(title) { + const match = String(title || "").match(/^([a-zA-Z]+)(?:\([^)]*\))?[!]?\s*:/); + if (!match) return null; + return PREFIX_TO_LABEL[match[1].toLowerCase()] || 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..a53310d39c --- /dev/null +++ b/.github/scripts/pr-labeler.test.cjs @@ -0,0 +1,118 @@ +"use strict"; + +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("returns null without a conventional prefix", () => { + assert.equal(detectTypeLabelFromTitle("Warn or restart stale app-server"), null); + assert.equal(detectTypeLabelFromTitle(""), 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("skips titles without a conventional prefix", () => { + const plan = planTypeLabelSync({ + title: "Warn or restart stale app-server", + currentLabels: [], + events: [], + }); + assert.deepEqual(plan, { skip: true, reason: "no-prefix" }); + }); +}); 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..72417606a4 100644 --- a/.github/workflows/pr-labeler.yml +++ b/.github/workflows/pr-labeler.yml @@ -1,5 +1,9 @@ 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] @@ -9,77 +13,93 @@ concurrency: cancel-in-progress: true permissions: + contents: read pull-requests: write + issues: read jobs: label: runs-on: ubuntu-latest steps: + - name: Checkout labeler script (default-branch trusted code) + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + 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' }; + 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', + owner, repo, + name: plan.detected, + color: colors[plan.detected] || 'ededed', }); - core.info(`Created missing label "${detected}"`); + core.info(`Created missing label "${plan.detected}"`); + } 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}`); } From 59d4ec4b493e007007b1293c85aede68c410f02e Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:15:40 +0200 Subject: [PATCH 2/4] fix(ci): address PR labeler review feedback Guard prefix lookup with an own-property check, pin checkout to the default branch, narrow workflow permissions to pulls:read/issues:write, and tolerate concurrent label creation races. --- .github/scripts/pr-labeler.cjs | 4 +++- .github/scripts/pr-labeler.test.cjs | 1 + .github/workflows/pr-labeler.yml | 23 +++++++++++++++-------- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/.github/scripts/pr-labeler.cjs b/.github/scripts/pr-labeler.cjs index 1c7c1e5ce2..235260999e 100644 --- a/.github/scripts/pr-labeler.cjs +++ b/.github/scripts/pr-labeler.cjs @@ -37,7 +37,9 @@ const BOT_ACTORS = new Set(["github-actions[bot]"]); function detectTypeLabelFromTitle(title) { const match = String(title || "").match(/^([a-zA-Z]+)(?:\([^)]*\))?[!]?\s*:/); if (!match) return null; - return PREFIX_TO_LABEL[match[1].toLowerCase()] || null; + const prefix = match[1].toLowerCase(); + if (!Object.prototype.hasOwnProperty.call(PREFIX_TO_LABEL, prefix)) return null; + return PREFIX_TO_LABEL[prefix]; } /** diff --git a/.github/scripts/pr-labeler.test.cjs b/.github/scripts/pr-labeler.test.cjs index a53310d39c..3825d7c346 100644 --- a/.github/scripts/pr-labeler.test.cjs +++ b/.github/scripts/pr-labeler.test.cjs @@ -20,6 +20,7 @@ describe("detectTypeLabelFromTitle", () => { it("returns null without a conventional prefix", () => { assert.equal(detectTypeLabelFromTitle("Warn or restart stale app-server"), null); assert.equal(detectTypeLabelFromTitle(""), null); + assert.equal(detectTypeLabelFromTitle("constructor: drop legacy"), null); }); }); diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml index 72417606a4..1dbfeb2110 100644 --- a/.github/workflows/pr-labeler.yml +++ b/.github/workflows/pr-labeler.yml @@ -14,8 +14,9 @@ concurrency: permissions: contents: read - pull-requests: write - issues: read + # pulls.get only needs read; label mutations use the issues API. + pull-requests: read + issues: write jobs: label: @@ -24,6 +25,7 @@ jobs: - 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 @@ -79,12 +81,17 @@ jobs: documentation: '0075ca', chore: 'e4e669', }; - await github.rest.issues.createLabel({ - owner, repo, - name: plan.detected, - color: colors[plan.detected] || 'ededed', - }); - core.info(`Created missing label "${plan.detected}"`); + 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; } From 34c6a6609b1d286c6dedb25556696170f7437e0c Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:32:37 +0200 Subject: [PATCH 3/4] feat(ci): label sentence-case PR titles without conventional colon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LLM-authored PRs like #524 often use titles such as 'Fix Console Go …' instead of 'fix: …'. Fall back to the first title word when it matches a known type prefix. --- .github/scripts/pr-labeler.cjs | 27 ++++++++++++++++++++------- .github/scripts/pr-labeler.test.cjs | 29 +++++++++++++++++++++++++++-- 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/.github/scripts/pr-labeler.cjs b/.github/scripts/pr-labeler.cjs index 235260999e..c57dce0d2f 100644 --- a/.github/scripts/pr-labeler.cjs +++ b/.github/scripts/pr-labeler.cjs @@ -1,7 +1,9 @@ "use strict"; /** - * Conventional-commit title → GitHub type label for PR Labeler. + * 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. */ @@ -29,17 +31,28 @@ 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 conventional-commit PR title to a managed type label. + * Map a PR title to a managed type label. * @param {string} title * @returns {string|null} */ function detectTypeLabelFromTitle(title) { - const match = String(title || "").match(/^([a-zA-Z]+)(?:\([^)]*\))?[!]?\s*:/); - if (!match) return null; - const prefix = match[1].toLowerCase(); - if (!Object.prototype.hasOwnProperty.call(PREFIX_TO_LABEL, prefix)) return null; - return PREFIX_TO_LABEL[prefix]; + 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; } /** diff --git a/.github/scripts/pr-labeler.test.cjs b/.github/scripts/pr-labeler.test.cjs index 3825d7c346..88499e3efc 100644 --- a/.github/scripts/pr-labeler.test.cjs +++ b/.github/scripts/pr-labeler.test.cjs @@ -17,10 +17,21 @@ describe("detectTypeLabelFromTitle", () => { assert.equal(detectTypeLabelFromTitle("chore!: drop legacy"), "chore"); }); - it("returns null without a conventional prefix", () => { + 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); }); }); @@ -108,7 +119,21 @@ describe("planTypeLabelSync", () => { assert.deepEqual(plan, { skip: true, reason: "human-override" }); }); - it("skips titles without a conventional prefix", () => { + 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: [], From c8fcc2b30a7e722bdd9caf787eb5a965b90e1aa7 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:33:26 +0200 Subject: [PATCH 4/4] fix(ci): cancel stale labeler runs on human label changes Listen for labeled and unlabeled pull_request_target events so a human type-label edit enqueues a fresher run in the per-PR concurrency group and cancels any in-flight title sync. Add workflow contract tests for the activity types and preserved security settings. --- .github/scripts/pr-labeler.test.cjs | 28 ++++++++++++++++++++++++++++ .github/workflows/pr-labeler.yml | 5 ++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/.github/scripts/pr-labeler.test.cjs b/.github/scripts/pr-labeler.test.cjs index 88499e3efc..71afd7836c 100644 --- a/.github/scripts/pr-labeler.test.cjs +++ b/.github/scripts/pr-labeler.test.cjs @@ -1,5 +1,7 @@ "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 { @@ -142,3 +144,29 @@ describe("planTypeLabelSync", () => { 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/pr-labeler.yml b/.github/workflows/pr-labeler.yml index 1dbfeb2110..8bc529b51f 100644 --- a/.github/workflows/pr-labeler.yml +++ b/.github/workflows/pr-labeler.yml @@ -6,7 +6,10 @@ name: PR Labeler # 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 }}