From 726d5808b55a5d1d06b14a63a6016189fbe69c95 Mon Sep 17 00:00:00 2001 From: Cole Davis Date: Fri, 12 Sep 2025 14:51:33 -0400 Subject: [PATCH 01/16] Add branch-name-template config option --- action.yml | 5 +++++ src/github/context.ts | 2 ++ src/github/operations/branch.ts | 2 +- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/action.yml b/action.yml index c36e8bb6f..b3d4bed3f 100644 --- a/action.yml +++ b/action.yml @@ -23,6 +23,10 @@ inputs: description: "The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format)" required: false default: "claude/" + branch_name_template: + description: "Template for branch naming. Available variables: {{prefix}}, {{entityType}}, {{entityNumber}}, {{timestamp}}, {{year}}, {{month}}, {{day}}, {{hour}}, {{minute}}, {{sha}}. Default: '{{prefix}}{{entityType}}-{{entityNumber}}-{{timestamp}}'" + required: false + default: "" allowed_bots: description: "Comma-separated list of allowed bot usernames, or '*' to allow all bots. Empty string (default) allows no bots." required: false @@ -150,6 +154,7 @@ runs: LABEL_TRIGGER: ${{ inputs.label_trigger }} BASE_BRANCH: ${{ inputs.base_branch }} BRANCH_PREFIX: ${{ inputs.branch_prefix }} + BRANCH_NAME_TEMPLATE: ${{ inputs.branch_name_template }} OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }} ALLOWED_BOTS: ${{ inputs.allowed_bots }} ALLOWED_NON_WRITE_USERS: ${{ inputs.allowed_non_write_users }} diff --git a/src/github/context.ts b/src/github/context.ts index 56a9233dc..7e1d3fd12 100644 --- a/src/github/context.ts +++ b/src/github/context.ts @@ -88,6 +88,7 @@ type BaseContext = { labelTrigger: string; baseBranch?: string; branchPrefix: string; + branchNameTemplate?: string; useStickyComment: boolean; useCommitSigning: boolean; botId: string; @@ -143,6 +144,7 @@ export function parseGitHubContext(): GitHubContext { labelTrigger: process.env.LABEL_TRIGGER ?? "", baseBranch: process.env.BASE_BRANCH, branchPrefix: process.env.BRANCH_PREFIX ?? "claude/", + branchNameTemplate: process.env.BRANCH_NAME_TEMPLATE, useStickyComment: process.env.USE_STICKY_COMMENT === "true", useCommitSigning: process.env.USE_COMMIT_SIGNING === "true", botId: process.env.BOT_ID ?? String(CLAUDE_APP_BOT_ID), diff --git a/src/github/operations/branch.ts b/src/github/operations/branch.ts index 42e78298e..994c695cf 100644 --- a/src/github/operations/branch.ts +++ b/src/github/operations/branch.ts @@ -26,7 +26,7 @@ export async function setupBranch( ): Promise { const { owner, repo } = context.repository; const entityNumber = context.entityNumber; - const { baseBranch, branchPrefix } = context.inputs; + const { baseBranch, branchPrefix, branchNameTemplate } = context.inputs; const isPR = context.isPR; if (isPR) { From 4991c0c94737fee7f999fafc345f150b1dab0093 Mon Sep 17 00:00:00 2001 From: Cole Davis Date: Fri, 12 Sep 2025 14:52:58 -0400 Subject: [PATCH 02/16] Logging --- src/github/operations/branch.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/github/operations/branch.ts b/src/github/operations/branch.ts index 994c695cf..3215d9c7d 100644 --- a/src/github/operations/branch.ts +++ b/src/github/operations/branch.ts @@ -97,6 +97,7 @@ export async function setupBranch( // - No underscores // - Max 50 chars (to allow for prefixes) const branchName = `${branchPrefix}${entityType}-${entityNumber}-${timestamp}`; + console.log(`Provided branch name template: ${branchNameTemplate}`); const newBranch = branchName.toLowerCase().substring(0, 50); try { From b5de2b991333c667ef2ca7dfc73a5980de07e8a3 Mon Sep 17 00:00:00 2001 From: Cole Davis Date: Fri, 12 Sep 2025 14:55:21 -0400 Subject: [PATCH 03/16] Use branch name template --- src/github/operations/branch.ts | 28 +++--- src/utils/branch-template.ts | 99 ++++++++++++++++++++ test/branch-template.test.ts | 157 ++++++++++++++++++++++++++++++++ 3 files changed, 270 insertions(+), 14 deletions(-) create mode 100644 src/utils/branch-template.ts create mode 100644 test/branch-template.test.ts diff --git a/src/github/operations/branch.ts b/src/github/operations/branch.ts index 3215d9c7d..3b7a038a8 100644 --- a/src/github/operations/branch.ts +++ b/src/github/operations/branch.ts @@ -12,6 +12,7 @@ import type { ParsedGitHubContext } from "../context"; import type { GitHubPullRequest } from "../types"; import type { Octokits } from "../api/client"; import type { FetchDataResult } from "../data/fetcher"; +import { generateBranchName } from "../../utils/branch-template"; export type BranchInfo = { baseBranch: string; @@ -87,18 +88,8 @@ export async function setupBranch( // Generate branch name for either an issue or closed/merged PR const entityType = isPR ? "pr" : "issue"; - // Create Kubernetes-compatible timestamp: lowercase, hyphens only, shorter format - const now = new Date(); - const 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")}`; - - // Ensure branch name is Kubernetes-compatible: - // - Lowercase only - // - Alphanumeric with hyphens - // - No underscores - // - Max 50 chars (to allow for prefixes) - const branchName = `${branchPrefix}${entityType}-${entityNumber}-${timestamp}`; - console.log(`Provided branch name template: ${branchNameTemplate}`); - const newBranch = branchName.toLowerCase().substring(0, 50); + // Get the SHA of the source branch to use in template + let sourceSHA: string | undefined; try { // Get the SHA of the source branch to verify it exists @@ -108,8 +99,17 @@ export async function setupBranch( ref: `heads/${sourceBranch}`, }); - const currentSHA = sourceBranchRef.data.object.sha; - console.log(`Source branch SHA: ${currentSHA}`); + sourceSHA = sourceBranchRef.data.object.sha; + console.log(`Source branch SHA: ${sourceSHA}`); + + // Generate branch name using template or default format + const newBranch = generateBranchName( + branchNameTemplate, + branchPrefix, + entityType, + entityNumber, + sourceSHA, + ); // For commit signing, defer branch creation to the file ops server if (context.inputs.useCommitSigning) { diff --git a/src/utils/branch-template.ts b/src/utils/branch-template.ts new file mode 100644 index 000000000..8cc3344b9 --- /dev/null +++ b/src/utils/branch-template.ts @@ -0,0 +1,99 @@ +#!/usr/bin/env bun + +/** + * Branch name template parsing and variable substitution utilities + */ + +export interface BranchTemplateVariables { + prefix: string; + entityType: string; + entityNumber: number; + timestamp: string; + year: string; + month: string; + day: string; + hour: string; + minute: string; + sha?: string; +} + +/** + * Replaces template variables in a branch name template + * Template format: {{variableName}} + */ +export function applyBranchTemplate( + template: string, + variables: BranchTemplateVariables, +): string { + let result = template; + + // Replace each variable + Object.entries(variables).forEach(([key, value]) => { + if (value !== undefined) { + const placeholder = `{{${key}}}`; + result = result.replaceAll(placeholder, String(value)); + } + }); + + return result; +} + +/** + * Generates template variables from current context + */ +export function createBranchTemplateVariables( + branchPrefix: string, + entityType: string, + entityNumber: number, + sha?: string, +): BranchTemplateVariables { + const now = new Date(); + + return { + prefix: branchPrefix, + entityType, + 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")}`, + year: String(now.getFullYear()), + month: String(now.getMonth() + 1).padStart(2, "0"), + day: String(now.getDate()).padStart(2, "0"), + hour: String(now.getHours()).padStart(2, "0"), + minute: String(now.getMinutes()).padStart(2, "0"), + sha: sha?.substring(0, 8), // First 8 characters of SHA + }; +} + +/** + * Generates a branch name using template or falls back to default format + */ +export function generateBranchName( + template: string | undefined, + branchPrefix: string, + entityType: string, + entityNumber: number, + sha?: string, +): string { + const variables = createBranchTemplateVariables( + branchPrefix, + entityType, + entityNumber, + sha, + ); + + let branchName: string; + + if (template && template.trim()) { + // Use custom template + branchName = applyBranchTemplate(template, variables); + } else { + // Use default format (backward compatibility) + branchName = `${branchPrefix}${entityType}-${entityNumber}-${variables.timestamp}`; + } + + // Ensure branch name is Kubernetes-compatible: + // - Lowercase only + // - Alphanumeric with hyphens + // - No underscores + // - Max 50 chars (to allow for prefixes) + return branchName.toLowerCase().substring(0, 50); +} diff --git a/test/branch-template.test.ts b/test/branch-template.test.ts new file mode 100644 index 000000000..6d47f2cda --- /dev/null +++ b/test/branch-template.test.ts @@ -0,0 +1,157 @@ +#!/usr/bin/env bun + +import { describe, it, expect } from "bun:test"; +import { + applyBranchTemplate, + createBranchTemplateVariables, + generateBranchName, +} from "../src/utils/branch-template"; + +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", + year: "2024", + month: "03", + day: "01", + hour: "14", + minute: "30", + 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}}_{{year}}{{month}}{{day}}_{{sha}}"; + const variables = { + prefix: "claude-", + entityType: "pr", + entityNumber: 456, + timestamp: "20240301-1430", + year: "2024", + month: "03", + day: "01", + hour: "14", + minute: "30", + sha: "abcd1234", + }; + + const result = applyBranchTemplate(template, variables); + expect(result).toBe("claude-fix/pr_456_20240301_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", + year: "2024", + month: "03", + day: "01", + hour: "14", + minute: "30", + }; + + const result = applyBranchTemplate(template, variables); + expect(result).toBe("feat/issue-{{missing}}-123"); + }); + }); + + describe("createBranchTemplateVariables", () => { + it("should create all required variables", () => { + const result = createBranchTemplateVariables( + "claude/", + "issue", + 123, + "abcdef123456", + ); + + expect(result.prefix).toBe("claude/"); + expect(result.entityType).toBe("issue"); + expect(result.entityNumber).toBe(123); + expect(result.sha).toBe("abcdef12"); + expect(result.timestamp).toMatch(/^\d{8}-\d{4}$/); + expect(result.year).toMatch(/^\d{4}$/); + expect(result.month).toMatch(/^\d{2}$/); + expect(result.day).toMatch(/^\d{2}$/); + expect(result.hour).toMatch(/^\d{2}$/); + expect(result.minute).toMatch(/^\d{2}$/); + }); + + it("should handle SHA truncation", () => { + const result = createBranchTemplateVariables( + "test/", + "pr", + 456, + "abcdef123456789", + ); + expect(result.sha).toBe("abcdef12"); + }); + + it("should handle missing SHA", () => { + const result = createBranchTemplateVariables("test/", "pr", 456); + expect(result.sha).toBeUndefined(); + }); + }); + + 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 apply Kubernetes-compatible transformations", () => { + const template = "{{prefix}}UPPERCASE_Branch-Name_{{entityNumber}}"; + const result = generateBranchName(template, "Feature/", "issue", 123); + + expect(result).toBe("feature/uppercase_branch-name_123"); + }); + + it("should truncate long branch names to 50 characters", () => { + const template = + "{{prefix}}very-long-branch-name-that-exceeds-the-maximum-allowed-length-{{entityNumber}}"; + const result = generateBranchName(template, "feature/", "issue", 123); + + expect(result.length).toBe(50); + expect(result).toBe("feature/very-long-branch-name-that-exceeds-the-max"); + }); + + 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"); + }); + }); +}); From 9c4bc5dc3524f872d535f83937f3c2ac89b55557 Mon Sep 17 00:00:00 2001 From: Cole Davis Date: Fri, 12 Sep 2025 15:21:34 -0400 Subject: [PATCH 04/16] Add label to template variables --- action.yml | 2 +- src/github/api/queries/github.ts | 10 +++++ src/github/operations/branch.ts | 12 ++++++ src/github/types.ts | 10 +++++ src/utils/branch-template.ts | 5 +++ test/branch-template.test.ts | 63 ++++++++++++++++++++++++++++++++ test/create-prompt.test.ts | 2 + test/data-formatter.test.ts | 6 +++ 8 files changed, 109 insertions(+), 1 deletion(-) diff --git a/action.yml b/action.yml index b3d4bed3f..f49eeeb4b 100644 --- a/action.yml +++ b/action.yml @@ -24,7 +24,7 @@ inputs: required: false default: "claude/" branch_name_template: - description: "Template for branch naming. Available variables: {{prefix}}, {{entityType}}, {{entityNumber}}, {{timestamp}}, {{year}}, {{month}}, {{day}}, {{hour}}, {{minute}}, {{sha}}. Default: '{{prefix}}{{entityType}}-{{entityNumber}}-{{timestamp}}'" + description: "Template for branch naming. Available variables: {{prefix}}, {{entityType}}, {{entityNumber}}, {{timestamp}}, {{year}}, {{month}}, {{day}}, {{hour}}, {{minute}}, {{sha}}, {{label}}. The {{label}} variable uses the first label from the issue/PR, falling back to {{entityType}} if no labels exist. Default: '{{prefix}}{{entityType}}-{{entityNumber}}-{{timestamp}}'" required: false default: "" allowed_bots: diff --git a/src/github/api/queries/github.ts b/src/github/api/queries/github.ts index 2341a5518..65fed0dfc 100644 --- a/src/github/api/queries/github.ts +++ b/src/github/api/queries/github.ts @@ -16,6 +16,11 @@ export const PR_QUERY = ` additions deletions state + labels(first: 10) { + nodes { + name + } + } commits(first: 100) { totalCount nodes { @@ -97,6 +102,11 @@ export const ISSUE_QUERY = ` } createdAt state + labels(first: 10) { + nodes { + name + } + } comments(first: 100) { nodes { id diff --git a/src/github/operations/branch.ts b/src/github/operations/branch.ts index 3b7a038a8..9f428c87f 100644 --- a/src/github/operations/branch.ts +++ b/src/github/operations/branch.ts @@ -14,6 +14,14 @@ import type { Octokits } from "../api/client"; import type { FetchDataResult } from "../data/fetcher"; import { generateBranchName } from "../../utils/branch-template"; +/** + * Extracts the first label from GitHub data, or returns undefined if no labels exist + */ +function extractFirstLabel(githubData: FetchDataResult): string | undefined { + const labels = githubData.contextData.labels?.nodes; + return labels && labels.length > 0 ? labels[0]?.name : undefined; +} + export type BranchInfo = { baseBranch: string; claudeBranch?: string; @@ -102,6 +110,9 @@ export async function setupBranch( sourceSHA = sourceBranchRef.data.object.sha; console.log(`Source branch SHA: ${sourceSHA}`); + // Extract first label from GitHub data + const firstLabel = extractFirstLabel(githubData); + // Generate branch name using template or default format const newBranch = generateBranchName( branchNameTemplate, @@ -109,6 +120,7 @@ export async function setupBranch( entityType, entityNumber, sourceSHA, + firstLabel, ); // For commit signing, defer branch creation to the file ops server diff --git a/src/github/types.ts b/src/github/types.ts index 41e08969f..0b66875f6 100644 --- a/src/github/types.ts +++ b/src/github/types.ts @@ -61,6 +61,11 @@ export type GitHubPullRequest = { additions: number; deletions: number; state: string; + labels: { + nodes: Array<{ + name: string; + }>; + }; commits: { totalCount: number; nodes: Array<{ @@ -84,6 +89,11 @@ export type GitHubIssue = { author: GitHubAuthor; createdAt: string; state: string; + labels: { + nodes: Array<{ + name: string; + }>; + }; comments: { nodes: GitHubComment[]; }; diff --git a/src/utils/branch-template.ts b/src/utils/branch-template.ts index 8cc3344b9..956dbd153 100644 --- a/src/utils/branch-template.ts +++ b/src/utils/branch-template.ts @@ -15,6 +15,7 @@ export interface BranchTemplateVariables { hour: string; minute: string; sha?: string; + label?: string; } /** @@ -46,6 +47,7 @@ export function createBranchTemplateVariables( entityType: string, entityNumber: number, sha?: string, + label?: string, ): BranchTemplateVariables { const now = new Date(); @@ -60,6 +62,7 @@ export function createBranchTemplateVariables( hour: String(now.getHours()).padStart(2, "0"), minute: 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 }; } @@ -72,12 +75,14 @@ export function generateBranchName( entityType: string, entityNumber: number, sha?: string, + label?: string, ): string { const variables = createBranchTemplateVariables( branchPrefix, entityType, entityNumber, sha, + label, ); let branchName: string; diff --git a/test/branch-template.test.ts b/test/branch-template.test.ts index 6d47f2cda..762f06fb9 100644 --- a/test/branch-template.test.ts +++ b/test/branch-template.test.ts @@ -81,6 +81,7 @@ describe("branch template utilities", () => { expect(result.entityType).toBe("issue"); expect(result.entityNumber).toBe(123); expect(result.sha).toBe("abcdef12"); + expect(result.label).toBe("issue"); // fallback to entityType expect(result.timestamp).toMatch(/^\d{8}-\d{4}$/); expect(result.year).toMatch(/^\d{4}$/); expect(result.month).toMatch(/^\d{2}$/); @@ -103,6 +104,33 @@ describe("branch template utilities", () => { const result = createBranchTemplateVariables("test/", "pr", 456); expect(result.sha).toBeUndefined(); }); + + it("should use provided label when available", () => { + const result = createBranchTemplateVariables( + "test/", + "issue", + 123, + undefined, + "bug", + ); + expect(result.label).toBe("bug"); + }); + + it("should fallback to entityType when label is not provided", () => { + const result = createBranchTemplateVariables("test/", "pr", 456); + expect(result.label).toBe("pr"); + }); + + it("should fallback to entityType when label is empty string", () => { + const result = createBranchTemplateVariables( + "test/", + "issue", + 789, + undefined, + "", + ); + expect(result.label).toBe("issue"); + }); }); describe("generateBranchName", () => { @@ -153,5 +181,40 @@ describe("branch template utilities", () => { 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"); + }); }); }); diff --git a/test/create-prompt.test.ts b/test/create-prompt.test.ts index 06c46bbfc..905a6b4c6 100644 --- a/test/create-prompt.test.ts +++ b/test/create-prompt.test.ts @@ -61,6 +61,7 @@ describe("generatePrompt", () => { body: "This is a test PR", author: { login: "testuser" }, state: "OPEN", + labels: { nodes: [] }, createdAt: "2023-01-01T00:00:00Z", additions: 15, deletions: 5, @@ -475,6 +476,7 @@ describe("generatePrompt", () => { body: "The login form is not working", author: { login: "testuser" }, state: "OPEN", + labels: { nodes: [] }, createdAt: "2023-01-01T00:00:00Z", comments: { nodes: [], diff --git a/test/data-formatter.test.ts b/test/data-formatter.test.ts index 7ac455c47..4c6b150dd 100644 --- a/test/data-formatter.test.ts +++ b/test/data-formatter.test.ts @@ -28,6 +28,9 @@ describe("formatContext", () => { additions: 50, deletions: 30, state: "OPEN", + labels: { + nodes: [], + }, commits: { totalCount: 3, nodes: [], @@ -63,6 +66,9 @@ Changed Files: 2 files`, author: { login: "test-user" }, createdAt: "2023-01-01T00:00:00Z", state: "OPEN", + labels: { + nodes: [], + }, comments: { nodes: [], }, From 40c2c1e7b4821ef3dde952794c4dba2d23495a20 Mon Sep 17 00:00:00 2001 From: Cole Davis Date: Fri, 12 Sep 2025 18:53:08 -0400 Subject: [PATCH 05/16] Add description template variable --- action.yml | 2 +- src/github/operations/branch.ts | 4 + src/utils/branch-template.ts | 24 ++++++ test/branch-template.test.ts | 138 ++++++++++++++++++++++++++++++++ 4 files changed, 167 insertions(+), 1 deletion(-) diff --git a/action.yml b/action.yml index f49eeeb4b..26b187e39 100644 --- a/action.yml +++ b/action.yml @@ -24,7 +24,7 @@ inputs: required: false default: "claude/" branch_name_template: - description: "Template for branch naming. Available variables: {{prefix}}, {{entityType}}, {{entityNumber}}, {{timestamp}}, {{year}}, {{month}}, {{day}}, {{hour}}, {{minute}}, {{sha}}, {{label}}. The {{label}} variable uses the first label from the issue/PR, falling back to {{entityType}} if no labels exist. Default: '{{prefix}}{{entityType}}-{{entityNumber}}-{{timestamp}}'" + description: "Template for branch naming. Available variables: {{prefix}}, {{entityType}}, {{entityNumber}}, {{timestamp}}, {{year}}, {{month}}, {{day}}, {{hour}}, {{minute}}, {{sha}}, {{label}}, {{description}}. The {{label}} variable uses the first label from the issue/PR, falling back to {{entityType}} if no labels exist. The {{description}} variable uses the first 3 words of the issue/PR title converted to kebab-case. Default: '{{prefix}}{{entityType}}-{{entityNumber}}-{{timestamp}}'" required: false default: "" allowed_bots: diff --git a/src/github/operations/branch.ts b/src/github/operations/branch.ts index 9f428c87f..fc3f5b7f1 100644 --- a/src/github/operations/branch.ts +++ b/src/github/operations/branch.ts @@ -113,6 +113,9 @@ export async function setupBranch( // Extract first label from GitHub data const firstLabel = extractFirstLabel(githubData); + // Extract title from GitHub data + const title = githubData.contextData.title; + // Generate branch name using template or default format const newBranch = generateBranchName( branchNameTemplate, @@ -121,6 +124,7 @@ export async function setupBranch( entityNumber, sourceSHA, firstLabel, + title, ); // For commit signing, defer branch creation to the file ops server diff --git a/src/utils/branch-template.ts b/src/utils/branch-template.ts index 956dbd153..5839c92a9 100644 --- a/src/utils/branch-template.ts +++ b/src/utils/branch-template.ts @@ -4,6 +4,25 @@ * Branch name template parsing and variable substitution utilities */ +/** + * Extracts the first three words from a title and converts them to kebab-case + */ +function extractDescription(title: string): string { + if (!title || title.trim() === "") { + return ""; + } + + return title + .trim() // Remove leading/trailing whitespace + .split(/\s+/) // Split on whitespace + .slice(0, 3) // Take first 3 words + .join("-") // Join with hyphens + .toLowerCase() // Convert to lowercase + .replace(/[^a-z0-9-]/g, "") // Remove non-alphanumeric except hyphens + .replace(/-+/g, "-") // Replace multiple hyphens with single + .replace(/^-|-$/g, ""); // Remove leading/trailing hyphens +} + export interface BranchTemplateVariables { prefix: string; entityType: string; @@ -16,6 +35,7 @@ export interface BranchTemplateVariables { minute: string; sha?: string; label?: string; + description?: string; } /** @@ -48,6 +68,7 @@ export function createBranchTemplateVariables( entityNumber: number, sha?: string, label?: string, + title?: string, ): BranchTemplateVariables { const now = new Date(); @@ -63,6 +84,7 @@ export function createBranchTemplateVariables( minute: 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 + description: title !== undefined ? extractDescription(title) : undefined, }; } @@ -76,6 +98,7 @@ export function generateBranchName( entityNumber: number, sha?: string, label?: string, + title?: string, ): string { const variables = createBranchTemplateVariables( branchPrefix, @@ -83,6 +106,7 @@ export function generateBranchName( entityNumber, sha, label, + title, ); let branchName: string; diff --git a/test/branch-template.test.ts b/test/branch-template.test.ts index 762f06fb9..ce8a966ee 100644 --- a/test/branch-template.test.ts +++ b/test/branch-template.test.ts @@ -131,6 +131,83 @@ describe("branch template utilities", () => { ); expect(result.label).toBe("issue"); }); + + it("should extract description from title when provided", () => { + const result = createBranchTemplateVariables( + "test/", + "issue", + 123, + undefined, + undefined, + "Fix login bug with OAuth", + ); + expect(result.description).toBe("fix-login-bug"); + }); + + it("should handle title with special characters", () => { + const result = createBranchTemplateVariables( + "test/", + "issue", + 456, + undefined, + undefined, + "Add: User Registration & Email Validation", + ); + expect(result.description).toBe("add-user-registration"); + }); + + it("should handle title with fewer than 3 words", () => { + const result = createBranchTemplateVariables( + "test/", + "issue", + 789, + undefined, + undefined, + "Bug fix", + ); + expect(result.description).toBe("bug-fix"); + }); + + it("should handle single word title", () => { + const result = createBranchTemplateVariables( + "test/", + "issue", + 101, + undefined, + undefined, + "Refactoring", + ); + expect(result.description).toBe("refactoring"); + }); + + it("should handle empty title", () => { + const result = createBranchTemplateVariables( + "test/", + "issue", + 202, + undefined, + undefined, + "", + ); + expect(result.description).toBe(""); + }); + + it("should handle title with extra whitespace", () => { + const result = createBranchTemplateVariables( + "test/", + "issue", + 303, + undefined, + undefined, + " Update README documentation ", + ); + expect(result.description).toBe("update-readme-documentation"); + }); + + it("should not set description when title is not provided", () => { + const result = createBranchTemplateVariables("test/", "issue", 404); + expect(result.description).toBeUndefined(); + }); }); describe("generateBranchName", () => { @@ -216,5 +293,66 @@ describe("branch template utilities", () => { expect(result).toBe("dev/enhancement-issue_789"); }); + + 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/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-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-789"); + }); + + 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"); + }); }); }); From 32e7aeee0e458b9b45ccd01c2c6b0921675225c7 Mon Sep 17 00:00:00 2001 From: Cole Davis Date: Tue, 16 Sep 2025 20:26:12 -0400 Subject: [PATCH 06/16] More concise description for branch_name_template --- action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/action.yml b/action.yml index 26b187e39..5ab88776e 100644 --- a/action.yml +++ b/action.yml @@ -24,7 +24,7 @@ inputs: required: false default: "claude/" branch_name_template: - description: "Template for branch naming. Available variables: {{prefix}}, {{entityType}}, {{entityNumber}}, {{timestamp}}, {{year}}, {{month}}, {{day}}, {{hour}}, {{minute}}, {{sha}}, {{label}}, {{description}}. The {{label}} variable uses the first label from the issue/PR, falling back to {{entityType}} if no labels exist. The {{description}} variable uses the first 3 words of the issue/PR title converted to kebab-case. Default: '{{prefix}}{{entityType}}-{{entityNumber}}-{{timestamp}}'" + description: "Template for branch naming. Available variables: {{prefix}}, {{entityType}}, {{entityNumber}}, {{timestamp}}, {{year}}, {{month}}, {{day}}, {{hour}}, {{minute}}, {{sha}}, {{label}}, {{description}}. {{label}} will be first label from the issue/PR, or {{entityType}} as a fallback. {{description}} will be the first 3 words of the issue/PR title in kebab-case. Default: '{{prefix}}{{entityType}}-{{entityNumber}}-{{timestamp}}'" required: false default: "" allowed_bots: From 6246ecdcb51949d62695dff1a934c8f0f1124e56 Mon Sep 17 00:00:00 2001 From: Cole Davis Date: Tue, 16 Sep 2025 20:31:18 -0400 Subject: [PATCH 07/16] Remove more granular time template variables --- action.yml | 2 +- src/utils/branch-template.ts | 10 ---------- test/branch-template.test.ts | 24 ++---------------------- 3 files changed, 3 insertions(+), 33 deletions(-) diff --git a/action.yml b/action.yml index 5ab88776e..12c75fe8a 100644 --- a/action.yml +++ b/action.yml @@ -24,7 +24,7 @@ inputs: required: false default: "claude/" branch_name_template: - description: "Template for branch naming. Available variables: {{prefix}}, {{entityType}}, {{entityNumber}}, {{timestamp}}, {{year}}, {{month}}, {{day}}, {{hour}}, {{minute}}, {{sha}}, {{label}}, {{description}}. {{label}} will be first label from the issue/PR, or {{entityType}} as a fallback. {{description}} will be the first 3 words of the issue/PR title in kebab-case. Default: '{{prefix}}{{entityType}}-{{entityNumber}}-{{timestamp}}'" + description: "Template for branch naming. Available variables: {{prefix}}, {{entityType}}, {{entityNumber}}, {{timestamp}}, {{sha}}, {{label}}, {{description}}. {{label}} will be first label from the issue/PR, or {{entityType}} as a fallback. {{description}} will be the first 3 words of the issue/PR title in kebab-case. Default: '{{prefix}}{{entityType}}-{{entityNumber}}-{{timestamp}}'" required: false default: "" allowed_bots: diff --git a/src/utils/branch-template.ts b/src/utils/branch-template.ts index 5839c92a9..0d5a34e40 100644 --- a/src/utils/branch-template.ts +++ b/src/utils/branch-template.ts @@ -28,11 +28,6 @@ export interface BranchTemplateVariables { entityType: string; entityNumber: number; timestamp: string; - year: string; - month: string; - day: string; - hour: string; - minute: string; sha?: string; label?: string; description?: string; @@ -77,11 +72,6 @@ export function createBranchTemplateVariables( entityType, 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")}`, - year: String(now.getFullYear()), - month: String(now.getMonth() + 1).padStart(2, "0"), - day: String(now.getDate()).padStart(2, "0"), - hour: String(now.getHours()).padStart(2, "0"), - minute: 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 description: title !== undefined ? extractDescription(title) : undefined, diff --git a/test/branch-template.test.ts b/test/branch-template.test.ts index ce8a966ee..d70bc880d 100644 --- a/test/branch-template.test.ts +++ b/test/branch-template.test.ts @@ -17,11 +17,6 @@ describe("branch template utilities", () => { entityType: "issue", entityNumber: 123, timestamp: "20240301-1430", - year: "2024", - month: "03", - day: "01", - hour: "14", - minute: "30", sha: "abcd1234", }; @@ -31,22 +26,17 @@ describe("branch template utilities", () => { it("should handle custom templates with multiple variables", () => { const template = - "{{prefix}}fix/{{entityType}}_{{entityNumber}}_{{year}}{{month}}{{day}}_{{sha}}"; + "{{prefix}}fix/{{entityType}}_{{entityNumber}}_{{timestamp}}_{{sha}}"; const variables = { prefix: "claude-", entityType: "pr", entityNumber: 456, timestamp: "20240301-1430", - year: "2024", - month: "03", - day: "01", - hour: "14", - minute: "30", sha: "abcd1234", }; const result = applyBranchTemplate(template, variables); - expect(result).toBe("claude-fix/pr_456_20240301_abcd1234"); + expect(result).toBe("claude-fix/pr_456_20240301-1430_abcd1234"); }); it("should handle templates with missing variables gracefully", () => { @@ -56,11 +46,6 @@ describe("branch template utilities", () => { entityType: "issue", entityNumber: 123, timestamp: "20240301-1430", - year: "2024", - month: "03", - day: "01", - hour: "14", - minute: "30", }; const result = applyBranchTemplate(template, variables); @@ -83,11 +68,6 @@ describe("branch template utilities", () => { expect(result.sha).toBe("abcdef12"); expect(result.label).toBe("issue"); // fallback to entityType expect(result.timestamp).toMatch(/^\d{8}-\d{4}$/); - expect(result.year).toMatch(/^\d{4}$/); - expect(result.month).toMatch(/^\d{2}$/); - expect(result.day).toMatch(/^\d{2}$/); - expect(result.hour).toMatch(/^\d{2}$/); - expect(result.minute).toMatch(/^\d{2}$/); }); it("should handle SHA truncation", () => { From e758a32de7e93feb1b01131534f572c92355724b Mon Sep 17 00:00:00 2001 From: Cole Davis Date: Tue, 16 Sep 2025 20:33:16 -0400 Subject: [PATCH 08/16] Only fetch first label --- src/github/api/queries/github.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/github/api/queries/github.ts b/src/github/api/queries/github.ts index 65fed0dfc..51faad0d7 100644 --- a/src/github/api/queries/github.ts +++ b/src/github/api/queries/github.ts @@ -16,7 +16,7 @@ export const PR_QUERY = ` additions deletions state - labels(first: 10) { + labels(first: 1) { nodes { name } @@ -102,7 +102,7 @@ export const ISSUE_QUERY = ` } createdAt state - labels(first: 10) { + labels(first: 1) { nodes { name } From 1a49e00b3e7b6ae70065ed53855154ec04f5fcef Mon Sep 17 00:00:00 2001 From: Cole Davis Date: Tue, 16 Sep 2025 20:50:52 -0400 Subject: [PATCH 09/16] Add check for empty template-generated name --- src/utils/branch-template.ts | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/utils/branch-template.ts b/src/utils/branch-template.ts index 0d5a34e40..29c9ad6f3 100644 --- a/src/utils/branch-template.ts +++ b/src/utils/branch-template.ts @@ -99,20 +99,19 @@ export function generateBranchName( title, ); - let branchName: string; + if (template?.trim()) { + const branchName = applyBranchTemplate(template, variables); - if (template && template.trim()) { - // Use custom template - branchName = applyBranchTemplate(template, variables); - } else { - // Use default format (backward compatibility) - branchName = `${branchPrefix}${entityType}-${entityNumber}-${variables.timestamp}`; + // Return generated name if non-empty + if (branchName.trim().length > 0) return branchName; + + // Log fallback when custom template produces empty result + console.log( + `Branch template '${template}' generated empty result, falling back to default format`, + ); } - // Ensure branch name is Kubernetes-compatible: - // - Lowercase only - // - Alphanumeric with hyphens - // - No underscores - // - Max 50 chars (to allow for prefixes) + const branchName = `${branchPrefix}${entityType}-${entityNumber}-${variables.timestamp}`; + // Kubernetes compatible: lowercase, max 50 chars, alphanumeric and hyphens only return branchName.toLowerCase().substring(0, 50); } From 35c0ca2406f0d6d373cfbe324b7cf58beededca9 Mon Sep 17 00:00:00 2001 From: Cole Davis Date: Tue, 16 Sep 2025 20:54:07 -0400 Subject: [PATCH 10/16] Clean up comments, docstrings --- src/utils/branch-template.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/utils/branch-template.ts b/src/utils/branch-template.ts index 29c9ad6f3..ed868e557 100644 --- a/src/utils/branch-template.ts +++ b/src/utils/branch-template.ts @@ -79,7 +79,7 @@ export function createBranchTemplateVariables( } /** - * Generates a branch name using template or falls back to default format + * Generates a branch name from the provided `template` and set of `variables`. Uses a default format if the template is empty or produces an empty result. */ export function generateBranchName( template: string | undefined, @@ -102,10 +102,9 @@ export function generateBranchName( if (template?.trim()) { const branchName = applyBranchTemplate(template, variables); - // Return generated name if non-empty + // Some templates could produce empty results- validate if (branchName.trim().length > 0) return branchName; - // Log fallback when custom template produces empty result console.log( `Branch template '${template}' generated empty result, falling back to default format`, ); From ddb3ef092b537e23ad0dc0dce71caeb2029805f3 Mon Sep 17 00:00:00 2001 From: Cole Davis Date: Tue, 16 Sep 2025 21:05:17 -0400 Subject: [PATCH 11/16] Merge createBranchTemplateVariables into generateBranchName --- src/utils/branch-template.ts | 33 ++++++--------------------------- 1 file changed, 6 insertions(+), 27 deletions(-) diff --git a/src/utils/branch-template.ts b/src/utils/branch-template.ts index ed868e557..bfaa81ad2 100644 --- a/src/utils/branch-template.ts +++ b/src/utils/branch-template.ts @@ -55,49 +55,28 @@ export function applyBranchTemplate( } /** - * Generates template variables from current context + * Generates a branch name from the provided `template` and set of `variables`. Uses a default format if the template is empty or produces an empty result. */ -export function createBranchTemplateVariables( +export function generateBranchName( + template: string | undefined, branchPrefix: string, entityType: string, entityNumber: number, sha?: string, label?: string, title?: string, -): BranchTemplateVariables { +): string { const now = new Date(); - return { + const variables: BranchTemplateVariables = { prefix: branchPrefix, entityType, 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 - description: title !== undefined ? extractDescription(title) : undefined, + description: title ? extractDescription(title) : undefined, }; -} - -/** - * Generates a branch name from the provided `template` and set of `variables`. Uses a default format if the template is empty or produces an empty result. - */ -export function generateBranchName( - template: string | undefined, - branchPrefix: string, - entityType: string, - entityNumber: number, - sha?: string, - label?: string, - title?: string, -): string { - const variables = createBranchTemplateVariables( - branchPrefix, - entityType, - entityNumber, - sha, - label, - title, - ); if (template?.trim()) { const branchName = applyBranchTemplate(template, variables); From 60112e2967eded458d1196a4619febe514558a00 Mon Sep 17 00:00:00 2001 From: Cole Davis Date: Tue, 16 Sep 2025 21:16:23 -0400 Subject: [PATCH 12/16] Still replace undefined values --- src/utils/branch-template.ts | 7 +- test/branch-template.test.ts | 180 +++++++---------------------------- 2 files changed, 40 insertions(+), 147 deletions(-) diff --git a/src/utils/branch-template.ts b/src/utils/branch-template.ts index bfaa81ad2..64b22a2d6 100644 --- a/src/utils/branch-template.ts +++ b/src/utils/branch-template.ts @@ -45,10 +45,9 @@ export function applyBranchTemplate( // Replace each variable Object.entries(variables).forEach(([key, value]) => { - if (value !== undefined) { - const placeholder = `{{${key}}}`; - result = result.replaceAll(placeholder, String(value)); - } + const placeholder = `{{${key}}}`; + const replacement = value ? String(value) : ""; + result = result.replaceAll(placeholder, replacement); }); return result; diff --git a/test/branch-template.test.ts b/test/branch-template.test.ts index d70bc880d..4ad2b6ebb 100644 --- a/test/branch-template.test.ts +++ b/test/branch-template.test.ts @@ -3,7 +3,6 @@ import { describe, it, expect } from "bun:test"; import { applyBranchTemplate, - createBranchTemplateVariables, generateBranchName, } from "../src/utils/branch-template"; @@ -53,143 +52,6 @@ describe("branch template utilities", () => { }); }); - describe("createBranchTemplateVariables", () => { - it("should create all required variables", () => { - const result = createBranchTemplateVariables( - "claude/", - "issue", - 123, - "abcdef123456", - ); - - expect(result.prefix).toBe("claude/"); - expect(result.entityType).toBe("issue"); - expect(result.entityNumber).toBe(123); - expect(result.sha).toBe("abcdef12"); - expect(result.label).toBe("issue"); // fallback to entityType - expect(result.timestamp).toMatch(/^\d{8}-\d{4}$/); - }); - - it("should handle SHA truncation", () => { - const result = createBranchTemplateVariables( - "test/", - "pr", - 456, - "abcdef123456789", - ); - expect(result.sha).toBe("abcdef12"); - }); - - it("should handle missing SHA", () => { - const result = createBranchTemplateVariables("test/", "pr", 456); - expect(result.sha).toBeUndefined(); - }); - - it("should use provided label when available", () => { - const result = createBranchTemplateVariables( - "test/", - "issue", - 123, - undefined, - "bug", - ); - expect(result.label).toBe("bug"); - }); - - it("should fallback to entityType when label is not provided", () => { - const result = createBranchTemplateVariables("test/", "pr", 456); - expect(result.label).toBe("pr"); - }); - - it("should fallback to entityType when label is empty string", () => { - const result = createBranchTemplateVariables( - "test/", - "issue", - 789, - undefined, - "", - ); - expect(result.label).toBe("issue"); - }); - - it("should extract description from title when provided", () => { - const result = createBranchTemplateVariables( - "test/", - "issue", - 123, - undefined, - undefined, - "Fix login bug with OAuth", - ); - expect(result.description).toBe("fix-login-bug"); - }); - - it("should handle title with special characters", () => { - const result = createBranchTemplateVariables( - "test/", - "issue", - 456, - undefined, - undefined, - "Add: User Registration & Email Validation", - ); - expect(result.description).toBe("add-user-registration"); - }); - - it("should handle title with fewer than 3 words", () => { - const result = createBranchTemplateVariables( - "test/", - "issue", - 789, - undefined, - undefined, - "Bug fix", - ); - expect(result.description).toBe("bug-fix"); - }); - - it("should handle single word title", () => { - const result = createBranchTemplateVariables( - "test/", - "issue", - 101, - undefined, - undefined, - "Refactoring", - ); - expect(result.description).toBe("refactoring"); - }); - - it("should handle empty title", () => { - const result = createBranchTemplateVariables( - "test/", - "issue", - 202, - undefined, - undefined, - "", - ); - expect(result.description).toBe(""); - }); - - it("should handle title with extra whitespace", () => { - const result = createBranchTemplateVariables( - "test/", - "issue", - 303, - undefined, - undefined, - " Update README documentation ", - ); - expect(result.description).toBe("update-readme-documentation"); - }); - - it("should not set description when title is not provided", () => { - const result = createBranchTemplateVariables("test/", "issue", 404); - expect(result.description).toBeUndefined(); - }); - }); - describe("generateBranchName", () => { it("should use custom template when provided", () => { const template = "{{prefix}}custom-{{entityType}}_{{entityNumber}}"; @@ -210,20 +72,28 @@ describe("branch template utilities", () => { expect(result).toMatch(/^claude\/pr-456-\d{8}-\d{4}$/); }); - it("should apply Kubernetes-compatible transformations", () => { + 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"); + expect(result).toBe("Feature/UPPERCASE_Branch-Name_123"); }); - it("should truncate long branch names to 50 characters", () => { + 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.length).toBe(50); - expect(result).toBe("feature/very-long-branch-name-that-exceeds-the-max"); + 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", () => { @@ -334,5 +204,29 @@ describe("branch template utilities", () => { 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); + }); }); }); From b12957001c698efa738e4a257f9f04d465e2e5ea Mon Sep 17 00:00:00 2001 From: Cole Davis Date: Tue, 16 Sep 2025 21:46:32 -0400 Subject: [PATCH 13/16] Fall back to default on duplicate branch --- src/github/operations/branch.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/github/operations/branch.ts b/src/github/operations/branch.ts index fc3f5b7f1..565bcff8b 100644 --- a/src/github/operations/branch.ts +++ b/src/github/operations/branch.ts @@ -117,7 +117,7 @@ export async function setupBranch( const title = githubData.contextData.title; // Generate branch name using template or default format - const newBranch = generateBranchName( + let newBranch = generateBranchName( branchNameTemplate, branchPrefix, entityType, @@ -127,6 +127,27 @@ export async function setupBranch( title, ); + // Check if generated branch already exists on remote + try { + await $`git ls-remote --exit-code origin refs/heads/${newBranch}`.quiet(); + + // If we get here, branch exists (exit code 0) + console.log( + `Branch '${newBranch}' already exists, falling back to default format`, + ); + newBranch = generateBranchName( + undefined, // Force default template + branchPrefix, + entityType, + entityNumber, + sourceSHA, + firstLabel, + title, + ); + } catch { + // Branch doesn't exist (non-zero exit code), continue with generated name + } + // For commit signing, defer branch creation to the file ops server if (context.inputs.useCommitSigning) { console.log( From 5b9f7185a9c306ba3982a1237adf4232bfe6afa3 Mon Sep 17 00:00:00 2001 From: Cole Davis Date: Fri, 3 Oct 2025 10:30:50 -0400 Subject: [PATCH 14/16] Parameterize description wordcount --- src/utils/branch-template.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/utils/branch-template.ts b/src/utils/branch-template.ts index 64b22a2d6..45e3bb3ca 100644 --- a/src/utils/branch-template.ts +++ b/src/utils/branch-template.ts @@ -4,10 +4,12 @@ * Branch name template parsing and variable substitution utilities */ +const NUM_DESCRIPTION_WORDS = 3; + /** - * Extracts the first three words from a title and converts them to kebab-case + * Extracts the first `numWords` words from a title and converts them to kebab-case */ -function extractDescription(title: string): string { +function extractDescription(title: string, numWords: number = NUM_DESCRIPTION_WORDS): string { if (!title || title.trim() === "") { return ""; } @@ -15,7 +17,7 @@ function extractDescription(title: string): string { return title .trim() // Remove leading/trailing whitespace .split(/\s+/) // Split on whitespace - .slice(0, 3) // Take first 3 words + .slice(0, numWords) // Only first `numWords` words .join("-") // Join with hyphens .toLowerCase() // Convert to lowercase .replace(/[^a-z0-9-]/g, "") // Remove non-alphanumeric except hyphens From e697aff9d62fee1424d3ef5512a0cb9ab26ebb8e Mon Sep 17 00:00:00 2001 From: Cole Davis Date: Fri, 3 Oct 2025 10:31:25 -0400 Subject: [PATCH 15/16] Remove some over-explanatory comments --- src/utils/branch-template.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/utils/branch-template.ts b/src/utils/branch-template.ts index 45e3bb3ca..6fe1bf6f4 100644 --- a/src/utils/branch-template.ts +++ b/src/utils/branch-template.ts @@ -15,11 +15,11 @@ function extractDescription(title: string, numWords: number = NUM_DESCRIPTION_WO } return title - .trim() // Remove leading/trailing whitespace - .split(/\s+/) // Split on whitespace + .trim() + .split(/\s+/) .slice(0, numWords) // Only first `numWords` words - .join("-") // Join with hyphens - .toLowerCase() // Convert to lowercase + .join("-") + .toLowerCase() .replace(/[^a-z0-9-]/g, "") // Remove non-alphanumeric except hyphens .replace(/-+/g, "-") // Replace multiple hyphens with single .replace(/^-|-$/g, ""); // Remove leading/trailing hyphens From 8c3697b857a3232c9854bd7d9f3104aaf18098aa Mon Sep 17 00:00:00 2001 From: Cole Davis Date: Fri, 3 Oct 2025 10:32:43 -0400 Subject: [PATCH 16/16] NUM_DESCRIPTION_WORDS: 3 -> 5 --- action.yml | 2 +- src/utils/branch-template.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/action.yml b/action.yml index 316d5fad6..f97f87645 100644 --- a/action.yml +++ b/action.yml @@ -24,7 +24,7 @@ inputs: required: false default: "claude/" branch_name_template: - description: "Template for branch naming. Available variables: {{prefix}}, {{entityType}}, {{entityNumber}}, {{timestamp}}, {{sha}}, {{label}}, {{description}}. {{label}} will be first label from the issue/PR, or {{entityType}} as a fallback. {{description}} will be the first 3 words of the issue/PR title in kebab-case. Default: '{{prefix}}{{entityType}}-{{entityNumber}}-{{timestamp}}'" + description: "Template for branch naming. Available variables: {{prefix}}, {{entityType}}, {{entityNumber}}, {{timestamp}}, {{sha}}, {{label}}, {{description}}. {{label}} will be first label from the issue/PR, or {{entityType}} as a fallback. {{description}} will be the first 5 words of the issue/PR title in kebab-case. Default: '{{prefix}}{{entityType}}-{{entityNumber}}-{{timestamp}}'" required: false default: "" allowed_bots: diff --git a/src/utils/branch-template.ts b/src/utils/branch-template.ts index 6fe1bf6f4..cc15d9de1 100644 --- a/src/utils/branch-template.ts +++ b/src/utils/branch-template.ts @@ -4,7 +4,7 @@ * Branch name template parsing and variable substitution utilities */ -const NUM_DESCRIPTION_WORDS = 3; +const NUM_DESCRIPTION_WORDS = 5; /** * Extracts the first `numWords` words from a title and converts them to kebab-case