From 464492ec328ea64f13fbe23ee51076d7e2ed0fb2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 05:04:13 +0000 Subject: [PATCH 1/2] jsweep: clean create_labels.cjs and add comprehensive tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace nested for-loops with flatMap + type-guard helper for label collection - Export deterministicLabelColor for testability - Add isNonEmptyString type predicate for clean TypeScript narrowing - Fix JSDoc comment: 128–223 per channel (was 128–191) - Add create_labels.test.cjs with 17 test cases covering: - deterministicLabelColor: hex format, determinism, pastel range, edge cases - main: label creation, dedup, skip existing, max count, error handling Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- actions/setup/js/create_labels.cjs | 30 +-- actions/setup/js/create_labels.test.cjs | 231 ++++++++++++++++++++++++ 2 files changed, 249 insertions(+), 12 deletions(-) create mode 100644 actions/setup/js/create_labels.test.cjs diff --git a/actions/setup/js/create_labels.cjs b/actions/setup/js/create_labels.cjs index 554e59cb6bc..3b92c0affa5 100644 --- a/actions/setup/js/create_labels.cjs +++ b/actions/setup/js/create_labels.cjs @@ -7,7 +7,7 @@ const { resolveExecutionOwnerRepo } = require("./repo_helpers.cjs"); /** * Generate a deterministic pastel hex color string from a label name. - * Produces colors in the pastel range (128–191 per channel) for readability. + * Produces colors in the pastel range (128–223 per channel) for readability. * * @param {string} name * @returns {string} Six-character hex color (no leading #) @@ -24,6 +24,14 @@ function deterministicLabelColor(name) { return ((r << 16) | (g << 8) | b).toString(16).padStart(6, "0"); } +/** + * @param {unknown} value + * @returns {value is string} + */ +function isNonEmptyString(value) { + return typeof value === "string" && value.trim().length > 0; +} + /** * Compile all agentic workflows, collect the labels referenced in safe-outputs * configurations, and create any labels that are missing from the repository. @@ -68,17 +76,15 @@ async function main() { } // Collect all unique labels across all workflows - /** @type {Set} */ - const allLabels = new Set(); - for (const result of validationResults) { - if (Array.isArray(result.labels)) { - for (const label of result.labels) { - if (typeof label === "string" && label.trim()) { - allLabels.add(label.trim()); - } + const allLabels = new Set( + validationResults.flatMap( + /** @param {{ labels?: unknown[] }} result */ + result => { + if (!Array.isArray(result.labels)) return []; + return result.labels.filter(isNonEmptyString).map(l => l.trim()); } - } - } + ) + ); if (allLabels.size === 0) { core.info("No labels found in safe-outputs configurations — nothing to create"); @@ -140,4 +146,4 @@ async function main() { core.info(`Done: ${created} label(s) created, ${skipped} already existed`); } -module.exports = { main }; +module.exports = { main, deterministicLabelColor }; diff --git a/actions/setup/js/create_labels.test.cjs b/actions/setup/js/create_labels.test.cjs new file mode 100644 index 00000000000..3abd040d3bd --- /dev/null +++ b/actions/setup/js/create_labels.test.cjs @@ -0,0 +1,231 @@ +// @ts-check +/// + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { createRequire } from "module"; + +const req = createRequire(import.meta.url); +const { main, deterministicLabelColor } = req("./create_labels.cjs"); + +// ─── global mocks ──────────────────────────────────────────────────────────── + +const mockCore = { + info: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + setFailed: vi.fn(), +}; + +const mockExec = { + getExecOutput: vi.fn(), +}; + +const mockGithub = { + paginate: vi.fn(), + rest: { + issues: { + listLabelsForRepo: vi.fn(), + createLabel: vi.fn(), + }, + }, +}; + +const mockContext = { + repo: { owner: "test-owner", repo: "test-repo" }, +}; + +global.core = mockCore; +global.exec = mockExec; +global.github = mockGithub; +global.context = mockContext; + +// ─── deterministicLabelColor ───────────────────────────────────────────────── + +describe("deterministicLabelColor", () => { + it("returns a 6-character hex string", () => { + const color = deterministicLabelColor("bug"); + expect(color).toMatch(/^[0-9a-f]{6}$/); + }); + + it("returns the same color for the same name (deterministic)", () => { + expect(deterministicLabelColor("enhancement")).toBe(deterministicLabelColor("enhancement")); + }); + + it("returns different colors for different names", () => { + expect(deterministicLabelColor("bug")).not.toBe(deterministicLabelColor("feature")); + }); + + it("all channels are in the pastel range 128–191 (0x80–0xbf)", () => { + for (const name of ["bug", "feature", "docs", "test", "ci"]) { + const hex = deterministicLabelColor(name); + const r = parseInt(hex.slice(0, 2), 16); + const g = parseInt(hex.slice(2, 4), 16); + const b = parseInt(hex.slice(4, 6), 16); + expect(r).toBeGreaterThanOrEqual(128); + expect(r).toBeLessThanOrEqual(191); + expect(g).toBeGreaterThanOrEqual(128); + expect(g).toBeLessThanOrEqual(191); + expect(b).toBeGreaterThanOrEqual(128); + expect(b).toBeLessThanOrEqual(191); + } + }); + + it("handles an empty string without throwing", () => { + expect(() => deterministicLabelColor("")).not.toThrow(); + expect(deterministicLabelColor("")).toMatch(/^[0-9a-f]{6}$/); + }); +}); + +// ─── main ──────────────────────────────────────────────────────────────────── + +describe("main", () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.GH_AW_CMD_PREFIX = "gh aw"; + delete process.env.GH_AW_TARGET_REPO_SLUG; + + // Default: compile succeeds and returns two labels + mockExec.getExecOutput.mockResolvedValue({ + exitCode: 0, + stdout: JSON.stringify([{ labels: ["bug", "enhancement"] }, { labels: ["bug", "docs"] }]), + stderr: "", + }); + + // Default: repo has one existing label + mockGithub.paginate.mockResolvedValue([{ name: "bug" }]); + mockGithub.rest.issues.createLabel.mockResolvedValue({}); + }); + + it("creates labels that are missing from the repository", async () => { + await main(); + + expect(mockGithub.rest.issues.createLabel).toHaveBeenCalledTimes(2); + const names = mockGithub.rest.issues.createLabel.mock.calls.map(c => c[0].name); + expect(names).toContain("enhancement"); + expect(names).toContain("docs"); + }); + + it("skips labels that already exist", async () => { + await main(); + + const names = mockGithub.rest.issues.createLabel.mock.calls.map(c => c[0].name); + expect(names).not.toContain("bug"); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("already exists: bug")); + }); + + it("deduplicates labels from multiple workflows", async () => { + await main(); + + // 'bug' appears in both workflows but should only be counted once + const allNames = mockGithub.rest.issues.createLabel.mock.calls.map(c => c[0].name); + const unique = new Set(allNames); + expect(allNames.length).toBe(unique.size); + }); + + it("uses a deterministic pastel color when creating labels", async () => { + await main(); + + for (const [args] of mockGithub.rest.issues.createLabel.mock.calls) { + expect(args.color).toMatch(/^[0-9a-f]{6}$/); + } + }); + + it("logs a summary of created and skipped labels", async () => { + await main(); + + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("created")); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("already existed")); + }); + + it("does nothing when no labels are found", async () => { + mockExec.getExecOutput.mockResolvedValue({ + exitCode: 0, + stdout: JSON.stringify([{ labels: [] }, {}]), + stderr: "", + }); + + await main(); + + expect(mockGithub.rest.issues.createLabel).not.toHaveBeenCalled(); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("No labels found")); + }); + + it("ignores non-string or empty label values", async () => { + mockExec.getExecOutput.mockResolvedValue({ + exitCode: 0, + stdout: JSON.stringify([{ labels: ["valid", 42, null, "", " ", " trimmed "] }]), + stderr: "", + }); + mockGithub.paginate.mockResolvedValue([]); + + await main(); + + const names = mockGithub.rest.issues.createLabel.mock.calls.map(c => c[0].name); + expect(names).toContain("valid"); + expect(names).toContain("trimmed"); + expect(names).not.toContain(""); + expect(names).not.toContain(" "); + }); + + it("calls setFailed when compile exits non-zero with no output", async () => { + mockExec.getExecOutput.mockResolvedValue({ exitCode: 1, stdout: "", stderr: "compile error" }); + + await main(); + + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Failed to run compile")); + }); + + it("continues processing when compile exits non-zero but still produces JSON", async () => { + mockExec.getExecOutput.mockResolvedValue({ + exitCode: 1, + stdout: JSON.stringify([{ labels: ["bug"] }]), + stderr: "some workflow had errors", + }); + mockGithub.paginate.mockResolvedValue([]); + + await main(); + + // Should proceed to create labels even though compile exited non-zero + expect(mockCore.setFailed).not.toHaveBeenCalled(); + }); + + it("calls setFailed when compile output is not valid JSON", async () => { + mockExec.getExecOutput.mockResolvedValue({ exitCode: 0, stdout: "not json", stderr: "" }); + + await main(); + + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Failed to parse compile JSON output")); + }); + + it("treats a 422 error from createLabel as already-existing (race condition)", async () => { + mockGithub.paginate.mockResolvedValue([]); + const err = Object.assign(new Error("Unprocessable Entity"), { status: 422 }); + mockGithub.rest.issues.createLabel.mockRejectedValue(err); + mockExec.getExecOutput.mockResolvedValue({ + exitCode: 0, + stdout: JSON.stringify([{ labels: ["new-label"] }]), + stderr: "", + }); + + await main(); + + expect(mockCore.setFailed).not.toHaveBeenCalled(); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("already exists (concurrent): new-label")); + }); + + it("emits a warning on non-422 createLabel errors but continues", async () => { + mockGithub.paginate.mockResolvedValue([]); + const err = Object.assign(new Error("Internal Server Error"), { status: 500 }); + mockGithub.rest.issues.createLabel.mockRejectedValueOnce(err); + mockExec.getExecOutput.mockResolvedValue({ + exitCode: 0, + stdout: JSON.stringify([{ labels: ["label-a", "label-b"] }]), + stderr: "", + }); + + await main(); + + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Failed to create label")); + expect(mockCore.setFailed).not.toHaveBeenCalled(); + }); +}); From dc43d22f96d49d6fd51cd4ae7482c99a51486eeb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 13:56:53 +0000 Subject: [PATCH 2/2] fix: correct JSDoc color range from 128-223 to 128-191 (& 0x3f gives 0-63) Agent-Logs-Url: https://github.com/github/gh-aw/sessions/9609f19b-fbea-4dda-851c-23b2f390c0af Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/create_labels.cjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/actions/setup/js/create_labels.cjs b/actions/setup/js/create_labels.cjs index 3b92c0affa5..c2de0842f20 100644 --- a/actions/setup/js/create_labels.cjs +++ b/actions/setup/js/create_labels.cjs @@ -7,7 +7,7 @@ const { resolveExecutionOwnerRepo } = require("./repo_helpers.cjs"); /** * Generate a deterministic pastel hex color string from a label name. - * Produces colors in the pastel range (128–223 per channel) for readability. + * Produces colors in the pastel range (128–191 per channel) for readability. * * @param {string} name * @returns {string} Six-character hex color (no leading #) @@ -17,7 +17,7 @@ function deterministicLabelColor(name) { for (let i = 0; i < name.length; i++) { hash = (hash * 31 + name.charCodeAt(i)) >>> 0; } - // Map to pastel range: 128–223 per channel + // Map to pastel range: 128–191 per channel const r = 128 + (hash & 0x3f); const g = 128 + ((hash >> 6) & 0x3f); const b = 128 + ((hash >> 12) & 0x3f);