Files
claude-code-action/src/entrypoints/update-comment-link.ts
claude[bot] e8d2b8d5df feat: persist issue branches for reuse on subsequent invocations
- Check for existing Claude branches before creating new ones for issues
- Skip automatic branch deletion for issue branches to allow reuse
- Add context awareness so Claude knows when reusing an existing branch
- Set IS_REUSED_BRANCH output for tracking branch reuse status

This change allows users to iteratively ask Claude to make changes on the same branch before creating a PR, as requested in issue #103.

Co-authored-by: ashwin-ant <ashwin-ant@users.noreply.github.com>
2025-05-31 05:27:07 +00:00

259 lines
8.9 KiB
TypeScript

#!/usr/bin/env bun
import { createOctokit } from "../github/api/client";
import * as fs from "fs/promises";
import {
updateCommentBody,
type CommentUpdateInput,
} from "../github/operations/comment-logic";
import {
parseGitHubContext,
isPullRequestReviewCommentEvent,
} from "../github/context";
import { GITHUB_SERVER_URL } from "../github/api/config";
import { checkAndDeleteEmptyBranch } from "../github/operations/branch-cleanup";
async function run() {
try {
const commentId = parseInt(process.env.CLAUDE_COMMENT_ID!);
const githubToken = process.env.GITHUB_TOKEN!;
const claudeBranch = process.env.CLAUDE_BRANCH;
const baseBranch = process.env.BASE_BRANCH || "main";
const triggerUsername = process.env.TRIGGER_USERNAME;
const context = parseGitHubContext();
const { owner, repo } = context.repository;
const octokit = createOctokit(githubToken);
const serverUrl = GITHUB_SERVER_URL;
const jobUrl = `${serverUrl}/${owner}/${repo}/actions/runs/${process.env.GITHUB_RUN_ID}`;
let comment;
let isPRReviewComment = false;
try {
// GitHub has separate ID namespaces for review comments and issue comments
// We need to use the correct API based on the event type
if (isPullRequestReviewCommentEvent(context)) {
// For PR review comments, use the pulls API
console.log(`Fetching PR review comment ${commentId}`);
const { data: prComment } = await octokit.rest.pulls.getReviewComment({
owner,
repo,
comment_id: commentId,
});
comment = prComment;
isPRReviewComment = true;
console.log("Successfully fetched as PR review comment");
}
// For all other event types, use the issues API
if (!comment) {
console.log(`Fetching issue comment ${commentId}`);
const { data: issueComment } = await octokit.rest.issues.getComment({
owner,
repo,
comment_id: commentId,
});
comment = issueComment;
isPRReviewComment = false;
console.log("Successfully fetched as issue comment");
}
} catch (finalError) {
// If all attempts fail, try to determine more information about the comment
console.error("Failed to fetch comment. Debug info:");
console.error(`Comment ID: ${commentId}`);
console.error(`Event name: ${context.eventName}`);
console.error(`Entity number: ${context.entityNumber}`);
console.error(`Repository: ${context.repository.full_name}`);
// Try to get the PR info to understand the comment structure
try {
const { data: pr } = await octokit.rest.pulls.get({
owner,
repo,
pull_number: context.entityNumber,
});
console.log(`PR state: ${pr.state}`);
console.log(`PR comments count: ${pr.comments}`);
console.log(`PR review comments count: ${pr.review_comments}`);
} catch {
console.error("Could not fetch PR info for debugging");
}
throw finalError;
}
const currentBody = comment.body ?? "";
// Check if we need to add branch link for new branches
// For issues, we don't delete branches anymore to allow reuse
const skipBranchDeletion = !context.isPR && claudeBranch;
let shouldDeleteBranch = false;
let branchLink = "";
if (skipBranchDeletion) {
// For issue branches, just add the branch link without checking for deletion
const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${claudeBranch}`;
branchLink = `\n[View branch](${branchUrl})`;
console.log(`Keeping issue branch ${claudeBranch} for potential reuse`);
} else {
// For PR branches, use the existing cleanup logic
const result = await checkAndDeleteEmptyBranch(
octokit,
owner,
repo,
claudeBranch,
baseBranch,
);
shouldDeleteBranch = result.shouldDeleteBranch;
branchLink = result.branchLink;
}
// Check if we need to add PR URL when we have a new branch
let prLink = "";
// If claudeBranch is set, it means we created a new branch (for issues or closed/merged PRs)
if (claudeBranch && !shouldDeleteBranch) {
// Check if comment already contains a PR URL
const serverUrlPattern = serverUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const prUrlPattern = new RegExp(
`${serverUrlPattern}\\/.+\\/compare\\/${baseBranch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\.\\.\\.`,
);
const containsPRUrl = currentBody.match(prUrlPattern);
if (!containsPRUrl) {
// Check if there are changes to the branch compared to the default branch
try {
const { data: comparison } =
await octokit.rest.repos.compareCommitsWithBasehead({
owner,
repo,
basehead: `${baseBranch}...${claudeBranch}`,
});
// If there are changes (commits or file changes), add the PR URL
if (
comparison.total_commits > 0 ||
(comparison.files && comparison.files.length > 0)
) {
const entityType = context.isPR ? "PR" : "Issue";
const prTitle = encodeURIComponent(
`${entityType} #${context.entityNumber}: Changes from Claude`,
);
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}`;
prLink = `\n[Create a PR](${prUrl})`;
}
} catch (error) {
console.error("Error checking for changes in branch:", error);
// Don't fail the entire update if we can't check for changes
}
}
}
// Check if action failed and read output file for execution details
let executionDetails: {
cost_usd?: number;
duration_ms?: number;
duration_api_ms?: number;
} | null = null;
let actionFailed = false;
let errorDetails: string | undefined;
// First check if prepare step failed
const prepareSuccess = process.env.PREPARE_SUCCESS !== "false";
const prepareError = process.env.PREPARE_ERROR;
if (!prepareSuccess && prepareError) {
actionFailed = true;
errorDetails = prepareError;
} else {
// Check for existence of output file and parse it if available
try {
const outputFile = process.env.OUTPUT_FILE;
if (outputFile) {
const fileContent = await fs.readFile(outputFile, "utf8");
const outputData = JSON.parse(fileContent);
// Output file is an array, get the last element which contains execution details
if (Array.isArray(outputData) && outputData.length > 0) {
const lastElement = outputData[outputData.length - 1];
if (
lastElement.role === "system" &&
"cost_usd" in lastElement &&
"duration_ms" in lastElement
) {
executionDetails = {
cost_usd: lastElement.cost_usd,
duration_ms: lastElement.duration_ms,
duration_api_ms: lastElement.duration_api_ms,
};
}
}
}
// Check if the Claude action failed
const claudeSuccess = process.env.CLAUDE_SUCCESS !== "false";
actionFailed = !claudeSuccess;
} catch (error) {
console.error("Error reading output file:", error);
// If we can't read the file, check for any failure markers
actionFailed = process.env.CLAUDE_SUCCESS === "false";
}
}
// Prepare input for updateCommentBody function
const commentInput: CommentUpdateInput = {
currentBody,
actionFailed,
executionDetails,
jobUrl,
branchLink,
prLink,
branchName: shouldDeleteBranch ? undefined : claudeBranch,
triggerUsername,
errorDetails,
};
const updatedBody = updateCommentBody(commentInput);
// Update the comment using the appropriate API
try {
if (isPRReviewComment) {
await octokit.rest.pulls.updateReviewComment({
owner,
repo,
comment_id: commentId,
body: updatedBody,
});
} else {
await octokit.rest.issues.updateComment({
owner,
repo,
comment_id: commentId,
body: updatedBody,
});
}
console.log(
`✅ Updated ${isPRReviewComment ? "PR review" : "issue"} comment ${commentId} with job link`,
);
} catch (updateError) {
console.error(
`Failed to update ${isPRReviewComment ? "PR review" : "issue"} comment:`,
updateError,
);
throw updateError;
}
process.exit(0);
} catch (error) {
console.error("Error updating comment with job link:", error);
process.exit(1);
}
}
run();