Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions .github/scripts/pr-labeler.cjs
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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* 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,
};
172 changes: 172 additions & 0 deletions .github/scripts/pr-labeler.test.cjs
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/);
});
});
7 changes: 7 additions & 0 deletions .github/workflows/issue-quality-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,33 @@ 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"
- ".github/scripts/issue-triage.test.cjs"
- ".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:
paths:
- ".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"
- ".github/scripts/issue-triage.test.cjs"
- ".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"

Expand All @@ -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
Expand Down
Loading
Loading