Compare commits

..

1 Commits

Author SHA1 Message Date
Claude
fb6c6b51f4 fix: Encode branch names in URLs to prevent truncation in markdown links
Branch names containing special characters (particularly parentheses)
were breaking markdown links in GitHub comments. This caused URLs to be
truncated when clicked.

Changes:
- Add encodeBranchName() helper function that:
  - Uses encodeURIComponent for basic encoding
  - Preserves forward slashes (GitHub expects literal / in branch URLs)
  - Manually encodes parentheses (not encoded by encodeURIComponent per RFC 3986)
- Apply encoding to branch URLs in:
  - update-comment-link.ts (PR compare URLs)
  - branch-cleanup.ts (branch tree URLs)
  - comment-logic.ts (branch tree URLs)
  - comments/common.ts (branch tree URLs)
- Improve PR link regex to use greedy match with end anchor
- Add test for branch names with special characters
2025-12-09 06:34:34 +00:00
11 changed files with 94 additions and 461 deletions

View File

@@ -127,9 +127,6 @@ outputs:
structured_output: structured_output:
description: "JSON string containing all structured output fields when --json-schema is provided in claude_args. Use fromJSON() to parse: fromJSON(steps.id.outputs.structured_output).field_name" description: "JSON string containing all structured output fields when --json-schema is provided in claude_args. Use fromJSON() to parse: fromJSON(steps.id.outputs.structured_output).field_name"
value: ${{ steps.claude-code.outputs.structured_output }} value: ${{ steps.claude-code.outputs.structured_output }}
session_id:
description: "The Claude Code session ID that can be used with --resume to continue this conversation"
value: ${{ steps.claude-code.outputs.session_id }}
runs: runs:
using: "composite" using: "composite"
@@ -198,14 +195,10 @@ runs:
# Install Claude Code if no custom executable is provided # Install Claude Code if no custom executable is provided
if [ -z "$PATH_TO_CLAUDE_CODE_EXECUTABLE" ]; then if [ -z "$PATH_TO_CLAUDE_CODE_EXECUTABLE" ]; then
CLAUDE_CODE_VERSION="2.0.69" CLAUDE_CODE_VERSION="2.0.62"
echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..." echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..."
for attempt in 1 2 3; do for attempt in 1 2 3; do
echo "Installation attempt $attempt..." echo "Installation attempt $attempt..."
# Clean up stale lock files before retry
rm -rf ~/.claude/.locks 2>/dev/null || true
if command -v timeout &> /dev/null; then if command -v timeout &> /dev/null; then
timeout 120 bash -c "curl -fsSL https://claude.ai/install.sh | bash -s -- $CLAUDE_CODE_VERSION" && break timeout 120 bash -c "curl -fsSL https://claude.ai/install.sh | bash -s -- $CLAUDE_CODE_VERSION" && break
else else
@@ -218,15 +211,8 @@ runs:
echo "Installation failed, retrying..." echo "Installation failed, retrying..."
sleep 5 sleep 5
done done
# Add to PATH and validate installation
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
export PATH="$HOME/.local/bin:$PATH"
if ! command -v claude &> /dev/null; then
echo "Installation failed: claude binary not found in PATH"
exit 1
fi
echo "Claude Code installed successfully" echo "Claude Code installed successfully"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
else else
echo "Using custom Claude Code executable: $PATH_TO_CLAUDE_CODE_EXECUTABLE" echo "Using custom Claude Code executable: $PATH_TO_CLAUDE_CODE_EXECUTABLE"
# Add the directory containing the custom executable to PATH # Add the directory containing the custom executable to PATH

View File

@@ -82,9 +82,6 @@ outputs:
structured_output: structured_output:
description: "JSON string containing all structured output fields when --json-schema is provided in claude_args (use fromJSON() or jq to parse)" description: "JSON string containing all structured output fields when --json-schema is provided in claude_args (use fromJSON() or jq to parse)"
value: ${{ steps.run_claude.outputs.structured_output }} value: ${{ steps.run_claude.outputs.structured_output }}
session_id:
description: "The Claude Code session ID that can be used with --resume to continue this conversation"
value: ${{ steps.run_claude.outputs.session_id }}
runs: runs:
using: "composite" using: "composite"
@@ -124,14 +121,10 @@ runs:
PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }} PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
run: | run: |
if [ -z "$PATH_TO_CLAUDE_CODE_EXECUTABLE" ]; then if [ -z "$PATH_TO_CLAUDE_CODE_EXECUTABLE" ]; then
CLAUDE_CODE_VERSION="2.0.69" CLAUDE_CODE_VERSION="2.0.62"
echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..." echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..."
for attempt in 1 2 3; do for attempt in 1 2 3; do
echo "Installation attempt $attempt..." echo "Installation attempt $attempt..."
# Clean up stale lock files before retry
rm -rf ~/.claude/.locks 2>/dev/null || true
if command -v timeout &> /dev/null; then if command -v timeout &> /dev/null; then
timeout 120 bash -c "curl -fsSL https://claude.ai/install.sh | bash -s -- $CLAUDE_CODE_VERSION" && break timeout 120 bash -c "curl -fsSL https://claude.ai/install.sh | bash -s -- $CLAUDE_CODE_VERSION" && break
else else
@@ -144,14 +137,6 @@ runs:
echo "Installation failed, retrying..." echo "Installation failed, retrying..."
sleep 5 sleep 5
done done
# Add to PATH and validate installation
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
export PATH="$HOME/.local/bin:$PATH"
if ! command -v claude &> /dev/null; then
echo "Installation failed: claude binary not found in PATH"
exit 1
fi
echo "Claude Code installed successfully" echo "Claude Code installed successfully"
else else
echo "Using custom Claude Code executable: $PATH_TO_CLAUDE_CODE_EXECUTABLE" echo "Using custom Claude Code executable: $PATH_TO_CLAUDE_CODE_EXECUTABLE"

View File

@@ -124,36 +124,6 @@ export function prepareRunConfig(
}; };
} }
/**
* Parses session_id from execution file and sets GitHub Action output
* Exported for testing
*/
export async function parseAndSetSessionId(
executionFile: string,
): Promise<void> {
try {
const content = await readFile(executionFile, "utf-8");
const messages = JSON.parse(content) as {
type: string;
subtype?: string;
session_id?: string;
}[];
// Find the system.init message which contains session_id
const initMessage = messages.find(
(m) => m.type === "system" && m.subtype === "init",
);
if (initMessage?.session_id) {
core.setOutput("session_id", initMessage.session_id);
core.info(`Set session_id: ${initMessage.session_id}`);
}
} catch (error) {
// Don't fail the action if session_id extraction fails
core.warning(`Failed to extract session_id: ${error}`);
}
}
/** /**
* Parses structured_output from execution file and sets GitHub Action outputs * Parses structured_output from execution file and sets GitHub Action outputs
* Only runs if --json-schema was explicitly provided in claude_args * Only runs if --json-schema was explicitly provided in claude_args
@@ -197,8 +167,8 @@ export async function parseAndSetStructuredOutputs(
} }
export async function runClaude(promptPath: string, options: ClaudeOptions) { export async function runClaude(promptPath: string, options: ClaudeOptions) {
// Feature flag: use SDK path by default, set USE_AGENT_SDK=false to use CLI // Feature flag: use SDK path when USE_AGENT_SDK=true
const useAgentSdk = process.env.USE_AGENT_SDK !== "false"; const useAgentSdk = process.env.USE_AGENT_SDK === "true";
console.log( console.log(
`Using ${useAgentSdk ? "Agent SDK" : "CLI"} path (USE_AGENT_SDK=${process.env.USE_AGENT_SDK ?? "unset"})`, `Using ${useAgentSdk ? "Agent SDK" : "CLI"} path (USE_AGENT_SDK=${process.env.USE_AGENT_SDK ?? "unset"})`,
); );
@@ -398,9 +368,6 @@ export async function runClaude(promptPath: string, options: ClaudeOptions) {
core.setOutput("execution_file", EXECUTION_FILE); core.setOutput("execution_file", EXECUTION_FILE);
// Extract and set session_id
await parseAndSetSessionId(EXECUTION_FILE);
// Parse and set structured outputs only if user provided --json-schema in claude_args // Parse and set structured outputs only if user provided --json-schema in claude_args
if (hasJsonSchema) { if (hasJsonSchema) {
try { try {

View File

@@ -4,10 +4,7 @@ import { describe, test, expect, afterEach, beforeEach, spyOn } from "bun:test";
import { writeFile, unlink } from "fs/promises"; import { writeFile, unlink } from "fs/promises";
import { tmpdir } from "os"; import { tmpdir } from "os";
import { join } from "path"; import { join } from "path";
import { import { parseAndSetStructuredOutputs } from "../src/run-claude";
parseAndSetStructuredOutputs,
parseAndSetSessionId,
} from "../src/run-claude";
import * as core from "@actions/core"; import * as core from "@actions/core";
// Mock execution file path // Mock execution file path
@@ -38,19 +35,16 @@ async function createMockExecutionFile(
// Spy on core functions // Spy on core functions
let setOutputSpy: any; let setOutputSpy: any;
let infoSpy: any; let infoSpy: any;
let warningSpy: any;
beforeEach(() => { beforeEach(() => {
setOutputSpy = spyOn(core, "setOutput").mockImplementation(() => {}); setOutputSpy = spyOn(core, "setOutput").mockImplementation(() => {});
infoSpy = spyOn(core, "info").mockImplementation(() => {}); infoSpy = spyOn(core, "info").mockImplementation(() => {});
warningSpy = spyOn(core, "warning").mockImplementation(() => {});
}); });
describe("parseAndSetStructuredOutputs", () => { describe("parseAndSetStructuredOutputs", () => {
afterEach(async () => { afterEach(async () => {
setOutputSpy?.mockRestore(); setOutputSpy?.mockRestore();
infoSpy?.mockRestore(); infoSpy?.mockRestore();
warningSpy?.mockRestore();
try { try {
await unlink(TEST_EXECUTION_FILE); await unlink(TEST_EXECUTION_FILE);
} catch { } catch {
@@ -162,66 +156,3 @@ describe("parseAndSetStructuredOutputs", () => {
); );
}); });
}); });
describe("parseAndSetSessionId", () => {
afterEach(async () => {
setOutputSpy?.mockRestore();
infoSpy?.mockRestore();
warningSpy?.mockRestore();
try {
await unlink(TEST_EXECUTION_FILE);
} catch {
// Ignore if file doesn't exist
}
});
test("should extract session_id from system.init message", async () => {
const messages = [
{ type: "system", subtype: "init", session_id: "test-session-123" },
{ type: "result", cost_usd: 0.01 },
];
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
await parseAndSetSessionId(TEST_EXECUTION_FILE);
expect(setOutputSpy).toHaveBeenCalledWith("session_id", "test-session-123");
expect(infoSpy).toHaveBeenCalledWith("Set session_id: test-session-123");
});
test("should handle missing session_id gracefully", async () => {
const messages = [
{ type: "system", subtype: "init" },
{ type: "result", cost_usd: 0.01 },
];
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
await parseAndSetSessionId(TEST_EXECUTION_FILE);
expect(setOutputSpy).not.toHaveBeenCalled();
});
test("should handle missing system.init message gracefully", async () => {
const messages = [{ type: "result", cost_usd: 0.01 }];
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
await parseAndSetSessionId(TEST_EXECUTION_FILE);
expect(setOutputSpy).not.toHaveBeenCalled();
});
test("should handle malformed JSON gracefully with warning", async () => {
await writeFile(TEST_EXECUTION_FILE, "{ invalid json");
await parseAndSetSessionId(TEST_EXECUTION_FILE);
expect(setOutputSpy).not.toHaveBeenCalled();
expect(warningSpy).toHaveBeenCalled();
});
test("should handle non-existent file gracefully with warning", async () => {
await parseAndSetSessionId("/nonexistent/file.json");
expect(setOutputSpy).not.toHaveBeenCalled();
expect(warningSpy).toHaveBeenCalled();
});
});

View File

@@ -15,6 +15,20 @@ import { GITHUB_SERVER_URL } from "../github/api/config";
import { checkAndCommitOrDeleteBranch } from "../github/operations/branch-cleanup"; import { checkAndCommitOrDeleteBranch } from "../github/operations/branch-cleanup";
import { updateClaudeComment } from "../github/operations/comments/update-claude-comment"; import { updateClaudeComment } from "../github/operations/comments/update-claude-comment";
/**
* Encodes a branch name for use in a URL, preserving forward slashes.
* GitHub expects literal slashes in branch names (e.g., /tree/feature/branch)
* but other special characters like parentheses need to be encoded.
* Note: encodeURIComponent doesn't encode ( ) ! ' * ~ per RFC 3986,
* but parentheses break markdown links so we encode them manually.
*/
function encodeBranchName(branchName: string): string {
return encodeURIComponent(branchName)
.replace(/%2F/gi, "/")
.replace(/\(/g, "%28")
.replace(/\)/g, "%29");
}
async function run() { async function run() {
try { try {
const commentId = parseInt(process.env.CLAUDE_COMMENT_ID!); const commentId = parseInt(process.env.CLAUDE_COMMENT_ID!);
@@ -140,7 +154,7 @@ async function run() {
const prBody = encodeURIComponent( const prBody = encodeURIComponent(
`This PR addresses ${entityType.toLowerCase()} #${context.entityNumber}\n\nGenerated with [Claude Code](https://claude.ai/code)`, `This PR addresses ${entityType.toLowerCase()} #${context.entityNumber}\n\nGenerated with [Claude Code](https://claude.ai/code)`,
); );
const prUrl = `${serverUrl}/${owner}/${repo}/compare/${baseBranch}...${claudeBranch}?quick_pull=1&title=${prTitle}&body=${prBody}`; const prUrl = `${serverUrl}/${owner}/${repo}/compare/${encodeBranchName(baseBranch)}...${encodeBranchName(claudeBranch)}?quick_pull=1&title=${prTitle}&body=${prBody}`;
prLink = `\n[Create a PR](${prUrl})`; prLink = `\n[Create a PR](${prUrl})`;
} }
} catch (error) { } catch (error) {

View File

@@ -2,6 +2,20 @@ import type { Octokits } from "../api/client";
import { GITHUB_SERVER_URL } from "../api/config"; import { GITHUB_SERVER_URL } from "../api/config";
import { $ } from "bun"; import { $ } from "bun";
/**
* Encodes a branch name for use in a URL, preserving forward slashes.
* GitHub expects literal slashes in branch names (e.g., /tree/feature/branch)
* but other special characters like parentheses need to be encoded.
* Note: encodeURIComponent doesn't encode ( ) ! ' * ~ per RFC 3986,
* but parentheses break markdown links so we encode them manually.
*/
function encodeBranchName(branchName: string): string {
return encodeURIComponent(branchName)
.replace(/%2F/gi, "/")
.replace(/\(/g, "%28")
.replace(/\)/g, "%29");
}
export async function checkAndCommitOrDeleteBranch( export async function checkAndCommitOrDeleteBranch(
octokit: Octokits, octokit: Octokits,
owner: string, owner: string,
@@ -80,7 +94,7 @@ export async function checkAndCommitOrDeleteBranch(
); );
// Set branch link since we now have commits // Set branch link since we now have commits
const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${claudeBranch}`; const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${encodeBranchName(claudeBranch)}`;
branchLink = `\n[View branch](${branchUrl})`; branchLink = `\n[View branch](${branchUrl})`;
} else { } else {
console.log( console.log(
@@ -91,7 +105,7 @@ export async function checkAndCommitOrDeleteBranch(
} catch (gitError) { } catch (gitError) {
console.error("Error checking/committing changes:", gitError); console.error("Error checking/committing changes:", gitError);
// If we can't check git status, assume the branch might have changes // If we can't check git status, assume the branch might have changes
const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${claudeBranch}`; const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${encodeBranchName(claudeBranch)}`;
branchLink = `\n[View branch](${branchUrl})`; branchLink = `\n[View branch](${branchUrl})`;
} }
} else { } else {
@@ -102,13 +116,13 @@ export async function checkAndCommitOrDeleteBranch(
} }
} else { } else {
// Only add branch link if there are commits // Only add branch link if there are commits
const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${claudeBranch}`; const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${encodeBranchName(claudeBranch)}`;
branchLink = `\n[View branch](${branchUrl})`; branchLink = `\n[View branch](${branchUrl})`;
} }
} catch (error) { } catch (error) {
console.error("Error comparing commits on Claude branch:", error); console.error("Error comparing commits on Claude branch:", error);
// If we can't compare but the branch exists remotely, include the branch link // If we can't compare but the branch exists remotely, include the branch link
const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${claudeBranch}`; const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${encodeBranchName(claudeBranch)}`;
branchLink = `\n[View branch](${branchUrl})`; branchLink = `\n[View branch](${branchUrl})`;
} }
} }

View File

@@ -6,112 +6,13 @@
* - For Issues: Create a new branch * - For Issues: Create a new branch
*/ */
import { execFileSync } from "child_process"; import { $ } from "bun";
import * as core from "@actions/core"; import * as core from "@actions/core";
import type { ParsedGitHubContext } from "../context"; 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";
/**
* 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 = { export type BranchInfo = {
baseBranch: string; baseBranch: string;
claudeBranch?: string; claudeBranch?: string;
@@ -152,19 +53,14 @@ export async function setupBranch(
`PR #${entityNumber}: ${commitCount} commits, using fetch depth ${fetchDepth}`, `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) // Execute git commands to checkout PR branch (dynamic depth based on PR size)
// Using execFileSync instead of shell template literals for security await $`git fetch origin --depth=${fetchDepth} ${branchName}`;
execGit(["fetch", "origin", `--depth=${fetchDepth}`, branchName]); await $`git checkout ${branchName} --`;
execGit(["checkout", branchName, "--"]);
console.log(`Successfully checked out PR branch for PR #${entityNumber}`); console.log(`Successfully checked out PR branch for PR #${entityNumber}`);
// For open PRs, we need to get the base branch of the PR // For open PRs, we need to get the base branch of the PR
const baseBranch = prData.baseRefName; const baseBranch = prData.baseRefName;
validateBranchName(baseBranch);
return { return {
baseBranch, baseBranch,
@@ -222,9 +118,8 @@ export async function setupBranch(
// Ensure we're on the source branch // Ensure we're on the source branch
console.log(`Fetching and checking out source branch: ${sourceBranch}`); console.log(`Fetching and checking out source branch: ${sourceBranch}`);
validateBranchName(sourceBranch); await $`git fetch origin ${sourceBranch} --depth=1`;
execGit(["fetch", "origin", sourceBranch, "--depth=1"]); await $`git checkout ${sourceBranch}`;
execGit(["checkout", sourceBranch, "--"]);
// Set outputs for GitHub Actions // Set outputs for GitHub Actions
core.setOutput("CLAUDE_BRANCH", newBranch); core.setOutput("CLAUDE_BRANCH", newBranch);
@@ -243,13 +138,11 @@ export async function setupBranch(
// Fetch and checkout the source branch first to ensure we branch from the correct base // Fetch and checkout the source branch first to ensure we branch from the correct base
console.log(`Fetching and checking out source branch: ${sourceBranch}`); console.log(`Fetching and checking out source branch: ${sourceBranch}`);
validateBranchName(sourceBranch); await $`git fetch origin ${sourceBranch} --depth=1`;
validateBranchName(newBranch); await $`git checkout ${sourceBranch}`;
execGit(["fetch", "origin", sourceBranch, "--depth=1"]);
execGit(["checkout", sourceBranch, "--"]);
// Create and checkout the new branch from the source branch // Create and checkout the new branch from the source branch
execGit(["checkout", "-b", newBranch]); await $`git checkout -b ${newBranch}`;
console.log( console.log(
`Successfully created and checked out local branch: ${newBranch}`, `Successfully created and checked out local branch: ${newBranch}`,

View File

@@ -1,5 +1,19 @@
import { GITHUB_SERVER_URL } from "../api/config"; import { GITHUB_SERVER_URL } from "../api/config";
/**
* Encodes a branch name for use in a URL, preserving forward slashes.
* GitHub expects literal slashes in branch names (e.g., /tree/feature/branch)
* but other special characters like parentheses need to be encoded.
* Note: encodeURIComponent doesn't encode ( ) ! ' * ~ per RFC 3986,
* but parentheses break markdown links so we encode them manually.
*/
function encodeBranchName(branchName: string): string {
return encodeURIComponent(branchName)
.replace(/%2F/gi, "/")
.replace(/\(/g, "%28")
.replace(/\)/g, "%29");
}
export type ExecutionDetails = { export type ExecutionDetails = {
total_cost_usd?: number; total_cost_usd?: number;
duration_ms?: number; duration_ms?: number;
@@ -160,7 +174,7 @@ export function updateCommentBody(input: CommentUpdateInput): string {
// Extract owner/repo from jobUrl // Extract owner/repo from jobUrl
const repoMatch = jobUrl.match(/github\.com\/([^\/]+)\/([^\/]+)\//); const repoMatch = jobUrl.match(/github\.com\/([^\/]+)\/([^\/]+)\//);
if (repoMatch) { if (repoMatch) {
branchUrl = `${GITHUB_SERVER_URL}/${repoMatch[1]}/${repoMatch[2]}/tree/${finalBranchName}`; branchUrl = `${GITHUB_SERVER_URL}/${repoMatch[1]}/${repoMatch[2]}/tree/${encodeBranchName(finalBranchName)}`;
} }
} }
@@ -172,8 +186,9 @@ export function updateCommentBody(input: CommentUpdateInput): string {
} }
// Add PR link (either from content or provided) // Add PR link (either from content or provided)
// Use greedy match with end anchor to capture full URL even if it contains parentheses
const prUrl = const prUrl =
prLinkFromContent || (prLink ? prLink.match(/\(([^)]+)\)/)?.[1] : ""); prLinkFromContent || (prLink ? prLink.match(/\((.+)\)$/)?.[1] : "");
if (prUrl) { if (prUrl) {
links += ` • [Create PR ➔](${prUrl})`; links += ` • [Create PR ➔](${prUrl})`;
} }

View File

@@ -12,12 +12,26 @@ export function createJobRunLink(
return `[View job run](${jobRunUrl})`; return `[View job run](${jobRunUrl})`;
} }
/**
* Encodes a branch name for use in a URL, preserving forward slashes.
* GitHub expects literal slashes in branch names (e.g., /tree/feature/branch)
* but other special characters like parentheses need to be encoded.
* Note: encodeURIComponent doesn't encode ( ) ! ' * ~ per RFC 3986,
* but parentheses break markdown links so we encode them manually.
*/
function encodeBranchName(branchName: string): string {
return encodeURIComponent(branchName)
.replace(/%2F/gi, "/")
.replace(/\(/g, "%28")
.replace(/\)/g, "%29");
}
export function createBranchLink( export function createBranchLink(
owner: string, owner: string,
repo: string, repo: string,
branchName: string, branchName: string,
): string { ): string {
const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${branchName}`; const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${encodeBranchName(branchName)}`;
return `\n[View branch](${branchUrl})`; return `\n[View branch](${branchUrl})`;
} }

View File

@@ -139,6 +139,21 @@ describe("updateCommentBody", () => {
); );
expect(result).not.toContain("View branch"); expect(result).not.toContain("View branch");
}); });
it("encodes special characters in branch names while preserving slashes", () => {
const input = {
...baseInput,
branchName: "feature/fix(issue)-test",
};
const result = updateCommentBody(input);
// Branch name display should show the original name
expect(result).toContain("`feature/fix(issue)-test`");
// URL should have encoded parentheses but preserved slashes
expect(result).toContain(
"https://github.com/owner/repo/tree/feature/fix%28issue%29-test",
);
});
}); });
describe("PR link", () => { describe("PR link", () => {

View File

@@ -1,201 +0,0 @@
import { describe, expect, it } from "bun:test";
import { validateBranchName } from "../src/github/operations/branch";
describe("validateBranchName", () => {
describe("valid branch names", () => {
it("should accept simple alphanumeric names", () => {
expect(() => validateBranchName("main")).not.toThrow();
expect(() => validateBranchName("feature123")).not.toThrow();
expect(() => validateBranchName("Branch1")).not.toThrow();
});
it("should accept names with hyphens", () => {
expect(() => validateBranchName("feature-branch")).not.toThrow();
expect(() => validateBranchName("fix-bug-123")).not.toThrow();
});
it("should accept names with underscores", () => {
expect(() => validateBranchName("feature_branch")).not.toThrow();
expect(() => validateBranchName("fix_bug_123")).not.toThrow();
});
it("should accept names with forward slashes", () => {
expect(() => validateBranchName("feature/new-thing")).not.toThrow();
expect(() => validateBranchName("user/feature/branch")).not.toThrow();
});
it("should accept names with periods", () => {
expect(() => validateBranchName("v1.0.0")).not.toThrow();
expect(() => validateBranchName("release.1.2.3")).not.toThrow();
});
it("should accept typical branch name formats", () => {
expect(() =>
validateBranchName("claude/issue-123-20250101-1234"),
).not.toThrow();
expect(() => validateBranchName("refs/heads/main")).not.toThrow();
expect(() => validateBranchName("bugfix/JIRA-1234")).not.toThrow();
});
});
describe("command injection attempts", () => {
it("should reject shell command substitution with $()", () => {
expect(() => validateBranchName("$(whoami)")).toThrow();
expect(() => validateBranchName("branch-$(rm -rf /)")).toThrow();
expect(() => validateBranchName("test$(cat /etc/passwd)")).toThrow();
});
it("should reject shell command substitution with backticks", () => {
expect(() => validateBranchName("`whoami`")).toThrow();
expect(() => validateBranchName("branch-`rm -rf /`")).toThrow();
});
it("should reject command chaining with semicolons", () => {
expect(() => validateBranchName("branch; rm -rf /")).toThrow();
expect(() => validateBranchName("test;whoami")).toThrow();
});
it("should reject command chaining with &&", () => {
expect(() => validateBranchName("branch && rm -rf /")).toThrow();
expect(() => validateBranchName("test&&whoami")).toThrow();
});
it("should reject command chaining with ||", () => {
expect(() => validateBranchName("branch || rm -rf /")).toThrow();
expect(() => validateBranchName("test||whoami")).toThrow();
});
it("should reject pipe characters", () => {
expect(() => validateBranchName("branch | cat")).toThrow();
expect(() => validateBranchName("test|grep password")).toThrow();
});
it("should reject redirection operators", () => {
expect(() => validateBranchName("branch > /etc/passwd")).toThrow();
expect(() => validateBranchName("branch < input")).toThrow();
expect(() => validateBranchName("branch >> file")).toThrow();
});
});
describe("option injection attempts", () => {
it("should reject branch names starting with dash", () => {
expect(() => validateBranchName("-x")).toThrow(
/cannot start with a dash/,
);
expect(() => validateBranchName("--help")).toThrow(
/cannot start with a dash/,
);
expect(() => validateBranchName("-")).toThrow(/cannot start with a dash/);
expect(() => validateBranchName("--version")).toThrow(
/cannot start with a dash/,
);
expect(() => validateBranchName("-rf")).toThrow(
/cannot start with a dash/,
);
});
});
describe("path traversal attempts", () => {
it("should reject double dot sequences", () => {
expect(() => validateBranchName("../../../etc")).toThrow();
expect(() => validateBranchName("branch/../secret")).toThrow(/'\.\.'$/);
expect(() => validateBranchName("a..b")).toThrow(/'\.\.'$/);
});
});
describe("git-specific invalid patterns", () => {
it("should reject @{ sequence", () => {
expect(() => validateBranchName("branch@{1}")).toThrow(/@{/);
expect(() => validateBranchName("HEAD@{yesterday}")).toThrow(/@{/);
});
it("should reject .lock suffix", () => {
expect(() => validateBranchName("branch.lock")).toThrow(/\.lock/);
expect(() => validateBranchName("feature.lock")).toThrow(/\.lock/);
});
it("should reject consecutive slashes", () => {
expect(() => validateBranchName("feature//branch")).toThrow(
/consecutive slashes/,
);
expect(() => validateBranchName("a//b//c")).toThrow(
/consecutive slashes/,
);
});
it("should reject trailing slashes", () => {
expect(() => validateBranchName("feature/")).toThrow(
/cannot end with a slash/,
);
expect(() => validateBranchName("branch/")).toThrow(
/cannot end with a slash/,
);
});
it("should reject leading periods", () => {
expect(() => validateBranchName(".hidden")).toThrow();
});
it("should reject trailing periods", () => {
expect(() => validateBranchName("branch.")).toThrow(
/cannot start or end with a period/,
);
});
it("should reject special git refspec characters", () => {
expect(() => validateBranchName("branch~1")).toThrow();
expect(() => validateBranchName("branch^2")).toThrow();
expect(() => validateBranchName("branch:ref")).toThrow();
expect(() => validateBranchName("branch?")).toThrow();
expect(() => validateBranchName("branch*")).toThrow();
expect(() => validateBranchName("branch[0]")).toThrow();
expect(() => validateBranchName("branch\\path")).toThrow();
});
});
describe("control characters and special characters", () => {
it("should reject null bytes", () => {
expect(() => validateBranchName("branch\x00name")).toThrow();
});
it("should reject other control characters", () => {
expect(() => validateBranchName("branch\x01name")).toThrow();
expect(() => validateBranchName("branch\x1Fname")).toThrow();
expect(() => validateBranchName("branch\x7Fname")).toThrow();
});
it("should reject spaces", () => {
expect(() => validateBranchName("branch name")).toThrow();
expect(() => validateBranchName("feature branch")).toThrow();
});
it("should reject newlines and tabs", () => {
expect(() => validateBranchName("branch\nname")).toThrow();
expect(() => validateBranchName("branch\tname")).toThrow();
});
});
describe("empty and whitespace", () => {
it("should reject empty strings", () => {
expect(() => validateBranchName("")).toThrow(/cannot be empty/);
});
it("should reject whitespace-only strings", () => {
expect(() => validateBranchName(" ")).toThrow();
expect(() => validateBranchName("\t\n")).toThrow();
});
});
describe("edge cases", () => {
it("should accept single alphanumeric character", () => {
expect(() => validateBranchName("a")).not.toThrow();
expect(() => validateBranchName("1")).not.toThrow();
});
it("should reject single special characters", () => {
expect(() => validateBranchName(".")).toThrow();
expect(() => validateBranchName("/")).toThrow();
expect(() => validateBranchName("-")).toThrow();
});
});
});