diff --git a/action.yml b/action.yml index ebfe06ae0..f97f87645 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}}, {{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: 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/api/queries/github.ts b/src/github/api/queries/github.ts index 2341a5518..51faad0d7 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: 1) { + nodes { + name + } + } commits(first: 100) { totalCount nodes { @@ -97,6 +102,11 @@ export const ISSUE_QUERY = ` } createdAt state + labels(first: 1) { + nodes { + name + } + } comments(first: 100) { nodes { id diff --git a/src/github/context.ts b/src/github/context.ts index 92f272cb1..8c947a690 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..565bcff8b 100644 --- a/src/github/operations/branch.ts +++ b/src/github/operations/branch.ts @@ -12,6 +12,15 @@ 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"; + +/** + * 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; @@ -26,7 +35,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) { @@ -87,17 +96,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}`; - 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 @@ -107,8 +107,46 @@ 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}`); + + // 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 + let newBranch = generateBranchName( + branchNameTemplate, + branchPrefix, + entityType, + entityNumber, + sourceSHA, + firstLabel, + 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) { 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 new file mode 100644 index 000000000..cc15d9de1 --- /dev/null +++ b/src/utils/branch-template.ts @@ -0,0 +1,96 @@ +#!/usr/bin/env bun + +/** + * Branch name template parsing and variable substitution utilities + */ + +const NUM_DESCRIPTION_WORDS = 5; + +/** + * Extracts the first `numWords` words from a title and converts them to kebab-case + */ +function extractDescription(title: string, numWords: number = NUM_DESCRIPTION_WORDS): string { + if (!title || title.trim() === "") { + return ""; + } + + return title + .trim() + .split(/\s+/) + .slice(0, numWords) // Only first `numWords` words + .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 +} + +export interface BranchTemplateVariables { + prefix: string; + entityType: string; + entityNumber: number; + timestamp: string; + sha?: string; + label?: string; + description?: 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]) => { + const placeholder = `{{${key}}}`; + const replacement = value ? String(value) : ""; + result = result.replaceAll(placeholder, replacement); + }); + + return result; +} + +/** + * 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 now = new Date(); + + 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 ? extractDescription(title) : undefined, + }; + + if (template?.trim()) { + const branchName = applyBranchTemplate(template, variables); + + // Some templates could produce empty results- validate + if (branchName.trim().length > 0) return branchName; + + console.log( + `Branch template '${template}' generated empty result, falling back to default format`, + ); + } + + const branchName = `${branchPrefix}${entityType}-${entityNumber}-${variables.timestamp}`; + // Kubernetes compatible: lowercase, max 50 chars, alphanumeric and hyphens only + 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..4ad2b6ebb --- /dev/null +++ b/test/branch-template.test.ts @@ -0,0 +1,232 @@ +#!/usr/bin/env bun + +import { describe, it, expect } from "bun:test"; +import { + applyBranchTemplate, + 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", + 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 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"); + }); + + 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); + }); + }); +}); 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: [], },