From 08500f52ad6374201b8ab643e5d14a9f10d98c69 Mon Sep 17 00:00:00 2001 From: Jeff Spahr Date: Thu, 23 Apr 2026 18:22:04 -0400 Subject: [PATCH 1/2] ci: backfill release labels after merge Signed-off-by: Jeff Spahr --- .github/scripts/release-label-backfill.mjs | 167 ++++++++++++++++++ .../scripts/release-label-backfill.test.mjs | 32 ++++ .github/workflows/release-label-backfill.yml | 28 +++ 3 files changed, 227 insertions(+) create mode 100644 .github/scripts/release-label-backfill.mjs create mode 100644 .github/scripts/release-label-backfill.test.mjs create mode 100644 .github/workflows/release-label-backfill.yml diff --git a/.github/scripts/release-label-backfill.mjs b/.github/scripts/release-label-backfill.mjs new file mode 100644 index 000000000..49f9beb57 --- /dev/null +++ b/.github/scripts/release-label-backfill.mjs @@ -0,0 +1,167 @@ +import { pathToFileURL } from "node:url"; + +const CATEGORY_LABELS = new Set([ + "enhancement", + "bug", + "documentation", + "testing", + "dependencies", +]); + +const ENSURED_LABELS = { + dependencies: { + color: "0366d6", + description: "Dependency updates and version bumps", + }, +}; + +export function classifyTitle(title) { + const rules = [ + [/^feat(?:\([^)]*\))?:/i, "enhancement"], + [/^fix(?:\([^)]*\))?:/i, "bug"], + [/^docs(?:\([^)]*\))?:/i, "documentation"], + [/^test(?:\([^)]*\))?:/i, "testing"], + [/^chore\(deps\):/i, "dependencies"], + [/^\[feature\]/i, "enhancement"], + [/^\[bug\]/i, "bug"], + [/^\[docs\]/i, "documentation"], + ]; + + for (const [pattern, label] of rules) { + if (pattern.test(title)) { + return label; + } + } + + return null; +} + +export function shouldBackfill(labels, targetLabel) { + if (!targetLabel) { + return false; + } + + if (labels.includes("ignore-for-release")) { + return false; + } + + return !labels.some((label) => CATEGORY_LABELS.has(label)); +} + +async function githubRequest(token, path, { method = "GET", body } = {}) { + const response = await fetch(`https://api.github.com${path}`, { + method, + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "User-Agent": "kagent-release-label-backfill", + "X-GitHub-Api-Version": "2022-11-28", + }, + body: body ? JSON.stringify(body) : undefined, + }); + + if (response.status === 204) { + return null; + } + + const text = await response.text(); + const payload = text ? JSON.parse(text) : null; + + if (!response.ok) { + const message = payload?.message ?? response.statusText; + const error = new Error(`${method} ${path} failed: ${response.status} ${message}`); + error.status = response.status; + throw error; + } + + return payload; +} + +async function ensureLabel(token, repository, name, definition) { + try { + await githubRequest(token, `/repos/${repository}/labels/${encodeURIComponent(name)}`); + console.log(`label exists: ${name}`); + return; + } catch (error) { + if (error.status !== 404) { + throw error; + } + } + + await githubRequest(token, `/repos/${repository}/labels`, { + method: "POST", + body: { + name, + color: definition.color, + description: definition.description, + }, + }); + console.log(`created label: ${name}`); +} + +async function listOpenPullRequests(token, repository) { + const pullRequests = []; + + for (let page = 1; ; page += 1) { + const issues = await githubRequest( + token, + `/repos/${repository}/issues?state=open&per_page=100&page=${page}`, + ); + const openPullRequests = issues.filter((issue) => issue.pull_request); + + pullRequests.push(...openPullRequests); + + if (issues.length < 100) { + break; + } + } + + return pullRequests; +} + +async function addLabel(token, repository, issueNumber, label) { + await githubRequest(token, `/repos/${repository}/issues/${issueNumber}/labels`, { + method: "POST", + body: { labels: [label] }, + }); +} + +export async function backfillReleaseLabels({ + token = process.env.GITHUB_TOKEN, + repository = process.env.GITHUB_REPOSITORY, +} = {}) { + if (!token) { + throw new Error("GITHUB_TOKEN is required"); + } + if (!repository) { + throw new Error("GITHUB_REPOSITORY is required"); + } + + for (const [name, definition] of Object.entries(ENSURED_LABELS)) { + await ensureLabel(token, repository, name, definition); + } + + const pullRequests = await listOpenPullRequests(token, repository); + let labeledCount = 0; + + for (const pullRequest of pullRequests) { + const labels = pullRequest.labels.map((label) => label.name); + const targetLabel = classifyTitle(pullRequest.title); + + if (!shouldBackfill(labels, targetLabel)) { + continue; + } + + await addLabel(token, repository, pullRequest.number, targetLabel); + labeledCount += 1; + console.log(`labeled #${pullRequest.number} with ${targetLabel}`); + } + + console.log( + `processed ${pullRequests.length} open pull requests, added ${labeledCount} release labels`, + ); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await backfillReleaseLabels(); +} diff --git a/.github/scripts/release-label-backfill.test.mjs b/.github/scripts/release-label-backfill.test.mjs new file mode 100644 index 000000000..22f2f61f1 --- /dev/null +++ b/.github/scripts/release-label-backfill.test.mjs @@ -0,0 +1,32 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { classifyTitle, shouldBackfill } from "./release-label-backfill.mjs"; + +test("classifyTitle maps supported conventional prefixes", () => { + assert.equal(classifyTitle("feat(ui): add a button"), "enhancement"); + assert.equal(classifyTitle("fix: handle empty input"), "bug"); + assert.equal(classifyTitle("docs(api): update README"), "documentation"); + assert.equal(classifyTitle("test: cover label sweeper"), "testing"); + assert.equal(classifyTitle("chore(deps): bump lucide-react"), "dependencies"); +}); + +test("classifyTitle maps supported legacy bracket prefixes", () => { + assert.equal(classifyTitle("[FEATURE] add support for x"), "enhancement"); + assert.equal(classifyTitle("[BUG] fix exporter config"), "bug"); + assert.equal(classifyTitle("[DOCS] correct the install guide"), "documentation"); +}); + +test("classifyTitle ignores ambiguous titles", () => { + assert.equal(classifyTitle("Add askUser config"), null); + assert.equal(classifyTitle("Improve session resilience"), null); + assert.equal(classifyTitle("cli: add --provider flag"), null); +}); + +test("shouldBackfill skips unlabeled categories only when safe", () => { + assert.equal(shouldBackfill([], "enhancement"), true); + assert.equal(shouldBackfill(["stale"], "bug"), true); + assert.equal(shouldBackfill(["ignore-for-release"], "dependencies"), false); + assert.equal(shouldBackfill(["bug"], "bug"), false); + assert.equal(shouldBackfill([], null), false); +}); diff --git a/.github/workflows/release-label-backfill.yml b/.github/workflows/release-label-backfill.yml new file mode 100644 index 000000000..9ebcf8058 --- /dev/null +++ b/.github/workflows/release-label-backfill.yml @@ -0,0 +1,28 @@ +name: Backfill Release Labels + +on: + push: + branches: [main] + paths: + - ".github/workflows/release-label-backfill.yml" + - ".github/workflows/conventional-label.yml" + - ".github/release.yml" + - ".github/dependabot.yml" + - ".github/scripts/release-label-backfill.mjs" + workflow_dispatch: + +jobs: + backfill-release-labels: + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + pull-requests: write + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Backfill release labels + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node .github/scripts/release-label-backfill.mjs From 2a1ac91f4e69311217e57ef56ec70bc546e19f44 Mon Sep 17 00:00:00 2001 From: Jeff Spahr Date: Mon, 27 Apr 2026 23:35:49 -0400 Subject: [PATCH 2/2] ci: narrow release label workflow to dependencies label Signed-off-by: Jeff Spahr --- .github/scripts/release-label-backfill.mjs | 138 +++--------------- .../scripts/release-label-backfill.test.mjs | 87 ++++++++--- .github/workflows/release-label-backfill.yml | 8 +- 3 files changed, 87 insertions(+), 146 deletions(-) diff --git a/.github/scripts/release-label-backfill.mjs b/.github/scripts/release-label-backfill.mjs index 49f9beb57..4f13e541e 100644 --- a/.github/scripts/release-label-backfill.mjs +++ b/.github/scripts/release-label-backfill.mjs @@ -1,53 +1,11 @@ import { pathToFileURL } from "node:url"; -const CATEGORY_LABELS = new Set([ - "enhancement", - "bug", - "documentation", - "testing", - "dependencies", -]); - -const ENSURED_LABELS = { - dependencies: { - color: "0366d6", - description: "Dependency updates and version bumps", - }, +const DEPENDENCIES_LABEL = { + name: "dependencies", + color: "0366d6", + description: "Dependency updates and version bumps", }; -export function classifyTitle(title) { - const rules = [ - [/^feat(?:\([^)]*\))?:/i, "enhancement"], - [/^fix(?:\([^)]*\))?:/i, "bug"], - [/^docs(?:\([^)]*\))?:/i, "documentation"], - [/^test(?:\([^)]*\))?:/i, "testing"], - [/^chore\(deps\):/i, "dependencies"], - [/^\[feature\]/i, "enhancement"], - [/^\[bug\]/i, "bug"], - [/^\[docs\]/i, "documentation"], - ]; - - for (const [pattern, label] of rules) { - if (pattern.test(title)) { - return label; - } - } - - return null; -} - -export function shouldBackfill(labels, targetLabel) { - if (!targetLabel) { - return false; - } - - if (labels.includes("ignore-for-release")) { - return false; - } - - return !labels.some((label) => CATEGORY_LABELS.has(label)); -} - async function githubRequest(token, path, { method = "GET", body } = {}) { const response = await fetch(`https://api.github.com${path}`, { method, @@ -77,56 +35,7 @@ async function githubRequest(token, path, { method = "GET", body } = {}) { return payload; } -async function ensureLabel(token, repository, name, definition) { - try { - await githubRequest(token, `/repos/${repository}/labels/${encodeURIComponent(name)}`); - console.log(`label exists: ${name}`); - return; - } catch (error) { - if (error.status !== 404) { - throw error; - } - } - - await githubRequest(token, `/repos/${repository}/labels`, { - method: "POST", - body: { - name, - color: definition.color, - description: definition.description, - }, - }); - console.log(`created label: ${name}`); -} - -async function listOpenPullRequests(token, repository) { - const pullRequests = []; - - for (let page = 1; ; page += 1) { - const issues = await githubRequest( - token, - `/repos/${repository}/issues?state=open&per_page=100&page=${page}`, - ); - const openPullRequests = issues.filter((issue) => issue.pull_request); - - pullRequests.push(...openPullRequests); - - if (issues.length < 100) { - break; - } - } - - return pullRequests; -} - -async function addLabel(token, repository, issueNumber, label) { - await githubRequest(token, `/repos/${repository}/issues/${issueNumber}/labels`, { - method: "POST", - body: { labels: [label] }, - }); -} - -export async function backfillReleaseLabels({ +export async function ensureDependenciesLabel({ token = process.env.GITHUB_TOKEN, repository = process.env.GITHUB_REPOSITORY, } = {}) { @@ -137,31 +46,26 @@ export async function backfillReleaseLabels({ throw new Error("GITHUB_REPOSITORY is required"); } - for (const [name, definition] of Object.entries(ENSURED_LABELS)) { - await ensureLabel(token, repository, name, definition); - } - - const pullRequests = await listOpenPullRequests(token, repository); - let labeledCount = 0; - - for (const pullRequest of pullRequests) { - const labels = pullRequest.labels.map((label) => label.name); - const targetLabel = classifyTitle(pullRequest.title); - - if (!shouldBackfill(labels, targetLabel)) { - continue; + try { + await githubRequest( + token, + `/repos/${repository}/labels/${encodeURIComponent(DEPENDENCIES_LABEL.name)}`, + ); + console.log(`label exists: ${DEPENDENCIES_LABEL.name}`); + return; + } catch (error) { + if (error.status !== 404) { + throw error; } - - await addLabel(token, repository, pullRequest.number, targetLabel); - labeledCount += 1; - console.log(`labeled #${pullRequest.number} with ${targetLabel}`); } - console.log( - `processed ${pullRequests.length} open pull requests, added ${labeledCount} release labels`, - ); + await githubRequest(token, `/repos/${repository}/labels`, { + method: "POST", + body: DEPENDENCIES_LABEL, + }); + console.log(`created label: ${DEPENDENCIES_LABEL.name}`); } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - await backfillReleaseLabels(); + await ensureDependenciesLabel(); } diff --git a/.github/scripts/release-label-backfill.test.mjs b/.github/scripts/release-label-backfill.test.mjs index 22f2f61f1..deb0d64cd 100644 --- a/.github/scripts/release-label-backfill.test.mjs +++ b/.github/scripts/release-label-backfill.test.mjs @@ -1,32 +1,71 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { classifyTitle, shouldBackfill } from "./release-label-backfill.mjs"; - -test("classifyTitle maps supported conventional prefixes", () => { - assert.equal(classifyTitle("feat(ui): add a button"), "enhancement"); - assert.equal(classifyTitle("fix: handle empty input"), "bug"); - assert.equal(classifyTitle("docs(api): update README"), "documentation"); - assert.equal(classifyTitle("test: cover label sweeper"), "testing"); - assert.equal(classifyTitle("chore(deps): bump lucide-react"), "dependencies"); -}); +import { ensureDependenciesLabel } from "./release-label-backfill.mjs"; -test("classifyTitle maps supported legacy bracket prefixes", () => { - assert.equal(classifyTitle("[FEATURE] add support for x"), "enhancement"); - assert.equal(classifyTitle("[BUG] fix exporter config"), "bug"); - assert.equal(classifyTitle("[DOCS] correct the install guide"), "documentation"); -}); +test("ensureDependenciesLabel skips creation when the label already exists", async () => { + const requests = []; + const originalFetch = global.fetch; + + global.fetch = async (url, options = {}) => { + requests.push({ url, options }); + return new Response(JSON.stringify({ name: "dependencies" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; -test("classifyTitle ignores ambiguous titles", () => { - assert.equal(classifyTitle("Add askUser config"), null); - assert.equal(classifyTitle("Improve session resilience"), null); - assert.equal(classifyTitle("cli: add --provider flag"), null); + try { + await ensureDependenciesLabel({ + token: "test-token", + repository: "kagent-dev/kagent", + }); + } finally { + global.fetch = originalFetch; + } + + assert.equal(requests.length, 1); + assert.match(requests[0].url, /\/repos\/kagent-dev\/kagent\/labels\/dependencies$/); + assert.equal(requests[0].options.method ?? "GET", "GET"); }); -test("shouldBackfill skips unlabeled categories only when safe", () => { - assert.equal(shouldBackfill([], "enhancement"), true); - assert.equal(shouldBackfill(["stale"], "bug"), true); - assert.equal(shouldBackfill(["ignore-for-release"], "dependencies"), false); - assert.equal(shouldBackfill(["bug"], "bug"), false); - assert.equal(shouldBackfill([], null), false); +test("ensureDependenciesLabel creates the label when it is missing", async () => { + const requests = []; + const originalFetch = global.fetch; + + global.fetch = async (url, options = {}) => { + requests.push({ url, options }); + + if (requests.length === 1) { + return new Response(JSON.stringify({ message: "Not Found" }), { + status: 404, + headers: { "content-type": "application/json" }, + }); + } + + return new Response(JSON.stringify({ name: "dependencies" }), { + status: 201, + headers: { "content-type": "application/json" }, + }); + }; + + try { + await ensureDependenciesLabel({ + token: "test-token", + repository: "kagent-dev/kagent", + }); + } finally { + global.fetch = originalFetch; + } + + assert.equal(requests.length, 2); + assert.match(requests[0].url, /\/repos\/kagent-dev\/kagent\/labels\/dependencies$/); + assert.equal(requests[0].options.method ?? "GET", "GET"); + assert.match(requests[1].url, /\/repos\/kagent-dev\/kagent\/labels$/); + assert.equal(requests[1].options.method, "POST"); + assert.deepEqual(JSON.parse(requests[1].options.body), { + name: "dependencies", + color: "0366d6", + description: "Dependency updates and version bumps", + }); }); diff --git a/.github/workflows/release-label-backfill.yml b/.github/workflows/release-label-backfill.yml index 9ebcf8058..10fd0afec 100644 --- a/.github/workflows/release-label-backfill.yml +++ b/.github/workflows/release-label-backfill.yml @@ -1,28 +1,26 @@ -name: Backfill Release Labels +name: Ensure Dependencies Label on: push: branches: [main] paths: - ".github/workflows/release-label-backfill.yml" - - ".github/workflows/conventional-label.yml" - ".github/release.yml" - ".github/dependabot.yml" - ".github/scripts/release-label-backfill.mjs" workflow_dispatch: jobs: - backfill-release-labels: + ensure-dependencies-label: runs-on: ubuntu-latest permissions: contents: read issues: write - pull-requests: write steps: - name: Checkout repository uses: actions/checkout@v6 - - name: Backfill release labels + - name: Ensure dependencies label exists env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: node .github/scripts/release-label-backfill.mjs