diff --git a/src/github/operations/branch.ts b/src/github/operations/branch.ts index 3215d9c..3b7a038 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 0000000..8cc3344 --- /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 0000000..6d47f2c --- /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"); + }); + }); +});