mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-29 04:02:23 +08:00
* fix: add missing import and update tests for branch template feature
- Add missing `import { $ } from 'bun'` in branch.ts
- Add missing `labels` property to pull-request-target.test.ts fixture
- Update branch-template tests to expect 5-word descriptions
* address review feedback: update comment and add truncation test
310 lines
10 KiB
TypeScript
310 lines
10 KiB
TypeScript
#!/usr/bin/env bun
|
|
|
|
/**
|
|
* Setup the appropriate branch based on the event type:
|
|
* - For PRs: Checkout the PR branch
|
|
* - For Issues: Create a new branch
|
|
*/
|
|
|
|
import { $ } from "bun";
|
|
import { execFileSync } from "child_process";
|
|
import * as core from "@actions/core";
|
|
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.
|
|
* This prevents command injection by ensuring only safe characters are used.
|
|
*
|
|
* Valid branch names:
|
|
* - Start with alphanumeric character (not dash, to prevent option injection)
|
|
* - Contain only alphanumeric, forward slash, hyphen, underscore, or period
|
|
* - Do not start or end with a period
|
|
* - Do not end with a slash
|
|
* - Do not contain '..' (path traversal)
|
|
* - Do not contain '//' (consecutive slashes)
|
|
* - Do not end with '.lock'
|
|
* - Do not contain '@{'
|
|
* - Do not contain control characters or special git characters (~^:?*[\])
|
|
*/
|
|
export function validateBranchName(branchName: string): void {
|
|
// Check for empty or whitespace-only names
|
|
if (!branchName || branchName.trim().length === 0) {
|
|
throw new Error("Branch name cannot be empty");
|
|
}
|
|
|
|
// Check for leading dash (prevents option injection like --help, -x)
|
|
if (branchName.startsWith("-")) {
|
|
throw new Error(
|
|
`Invalid branch name: "${branchName}". Branch names cannot start with a dash.`,
|
|
);
|
|
}
|
|
|
|
// Check for control characters and special git characters (~^:?*[\])
|
|
// eslint-disable-next-line no-control-regex
|
|
if (/[\x00-\x1F\x7F ~^:?*[\]\\]/.test(branchName)) {
|
|
throw new Error(
|
|
`Invalid branch name: "${branchName}". Branch names cannot contain control characters, spaces, or special git characters (~^:?*[\\]).`,
|
|
);
|
|
}
|
|
|
|
// Strict whitelist pattern: alphanumeric start, then alphanumeric/slash/hyphen/underscore/period
|
|
const validPattern = /^[a-zA-Z0-9][a-zA-Z0-9/_.-]*$/;
|
|
|
|
if (!validPattern.test(branchName)) {
|
|
throw new Error(
|
|
`Invalid branch name: "${branchName}". Branch names must start with an alphanumeric character and contain only alphanumeric characters, forward slashes, hyphens, underscores, or periods.`,
|
|
);
|
|
}
|
|
|
|
// Check for leading/trailing periods
|
|
if (branchName.startsWith(".") || branchName.endsWith(".")) {
|
|
throw new Error(
|
|
`Invalid branch name: "${branchName}". Branch names cannot start or end with a period.`,
|
|
);
|
|
}
|
|
|
|
// Check for trailing slash
|
|
if (branchName.endsWith("/")) {
|
|
throw new Error(
|
|
`Invalid branch name: "${branchName}". Branch names cannot end with a slash.`,
|
|
);
|
|
}
|
|
|
|
// Check for consecutive slashes
|
|
if (branchName.includes("//")) {
|
|
throw new Error(
|
|
`Invalid branch name: "${branchName}". Branch names cannot contain consecutive slashes.`,
|
|
);
|
|
}
|
|
|
|
// Additional git-specific validations
|
|
if (branchName.includes("..")) {
|
|
throw new Error(
|
|
`Invalid branch name: "${branchName}". Branch names cannot contain '..'`,
|
|
);
|
|
}
|
|
|
|
if (branchName.endsWith(".lock")) {
|
|
throw new Error(
|
|
`Invalid branch name: "${branchName}". Branch names cannot end with '.lock'`,
|
|
);
|
|
}
|
|
|
|
if (branchName.includes("@{")) {
|
|
throw new Error(
|
|
`Invalid branch name: "${branchName}". Branch names cannot contain '@{'`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Executes a git command safely using execFileSync to avoid shell interpolation.
|
|
*
|
|
* Security: execFileSync passes arguments directly to the git binary without
|
|
* invoking a shell, preventing command injection attacks where malicious input
|
|
* could be interpreted as shell commands (e.g., branch names containing `;`, `|`, `&&`).
|
|
*
|
|
* @param args - Git command arguments (e.g., ["checkout", "branch-name"])
|
|
*/
|
|
function execGit(args: string[]): void {
|
|
execFileSync("git", args, { stdio: "inherit" });
|
|
}
|
|
|
|
export type BranchInfo = {
|
|
baseBranch: string;
|
|
claudeBranch?: string;
|
|
currentBranch: string;
|
|
};
|
|
|
|
export async function setupBranch(
|
|
octokits: Octokits,
|
|
githubData: FetchDataResult,
|
|
context: ParsedGitHubContext,
|
|
): Promise<BranchInfo> {
|
|
const { owner, repo } = context.repository;
|
|
const entityNumber = context.entityNumber;
|
|
const { baseBranch, branchPrefix, branchNameTemplate } = context.inputs;
|
|
const isPR = context.isPR;
|
|
|
|
if (isPR) {
|
|
const prData = githubData.contextData as GitHubPullRequest;
|
|
const prState = prData.state;
|
|
|
|
// Check if PR is closed or merged
|
|
if (prState === "CLOSED" || prState === "MERGED") {
|
|
console.log(
|
|
`PR #${entityNumber} is ${prState}, creating new branch from source...`,
|
|
);
|
|
// Fall through to create a new branch like we do for issues
|
|
} else {
|
|
// Handle open PR: Checkout the PR branch
|
|
console.log("This is an open PR, checking out PR branch...");
|
|
|
|
const branchName = prData.headRefName;
|
|
|
|
// Determine optimal fetch depth based on PR commit count, with a minimum of 20
|
|
const commitCount = prData.commits.totalCount;
|
|
const fetchDepth = Math.max(commitCount, 20);
|
|
|
|
console.log(
|
|
`PR #${entityNumber}: ${commitCount} commits, using fetch depth ${fetchDepth}`,
|
|
);
|
|
|
|
// Validate branch names before use to prevent command injection
|
|
validateBranchName(branchName);
|
|
|
|
// Execute git commands to checkout PR branch (dynamic depth based on PR size)
|
|
// Using execFileSync instead of shell template literals for security
|
|
execGit(["fetch", "origin", `--depth=${fetchDepth}`, branchName]);
|
|
execGit(["checkout", branchName, "--"]);
|
|
|
|
console.log(`Successfully checked out PR branch for PR #${entityNumber}`);
|
|
|
|
// For open PRs, we need to get the base branch of the PR
|
|
const baseBranch = prData.baseRefName;
|
|
validateBranchName(baseBranch);
|
|
|
|
return {
|
|
baseBranch,
|
|
currentBranch: branchName,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Determine source branch - use baseBranch if provided, otherwise fetch default
|
|
let sourceBranch: string;
|
|
|
|
if (baseBranch) {
|
|
// Use provided base branch for source
|
|
sourceBranch = baseBranch;
|
|
} else {
|
|
// No base branch provided, fetch the default branch to use as source
|
|
const repoResponse = await octokits.rest.repos.get({
|
|
owner,
|
|
repo,
|
|
});
|
|
sourceBranch = repoResponse.data.default_branch;
|
|
}
|
|
|
|
// Generate branch name for either an issue or closed/merged PR
|
|
const entityType = isPR ? "pr" : "issue";
|
|
|
|
// 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
|
|
const sourceBranchRef = await octokits.rest.git.getRef({
|
|
owner,
|
|
repo,
|
|
ref: `heads/${sourceBranch}`,
|
|
});
|
|
|
|
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) {
|
|
console.log(
|
|
`Branch name generated: ${newBranch} (will be created by file ops server on first commit)`,
|
|
);
|
|
|
|
// Ensure we're on the source branch
|
|
console.log(`Fetching and checking out source branch: ${sourceBranch}`);
|
|
validateBranchName(sourceBranch);
|
|
execGit(["fetch", "origin", sourceBranch, "--depth=1"]);
|
|
execGit(["checkout", sourceBranch, "--"]);
|
|
|
|
// Set outputs for GitHub Actions
|
|
core.setOutput("CLAUDE_BRANCH", newBranch);
|
|
core.setOutput("BASE_BRANCH", sourceBranch);
|
|
return {
|
|
baseBranch: sourceBranch,
|
|
claudeBranch: newBranch,
|
|
currentBranch: sourceBranch, // Stay on source branch for now
|
|
};
|
|
}
|
|
|
|
// For non-signing case, create and checkout the branch locally only
|
|
console.log(
|
|
`Creating local branch ${newBranch} for ${entityType} #${entityNumber} from source branch: ${sourceBranch}...`,
|
|
);
|
|
|
|
// Fetch and checkout the source branch first to ensure we branch from the correct base
|
|
console.log(`Fetching and checking out source branch: ${sourceBranch}`);
|
|
validateBranchName(sourceBranch);
|
|
validateBranchName(newBranch);
|
|
execGit(["fetch", "origin", sourceBranch, "--depth=1"]);
|
|
execGit(["checkout", sourceBranch, "--"]);
|
|
|
|
// Create and checkout the new branch from the source branch
|
|
execGit(["checkout", "-b", newBranch]);
|
|
|
|
console.log(
|
|
`Successfully created and checked out local branch: ${newBranch}`,
|
|
);
|
|
|
|
// Set outputs for GitHub Actions
|
|
core.setOutput("CLAUDE_BRANCH", newBranch);
|
|
core.setOutput("BASE_BRANCH", sourceBranch);
|
|
return {
|
|
baseBranch: sourceBranch,
|
|
claudeBranch: newBranch,
|
|
currentBranch: newBranch,
|
|
};
|
|
} catch (error) {
|
|
console.error("Error in branch setup:", error);
|
|
process.exit(1);
|
|
}
|
|
}
|