diff --git a/src/entrypoints/update-comment-link.ts b/src/entrypoints/update-comment-link.ts index 849f954..f1be00e 100644 --- a/src/entrypoints/update-comment-link.ts +++ b/src/entrypoints/update-comment-link.ts @@ -15,6 +15,20 @@ import { GITHUB_SERVER_URL } from "../github/api/config"; import { checkAndCommitOrDeleteBranch } from "../github/operations/branch-cleanup"; 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() { try { const commentId = parseInt(process.env.CLAUDE_COMMENT_ID!); @@ -140,7 +154,7 @@ async function run() { const prBody = encodeURIComponent( `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})`; } } catch (error) { diff --git a/src/github/operations/branch-cleanup.ts b/src/github/operations/branch-cleanup.ts index 88de6de..d7f120c 100644 --- a/src/github/operations/branch-cleanup.ts +++ b/src/github/operations/branch-cleanup.ts @@ -2,6 +2,20 @@ import type { Octokits } from "../api/client"; import { GITHUB_SERVER_URL } from "../api/config"; 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( octokit: Octokits, owner: string, @@ -80,7 +94,7 @@ export async function checkAndCommitOrDeleteBranch( ); // 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})`; } else { console.log( @@ -91,7 +105,7 @@ export async function checkAndCommitOrDeleteBranch( } catch (gitError) { console.error("Error checking/committing changes:", gitError); // 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})`; } } else { @@ -102,13 +116,13 @@ export async function checkAndCommitOrDeleteBranch( } } else { // 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})`; } } catch (error) { console.error("Error comparing commits on Claude branch:", error); // 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})`; } } diff --git a/src/github/operations/comment-logic.ts b/src/github/operations/comment-logic.ts index 03b5d86..7032b20 100644 --- a/src/github/operations/comment-logic.ts +++ b/src/github/operations/comment-logic.ts @@ -1,5 +1,19 @@ 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 = { total_cost_usd?: number; duration_ms?: number; @@ -160,7 +174,7 @@ export function updateCommentBody(input: CommentUpdateInput): string { // Extract owner/repo from jobUrl const repoMatch = jobUrl.match(/github\.com\/([^\/]+)\/([^\/]+)\//); 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) + // Use greedy match with end anchor to capture full URL even if it contains parentheses const prUrl = - prLinkFromContent || (prLink ? prLink.match(/\(([^)]+)\)/)?.[1] : ""); + prLinkFromContent || (prLink ? prLink.match(/\((.+)\)$/)?.[1] : ""); if (prUrl) { links += ` • [Create PR ➔](${prUrl})`; } diff --git a/src/github/operations/comments/common.ts b/src/github/operations/comments/common.ts index df24c03..56b92d8 100644 --- a/src/github/operations/comments/common.ts +++ b/src/github/operations/comments/common.ts @@ -12,12 +12,26 @@ export function createJobRunLink( 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( owner: string, repo: string, branchName: 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})`; } diff --git a/test/comment-logic.test.ts b/test/comment-logic.test.ts index d55c82d..658eb8d 100644 --- a/test/comment-logic.test.ts +++ b/test/comment-logic.test.ts @@ -139,6 +139,21 @@ describe("updateCommentBody", () => { ); 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", () => {