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
This commit is contained in:
Cole D
2026-01-07 20:17:26 -05:00
committed by GitHub
parent cefa60067a
commit c247cb152d
9 changed files with 415 additions and 14 deletions

View File

@@ -18,6 +18,11 @@ export const PR_QUERY = `
additions
deletions
state
labels(first: 1) {
nodes {
name
}
}
commits(first: 100) {
totalCount
nodes {
@@ -101,6 +106,11 @@ export const ISSUE_QUERY = `
updatedAt
lastEditedAt
state
labels(first: 1) {
nodes {
name
}
}
comments(first: 100) {
nodes {
id

View File

@@ -88,6 +88,7 @@ type BaseContext = {
labelTrigger: string;
baseBranch?: string;
branchPrefix: string;
branchNameTemplate?: string;
useStickyComment: boolean;
useCommitSigning: boolean;
sshSigningKey: string;
@@ -145,6 +146,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",
sshSigningKey: process.env.SSH_SIGNING_KEY || "",

View File

@@ -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;
}
/**
* Validates a git branch name against a strict whitelist pattern.
@@ -125,7 +134,7 @@ export async function setupBranch(
): Promise<BranchInfo> {
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) {
@@ -191,17 +200,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
@@ -211,8 +211,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) {

View File

@@ -63,6 +63,11 @@ export type GitHubPullRequest = {
additions: number;
deletions: number;
state: string;
labels: {
nodes: Array<{
name: string;
}>;
};
commits: {
totalCount: number;
nodes: Array<{
@@ -88,6 +93,11 @@ export type GitHubIssue = {
updatedAt?: string;
lastEditedAt?: string;
state: string;
labels: {
nodes: Array<{
name: string;
}>;
};
comments: {
nodes: GitHubComment[];
};