Skip to content

Commit c247cb1

Browse files
authored
feat: custom branch name templates (#571)
* Add branch-name-template config option * Logging * Use branch name template * Add label to template variables * Add description template variable * More concise description for branch_name_template * Remove more granular time template variables * Only fetch first label * Add check for empty template-generated name * Clean up comments, docstrings * Merge createBranchTemplateVariables into generateBranchName * Still replace undefined values * Fall back to default on duplicate branch * Parameterize description wordcount * Remove some over-explanatory comments * NUM_DESCRIPTION_WORDS: 3 -> 5
1 parent cefa600 commit c247cb1

File tree

9 files changed

+415
-14
lines changed

9 files changed

+415
-14
lines changed

action.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ inputs:
2323
description: "The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format)"
2424
required: false
2525
default: "claude/"
26+
branch_name_template:
27+
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}}'"
28+
required: false
29+
default: ""
2630
allowed_bots:
2731
description: "Comma-separated list of allowed bot usernames, or '*' to allow all bots. Empty string (default) allows no bots."
2832
required: false
@@ -178,6 +182,7 @@ runs:
178182
LABEL_TRIGGER: ${{ inputs.label_trigger }}
179183
BASE_BRANCH: ${{ inputs.base_branch }}
180184
BRANCH_PREFIX: ${{ inputs.branch_prefix }}
185+
BRANCH_NAME_TEMPLATE: ${{ inputs.branch_name_template }}
181186
OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }}
182187
ALLOWED_BOTS: ${{ inputs.allowed_bots }}
183188
ALLOWED_NON_WRITE_USERS: ${{ inputs.allowed_non_write_users }}

src/github/api/queries/github.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ export const PR_QUERY = `
1818
additions
1919
deletions
2020
state
21+
labels(first: 1) {
22+
nodes {
23+
name
24+
}
25+
}
2126
commits(first: 100) {
2227
totalCount
2328
nodes {
@@ -101,6 +106,11 @@ export const ISSUE_QUERY = `
101106
updatedAt
102107
lastEditedAt
103108
state
109+
labels(first: 1) {
110+
nodes {
111+
name
112+
}
113+
}
104114
comments(first: 100) {
105115
nodes {
106116
id

src/github/context.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ type BaseContext = {
8888
labelTrigger: string;
8989
baseBranch?: string;
9090
branchPrefix: string;
91+
branchNameTemplate?: string;
9192
useStickyComment: boolean;
9293
useCommitSigning: boolean;
9394
sshSigningKey: string;
@@ -145,6 +146,7 @@ export function parseGitHubContext(): GitHubContext {
145146
labelTrigger: process.env.LABEL_TRIGGER ?? "",
146147
baseBranch: process.env.BASE_BRANCH,
147148
branchPrefix: process.env.BRANCH_PREFIX ?? "claude/",
149+
branchNameTemplate: process.env.BRANCH_NAME_TEMPLATE,
148150
useStickyComment: process.env.USE_STICKY_COMMENT === "true",
149151
useCommitSigning: process.env.USE_COMMIT_SIGNING === "true",
150152
sshSigningKey: process.env.SSH_SIGNING_KEY || "",

src/github/operations/branch.ts

Lines changed: 52 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,15 @@ import type { ParsedGitHubContext } from "../context";
1212
import type { GitHubPullRequest } from "../types";
1313
import type { Octokits } from "../api/client";
1414
import type { FetchDataResult } from "../data/fetcher";
15+
import { generateBranchName } from "../../utils/branch-template";
16+
17+
/**
18+
* Extracts the first label from GitHub data, or returns undefined if no labels exist
19+
*/
20+
function extractFirstLabel(githubData: FetchDataResult): string | undefined {
21+
const labels = githubData.contextData.labels?.nodes;
22+
return labels && labels.length > 0 ? labels[0]?.name : undefined;
23+
}
1524

1625
/**
1726
* Validates a git branch name against a strict whitelist pattern.
@@ -125,7 +134,7 @@ export async function setupBranch(
125134
): Promise<BranchInfo> {
126135
const { owner, repo } = context.repository;
127136
const entityNumber = context.entityNumber;
128-
const { baseBranch, branchPrefix } = context.inputs;
137+
const { baseBranch, branchPrefix, branchNameTemplate } = context.inputs;
129138
const isPR = context.isPR;
130139

131140
if (isPR) {
@@ -191,17 +200,8 @@ export async function setupBranch(
191200
// Generate branch name for either an issue or closed/merged PR
192201
const entityType = isPR ? "pr" : "issue";
193202

194-
// Create Kubernetes-compatible timestamp: lowercase, hyphens only, shorter format
195-
const now = new Date();
196-
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")}`;
197-
198-
// Ensure branch name is Kubernetes-compatible:
199-
// - Lowercase only
200-
// - Alphanumeric with hyphens
201-
// - No underscores
202-
// - Max 50 chars (to allow for prefixes)
203-
const branchName = `${branchPrefix}${entityType}-${entityNumber}-${timestamp}`;
204-
const newBranch = branchName.toLowerCase().substring(0, 50);
203+
// Get the SHA of the source branch to use in template
204+
let sourceSHA: string | undefined;
205205

206206
try {
207207
// Get the SHA of the source branch to verify it exists
@@ -211,8 +211,46 @@ export async function setupBranch(
211211
ref: `heads/${sourceBranch}`,
212212
});
213213

214-
const currentSHA = sourceBranchRef.data.object.sha;
215-
console.log(`Source branch SHA: ${currentSHA}`);
214+
sourceSHA = sourceBranchRef.data.object.sha;
215+
console.log(`Source branch SHA: ${sourceSHA}`);
216+
217+
// Extract first label from GitHub data
218+
const firstLabel = extractFirstLabel(githubData);
219+
220+
// Extract title from GitHub data
221+
const title = githubData.contextData.title;
222+
223+
// Generate branch name using template or default format
224+
let newBranch = generateBranchName(
225+
branchNameTemplate,
226+
branchPrefix,
227+
entityType,
228+
entityNumber,
229+
sourceSHA,
230+
firstLabel,
231+
title,
232+
);
233+
234+
// Check if generated branch already exists on remote
235+
try {
236+
await $`git ls-remote --exit-code origin refs/heads/${newBranch}`.quiet();
237+
238+
// If we get here, branch exists (exit code 0)
239+
console.log(
240+
`Branch '${newBranch}' already exists, falling back to default format`,
241+
);
242+
newBranch = generateBranchName(
243+
undefined, // Force default template
244+
branchPrefix,
245+
entityType,
246+
entityNumber,
247+
sourceSHA,
248+
firstLabel,
249+
title,
250+
);
251+
} catch {
252+
// Branch doesn't exist (non-zero exit code), continue with generated name
253+
}
216254

217255
// For commit signing, defer branch creation to the file ops server
218256
if (context.inputs.useCommitSigning) {

src/github/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,11 @@ export type GitHubPullRequest = {
6363
additions: number;
6464
deletions: number;
6565
state: string;
66+
labels: {
67+
nodes: Array<{
68+
name: string;
69+
}>;
70+
};
6671
commits: {
6772
totalCount: number;
6873
nodes: Array<{
@@ -88,6 +93,11 @@ export type GitHubIssue = {
8893
updatedAt?: string;
8994
lastEditedAt?: string;
9095
state: string;
96+
labels: {
97+
nodes: Array<{
98+
name: string;
99+
}>;
100+
};
91101
comments: {
92102
nodes: GitHubComment[];
93103
};

src/utils/branch-template.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
#!/usr/bin/env bun
2+
3+
/**
4+
* Branch name template parsing and variable substitution utilities
5+
*/
6+
7+
const NUM_DESCRIPTION_WORDS = 5;
8+
9+
/**
10+
* Extracts the first `numWords` words from a title and converts them to kebab-case
11+
*/
12+
function extractDescription(title: string, numWords: number = NUM_DESCRIPTION_WORDS): string {
13+
if (!title || title.trim() === "") {
14+
return "";
15+
}
16+
17+
return title
18+
.trim()
19+
.split(/\s+/)
20+
.slice(0, numWords) // Only first `numWords` words
21+
.join("-")
22+
.toLowerCase()
23+
.replace(/[^a-z0-9-]/g, "") // Remove non-alphanumeric except hyphens
24+
.replace(/-+/g, "-") // Replace multiple hyphens with single
25+
.replace(/^-|-$/g, ""); // Remove leading/trailing hyphens
26+
}
27+
28+
export interface BranchTemplateVariables {
29+
prefix: string;
30+
entityType: string;
31+
entityNumber: number;
32+
timestamp: string;
33+
sha?: string;
34+
label?: string;
35+
description?: string;
36+
}
37+
38+
/**
39+
* Replaces template variables in a branch name template
40+
* Template format: {{variableName}}
41+
*/
42+
export function applyBranchTemplate(
43+
template: string,
44+
variables: BranchTemplateVariables,
45+
): string {
46+
let result = template;
47+
48+
// Replace each variable
49+
Object.entries(variables).forEach(([key, value]) => {
50+
const placeholder = `{{${key}}}`;
51+
const replacement = value ? String(value) : "";
52+
result = result.replaceAll(placeholder, replacement);
53+
});
54+
55+
return result;
56+
}
57+
58+
/**
59+
* 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.
60+
*/
61+
export function generateBranchName(
62+
template: string | undefined,
63+
branchPrefix: string,
64+
entityType: string,
65+
entityNumber: number,
66+
sha?: string,
67+
label?: string,
68+
title?: string,
69+
): string {
70+
const now = new Date();
71+
72+
const variables: BranchTemplateVariables = {
73+
prefix: branchPrefix,
74+
entityType,
75+
entityNumber,
76+
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")}`,
77+
sha: sha?.substring(0, 8), // First 8 characters of SHA
78+
label: label || entityType, // Fall back to entityType if no label
79+
description: title ? extractDescription(title) : undefined,
80+
};
81+
82+
if (template?.trim()) {
83+
const branchName = applyBranchTemplate(template, variables);
84+
85+
// Some templates could produce empty results- validate
86+
if (branchName.trim().length > 0) return branchName;
87+
88+
console.log(
89+
`Branch template '${template}' generated empty result, falling back to default format`,
90+
);
91+
}
92+
93+
const branchName = `${branchPrefix}${entityType}-${entityNumber}-${variables.timestamp}`;
94+
// Kubernetes compatible: lowercase, max 50 chars, alphanumeric and hyphens only
95+
return branchName.toLowerCase().substring(0, 50);
96+
}

0 commit comments

Comments
 (0)