-
Notifications
You must be signed in to change notification settings - Fork 1.2k
fix(ci): stop PR labeler from reverting human type labels #525
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+378
−43
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
9a43d78
fix(ci): stop PR labeler from reverting human type labels
Wibias 59d4ec4
fix(ci): address PR labeler review feedback
Wibias 34c6a66
feat(ci): label sentence-case PR titles without conventional colon
Wibias c8fcc2b
fix(ci): cancel stale labeler runs on human label changes
Wibias File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string>} [typeLabels] | ||
| * @param {Set<string>} [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, | ||
| }; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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/); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.