mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-08-22 03:18:54 +08:00
refactor: unify action into single composite step with run.ts entrypoint (#898)
* refactor: unify action into single composite step with run.ts entrypoint Consolidate the prepare and base-action phases into a single composite step that runs src/entrypoints/run.ts. This simplifies the action.yml from multiple steps to one execution step, while keeping the same behavior. Key changes: - Add src/entrypoints/run.ts as unified entrypoint - Simplify action.yml to single 'Run Claude Code Action' step - Pass all inputs via environment variables - Update base-action to accept inputs via env vars - Support agent mode auto-detection from prompt input * refactor: keep SSH signing cleanup and token revocation as separate action steps Move SSH signing key cleanup and app token revocation back to separate composite action steps in action.yml with always() conditions, rather than handling them inside run.ts. This keeps these cleanup concerns as independently visible steps in the workflow. * fix: address PR review feedback - Use path.dirname() instead of manual string slicing for executable path - Differentiate prepare vs execution errors in catch block so tracking comment accurately reflects which phase failed - Update CLAUDE.md architecture docs to reflect unified run.ts entrypoint and four-phase design * fix: address PR review feedback - Use path.dirname() instead of manual string slicing for executable path - Differentiate prepare vs execution errors in catch block so tracking comment accurately reflects which phase failed - Rewrite CLAUDE.md to focus on mental model, key concepts, and gotchas instead of exhaustive file listings
This commit is contained in:
@@ -1,6 +1,4 @@
|
||||
import * as core from "@actions/core";
|
||||
|
||||
export function collectActionInputsPresence(): void {
|
||||
export function collectActionInputsPresence(): string {
|
||||
const inputDefaults: Record<string, string> = {
|
||||
trigger_phrase: "@claude",
|
||||
assignee_trigger: "",
|
||||
@@ -32,8 +30,7 @@ export function collectActionInputsPresence(): void {
|
||||
const allInputsJson = process.env.ALL_INPUTS;
|
||||
if (!allInputsJson) {
|
||||
console.log("ALL_INPUTS environment variable not found");
|
||||
core.setOutput("action_inputs_present", JSON.stringify({}));
|
||||
return;
|
||||
return JSON.stringify({});
|
||||
}
|
||||
|
||||
let allInputs: Record<string, string>;
|
||||
@@ -41,8 +38,7 @@ export function collectActionInputsPresence(): void {
|
||||
allInputs = JSON.parse(allInputsJson);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse ALL_INPUTS JSON:", e);
|
||||
core.setOutput("action_inputs_present", JSON.stringify({}));
|
||||
return;
|
||||
return JSON.stringify({});
|
||||
}
|
||||
|
||||
const presentInputs: Record<string, boolean> = {};
|
||||
@@ -54,5 +50,5 @@ export function collectActionInputsPresence(): void {
|
||||
presentInputs[name] = isSet;
|
||||
}
|
||||
|
||||
core.setOutput("action_inputs_present", JSON.stringify(presentInputs));
|
||||
return JSON.stringify(presentInputs);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
/**
|
||||
* Unified entrypoint for the Claude Code Action.
|
||||
* Merges all previously separate action.yml steps (prepare, install, run, cleanup)
|
||||
* into a single TypeScript orchestrator.
|
||||
*/
|
||||
|
||||
import * as core from "@actions/core";
|
||||
import { dirname } from "path";
|
||||
import { spawn } from "child_process";
|
||||
import { appendFile } from "fs/promises";
|
||||
import { existsSync, readFileSync } from "fs";
|
||||
import { setupGitHubToken, WorkflowValidationSkipError } from "../github/token";
|
||||
import { checkWritePermissions } from "../github/validation/permissions";
|
||||
import { createOctokit } from "../github/api/client";
|
||||
import type { Octokits } from "../github/api/client";
|
||||
import { parseGitHubContext, isEntityContext } from "../github/context";
|
||||
import type { GitHubContext } from "../github/context";
|
||||
import { getMode } from "../modes/registry";
|
||||
import { prepare } from "../prepare";
|
||||
import { collectActionInputsPresence } from "./collect-inputs";
|
||||
import { updateCommentLink } from "./update-comment-link";
|
||||
import { formatTurnsFromData } from "./format-turns";
|
||||
import type { Turn } from "./format-turns";
|
||||
// Base-action imports (used directly instead of subprocess)
|
||||
import { validateEnvironmentVariables } from "../../base-action/src/validate-env";
|
||||
import { setupClaudeCodeSettings } from "../../base-action/src/setup-claude-code-settings";
|
||||
import { installPlugins } from "../../base-action/src/install-plugins";
|
||||
import { preparePrompt } from "../../base-action/src/prepare-prompt";
|
||||
import { runClaude } from "../../base-action/src/run-claude";
|
||||
import type { ClaudeRunResult } from "../../base-action/src/run-claude-sdk";
|
||||
|
||||
/**
|
||||
* Install Claude Code CLI, handling retry logic and custom executable paths.
|
||||
*/
|
||||
async function installClaudeCode(): Promise<void> {
|
||||
const customExecutable = process.env.PATH_TO_CLAUDE_CODE_EXECUTABLE;
|
||||
if (customExecutable) {
|
||||
console.log(`Using custom Claude Code executable: ${customExecutable}`);
|
||||
const claudeDir = dirname(customExecutable);
|
||||
// Add to PATH by appending to GITHUB_PATH
|
||||
const githubPath = process.env.GITHUB_PATH;
|
||||
if (githubPath) {
|
||||
await appendFile(githubPath, `${claudeDir}\n`);
|
||||
}
|
||||
// Also add to current process PATH
|
||||
process.env.PATH = `${claudeDir}:${process.env.PATH}`;
|
||||
return;
|
||||
}
|
||||
|
||||
const claudeCodeVersion = "2.1.31";
|
||||
console.log(`Installing Claude Code v${claudeCodeVersion}...`);
|
||||
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
console.log(`Installation attempt ${attempt}...`);
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(
|
||||
"bash",
|
||||
[
|
||||
"-c",
|
||||
`curl -fsSL https://claude.ai/install.sh | bash -s -- ${claudeCodeVersion}`,
|
||||
],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`Install failed with exit code ${code}`));
|
||||
});
|
||||
child.on("error", reject);
|
||||
});
|
||||
console.log("Claude Code installed successfully");
|
||||
// Add to PATH
|
||||
const homeBin = `${process.env.HOME}/.local/bin`;
|
||||
const githubPath = process.env.GITHUB_PATH;
|
||||
if (githubPath) {
|
||||
await appendFile(githubPath, `${homeBin}\n`);
|
||||
}
|
||||
process.env.PATH = `${homeBin}:${process.env.PATH}`;
|
||||
return;
|
||||
} catch (error) {
|
||||
if (attempt === 3) {
|
||||
throw new Error(
|
||||
`Failed to install Claude Code after 3 attempts: ${error}`,
|
||||
);
|
||||
}
|
||||
console.log("Installation failed, retrying...");
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the step summary from Claude's execution output file.
|
||||
*/
|
||||
async function writeStepSummary(executionFile: string): Promise<void> {
|
||||
const summaryFile = process.env.GITHUB_STEP_SUMMARY;
|
||||
if (!summaryFile) return;
|
||||
|
||||
try {
|
||||
const fileContent = readFileSync(executionFile, "utf-8");
|
||||
const data: Turn[] = JSON.parse(fileContent);
|
||||
const markdown = formatTurnsFromData(data);
|
||||
await appendFile(summaryFile, markdown);
|
||||
console.log("Successfully formatted Claude Code report");
|
||||
} catch (error) {
|
||||
console.error(`Failed to format output: ${error}`);
|
||||
// Fall back to raw JSON
|
||||
try {
|
||||
let fallback = "## Claude Code Report (Raw Output)\n\n";
|
||||
fallback +=
|
||||
"Failed to format output (please report). Here's the raw JSON:\n\n";
|
||||
fallback += "```json\n";
|
||||
fallback += readFileSync(executionFile, "utf-8");
|
||||
fallback += "\n```\n";
|
||||
await appendFile(summaryFile, fallback);
|
||||
} catch {
|
||||
console.error("Failed to write raw output to step summary");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
let githubToken: string | undefined;
|
||||
let commentId: number | undefined;
|
||||
let claudeBranch: string | undefined;
|
||||
let baseBranch: string | undefined;
|
||||
let executionFile: string | undefined;
|
||||
let claudeSuccess = false;
|
||||
let prepareSuccess = true;
|
||||
let prepareError: string | undefined;
|
||||
let context: GitHubContext | undefined;
|
||||
let octokit: Octokits | undefined;
|
||||
// Track whether we've completed prepare phase, so we can attribute errors correctly
|
||||
let prepareCompleted = false;
|
||||
try {
|
||||
// Phase 1: Prepare
|
||||
const actionInputsPresent = collectActionInputsPresence();
|
||||
context = parseGitHubContext();
|
||||
const mode = getMode(context);
|
||||
|
||||
try {
|
||||
githubToken = await setupGitHubToken();
|
||||
} catch (error) {
|
||||
if (error instanceof WorkflowValidationSkipError) {
|
||||
core.setOutput("skipped_due_to_workflow_validation_mismatch", "true");
|
||||
console.log("Exiting due to workflow validation skip");
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
octokit = createOctokit(githubToken);
|
||||
|
||||
// Set GITHUB_TOKEN and GH_TOKEN in process env for downstream usage
|
||||
process.env.GITHUB_TOKEN = githubToken;
|
||||
process.env.GH_TOKEN = githubToken;
|
||||
|
||||
// Check write permissions (only for entity contexts)
|
||||
if (isEntityContext(context)) {
|
||||
const hasWritePermissions = await checkWritePermissions(
|
||||
octokit.rest,
|
||||
context,
|
||||
context.inputs.allowedNonWriteUsers,
|
||||
!!process.env.OVERRIDE_GITHUB_TOKEN,
|
||||
);
|
||||
if (!hasWritePermissions) {
|
||||
throw new Error(
|
||||
"Actor does not have write permissions to the repository",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check trigger conditions
|
||||
const containsTrigger = mode.shouldTrigger(context);
|
||||
console.log(`Mode: ${mode.name}`);
|
||||
console.log(`Context prompt: ${context.inputs?.prompt || "NO PROMPT"}`);
|
||||
console.log(`Trigger result: ${containsTrigger}`);
|
||||
|
||||
if (!containsTrigger) {
|
||||
console.log("No trigger found, skipping remaining steps");
|
||||
core.setOutput("github_token", githubToken);
|
||||
return;
|
||||
}
|
||||
|
||||
// Run prepare
|
||||
const prepareResult = await prepare({
|
||||
context,
|
||||
octokit,
|
||||
mode,
|
||||
githubToken,
|
||||
});
|
||||
|
||||
commentId = prepareResult.commentId;
|
||||
claudeBranch = prepareResult.branchInfo.claudeBranch;
|
||||
baseBranch = prepareResult.branchInfo.baseBranch;
|
||||
prepareCompleted = true;
|
||||
|
||||
// Set system prompt if available
|
||||
if (mode.getSystemPrompt) {
|
||||
const modeContext = mode.prepareContext(context, {
|
||||
commentId: prepareResult.commentId,
|
||||
baseBranch: prepareResult.branchInfo.baseBranch,
|
||||
claudeBranch: prepareResult.branchInfo.claudeBranch,
|
||||
});
|
||||
const systemPrompt = mode.getSystemPrompt(modeContext);
|
||||
if (systemPrompt) {
|
||||
core.exportVariable("APPEND_SYSTEM_PROMPT", systemPrompt);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Install Claude Code CLI
|
||||
await installClaudeCode();
|
||||
|
||||
// Phase 3: Run Claude (import base-action directly)
|
||||
// Set env vars needed by the base-action code
|
||||
process.env.INPUT_ACTION_INPUTS_PRESENT = actionInputsPresent;
|
||||
process.env.CLAUDE_CODE_ACTION = "1";
|
||||
process.env.DETAILED_PERMISSION_MESSAGES = "1";
|
||||
|
||||
validateEnvironmentVariables();
|
||||
|
||||
await setupClaudeCodeSettings(process.env.INPUT_SETTINGS);
|
||||
|
||||
await installPlugins(
|
||||
process.env.INPUT_PLUGIN_MARKETPLACES,
|
||||
process.env.INPUT_PLUGINS,
|
||||
process.env.INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE,
|
||||
);
|
||||
|
||||
const promptFile =
|
||||
process.env.INPUT_PROMPT_FILE ||
|
||||
`${process.env.RUNNER_TEMP}/claude-prompts/claude-prompt.txt`;
|
||||
const promptConfig = await preparePrompt({
|
||||
prompt: "",
|
||||
promptFile,
|
||||
});
|
||||
|
||||
const claudeResult: ClaudeRunResult = await runClaude(promptConfig.path, {
|
||||
claudeArgs: prepareResult.claudeArgs,
|
||||
appendSystemPrompt: process.env.APPEND_SYSTEM_PROMPT,
|
||||
model: process.env.ANTHROPIC_MODEL,
|
||||
pathToClaudeCodeExecutable:
|
||||
process.env.INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE,
|
||||
showFullOutput: process.env.INPUT_SHOW_FULL_OUTPUT,
|
||||
});
|
||||
|
||||
claudeSuccess = claudeResult.conclusion === "success";
|
||||
executionFile = claudeResult.executionFile;
|
||||
|
||||
// Set action-level outputs
|
||||
if (claudeResult.executionFile) {
|
||||
core.setOutput("execution_file", claudeResult.executionFile);
|
||||
}
|
||||
if (claudeResult.sessionId) {
|
||||
core.setOutput("session_id", claudeResult.sessionId);
|
||||
}
|
||||
if (claudeResult.structuredOutput) {
|
||||
core.setOutput("structured_output", claudeResult.structuredOutput);
|
||||
}
|
||||
core.setOutput("conclusion", claudeResult.conclusion);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
// Only mark as prepare failure if we haven't completed the prepare phase
|
||||
if (!prepareCompleted) {
|
||||
prepareSuccess = false;
|
||||
prepareError = errorMessage;
|
||||
}
|
||||
core.setFailed(`Action failed with error: ${errorMessage}`);
|
||||
} finally {
|
||||
// Phase 4: Cleanup (always runs)
|
||||
|
||||
// Update tracking comment
|
||||
if (
|
||||
commentId &&
|
||||
context &&
|
||||
isEntityContext(context) &&
|
||||
githubToken &&
|
||||
octokit
|
||||
) {
|
||||
try {
|
||||
await updateCommentLink({
|
||||
commentId,
|
||||
githubToken,
|
||||
claudeBranch,
|
||||
baseBranch: baseBranch || "main",
|
||||
triggerUsername: context.actor,
|
||||
context,
|
||||
octokit,
|
||||
claudeSuccess,
|
||||
outputFile: executionFile,
|
||||
prepareSuccess,
|
||||
prepareError,
|
||||
useCommitSigning: context.inputs.useCommitSigning,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error updating comment with job link:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Write step summary
|
||||
if (executionFile && existsSync(executionFile)) {
|
||||
await writeStepSummary(executionFile);
|
||||
}
|
||||
|
||||
// Set remaining action-level outputs
|
||||
core.setOutput("branch_name", claudeBranch);
|
||||
core.setOutput("github_token", githubToken);
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
run();
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { createOctokit } from "../github/api/client";
|
||||
import type { Octokits } from "../github/api/client";
|
||||
import * as fs from "fs/promises";
|
||||
import {
|
||||
updateCommentBody,
|
||||
@@ -11,229 +12,257 @@ import {
|
||||
isPullRequestReviewCommentEvent,
|
||||
isEntityContext,
|
||||
} from "../github/context";
|
||||
import type { ParsedGitHubContext } from "../github/context";
|
||||
import { GITHUB_SERVER_URL } from "../github/api/config";
|
||||
import { checkAndCommitOrDeleteBranch } from "../github/operations/branch-cleanup";
|
||||
import { updateClaudeComment } from "../github/operations/comments/update-claude-comment";
|
||||
|
||||
export type UpdateCommentLinkParams = {
|
||||
commentId: number;
|
||||
githubToken: string;
|
||||
claudeBranch?: string;
|
||||
baseBranch: string;
|
||||
triggerUsername?: string;
|
||||
context: ParsedGitHubContext;
|
||||
octokit: Octokits;
|
||||
claudeSuccess: boolean;
|
||||
outputFile?: string;
|
||||
prepareSuccess: boolean;
|
||||
prepareError?: string;
|
||||
useCommitSigning: boolean;
|
||||
};
|
||||
|
||||
export async function updateCommentLink(
|
||||
params: UpdateCommentLinkParams,
|
||||
): Promise<void> {
|
||||
const {
|
||||
commentId,
|
||||
claudeBranch,
|
||||
baseBranch,
|
||||
triggerUsername,
|
||||
context,
|
||||
octokit,
|
||||
useCommitSigning,
|
||||
} = params;
|
||||
|
||||
const { owner, repo } = context.repository;
|
||||
|
||||
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
|
||||
const { shouldDeleteBranch, branchLink } = await checkAndCommitOrDeleteBranch(
|
||||
octokit,
|
||||
owner,
|
||||
repo,
|
||||
claudeBranch,
|
||||
baseBranch,
|
||||
useCommitSigning,
|
||||
);
|
||||
|
||||
// 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: {
|
||||
total_cost_usd?: number;
|
||||
duration_ms?: number;
|
||||
duration_api_ms?: number;
|
||||
} | null = null;
|
||||
let actionFailed = false;
|
||||
let errorDetails: string | undefined;
|
||||
|
||||
if (!params.prepareSuccess && params.prepareError) {
|
||||
actionFailed = true;
|
||||
errorDetails = params.prepareError;
|
||||
} else {
|
||||
// Check for existence of output file and parse it if available
|
||||
try {
|
||||
if (params.outputFile) {
|
||||
const fileContent = await fs.readFile(params.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.type === "result" &&
|
||||
"total_cost_usd" in lastElement &&
|
||||
"duration_ms" in lastElement
|
||||
) {
|
||||
executionDetails = {
|
||||
total_cost_usd: lastElement.total_cost_usd,
|
||||
duration_ms: lastElement.duration_ms,
|
||||
duration_api_ms: lastElement.duration_api_ms,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
actionFailed = !params.claudeSuccess;
|
||||
} catch (error) {
|
||||
console.error("Error reading output file:", error);
|
||||
actionFailed = !params.claudeSuccess;
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare input for updateCommentBody function
|
||||
const commentInput: CommentUpdateInput = {
|
||||
currentBody,
|
||||
actionFailed,
|
||||
executionDetails,
|
||||
jobUrl,
|
||||
branchLink,
|
||||
prLink,
|
||||
branchName: shouldDeleteBranch || !branchLink ? undefined : claudeBranch,
|
||||
triggerUsername,
|
||||
errorDetails,
|
||||
};
|
||||
|
||||
const updatedBody = updateCommentBody(commentInput);
|
||||
|
||||
try {
|
||||
await updateClaudeComment(octokit.rest, {
|
||||
owner,
|
||||
repo,
|
||||
commentId,
|
||||
body: updatedBody,
|
||||
isPullRequestReviewComment: isPRReviewComment,
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
// This script is only called for entity-based events
|
||||
if (!isEntityContext(context)) {
|
||||
throw new Error("update-comment-link requires an entity context");
|
||||
}
|
||||
|
||||
const { owner, repo } = context.repository;
|
||||
|
||||
const githubToken = process.env.GITHUB_TOKEN!;
|
||||
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
|
||||
const useCommitSigning = process.env.USE_COMMIT_SIGNING === "true";
|
||||
const { shouldDeleteBranch, branchLink } =
|
||||
await checkAndCommitOrDeleteBranch(
|
||||
octokit,
|
||||
owner,
|
||||
repo,
|
||||
claudeBranch,
|
||||
baseBranch,
|
||||
useCommitSigning,
|
||||
);
|
||||
|
||||
// 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: {
|
||||
total_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.type === "result" &&
|
||||
"total_cost_usd" in lastElement &&
|
||||
"duration_ms" in lastElement
|
||||
) {
|
||||
executionDetails = {
|
||||
total_cost_usd: lastElement.total_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 || !branchLink ? undefined : claudeBranch,
|
||||
triggerUsername,
|
||||
errorDetails,
|
||||
};
|
||||
|
||||
const updatedBody = updateCommentBody(commentInput);
|
||||
|
||||
try {
|
||||
await updateClaudeComment(octokit.rest, {
|
||||
owner,
|
||||
repo,
|
||||
commentId,
|
||||
body: updatedBody,
|
||||
isPullRequestReviewComment: isPRReviewComment,
|
||||
});
|
||||
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;
|
||||
}
|
||||
await updateCommentLink({
|
||||
commentId: parseInt(process.env.CLAUDE_COMMENT_ID!),
|
||||
githubToken,
|
||||
claudeBranch: process.env.CLAUDE_BRANCH,
|
||||
baseBranch: process.env.BASE_BRANCH || "main",
|
||||
triggerUsername: process.env.TRIGGER_USERNAME,
|
||||
context,
|
||||
octokit,
|
||||
claudeSuccess: process.env.CLAUDE_SUCCESS !== "false",
|
||||
outputFile: process.env.OUTPUT_FILE,
|
||||
prepareSuccess: process.env.PREPARE_SUCCESS !== "false",
|
||||
prepareError: process.env.PREPARE_ERROR,
|
||||
useCommitSigning: process.env.USE_COMMIT_SIGNING === "true",
|
||||
});
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
@@ -242,4 +271,6 @@ async function run() {
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
if (import.meta.main) {
|
||||
run();
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
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";
|
||||
@@ -265,9 +264,6 @@ export async function setupBranch(
|
||||
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,
|
||||
@@ -294,9 +290,6 @@ export async function setupBranch(
|
||||
`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,
|
||||
|
||||
+28
-32
@@ -3,6 +3,13 @@
|
||||
import * as core from "@actions/core";
|
||||
import { retryWithBackoff } from "../utils/retry";
|
||||
|
||||
export class WorkflowValidationSkipError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "WorkflowValidationSkipError";
|
||||
}
|
||||
}
|
||||
|
||||
async function getOidcToken(): Promise<string> {
|
||||
try {
|
||||
const oidcToken = await core.getIDToken("claude-code-github-action");
|
||||
@@ -96,8 +103,7 @@ async function exchangeForAppToken(
|
||||
console.log(
|
||||
"Action skipped due to workflow validation error. This is expected when adding Claude Code workflows to new repositories or on PRs with workflow changes. If you're seeing this, your workflow will begin working once you merge your PR.",
|
||||
);
|
||||
core.setOutput("skipped_due_to_workflow_validation_mismatch", "true");
|
||||
process.exit(0);
|
||||
throw new WorkflowValidationSkipError(message);
|
||||
}
|
||||
|
||||
console.error(
|
||||
@@ -120,36 +126,26 @@ async function exchangeForAppToken(
|
||||
}
|
||||
|
||||
export async function setupGitHubToken(): Promise<string> {
|
||||
try {
|
||||
// Check if GitHub token was provided as override
|
||||
const providedToken = process.env.OVERRIDE_GITHUB_TOKEN;
|
||||
// Check if GitHub token was provided as override
|
||||
const providedToken = process.env.OVERRIDE_GITHUB_TOKEN;
|
||||
|
||||
if (providedToken) {
|
||||
console.log("Using provided GITHUB_TOKEN for authentication");
|
||||
core.setOutput("GITHUB_TOKEN", providedToken);
|
||||
return providedToken;
|
||||
}
|
||||
|
||||
console.log("Requesting OIDC token...");
|
||||
const oidcToken = await retryWithBackoff(() => getOidcToken());
|
||||
console.log("OIDC token successfully obtained");
|
||||
|
||||
const permissions = parseAdditionalPermissions();
|
||||
|
||||
console.log("Exchanging OIDC token for app token...");
|
||||
const appToken = await retryWithBackoff(() =>
|
||||
exchangeForAppToken(oidcToken, permissions),
|
||||
);
|
||||
console.log("App token successfully obtained");
|
||||
|
||||
console.log("Using GITHUB_TOKEN from OIDC");
|
||||
core.setOutput("GITHUB_TOKEN", appToken);
|
||||
return appToken;
|
||||
} catch (error) {
|
||||
// Only set failed if we get here - workflow validation errors will exit(0) before this
|
||||
core.setFailed(
|
||||
`Failed to setup GitHub token: ${error}\n\nIf you instead wish to use this action with a custom GitHub token or custom GitHub app, provide a \`github_token\` in the \`uses\` section of the app in your workflow yml file.`,
|
||||
);
|
||||
process.exit(1);
|
||||
if (providedToken) {
|
||||
console.log("Using provided GITHUB_TOKEN for authentication");
|
||||
return providedToken;
|
||||
}
|
||||
|
||||
console.log("Requesting OIDC token...");
|
||||
const oidcToken = await retryWithBackoff(() => getOidcToken());
|
||||
console.log("OIDC token successfully obtained");
|
||||
|
||||
const permissions = parseAdditionalPermissions();
|
||||
|
||||
console.log("Exchanging OIDC token for app token...");
|
||||
const appToken = await retryWithBackoff(() =>
|
||||
exchangeForAppToken(oidcToken, permissions),
|
||||
);
|
||||
console.log("App token successfully obtained");
|
||||
|
||||
console.log("Using GITHUB_TOKEN from OIDC");
|
||||
return appToken;
|
||||
}
|
||||
|
||||
@@ -183,8 +183,6 @@ export const agentMode: Mode = {
|
||||
// Append user's claude_args (which may have more --mcp-config flags)
|
||||
claudeArgs = `${claudeArgs} ${userClaudeArgs}`.trim();
|
||||
|
||||
core.setOutput("claude_args", claudeArgs);
|
||||
|
||||
return {
|
||||
commentId: undefined,
|
||||
branchInfo: {
|
||||
@@ -193,6 +191,7 @@ export const agentMode: Mode = {
|
||||
claudeBranch: claudeBranch,
|
||||
},
|
||||
mcpConfig: ourMcpConfig,
|
||||
claudeArgs,
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as core from "@actions/core";
|
||||
import type { Mode, ModeOptions, ModeResult } from "../types";
|
||||
import { checkContainsTrigger } from "../../github/validation/trigger";
|
||||
import { checkHumanActor } from "../../github/validation/actor";
|
||||
@@ -211,12 +210,11 @@ export const tagMode: Mode = {
|
||||
claudeArgs += ` ${userClaudeArgs}`;
|
||||
}
|
||||
|
||||
core.setOutput("claude_args", claudeArgs.trim());
|
||||
|
||||
return {
|
||||
commentId,
|
||||
branchInfo,
|
||||
mcpConfig: ourMcpConfig,
|
||||
claudeArgs: claudeArgs.trim(),
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -97,4 +97,5 @@ export type ModeResult = {
|
||||
currentBranch: string;
|
||||
};
|
||||
mcpConfig: string;
|
||||
claudeArgs: string;
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ export type PrepareResult = {
|
||||
currentBranch: string;
|
||||
};
|
||||
mcpConfig: string;
|
||||
claudeArgs: string;
|
||||
};
|
||||
|
||||
export type PrepareOptions = {
|
||||
|
||||
Reference in New Issue
Block a user