From e64308ff97c392041c0ab0b92954a08e74dda7ac Mon Sep 17 00:00:00 2001 From: Paarth <103969158+pa-arth@users.noreply.github.com> Date: Wed, 15 Jul 2026 22:21:02 -0500 Subject: [PATCH] fix: sanitize {{label}} in branch name templates (#1492) A scoped label like area:permissions was substituted into the branch name verbatim, producing a ":" that validateBranchName rejects. Because the branch setup block catches that error and calls process.exit(1), the whole run died. {{description}} was already sanitized via extractDescription; {{label}} was the only free-text variable that skipped it. Add a sanitizeLabel helper (replaces invalid-char runs with a hyphen so scoped labels stay readable) and apply it before substitution, falling back to entityType when a label sanitizes to empty. Adds regression tests that also assert the result passes validateBranchName. --- src/utils/branch-template.ts | 16 +++++++++++- test/branch-template.test.ts | 48 ++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/utils/branch-template.ts b/src/utils/branch-template.ts index 0056dd66..fecfacd2 100644 --- a/src/utils/branch-template.ts +++ b/src/utils/branch-template.ts @@ -28,6 +28,20 @@ function extractDescription( .replace(/^-|-$/g, ""); // Remove leading/trailing hyphens } +/** + * Sanitizes a label into a git-safe branch segment. Labels are free-form and + * often scoped (e.g. "area:permissions"), so characters that are invalid in a + * branch name (":", "/", spaces, ...) are replaced with a hyphen rather than + * dropped, keeping the label readable. Returns "" if nothing usable remains. + */ +function sanitizeLabel(label: string): string { + return label + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") // Replace runs of invalid chars with a hyphen + .replace(/-+/g, "-") // Collapse multiple hyphens + .replace(/^-|-$/g, ""); // Remove leading/trailing hyphens +} + export interface BranchTemplateVariables { prefix: string; entityType: string; @@ -78,7 +92,7 @@ export function generateBranchName( entityNumber, timestamp: `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}-${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}`, sha: sha?.substring(0, 8), // First 8 characters of SHA - label: label || entityType, // Fall back to entityType if no label + label: (label && sanitizeLabel(label)) || entityType, // Sanitize; fall back to entityType if empty/no label description: title ? extractDescription(title) : undefined, }; diff --git a/test/branch-template.test.ts b/test/branch-template.test.ts index 62ab6c1c..418eaaea 100644 --- a/test/branch-template.test.ts +++ b/test/branch-template.test.ts @@ -5,6 +5,7 @@ import { applyBranchTemplate, generateBranchName, } from "../src/utils/branch-template"; +import { validateBranchName } from "../src/github/operations/branch"; describe("branch template utilities", () => { describe("applyBranchTemplate", () => { @@ -144,6 +145,53 @@ describe("branch template utilities", () => { expect(result).toBe("dev/enhancement-issue_789"); }); + it("should sanitize scoped labels that contain invalid git characters", () => { + const template = "{{prefix}}{{label}}/{{entityNumber}}"; + const result = generateBranchName( + template, + "claude/", + "issue", + 123, + undefined, + "area:permissions", + ); + + expect(result).toBe("claude/area-permissions/123"); + // Regression: an unsanitized ":" here previously failed validateBranchName + // and crashed the run via process.exit(1). + expect(() => validateBranchName(result)).not.toThrow(); + }); + + it("should replace spaces in labels with hyphens", () => { + const template = "{{prefix}}{{label}}-{{entityNumber}}"; + const result = generateBranchName( + template, + "fix/", + "issue", + 456, + undefined, + "needs review", + ); + + expect(result).toBe("fix/needs-review-456"); + expect(() => validateBranchName(result)).not.toThrow(); + }); + + it("should fall back to entityType when a label sanitizes to empty", () => { + const template = "{{prefix}}{{label}}-{{entityNumber}}"; + const result = generateBranchName( + template, + "fix/", + "pr", + 789, + undefined, + "🎉", + ); + + expect(result).toBe("fix/pr-789"); + expect(() => validateBranchName(result)).not.toThrow(); + }); + it("should use description in template when provided", () => { const template = "{{prefix}}{{description}}/{{entityNumber}}"; const result = generateBranchName(