-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Add custom branch name templates #571
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 13 commits
726d580
4991c0c
b5de2b9
9c4bc5d
40c2c1e
32e7aee
6246ecd
e758a32
1a49e00
35c0ca2
ddb3ef0
60112e2
b129570
02a1a97
5b9f718
e697aff
8c3697b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<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) { | ||
|
|
@@ -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 { | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would appreciate some feedback on this- a bare catch block feels like the wrong way to do this. |
||
| 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) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We don't enforce Kubernetes compatibility for the template case- I figured users that needed it could make their template compatible, and that this way we allow users to specify underscores or uppercase characters in their template if desired. |
||
|
|
||
| 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); | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The only new template variables here are label and description, which are admittedly the ones needed for my purposes. I'm avoiding Claude-generated descriptions or variables from external sources (e.g. Jira) for now from simplicity; I'd recommend a separate PR for additional template variables.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why only first 3 words? Would be great to have the full title as the branch name eg
claude/571-add-custom-branch-name-templatesThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Seems like going up to 5 words could be okay?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No solid reason for 3 words. Just personal preference and trying to keep to shorter branch names. 5 words is fine with me