mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-24 07:24:12 +08:00
Initial commit
This commit is contained in:
59
src/github/operations/branch-cleanup.ts
Normal file
59
src/github/operations/branch-cleanup.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { Octokits } from "../api/client";
|
||||
import { GITHUB_SERVER_URL } from "../api/config";
|
||||
|
||||
export async function checkAndDeleteEmptyBranch(
|
||||
octokit: Octokits,
|
||||
owner: string,
|
||||
repo: string,
|
||||
claudeBranch: string | undefined,
|
||||
defaultBranch: string,
|
||||
): Promise<{ shouldDeleteBranch: boolean; branchLink: string }> {
|
||||
let branchLink = "";
|
||||
let shouldDeleteBranch = false;
|
||||
|
||||
if (claudeBranch) {
|
||||
// Check if Claude made any commits to the branch
|
||||
try {
|
||||
const { data: comparison } =
|
||||
await octokit.rest.repos.compareCommitsWithBasehead({
|
||||
owner,
|
||||
repo,
|
||||
basehead: `${defaultBranch}...${claudeBranch}`,
|
||||
});
|
||||
|
||||
// If there are no commits, mark branch for deletion
|
||||
if (comparison.total_commits === 0) {
|
||||
console.log(
|
||||
`Branch ${claudeBranch} has no commits from Claude, will delete it`,
|
||||
);
|
||||
shouldDeleteBranch = true;
|
||||
} else {
|
||||
// Only add branch link if there are commits
|
||||
const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${claudeBranch}`;
|
||||
branchLink = `\n[View branch](${branchUrl})`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error checking for commits on Claude branch:", error);
|
||||
// If we can't check, assume the branch has commits to be safe
|
||||
const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${claudeBranch}`;
|
||||
branchLink = `\n[View branch](${branchUrl})`;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the branch if it has no commits
|
||||
if (shouldDeleteBranch && claudeBranch) {
|
||||
try {
|
||||
await octokit.rest.git.deleteRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: `heads/${claudeBranch}`,
|
||||
});
|
||||
console.log(`✅ Deleted empty branch: ${claudeBranch}`);
|
||||
} catch (deleteError) {
|
||||
console.error(`Failed to delete branch ${claudeBranch}:`, deleteError);
|
||||
// Continue even if deletion fails
|
||||
}
|
||||
}
|
||||
|
||||
return { shouldDeleteBranch, branchLink };
|
||||
}
|
||||
121
src/github/operations/branch.ts
Normal file
121
src/github/operations/branch.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
#!/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 * 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";
|
||||
|
||||
export type BranchInfo = {
|
||||
defaultBranch: 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 isPR = context.isPR;
|
||||
|
||||
// Get the default branch first
|
||||
const repoResponse = await octokits.rest.repos.get({
|
||||
owner,
|
||||
repo,
|
||||
});
|
||||
const defaultBranch = repoResponse.data.default_branch;
|
||||
|
||||
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 default...`,
|
||||
);
|
||||
// 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;
|
||||
|
||||
// Execute git commands to checkout PR branch
|
||||
await $`git fetch origin ${branchName}`;
|
||||
await $`git checkout ${branchName}`;
|
||||
|
||||
console.log(`Successfully checked out PR branch for PR #${entityNumber}`);
|
||||
|
||||
// For open PRs, return branch info
|
||||
return {
|
||||
defaultBranch,
|
||||
currentBranch: branchName,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Creating a new branch for either an issue or closed/merged PR
|
||||
const entityType = isPR ? "pr" : "issue";
|
||||
console.log(`Creating new branch for ${entityType} #${entityNumber}...`);
|
||||
|
||||
const timestamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/[:-]/g, "")
|
||||
.replace(/\.\d{3}Z/, "")
|
||||
.split("T")
|
||||
.join("_");
|
||||
|
||||
const newBranch = `claude/${entityType}-${entityNumber}-${timestamp}`;
|
||||
|
||||
try {
|
||||
// Get the SHA of the default branch
|
||||
const defaultBranchRef = await octokits.rest.git.getRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: `heads/${defaultBranch}`,
|
||||
});
|
||||
|
||||
const currentSHA = defaultBranchRef.data.object.sha;
|
||||
|
||||
console.log(`Current SHA: ${currentSHA}`);
|
||||
|
||||
// Create branch using GitHub API
|
||||
await octokits.rest.git.createRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: `refs/heads/${newBranch}`,
|
||||
sha: currentSHA,
|
||||
});
|
||||
|
||||
// Checkout the new branch
|
||||
await $`git fetch origin ${newBranch}`;
|
||||
await $`git checkout ${newBranch}`;
|
||||
|
||||
console.log(
|
||||
`Successfully created and checked out new branch: ${newBranch}`,
|
||||
);
|
||||
|
||||
// Set outputs for GitHub Actions
|
||||
core.setOutput("CLAUDE_BRANCH", newBranch);
|
||||
core.setOutput("DEFAULT_BRANCH", defaultBranch);
|
||||
return {
|
||||
defaultBranch,
|
||||
claudeBranch: newBranch,
|
||||
currentBranch: newBranch,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error creating branch:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
194
src/github/operations/comment-logic.ts
Normal file
194
src/github/operations/comment-logic.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { GITHUB_SERVER_URL } from "../api/config";
|
||||
|
||||
export type ExecutionDetails = {
|
||||
cost_usd?: number;
|
||||
duration_ms?: number;
|
||||
duration_api_ms?: number;
|
||||
};
|
||||
|
||||
export type CommentUpdateInput = {
|
||||
currentBody: string;
|
||||
actionFailed: boolean;
|
||||
executionDetails: ExecutionDetails | null;
|
||||
jobUrl: string;
|
||||
branchLink?: string;
|
||||
prLink?: string;
|
||||
branchName?: string;
|
||||
triggerUsername?: string;
|
||||
};
|
||||
|
||||
export function ensureProperlyEncodedUrl(url: string): string | null {
|
||||
try {
|
||||
// First, try to parse the URL to see if it's already properly encoded
|
||||
new URL(url);
|
||||
if (url.includes(" ")) {
|
||||
const [baseUrl, queryString] = url.split("?");
|
||||
if (queryString) {
|
||||
// Parse query parameters and re-encode them properly
|
||||
const params = new URLSearchParams();
|
||||
const pairs = queryString.split("&");
|
||||
for (const pair of pairs) {
|
||||
const [key, value = ""] = pair.split("=");
|
||||
if (key) {
|
||||
// Decode first in case it's partially encoded, then encode properly
|
||||
params.set(key, decodeURIComponent(value));
|
||||
}
|
||||
}
|
||||
return `${baseUrl}?${params.toString()}`;
|
||||
}
|
||||
// If no query string, just encode spaces
|
||||
return url.replace(/ /g, "%20");
|
||||
}
|
||||
return url;
|
||||
} catch (e) {
|
||||
// If URL parsing fails, try basic fixes
|
||||
try {
|
||||
// Replace spaces with %20
|
||||
let fixedUrl = url.replace(/ /g, "%20");
|
||||
|
||||
// Ensure colons in parameter values are encoded (but not in http:// or after domain)
|
||||
const urlParts = fixedUrl.split("?");
|
||||
if (urlParts.length > 1 && urlParts[1]) {
|
||||
const [baseUrl, queryString] = urlParts;
|
||||
// Encode colons in the query string that aren't already encoded
|
||||
const fixedQuery = queryString.replace(/([^%]|^):(?!%2F%2F)/g, "$1%3A");
|
||||
fixedUrl = `${baseUrl}?${fixedQuery}`;
|
||||
}
|
||||
|
||||
// Try to validate the fixed URL
|
||||
new URL(fixedUrl);
|
||||
return fixedUrl;
|
||||
} catch {
|
||||
// If we still can't create a valid URL, return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function updateCommentBody(input: CommentUpdateInput): string {
|
||||
const originalBody = input.currentBody;
|
||||
const {
|
||||
executionDetails,
|
||||
jobUrl,
|
||||
branchLink,
|
||||
prLink,
|
||||
actionFailed,
|
||||
branchName,
|
||||
triggerUsername,
|
||||
} = input;
|
||||
|
||||
// Extract content from the original comment body
|
||||
// First, remove the "Claude Code is working…" or "Claude Code is working..." message
|
||||
const workingPattern = /Claude Code is working[…\.]{1,3}(?:\s*<img[^>]*>)?/i;
|
||||
let bodyContent = originalBody.replace(workingPattern, "").trim();
|
||||
|
||||
// Check if there's a PR link in the content
|
||||
let prLinkFromContent = "";
|
||||
|
||||
// Match the entire markdown link structure
|
||||
const prLinkPattern = /\[Create .* PR\]\((.*)\)$/m;
|
||||
const prLinkMatch = bodyContent.match(prLinkPattern);
|
||||
|
||||
if (prLinkMatch && prLinkMatch[1]) {
|
||||
const encodedUrl = ensureProperlyEncodedUrl(prLinkMatch[1]);
|
||||
if (encodedUrl) {
|
||||
prLinkFromContent = encodedUrl;
|
||||
// Remove the PR link from the content
|
||||
bodyContent = bodyContent.replace(prLinkMatch[0], "").trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate duration string if available
|
||||
let durationStr = "";
|
||||
if (executionDetails?.duration_ms !== undefined) {
|
||||
const totalSeconds = Math.round(executionDetails.duration_ms / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
durationStr = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
|
||||
}
|
||||
|
||||
// Build the header
|
||||
let header = "";
|
||||
|
||||
if (actionFailed) {
|
||||
header = "**Claude encountered an error";
|
||||
if (durationStr) {
|
||||
header += ` after ${durationStr}`;
|
||||
}
|
||||
header += "**";
|
||||
} else {
|
||||
// Get the username from triggerUsername or extract from content
|
||||
const usernameMatch = bodyContent.match(/@([a-zA-Z0-9-]+)/);
|
||||
const username =
|
||||
triggerUsername || (usernameMatch ? usernameMatch[1] : "user");
|
||||
|
||||
header = `**Claude finished @${username}'s task`;
|
||||
if (durationStr) {
|
||||
header += ` in ${durationStr}`;
|
||||
}
|
||||
header += "**";
|
||||
}
|
||||
|
||||
// Add links section
|
||||
let links = ` —— [View job](${jobUrl})`;
|
||||
|
||||
// Add branch name with link
|
||||
if (branchName || branchLink) {
|
||||
let finalBranchName = branchName;
|
||||
let branchUrl = "";
|
||||
|
||||
if (branchLink) {
|
||||
// Extract the branch URL from the link
|
||||
const urlMatch = branchLink.match(/\((https:\/\/.*)\)/);
|
||||
if (urlMatch && urlMatch[1]) {
|
||||
branchUrl = urlMatch[1];
|
||||
}
|
||||
|
||||
// Extract branch name from link if not provided
|
||||
if (!finalBranchName) {
|
||||
const branchNameMatch = branchLink.match(/tree\/([^"'\)]+)/);
|
||||
if (branchNameMatch) {
|
||||
finalBranchName = branchNameMatch[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we don't have a URL yet but have a branch name, construct it
|
||||
if (!branchUrl && finalBranchName) {
|
||||
// Extract owner/repo from jobUrl
|
||||
const repoMatch = jobUrl.match(/github\.com\/([^\/]+)\/([^\/]+)\//);
|
||||
if (repoMatch) {
|
||||
branchUrl = `${GITHUB_SERVER_URL}/${repoMatch[1]}/${repoMatch[2]}/tree/${finalBranchName}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (finalBranchName && branchUrl) {
|
||||
links += ` • [\`${finalBranchName}\`](${branchUrl})`;
|
||||
} else if (finalBranchName) {
|
||||
links += ` • \`${finalBranchName}\``;
|
||||
}
|
||||
}
|
||||
|
||||
// Add PR link (either from content or provided)
|
||||
const prUrl =
|
||||
prLinkFromContent || (prLink ? prLink.match(/\(([^)]+)\)/)?.[1] : "");
|
||||
if (prUrl) {
|
||||
links += ` • [Create PR ➔](${prUrl})`;
|
||||
}
|
||||
|
||||
// Build the new body with blank line between header and separator
|
||||
let newBody = `${header}${links}\n\n---\n`;
|
||||
|
||||
// Clean up the body content
|
||||
// Remove any existing View job run, branch links from the bottom
|
||||
bodyContent = bodyContent.replace(/\n?\[View job run\]\([^\)]+\)/g, "");
|
||||
bodyContent = bodyContent.replace(/\n?\[View branch\]\([^\)]+\)/g, "");
|
||||
|
||||
// Remove any existing duration info at the bottom
|
||||
bodyContent = bodyContent.replace(/\n*---\n*Duration: [0-9]+m? [0-9]+s/g, "");
|
||||
|
||||
// Add the cleaned body content
|
||||
newBody += bodyContent;
|
||||
|
||||
return newBody.trim();
|
||||
}
|
||||
33
src/github/operations/comments/common.ts
Normal file
33
src/github/operations/comments/common.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { GITHUB_SERVER_URL } from "../../api/config";
|
||||
|
||||
export const SPINNER_HTML =
|
||||
'<img src="https://github.com/user-attachments/assets/5ac382c7-e004-429b-8e35-7feb3e8f9c6f" width="14px" height="14px" style="vertical-align: middle; margin-left: 4px;" />';
|
||||
|
||||
export function createJobRunLink(
|
||||
owner: string,
|
||||
repo: string,
|
||||
runId: string,
|
||||
): string {
|
||||
const jobRunUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/actions/runs/${runId}`;
|
||||
return `[View job run](${jobRunUrl})`;
|
||||
}
|
||||
|
||||
export function createBranchLink(
|
||||
owner: string,
|
||||
repo: string,
|
||||
branchName: string,
|
||||
): string {
|
||||
const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${branchName}`;
|
||||
return `\n[View branch](${branchUrl})`;
|
||||
}
|
||||
|
||||
export function createCommentBody(
|
||||
jobRunLink: string,
|
||||
branchLink: string = "",
|
||||
): string {
|
||||
return `Claude Code is working… ${SPINNER_HTML}
|
||||
|
||||
I'll analyze this and get back to you.
|
||||
|
||||
${jobRunLink}${branchLink}`;
|
||||
}
|
||||
73
src/github/operations/comments/create-initial.ts
Normal file
73
src/github/operations/comments/create-initial.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
/**
|
||||
* Create the initial tracking comment when Claude Code starts working
|
||||
* This comment shows the working status and includes a link to the job run
|
||||
*/
|
||||
|
||||
import { appendFileSync } from "fs";
|
||||
import { createJobRunLink, createCommentBody } from "./common";
|
||||
import {
|
||||
isPullRequestReviewCommentEvent,
|
||||
type ParsedGitHubContext,
|
||||
} from "../../context";
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
|
||||
export async function createInitialComment(
|
||||
octokit: Octokit,
|
||||
context: ParsedGitHubContext,
|
||||
) {
|
||||
const { owner, repo } = context.repository;
|
||||
|
||||
const jobRunLink = createJobRunLink(owner, repo, context.runId);
|
||||
const initialBody = createCommentBody(jobRunLink);
|
||||
|
||||
try {
|
||||
let response;
|
||||
|
||||
// Only use createReplyForReviewComment if it's a PR review comment AND we have a comment_id
|
||||
if (isPullRequestReviewCommentEvent(context)) {
|
||||
response = await octokit.rest.pulls.createReplyForReviewComment({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: context.entityNumber,
|
||||
comment_id: context.payload.comment.id,
|
||||
body: initialBody,
|
||||
});
|
||||
} else {
|
||||
// For all other cases (issues, issue comments, or missing comment_id)
|
||||
response = await octokit.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: context.entityNumber,
|
||||
body: initialBody,
|
||||
});
|
||||
}
|
||||
|
||||
// Output the comment ID for downstream steps using GITHUB_OUTPUT
|
||||
const githubOutput = process.env.GITHUB_OUTPUT!;
|
||||
appendFileSync(githubOutput, `claude_comment_id=${response.data.id}\n`);
|
||||
console.log(`✅ Created initial comment with ID: ${response.data.id}`);
|
||||
return response.data.id;
|
||||
} catch (error) {
|
||||
console.error("Error in initial comment:", error);
|
||||
|
||||
// Always fall back to regular issue comment if anything fails
|
||||
try {
|
||||
const response = await octokit.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: context.entityNumber,
|
||||
body: initialBody,
|
||||
});
|
||||
|
||||
const githubOutput = process.env.GITHUB_OUTPUT!;
|
||||
appendFileSync(githubOutput, `claude_comment_id=${response.data.id}\n`);
|
||||
console.log(`✅ Created fallback comment with ID: ${response.data.id}`);
|
||||
return response.data.id;
|
||||
} catch (fallbackError) {
|
||||
console.error("Error creating fallback comment:", fallbackError);
|
||||
throw fallbackError;
|
||||
}
|
||||
}
|
||||
}
|
||||
62
src/github/operations/comments/update-with-branch.ts
Normal file
62
src/github/operations/comments/update-with-branch.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
/**
|
||||
* Update the initial tracking comment with branch link
|
||||
* This happens after the branch is created for issues
|
||||
*/
|
||||
|
||||
import {
|
||||
createJobRunLink,
|
||||
createBranchLink,
|
||||
createCommentBody,
|
||||
} from "./common";
|
||||
import { type Octokits } from "../../api/client";
|
||||
import {
|
||||
isPullRequestReviewCommentEvent,
|
||||
type ParsedGitHubContext,
|
||||
} from "../../context";
|
||||
|
||||
export async function updateTrackingComment(
|
||||
octokit: Octokits,
|
||||
context: ParsedGitHubContext,
|
||||
commentId: number,
|
||||
branch?: string,
|
||||
) {
|
||||
const { owner, repo } = context.repository;
|
||||
|
||||
const jobRunLink = createJobRunLink(owner, repo, context.runId);
|
||||
|
||||
// Add branch link for issues (not PRs)
|
||||
let branchLink = "";
|
||||
if (branch && !context.isPR) {
|
||||
branchLink = createBranchLink(owner, repo, branch);
|
||||
}
|
||||
|
||||
const updatedBody = createCommentBody(jobRunLink, branchLink);
|
||||
|
||||
// Update the existing comment with the branch link
|
||||
try {
|
||||
if (isPullRequestReviewCommentEvent(context)) {
|
||||
// For PR review comments (inline comments), use the pulls API
|
||||
await octokit.rest.pulls.updateReviewComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: commentId,
|
||||
body: updatedBody,
|
||||
});
|
||||
console.log(`✅ Updated PR review comment ${commentId} with branch link`);
|
||||
} else {
|
||||
// For all other comments, use the issues API
|
||||
await octokit.rest.issues.updateComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: commentId,
|
||||
body: updatedBody,
|
||||
});
|
||||
console.log(`✅ Updated issue comment ${commentId} with branch link`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating comment with branch link:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user