mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-08-22 03:18:54 +08:00
A branch_name_template that places {{description}} (or another variable)
next to a slash crashes the run when the variable resolves to an empty
string. An issue/PR title with no ASCII-alphanumeric content — emoji-only,
CJK-only, or punctuation-only — makes extractDescription() return "", so a
template like "{{prefix}}{{description}}/{{entityNumber}}" produces
"claude//123". validateBranchName rejects consecutive (and leading/trailing)
slashes, and the thrown error propagates uncaught out of setupBranch,
aborting the entire run.
Normalize the templated result before the empty-result check: collapse runs
of slashes and drop any leading/trailing slash. Single-slash and dash
separators are untouched, so existing template behavior is unchanged; a
template that collapses to empty still falls back to the default format.
This is distinct from the {{label}} sanitization tracked in #1491 (and its
open PRs), which deliberately leave {{description}} alone — so this path
remained broken. Fixes the whole empty-segment class regardless of variable.
Adds regression tests for emoji-only and CJK-only titles, a trailing empty
segment, and a direct validateBranchName assertion proving the run no longer
aborts.
359 lines
11 KiB
TypeScript
359 lines
11 KiB
TypeScript
#!/usr/bin/env bun
|
|
|
|
import { describe, it, expect } from "bun:test";
|
|
import {
|
|
applyBranchTemplate,
|
|
generateBranchName,
|
|
} from "../src/utils/branch-template";
|
|
import { validateBranchName } from "../src/github/operations/branch";
|
|
|
|
describe("branch template utilities", () => {
|
|
describe("applyBranchTemplate", () => {
|
|
it("should replace all template variables", () => {
|
|
const template =
|
|
"{{prefix}}{{entityType}}-{{entityNumber}}-{{timestamp}}";
|
|
const variables = {
|
|
prefix: "feat/",
|
|
entityType: "issue",
|
|
entityNumber: 123,
|
|
timestamp: "20240301-1430",
|
|
sha: "abcd1234",
|
|
};
|
|
|
|
const result = applyBranchTemplate(template, variables);
|
|
expect(result).toBe("feat/issue-123-20240301-1430");
|
|
});
|
|
|
|
it("should handle custom templates with multiple variables", () => {
|
|
const template =
|
|
"{{prefix}}fix/{{entityType}}_{{entityNumber}}_{{timestamp}}_{{sha}}";
|
|
const variables = {
|
|
prefix: "claude-",
|
|
entityType: "pr",
|
|
entityNumber: 456,
|
|
timestamp: "20240301-1430",
|
|
sha: "abcd1234",
|
|
};
|
|
|
|
const result = applyBranchTemplate(template, variables);
|
|
expect(result).toBe("claude-fix/pr_456_20240301-1430_abcd1234");
|
|
});
|
|
|
|
it("should handle templates with missing variables gracefully", () => {
|
|
const template = "{{prefix}}{{entityType}}-{{missing}}-{{entityNumber}}";
|
|
const variables = {
|
|
prefix: "feat/",
|
|
entityType: "issue",
|
|
entityNumber: 123,
|
|
timestamp: "20240301-1430",
|
|
};
|
|
|
|
const result = applyBranchTemplate(template, variables);
|
|
expect(result).toBe("feat/issue-{{missing}}-123");
|
|
});
|
|
});
|
|
|
|
describe("generateBranchName", () => {
|
|
it("should use custom template when provided", () => {
|
|
const template = "{{prefix}}custom-{{entityType}}_{{entityNumber}}";
|
|
const result = generateBranchName(template, "feature/", "issue", 123);
|
|
|
|
expect(result).toBe("feature/custom-issue_123");
|
|
});
|
|
|
|
it("should use default format when template is empty", () => {
|
|
const result = generateBranchName("", "claude/", "issue", 123);
|
|
|
|
expect(result).toMatch(/^claude\/issue-123-\d{8}-\d{4}$/);
|
|
});
|
|
|
|
it("should use default format when template is undefined", () => {
|
|
const result = generateBranchName(undefined, "claude/", "pr", 456);
|
|
|
|
expect(result).toMatch(/^claude\/pr-456-\d{8}-\d{4}$/);
|
|
});
|
|
|
|
it("should preserve custom template formatting (no automatic lowercase/truncation)", () => {
|
|
const template = "{{prefix}}UPPERCASE_Branch-Name_{{entityNumber}}";
|
|
const result = generateBranchName(template, "Feature/", "issue", 123);
|
|
|
|
expect(result).toBe("Feature/UPPERCASE_Branch-Name_123");
|
|
});
|
|
|
|
it("should not truncate custom template results", () => {
|
|
const template =
|
|
"{{prefix}}very-long-branch-name-that-exceeds-the-maximum-allowed-length-{{entityNumber}}";
|
|
const result = generateBranchName(template, "feature/", "issue", 123);
|
|
|
|
expect(result).toBe(
|
|
"feature/very-long-branch-name-that-exceeds-the-maximum-allowed-length-123",
|
|
);
|
|
});
|
|
|
|
it("should apply Kubernetes-compatible transformations to default template only", () => {
|
|
const result = generateBranchName(undefined, "Feature/", "issue", 123);
|
|
|
|
expect(result).toMatch(/^feature\/issue-123-\d{8}-\d{4}$/);
|
|
expect(result.length).toBeLessThanOrEqual(50);
|
|
});
|
|
|
|
it("should handle SHA in template", () => {
|
|
const template = "{{prefix}}{{entityType}}-{{entityNumber}}-{{sha}}";
|
|
const result = generateBranchName(
|
|
template,
|
|
"fix/",
|
|
"pr",
|
|
789,
|
|
"abcdef123456",
|
|
);
|
|
|
|
expect(result).toBe("fix/pr-789-abcdef12");
|
|
});
|
|
|
|
it("should use label in template when provided", () => {
|
|
const template = "{{prefix}}{{label}}/{{entityNumber}}";
|
|
const result = generateBranchName(
|
|
template,
|
|
"feature/",
|
|
"issue",
|
|
123,
|
|
undefined,
|
|
"bug",
|
|
);
|
|
|
|
expect(result).toBe("feature/bug/123");
|
|
});
|
|
|
|
it("should fallback to entityType when label template is used but no label provided", () => {
|
|
const template = "{{prefix}}{{label}}-{{entityNumber}}";
|
|
const result = generateBranchName(template, "fix/", "pr", 456);
|
|
|
|
expect(result).toBe("fix/pr-456");
|
|
});
|
|
|
|
it("should handle template with both label and entityType", () => {
|
|
const template = "{{prefix}}{{label}}-{{entityType}}_{{entityNumber}}";
|
|
const result = generateBranchName(
|
|
template,
|
|
"dev/",
|
|
"issue",
|
|
789,
|
|
undefined,
|
|
"enhancement",
|
|
);
|
|
|
|
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(
|
|
template,
|
|
"feature/",
|
|
"issue",
|
|
123,
|
|
undefined,
|
|
undefined,
|
|
"Fix login bug with OAuth",
|
|
);
|
|
|
|
expect(result).toBe("feature/fix-login-bug-with-oauth/123");
|
|
});
|
|
|
|
it("should handle template with multiple variables including description", () => {
|
|
const template =
|
|
"{{prefix}}{{label}}/{{description}}-{{entityType}}_{{entityNumber}}";
|
|
const result = generateBranchName(
|
|
template,
|
|
"dev/",
|
|
"issue",
|
|
456,
|
|
undefined,
|
|
"bug",
|
|
"User authentication fails completely",
|
|
);
|
|
|
|
expect(result).toBe(
|
|
"dev/bug/user-authentication-fails-completely-issue_456",
|
|
);
|
|
});
|
|
|
|
it("should handle description with special characters in template", () => {
|
|
const template = "{{prefix}}{{description}}-{{entityNumber}}";
|
|
const result = generateBranchName(
|
|
template,
|
|
"fix/",
|
|
"pr",
|
|
789,
|
|
undefined,
|
|
undefined,
|
|
"Add: User Registration & Email Validation",
|
|
);
|
|
|
|
expect(result).toBe("fix/add-user-registration-email-789");
|
|
});
|
|
|
|
it("should truncate descriptions to exactly 5 words", () => {
|
|
const result = generateBranchName(
|
|
"{{prefix}}{{description}}/{{entityNumber}}",
|
|
"feature/",
|
|
"issue",
|
|
999,
|
|
undefined,
|
|
undefined,
|
|
"This is a very long title with many more than five words in it",
|
|
);
|
|
expect(result).toBe("feature/this-is-a-very-long/999");
|
|
});
|
|
|
|
it("should handle empty description in template", () => {
|
|
const template = "{{prefix}}{{description}}-{{entityNumber}}";
|
|
const result = generateBranchName(
|
|
template,
|
|
"test/",
|
|
"issue",
|
|
101,
|
|
undefined,
|
|
undefined,
|
|
"",
|
|
);
|
|
|
|
expect(result).toBe("test/-101");
|
|
});
|
|
|
|
it("should fallback to default format when template produces empty result", () => {
|
|
const template = "{{description}}"; // Will be empty if no title provided
|
|
const result = generateBranchName(template, "claude/", "issue", 123);
|
|
|
|
expect(result).toMatch(/^claude\/issue-123-\d{8}-\d{4}$/);
|
|
expect(result.length).toBeLessThanOrEqual(50);
|
|
});
|
|
|
|
it("should fallback to default format when template produces only whitespace", () => {
|
|
const template = " {{description}} "; // Will be " " if description is empty
|
|
const result = generateBranchName(
|
|
template,
|
|
"fix/",
|
|
"pr",
|
|
456,
|
|
undefined,
|
|
undefined,
|
|
"",
|
|
);
|
|
|
|
expect(result).toMatch(/^fix\/pr-456-\d{8}-\d{4}$/);
|
|
expect(result.length).toBeLessThanOrEqual(50);
|
|
});
|
|
|
|
// Regression: a title with no ASCII-alphanumeric content makes
|
|
// {{description}} sanitize to an empty string. Around a slash separator this
|
|
// previously produced "claude//123", which validateBranchName rejects
|
|
// ("cannot contain consecutive slashes"), aborting the entire run.
|
|
it("should collapse the double slash from an empty description (emoji-only title)", () => {
|
|
const template = "{{prefix}}{{description}}/{{entityNumber}}";
|
|
const result = generateBranchName(
|
|
template,
|
|
"claude/",
|
|
"issue",
|
|
123,
|
|
undefined,
|
|
undefined,
|
|
"🎉🎉🎉",
|
|
);
|
|
|
|
expect(result).toBe("claude/123");
|
|
});
|
|
|
|
it("should collapse the double slash for a CJK-only title", () => {
|
|
const template = "{{prefix}}{{description}}/{{entityNumber}}";
|
|
const result = generateBranchName(
|
|
template,
|
|
"claude/",
|
|
"issue",
|
|
123,
|
|
undefined,
|
|
undefined,
|
|
"日本語のタイトル",
|
|
);
|
|
|
|
expect(result).toBe("claude/123");
|
|
});
|
|
|
|
it("should drop a trailing slash left by an empty trailing segment", () => {
|
|
const template = "{{prefix}}{{entityNumber}}/{{description}}";
|
|
const result = generateBranchName(
|
|
template,
|
|
"claude/",
|
|
"issue",
|
|
123,
|
|
undefined,
|
|
undefined,
|
|
"!!! ???",
|
|
);
|
|
|
|
expect(result).toBe("claude/123");
|
|
});
|
|
|
|
it("should produce a name that passes validateBranchName when a segment is empty", () => {
|
|
const result = generateBranchName(
|
|
"{{prefix}}{{description}}/{{entityNumber}}",
|
|
"claude/",
|
|
"issue",
|
|
123,
|
|
undefined,
|
|
undefined,
|
|
"🎉",
|
|
);
|
|
|
|
expect(() => validateBranchName(result)).not.toThrow();
|
|
});
|
|
});
|
|
});
|