Skip to content

Commit ce4d03e

Browse files
authored
Merge pull request #525 from Wibias/fix/pr-labeler-respect-manual
fix(ci): stop PR labeler from reverting human type labels
2 parents 4618c93 + c8fcc2b commit ce4d03e

4 files changed

Lines changed: 378 additions & 43 deletions

File tree

‎.github/scripts/pr-labeler.cjs‎

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
"use strict";
2+
3+
/**
4+
* PR title → GitHub type label for PR Labeler.
5+
* Accepts conventional commits (`fix(scope): …`) and sentence-case fallbacks
6+
* (`Fix Console Go …`) for LLM-authored PRs that skip the prefix colon.
7+
* Kept as a pure module so override behavior can be unit-tested without Actions.
8+
*/
9+
10+
const PREFIX_TO_LABEL = Object.freeze({
11+
feat: "enhancement",
12+
feature: "enhancement",
13+
fix: "bug",
14+
bugfix: "bug",
15+
hotfix: "bug",
16+
docs: "documentation",
17+
doc: "documentation",
18+
chore: "chore",
19+
refactor: "chore",
20+
style: "chore",
21+
test: "chore",
22+
tests: "chore",
23+
ci: "chore",
24+
build: "chore",
25+
perf: "enhancement",
26+
revert: "chore",
27+
});
28+
29+
const TYPE_LABELS = new Set(Object.values(PREFIX_TO_LABEL));
30+
31+
/** Actors whose type-label mutations are treated as bot-owned (may be overwritten). */
32+
const BOT_ACTORS = new Set(["github-actions[bot]"]);
33+
34+
function labelForTitlePrefix(prefix) {
35+
const key = String(prefix || "").toLowerCase();
36+
if (!Object.prototype.hasOwnProperty.call(PREFIX_TO_LABEL, key)) return null;
37+
return PREFIX_TO_LABEL[key];
38+
}
39+
40+
/**
41+
* Map a PR title to a managed type label.
42+
* @param {string} title
43+
* @returns {string|null}
44+
*/
45+
function detectTypeLabelFromTitle(title) {
46+
const text = String(title || "");
47+
48+
const conventional = text.match(/^([a-zA-Z]+)(?:\([^)]*\))?[!]?\s*:/);
49+
if (conventional) return labelForTitlePrefix(conventional[1]);
50+
51+
// Sentence-case fallback (e.g. PR #524: "Fix Console Go tool schema sanitization").
52+
const sentence = text.match(/^([A-Za-z]+)\s+\S/);
53+
if (sentence) return labelForTitlePrefix(sentence[1]);
54+
55+
return null;
56+
}
57+
58+
/**
59+
* True when a human (any non-bot actor) has ever labeled or unlabeled a managed
60+
* type label on this PR. Mirrors issue-quality's sticky maintainerOverride:
61+
* once a person changes the bot's choice, later synchronize/edited runs must
62+
* not revert it.
63+
*
64+
* @param {Array<{ event?: string, label?: { name?: string }, actor?: { login?: string } }>} events
65+
* @param {Set<string>} [typeLabels]
66+
* @param {Set<string>} [botActors]
67+
* @returns {boolean}
68+
*/
69+
function hasHumanTypeLabelOverride(events, typeLabels = TYPE_LABELS, botActors = BOT_ACTORS) {
70+
if (!Array.isArray(events)) return false;
71+
for (const event of events) {
72+
if (event?.event !== "labeled" && event?.event !== "unlabeled") continue;
73+
const name = event.label?.name;
74+
if (!name || !typeLabels.has(name)) continue;
75+
const actor = event.actor?.login;
76+
if (actor && !botActors.has(actor)) return true;
77+
}
78+
return false;
79+
}
80+
81+
/**
82+
* Plan type-label add/remove mutations for a PR.
83+
*
84+
* @param {{
85+
* title: string,
86+
* currentLabels: string[],
87+
* events: Array<{ event?: string, label?: { name?: string }, actor?: { login?: string } }>,
88+
* }} input
89+
* @returns {{
90+
* skip: true,
91+
* reason: "human-override" | "no-prefix",
92+
* } | {
93+
* skip: false,
94+
* detected: string,
95+
* add: string|null,
96+
* remove: string[],
97+
* }}
98+
*/
99+
function planTypeLabelSync(input) {
100+
const title = input?.title ?? "";
101+
const currentLabels = Array.isArray(input?.currentLabels) ? input.currentLabels : [];
102+
const events = Array.isArray(input?.events) ? input.events : [];
103+
104+
if (hasHumanTypeLabelOverride(events)) {
105+
return { skip: true, reason: "human-override" };
106+
}
107+
108+
const detected = detectTypeLabelFromTitle(title);
109+
if (!detected) {
110+
return { skip: true, reason: "no-prefix" };
111+
}
112+
113+
const current = new Set(currentLabels);
114+
const remove = [...TYPE_LABELS].filter((label) => current.has(label) && label !== detected);
115+
const add = current.has(detected) ? null : detected;
116+
return { skip: false, detected, add, remove };
117+
}
118+
119+
module.exports = {
120+
PREFIX_TO_LABEL,
121+
TYPE_LABELS,
122+
BOT_ACTORS,
123+
detectTypeLabelFromTitle,
124+
hasHumanTypeLabelOverride,
125+
planTypeLabelSync,
126+
};
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
"use strict";
2+
3+
const fs = require("node:fs");
4+
const path = require("node:path");
5+
const { describe, it } = require("node:test");
6+
const assert = require("node:assert/strict");
7+
const {
8+
detectTypeLabelFromTitle,
9+
hasHumanTypeLabelOverride,
10+
planTypeLabelSync,
11+
TYPE_LABELS,
12+
} = require("./pr-labeler.cjs");
13+
14+
describe("detectTypeLabelFromTitle", () => {
15+
it("maps conventional prefixes to type labels", () => {
16+
assert.equal(detectTypeLabelFromTitle("fix(codex): warn after sync"), "bug");
17+
assert.equal(detectTypeLabelFromTitle("feat(images): add bridge"), "enhancement");
18+
assert.equal(detectTypeLabelFromTitle("docs: update guide"), "documentation");
19+
assert.equal(detectTypeLabelFromTitle("chore!: drop legacy"), "chore");
20+
});
21+
22+
it("maps sentence-case prefixes when no conventional colon is present", () => {
23+
assert.equal(
24+
detectTypeLabelFromTitle("Fix Console Go tool schema sanitization"),
25+
"bug",
26+
);
27+
assert.equal(detectTypeLabelFromTitle("Feat add Grok image bridge"), "enhancement");
28+
assert.equal(detectTypeLabelFromTitle("Docs update setup guide"), "documentation");
29+
});
30+
31+
it("returns null without a recognized prefix", () => {
32+
assert.equal(detectTypeLabelFromTitle("Warn or restart stale app-server"), null);
33+
assert.equal(detectTypeLabelFromTitle(""), null);
34+
assert.equal(detectTypeLabelFromTitle("constructor: drop legacy"), null);
35+
assert.equal(detectTypeLabelFromTitle("Fixed Console Go tool schema"), null);
36+
assert.equal(detectTypeLabelFromTitle("Fix"), null);
37+
});
38+
});
39+
40+
describe("hasHumanTypeLabelOverride", () => {
41+
it("is false when only the Actions bot touched type labels", () => {
42+
const events = [
43+
{ event: "labeled", label: { name: "bug" }, actor: { login: "github-actions[bot]" } },
44+
];
45+
assert.equal(hasHumanTypeLabelOverride(events), false);
46+
});
47+
48+
it("is true after a human replaces the bot type label (PR #518)", () => {
49+
const events = [
50+
{ event: "labeled", label: { name: "bug" }, actor: { login: "github-actions[bot]" } },
51+
{ event: "unlabeled", label: { name: "bug" }, actor: { login: "Wibias" } },
52+
{ event: "labeled", label: { name: "enhancement" }, actor: { login: "Wibias" } },
53+
];
54+
assert.equal(hasHumanTypeLabelOverride(events), true);
55+
});
56+
57+
it("stays true even if the bot later reverts the human choice", () => {
58+
const events = [
59+
{ event: "labeled", label: { name: "bug" }, actor: { login: "github-actions[bot]" } },
60+
{ event: "unlabeled", label: { name: "bug" }, actor: { login: "Wibias" } },
61+
{ event: "labeled", label: { name: "enhancement" }, actor: { login: "Wibias" } },
62+
{ event: "unlabeled", label: { name: "enhancement" }, actor: { login: "github-actions[bot]" } },
63+
{ event: "labeled", label: { name: "bug" }, actor: { login: "github-actions[bot]" } },
64+
];
65+
assert.equal(hasHumanTypeLabelOverride(events), true);
66+
});
67+
68+
it("ignores non-type labels from humans", () => {
69+
const events = [
70+
{ event: "labeled", label: { name: "bug" }, actor: { login: "github-actions[bot]" } },
71+
{ event: "labeled", label: { name: "needs-triage" }, actor: { login: "Wibias" } },
72+
];
73+
assert.equal(hasHumanTypeLabelOverride(events), false);
74+
});
75+
});
76+
77+
describe("planTypeLabelSync", () => {
78+
it("adds the detected label and removes other type labels when bot-owned", () => {
79+
const plan = planTypeLabelSync({
80+
title: "fix(codex): warn after sync",
81+
currentLabels: ["enhancement", "needs-triage"],
82+
events: [
83+
{ event: "labeled", label: { name: "enhancement" }, actor: { login: "github-actions[bot]" } },
84+
],
85+
});
86+
assert.deepEqual(plan, {
87+
skip: false,
88+
detected: "bug",
89+
add: "bug",
90+
remove: ["enhancement"],
91+
});
92+
assert.ok(TYPE_LABELS.has("bug"));
93+
});
94+
95+
it("is a no-op add when the detected label is already present", () => {
96+
const plan = planTypeLabelSync({
97+
title: "fix(codex): warn after sync",
98+
currentLabels: ["bug"],
99+
events: [
100+
{ event: "labeled", label: { name: "bug" }, actor: { login: "github-actions[bot]" } },
101+
],
102+
});
103+
assert.deepEqual(plan, {
104+
skip: false,
105+
detected: "bug",
106+
add: null,
107+
remove: [],
108+
});
109+
});
110+
111+
it("skips when a human has overridden the type label", () => {
112+
const plan = planTypeLabelSync({
113+
title: "fix(codex): warn after sync",
114+
currentLabels: ["enhancement"],
115+
events: [
116+
{ event: "labeled", label: { name: "bug" }, actor: { login: "github-actions[bot]" } },
117+
{ event: "unlabeled", label: { name: "bug" }, actor: { login: "Wibias" } },
118+
{ event: "labeled", label: { name: "enhancement" }, actor: { login: "Wibias" } },
119+
],
120+
});
121+
assert.deepEqual(plan, { skip: true, reason: "human-override" });
122+
});
123+
124+
it("labels sentence-case bug-fix titles (PR #524)", () => {
125+
const plan = planTypeLabelSync({
126+
title: "Fix Console Go tool schema sanitization",
127+
currentLabels: [],
128+
events: [],
129+
});
130+
assert.deepEqual(plan, {
131+
skip: false,
132+
detected: "bug",
133+
add: "bug",
134+
remove: [],
135+
});
136+
});
137+
138+
it("skips titles without a recognized prefix", () => {
139+
const plan = planTypeLabelSync({
140+
title: "Warn or restart stale app-server",
141+
currentLabels: [],
142+
events: [],
143+
});
144+
assert.deepEqual(plan, { skip: true, reason: "no-prefix" });
145+
});
146+
});
147+
148+
describe("pr-labeler workflow", () => {
149+
const workflowPath = path.join(__dirname, "../workflows/pr-labeler.yml");
150+
const workflow = fs.readFileSync(workflowPath, "utf8");
151+
152+
function pullRequestTargetTypes() {
153+
const match = workflow.match(/pull_request_target:\s*\n(?:[ \t].*\n)*?[ \t]+types:\s*\[([^\]]+)\]/);
154+
assert.ok(match, "expected pull_request_target types array in pr-labeler.yml");
155+
return match[1].split(",").map((type) => type.trim());
156+
}
157+
158+
it("listens for labeled and unlabeled so human overrides cancel stale sync runs", () => {
159+
const types = pullRequestTargetTypes();
160+
assert.ok(types.includes("labeled"), "missing pull_request_target type: labeled");
161+
assert.ok(types.includes("unlabeled"), "missing pull_request_target type: unlabeled");
162+
assert.ok(types.includes("synchronize"), "missing pull_request_target type: synchronize");
163+
});
164+
165+
it("keeps trusted default-branch checkout, concurrency cancel, and minimal permissions", () => {
166+
assert.match(workflow, /ref:\s*\$\{\{\s*github\.event\.repository\.default_branch\s*\}\}/);
167+
assert.match(workflow, /cancel-in-progress:\s*true/);
168+
assert.match(workflow, /pull-requests:\s*read/);
169+
assert.match(workflow, /issues:\s*write/);
170+
assert.doesNotMatch(workflow, /pull-requests:\s*write/);
171+
});
172+
});

‎.github/workflows/issue-quality-tests.yml‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,27 +6,33 @@ on:
66
- ".github/ISSUE_TEMPLATE/**"
77
- ".github/scripts/issue-quality.cjs"
88
- ".github/scripts/issue-quality.test.cjs"
9+
- ".github/scripts/pr-labeler.cjs"
10+
- ".github/scripts/pr-labeler.test.cjs"
911
- ".github/scripts/issue-translation.cjs"
1012
- ".github/scripts/issue-translation.test.cjs"
1113
- ".github/scripts/issue-triage.cjs"
1214
- ".github/scripts/issue-triage.test.cjs"
1315
- ".github/scripts/parse-issue-translation-response.cjs"
1416
- ".github/scripts/parse-issue-translation-response.test.cjs"
1517
- ".github/workflows/enforce-issue-quality.yml"
18+
- ".github/workflows/pr-labeler.yml"
1619
- ".github/workflows/issue-triage.yml"
1720
- ".github/workflows/issue-quality-tests.yml"
1821
push:
1922
paths:
2023
- ".github/ISSUE_TEMPLATE/**"
2124
- ".github/scripts/issue-quality.cjs"
2225
- ".github/scripts/issue-quality.test.cjs"
26+
- ".github/scripts/pr-labeler.cjs"
27+
- ".github/scripts/pr-labeler.test.cjs"
2328
- ".github/scripts/issue-translation.cjs"
2429
- ".github/scripts/issue-translation.test.cjs"
2530
- ".github/scripts/issue-triage.cjs"
2631
- ".github/scripts/issue-triage.test.cjs"
2732
- ".github/scripts/parse-issue-translation-response.cjs"
2833
- ".github/scripts/parse-issue-translation-response.test.cjs"
2934
- ".github/workflows/enforce-issue-quality.yml"
35+
- ".github/workflows/pr-labeler.yml"
3036
- ".github/workflows/issue-triage.yml"
3137
- ".github/workflows/issue-quality-tests.yml"
3238

@@ -46,6 +52,7 @@ jobs:
4652
- name: Run validator tests
4753
run: |
4854
node --test .github/scripts/issue-quality.test.cjs
55+
node --test .github/scripts/pr-labeler.test.cjs
4956
node --test .github/scripts/issue-translation.test.cjs
5057
node --test .github/scripts/issue-triage.test.cjs
5158
node --test .github/scripts/parse-issue-translation-response.test.cjs

0 commit comments

Comments
 (0)