Skip to content

Commit e64308f

Browse files
authored
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.
1 parent 58dc33d commit e64308f

2 files changed

Lines changed: 63 additions & 1 deletion

File tree

‎src/utils/branch-template.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,20 @@ function extractDescription(
2828
.replace(/^-|-$/g, ""); // Remove leading/trailing hyphens
2929
}
3030

31+
/**
32+
* Sanitizes a label into a git-safe branch segment. Labels are free-form and
33+
* often scoped (e.g. "area:permissions"), so characters that are invalid in a
34+
* branch name (":", "/", spaces, ...) are replaced with a hyphen rather than
35+
* dropped, keeping the label readable. Returns "" if nothing usable remains.
36+
*/
37+
function sanitizeLabel(label: string): string {
38+
return label
39+
.toLowerCase()
40+
.replace(/[^a-z0-9-]+/g, "-") // Replace runs of invalid chars with a hyphen
41+
.replace(/-+/g, "-") // Collapse multiple hyphens
42+
.replace(/^-|-$/g, ""); // Remove leading/trailing hyphens
43+
}
44+
3145
export interface BranchTemplateVariables {
3246
prefix: string;
3347
entityType: string;
@@ -78,7 +92,7 @@ export function generateBranchName(
7892
entityNumber,
7993
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")}`,
8094
sha: sha?.substring(0, 8), // First 8 characters of SHA
81-
label: label || entityType, // Fall back to entityType if no label
95+
label: (label && sanitizeLabel(label)) || entityType, // Sanitize; fall back to entityType if empty/no label
8296
description: title ? extractDescription(title) : undefined,
8397
};
8498

‎test/branch-template.test.ts‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
applyBranchTemplate,
66
generateBranchName,
77
} from "../src/utils/branch-template";
8+
import { validateBranchName } from "../src/github/operations/branch";
89

910
describe("branch template utilities", () => {
1011
describe("applyBranchTemplate", () => {
@@ -144,6 +145,53 @@ describe("branch template utilities", () => {
144145
expect(result).toBe("dev/enhancement-issue_789");
145146
});
146147

148+
it("should sanitize scoped labels that contain invalid git characters", () => {
149+
const template = "{{prefix}}{{label}}/{{entityNumber}}";
150+
const result = generateBranchName(
151+
template,
152+
"claude/",
153+
"issue",
154+
123,
155+
undefined,
156+
"area:permissions",
157+
);
158+
159+
expect(result).toBe("claude/area-permissions/123");
160+
// Regression: an unsanitized ":" here previously failed validateBranchName
161+
// and crashed the run via process.exit(1).
162+
expect(() => validateBranchName(result)).not.toThrow();
163+
});
164+
165+
it("should replace spaces in labels with hyphens", () => {
166+
const template = "{{prefix}}{{label}}-{{entityNumber}}";
167+
const result = generateBranchName(
168+
template,
169+
"fix/",
170+
"issue",
171+
456,
172+
undefined,
173+
"needs review",
174+
);
175+
176+
expect(result).toBe("fix/needs-review-456");
177+
expect(() => validateBranchName(result)).not.toThrow();
178+
});
179+
180+
it("should fall back to entityType when a label sanitizes to empty", () => {
181+
const template = "{{prefix}}{{label}}-{{entityNumber}}";
182+
const result = generateBranchName(
183+
template,
184+
"fix/",
185+
"pr",
186+
789,
187+
undefined,
188+
"🎉",
189+
);
190+
191+
expect(result).toBe("fix/pr-789");
192+
expect(() => validateBranchName(result)).not.toThrow();
193+
});
194+
147195
it("should use description in template when provided", () => {
148196
const template = "{{prefix}}{{description}}/{{entityNumber}}";
149197
const result = generateBranchName(

0 commit comments

Comments
 (0)