claude-code-action/src/utils/branch-template.ts
Paarth e64308ff97
fix: sanitize {{label}} in branch name templates (#1492)
A scoped label like area:permissions was substituted into the branch
name verbatim, producing a ":" that validateBranchName rejects. Because
the branch setup block catches that error and calls process.exit(1), the
whole run died. {{description}} was already sanitized via extractDescription;
{{label}} was the only free-text variable that skipped it.

Add a sanitizeLabel helper (replaces invalid-char runs with a hyphen so
scoped labels stay readable) and apply it before substitution, falling back
to entityType when a label sanitizes to empty. Adds regression tests that
also assert the result passes validateBranchName.
2026-07-15 20:21:02 -07:00

114 lines
3.4 KiB
TypeScript

#!/usr/bin/env bun
/**
* Branch name template parsing and variable substitution utilities
*/
const NUM_DESCRIPTION_WORDS = 5;
/**
* Extracts the first 5 words from a title and converts them to kebab-case
*/
function extractDescription(
title: string,
numWords: number = NUM_DESCRIPTION_WORDS,
): string {
if (!title || title.trim() === "") {
return "";
}
return title
.trim()
.split(/\s+/)
.slice(0, numWords) // Only first `numWords` words
.join("-")
.toLowerCase()
.replace(/[^a-z0-9-]/g, "") // Remove non-alphanumeric except hyphens
.replace(/-+/g, "-") // Replace multiple hyphens with single
.replace(/^-|-$/g, ""); // Remove leading/trailing hyphens
}
/**
* Sanitizes a label into a git-safe branch segment. Labels are free-form and
* often scoped (e.g. "area:permissions"), so characters that are invalid in a
* branch name (":", "/", spaces, ...) are replaced with a hyphen rather than
* dropped, keeping the label readable. Returns "" if nothing usable remains.
*/
function sanitizeLabel(label: string): string {
return label
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-") // Replace runs of invalid chars with a hyphen
.replace(/-+/g, "-") // Collapse multiple hyphens
.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 && sanitizeLabel(label)) || entityType, // Sanitize; fall back to entityType if empty/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);
}