Compare commits

...

13 Commits

Author SHA1 Message Date
Cole Davis
b12957001c Fall back to default on duplicate branch 2025-09-16 21:46:32 -04:00
Cole Davis
60112e2967 Still replace undefined values 2025-09-16 21:16:23 -04:00
Cole Davis
ddb3ef092b Merge createBranchTemplateVariables into generateBranchName 2025-09-16 21:05:17 -04:00
Cole Davis
35c0ca2406 Clean up comments, docstrings 2025-09-16 20:55:40 -04:00
Cole Davis
1a49e00b3e Add check for empty template-generated name 2025-09-16 20:50:52 -04:00
Cole Davis
e758a32de7 Only fetch first label 2025-09-16 20:33:16 -04:00
Cole Davis
6246ecdcb5 Remove more granular time template variables 2025-09-16 20:31:18 -04:00
Cole Davis
32e7aeee0e More concise description for branch_name_template 2025-09-16 20:26:12 -04:00
Cole Davis
40c2c1e7b4 Add description template variable 2025-09-15 14:22:56 -04:00
Cole Davis
9c4bc5dc35 Add label to template variables 2025-09-15 14:22:56 -04:00
Cole Davis
b5de2b9913 Use branch name template 2025-09-15 14:22:56 -04:00
Cole Davis
4991c0c947 Logging 2025-09-15 14:22:56 -04:00
Cole Davis
726d5808b5 Add branch-name-template config option 2025-09-15 14:22:56 -04:00
9 changed files with 413 additions and 14 deletions

View File

@@ -23,6 +23,10 @@ inputs:
description: "The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format)" description: "The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format)"
required: false required: false
default: "claude/" 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}}'"
required: false
default: ""
allowed_bots: allowed_bots:
description: "Comma-separated list of allowed bot usernames, or '*' to allow all bots. Empty string (default) allows no bots." description: "Comma-separated list of allowed bot usernames, or '*' to allow all bots. Empty string (default) allows no bots."
required: false required: false
@@ -150,6 +154,7 @@ runs:
LABEL_TRIGGER: ${{ inputs.label_trigger }} LABEL_TRIGGER: ${{ inputs.label_trigger }}
BASE_BRANCH: ${{ inputs.base_branch }} BASE_BRANCH: ${{ inputs.base_branch }}
BRANCH_PREFIX: ${{ inputs.branch_prefix }} BRANCH_PREFIX: ${{ inputs.branch_prefix }}
BRANCH_NAME_TEMPLATE: ${{ inputs.branch_name_template }}
OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }} OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }}
ALLOWED_BOTS: ${{ inputs.allowed_bots }} ALLOWED_BOTS: ${{ inputs.allowed_bots }}
ALLOWED_NON_WRITE_USERS: ${{ inputs.allowed_non_write_users }} ALLOWED_NON_WRITE_USERS: ${{ inputs.allowed_non_write_users }}

View File

@@ -16,6 +16,11 @@ export const PR_QUERY = `
additions additions
deletions deletions
state state
labels(first: 1) {
nodes {
name
}
}
commits(first: 100) { commits(first: 100) {
totalCount totalCount
nodes { nodes {
@@ -97,6 +102,11 @@ export const ISSUE_QUERY = `
} }
createdAt createdAt
state state
labels(first: 1) {
nodes {
name
}
}
comments(first: 100) { comments(first: 100) {
nodes { nodes {
id id

View File

@@ -88,6 +88,7 @@ type BaseContext = {
labelTrigger: string; labelTrigger: string;
baseBranch?: string; baseBranch?: string;
branchPrefix: string; branchPrefix: string;
branchNameTemplate?: string;
useStickyComment: boolean; useStickyComment: boolean;
useCommitSigning: boolean; useCommitSigning: boolean;
botId: string; botId: string;
@@ -143,6 +144,7 @@ export function parseGitHubContext(): GitHubContext {
labelTrigger: process.env.LABEL_TRIGGER ?? "", labelTrigger: process.env.LABEL_TRIGGER ?? "",
baseBranch: process.env.BASE_BRANCH, baseBranch: process.env.BASE_BRANCH,
branchPrefix: process.env.BRANCH_PREFIX ?? "claude/", branchPrefix: process.env.BRANCH_PREFIX ?? "claude/",
branchNameTemplate: process.env.BRANCH_NAME_TEMPLATE,
useStickyComment: process.env.USE_STICKY_COMMENT === "true", useStickyComment: process.env.USE_STICKY_COMMENT === "true",
useCommitSigning: process.env.USE_COMMIT_SIGNING === "true", useCommitSigning: process.env.USE_COMMIT_SIGNING === "true",
botId: process.env.BOT_ID ?? String(CLAUDE_APP_BOT_ID), botId: process.env.BOT_ID ?? String(CLAUDE_APP_BOT_ID),

View File

@@ -12,6 +12,15 @@ import type { ParsedGitHubContext } from "../context";
import type { GitHubPullRequest } from "../types"; import type { GitHubPullRequest } from "../types";
import type { Octokits } from "../api/client"; import type { Octokits } from "../api/client";
import type { FetchDataResult } from "../data/fetcher"; 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 = { export type BranchInfo = {
baseBranch: string; baseBranch: string;
@@ -26,7 +35,7 @@ export async function setupBranch(
): Promise<BranchInfo> { ): Promise<BranchInfo> {
const { owner, repo } = context.repository; const { owner, repo } = context.repository;
const entityNumber = context.entityNumber; const entityNumber = context.entityNumber;
const { baseBranch, branchPrefix } = context.inputs; const { baseBranch, branchPrefix, branchNameTemplate } = context.inputs;
const isPR = context.isPR; const isPR = context.isPR;
if (isPR) { if (isPR) {
@@ -87,17 +96,8 @@ export async function setupBranch(
// Generate branch name for either an issue or closed/merged PR // Generate branch name for either an issue or closed/merged PR
const entityType = isPR ? "pr" : "issue"; const entityType = isPR ? "pr" : "issue";
// Create Kubernetes-compatible timestamp: lowercase, hyphens only, shorter format // Get the SHA of the source branch to use in template
const now = new Date(); let sourceSHA: string | undefined;
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);
try { try {
// Get the SHA of the source branch to verify it exists // Get the SHA of the source branch to verify it exists
@@ -107,8 +107,46 @@ export async function setupBranch(
ref: `heads/${sourceBranch}`, ref: `heads/${sourceBranch}`,
}); });
const currentSHA = sourceBranchRef.data.object.sha; sourceSHA = sourceBranchRef.data.object.sha;
console.log(`Source branch SHA: ${currentSHA}`); 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 // For commit signing, defer branch creation to the file ops server
if (context.inputs.useCommitSigning) { if (context.inputs.useCommitSigning) {

View File

@@ -61,6 +61,11 @@ export type GitHubPullRequest = {
additions: number; additions: number;
deletions: number; deletions: number;
state: string; state: string;
labels: {
nodes: Array<{
name: string;
}>;
};
commits: { commits: {
totalCount: number; totalCount: number;
nodes: Array<{ nodes: Array<{
@@ -84,6 +89,11 @@ export type GitHubIssue = {
author: GitHubAuthor; author: GitHubAuthor;
createdAt: string; createdAt: string;
state: string; state: string;
labels: {
nodes: Array<{
name: string;
}>;
};
comments: { comments: {
nodes: GitHubComment[]; nodes: GitHubComment[];
}; };

View File

@@ -0,0 +1,94 @@
#!/usr/bin/env bun
/**
* 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;
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);
}

View File

@@ -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);
});
});
});

View File

@@ -61,6 +61,7 @@ describe("generatePrompt", () => {
body: "This is a test PR", body: "This is a test PR",
author: { login: "testuser" }, author: { login: "testuser" },
state: "OPEN", state: "OPEN",
labels: { nodes: [] },
createdAt: "2023-01-01T00:00:00Z", createdAt: "2023-01-01T00:00:00Z",
additions: 15, additions: 15,
deletions: 5, deletions: 5,
@@ -475,6 +476,7 @@ describe("generatePrompt", () => {
body: "The login form is not working", body: "The login form is not working",
author: { login: "testuser" }, author: { login: "testuser" },
state: "OPEN", state: "OPEN",
labels: { nodes: [] },
createdAt: "2023-01-01T00:00:00Z", createdAt: "2023-01-01T00:00:00Z",
comments: { comments: {
nodes: [], nodes: [],

View File

@@ -28,6 +28,9 @@ describe("formatContext", () => {
additions: 50, additions: 50,
deletions: 30, deletions: 30,
state: "OPEN", state: "OPEN",
labels: {
nodes: [],
},
commits: { commits: {
totalCount: 3, totalCount: 3,
nodes: [], nodes: [],
@@ -63,6 +66,9 @@ Changed Files: 2 files`,
author: { login: "test-user" }, author: { login: "test-user" },
createdAt: "2023-01-01T00:00:00Z", createdAt: "2023-01-01T00:00:00Z",
state: "OPEN", state: "OPEN",
labels: {
nodes: [],
},
comments: { comments: {
nodes: [], nodes: [],
}, },