diff --git a/action.yml b/action.yml index a0880cf..255defc 100644 --- a/action.yml +++ b/action.yml @@ -137,19 +137,19 @@ inputs: outputs: execution_file: description: "Path to the Claude Code execution output file" - value: ${{ steps.claude-code.outputs.execution_file }} + value: ${{ steps.run.outputs.execution_file }} branch_name: description: "The branch created by Claude Code for this execution" - value: ${{ steps.prepare.outputs.CLAUDE_BRANCH }} + value: ${{ steps.run.outputs.branch_name }} github_token: description: "The GitHub token used by the action (Claude App token if available)" - value: ${{ steps.prepare.outputs.github_token }} + value: ${{ steps.run.outputs.github_token }} 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" - value: ${{ steps.claude-code.outputs.structured_output }} + value: ${{ steps.run.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 }} + value: ${{ steps.run.outputs.session_id }} runs: using: "composite" @@ -178,12 +178,13 @@ runs: cd ${GITHUB_ACTION_PATH} bun install - - name: Prepare action - id: prepare + - name: Run Claude Code Action + id: run shell: bash run: | - bun run ${GITHUB_ACTION_PATH}/src/entrypoints/prepare.ts + bun run ${GITHUB_ACTION_PATH}/src/entrypoints/run.ts env: + # Prepare inputs MODE: ${{ inputs.mode }} PROMPT: ${{ inputs.prompt }} TRIGGER_PHRASE: ${{ inputs.trigger_phrase }} @@ -210,73 +211,19 @@ runs: CLAUDE_ARGS: ${{ inputs.claude_args }} ALL_INPUTS: ${{ toJson(inputs) }} - - name: Install Base Action Dependencies - if: steps.prepare.outputs.contains_trigger == 'true' - shell: bash - env: - PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }} - run: | - echo "Installing base-action dependencies..." - cd ${GITHUB_ACTION_PATH}/base-action - bun install - echo "Base-action dependencies installed" - cd - - - # Install Claude Code if no custom executable is provided - if [ -z "$PATH_TO_CLAUDE_CODE_EXECUTABLE" ]; then - CLAUDE_CODE_VERSION="2.1.31" - echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..." - for attempt in 1 2 3; do - echo "Installation attempt $attempt..." - if command -v timeout &> /dev/null; then - # Use --foreground to kill entire process group on timeout, --kill-after to send SIGKILL if SIGTERM fails - timeout --foreground --kill-after=10 120 bash -c "curl -fsSL https://claude.ai/install.sh | bash -s -- $CLAUDE_CODE_VERSION" && break - else - curl -fsSL https://claude.ai/install.sh | bash -s -- "$CLAUDE_CODE_VERSION" && break - fi - if [ $attempt -eq 3 ]; then - echo "Failed to install Claude Code after 3 attempts" - exit 1 - fi - echo "Installation failed, retrying..." - sleep 5 - done - echo "Claude Code installed successfully" - echo "$HOME/.local/bin" >> "$GITHUB_PATH" - else - echo "Using custom Claude Code executable: $PATH_TO_CLAUDE_CODE_EXECUTABLE" - # Add the directory containing the custom executable to PATH - CLAUDE_DIR=$(dirname "$PATH_TO_CLAUDE_CODE_EXECUTABLE") - echo "$CLAUDE_DIR" >> "$GITHUB_PATH" - fi - - - name: Run Claude Code - id: claude-code - if: steps.prepare.outputs.contains_trigger == 'true' - shell: bash - run: | - - # Run the base-action - bun run ${GITHUB_ACTION_PATH}/base-action/src/index.ts - env: # Base-action inputs - CLAUDE_CODE_ACTION: "1" INPUT_PROMPT_FILE: ${{ runner.temp }}/claude-prompts/claude-prompt.txt INPUT_SETTINGS: ${{ inputs.settings }} - INPUT_CLAUDE_ARGS: ${{ steps.prepare.outputs.claude_args }} INPUT_EXPERIMENTAL_SLASH_COMMANDS_DIR: ${{ github.action_path }}/slash-commands - INPUT_ACTION_INPUTS_PRESENT: ${{ steps.prepare.outputs.action_inputs_present }} INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }} INPUT_PATH_TO_BUN_EXECUTABLE: ${{ inputs.path_to_bun_executable }} INPUT_SHOW_FULL_OUTPUT: ${{ inputs.show_full_output }} INPUT_PLUGINS: ${{ inputs.plugins }} INPUT_PLUGIN_MARKETPLACES: ${{ inputs.plugin_marketplaces }} + PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }} # Model configuration - GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }} - GH_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }} NODE_VERSION: ${{ env.NODE_VERSION }} - DETAILED_PERMISSION_MESSAGES: "1" # Provider configuration ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }} @@ -323,63 +270,3 @@ runs: OTEL_METRIC_EXPORT_INTERVAL: ${{ env.OTEL_METRIC_EXPORT_INTERVAL }} OTEL_LOGS_EXPORT_INTERVAL: ${{ env.OTEL_LOGS_EXPORT_INTERVAL }} OTEL_RESOURCE_ATTRIBUTES: ${{ env.OTEL_RESOURCE_ATTRIBUTES }} - - - name: Update comment with job link - if: steps.prepare.outputs.contains_trigger == 'true' && steps.prepare.outputs.claude_comment_id && always() - shell: bash - run: | - bun run ${GITHUB_ACTION_PATH}/src/entrypoints/update-comment-link.ts - env: - REPOSITORY: ${{ github.repository }} - PR_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }} - CLAUDE_COMMENT_ID: ${{ steps.prepare.outputs.claude_comment_id }} - GITHUB_RUN_ID: ${{ github.run_id }} - GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }} - GH_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }} - GITHUB_EVENT_NAME: ${{ github.event_name }} - TRIGGER_COMMENT_ID: ${{ github.event.comment.id }} - CLAUDE_BRANCH: ${{ steps.prepare.outputs.CLAUDE_BRANCH }} - IS_PR: ${{ github.event.issue.pull_request != null || github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review_comment' }} - BASE_BRANCH: ${{ steps.prepare.outputs.BASE_BRANCH }} - CLAUDE_SUCCESS: ${{ steps.claude-code.outputs.conclusion == 'success' }} - OUTPUT_FILE: ${{ steps.claude-code.outputs.execution_file || '' }} - TRIGGER_USERNAME: ${{ github.event.comment.user.login || github.event.issue.user.login || github.event.pull_request.user.login || github.event.sender.login || github.triggering_actor || github.actor || '' }} - PREPARE_SUCCESS: ${{ steps.prepare.outcome == 'success' }} - PREPARE_ERROR: ${{ steps.prepare.outputs.prepare_error || '' }} - USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }} - USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }} - TRACK_PROGRESS: ${{ inputs.track_progress }} - - - name: Display Claude Code Report - if: steps.prepare.outputs.contains_trigger == 'true' && steps.claude-code.outputs.execution_file != '' - shell: bash - run: | - # Try to format the turns, but if it fails, dump the raw JSON - if bun run ${{ github.action_path }}/src/entrypoints/format-turns.ts "${{ steps.claude-code.outputs.execution_file }}" >> $GITHUB_STEP_SUMMARY 2>/dev/null; then - echo "Successfully formatted Claude Code report" - else - echo "## Claude Code Report (Raw Output)" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "Failed to format output (please report). Here's the raw JSON:" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo '```json' >> $GITHUB_STEP_SUMMARY - cat "${{ steps.claude-code.outputs.execution_file }}" >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - fi - - - name: Cleanup SSH signing key - if: always() && inputs.ssh_signing_key != '' - shell: bash - run: | - bun run ${GITHUB_ACTION_PATH}/src/entrypoints/cleanup-ssh-signing.ts - - - name: Revoke app token - if: always() && inputs.github_token == '' && steps.prepare.outputs.skipped_due_to_workflow_validation_mismatch != 'true' - shell: bash - run: | - curl -L \ - -X DELETE \ - -H "Accept: application/vnd.github+json" \ - -H "Authorization: Bearer ${{ steps.prepare.outputs.GITHUB_TOKEN }}" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - ${GITHUB_API_URL:-https://api.github.com}/installation/token diff --git a/base-action/src/index.ts b/base-action/src/index.ts index b10f1ca..970e79d 100644 --- a/base-action/src/index.ts +++ b/base-action/src/index.ts @@ -28,7 +28,7 @@ async function run() { promptFile: process.env.INPUT_PROMPT_FILE || "", }); - await runClaude(promptConfig.path, { + const result = await runClaude(promptConfig.path, { claudeArgs: process.env.INPUT_CLAUDE_ARGS, allowedTools: process.env.INPUT_ALLOWED_TOOLS, disallowedTools: process.env.INPUT_DISALLOWED_TOOLS, @@ -42,6 +42,18 @@ async function run() { process.env.INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE, showFullOutput: process.env.INPUT_SHOW_FULL_OUTPUT, }); + + // Set outputs for the standalone base-action + core.setOutput("conclusion", result.conclusion); + if (result.executionFile) { + core.setOutput("execution_file", result.executionFile); + } + if (result.sessionId) { + core.setOutput("session_id", result.sessionId); + } + if (result.structuredOutput) { + core.setOutput("structured_output", result.structuredOutput); + } } catch (error) { core.setFailed(`Action failed with error: ${error}`); core.setOutput("conclusion", "failure"); diff --git a/base-action/src/run-claude-sdk.ts b/base-action/src/run-claude-sdk.ts index 16ee3c4..d67c6c4 100644 --- a/base-action/src/run-claude-sdk.ts +++ b/base-action/src/run-claude-sdk.ts @@ -9,6 +9,13 @@ import type { } from "@anthropic-ai/claude-agent-sdk"; import type { ParsedSdkOptions } from "./parse-sdk-options"; +export type ClaudeRunResult = { + executionFile?: string; + sessionId?: string; + conclusion: "success" | "failure"; + structuredOutput?: string; +}; + const EXECUTION_FILE = `${process.env.RUNNER_TEMP}/claude-execution-output.json`; /** Filename for the user request file, written by prompt generation */ @@ -129,7 +136,7 @@ function sanitizeSdkOutput( export async function runClaudeWithSdk( promptPath: string, { sdkOptions, showFullOutput, hasJsonSchema }: ParsedSdkOptions, -): Promise { +): Promise { // Create prompt configuration - may be a string or multi-block message const prompt = await createPromptConfig(promptPath, showFullOutput); @@ -165,36 +172,38 @@ export async function runClaudeWithSdk( } } catch (error) { console.error("SDK execution error:", error); - core.setOutput("conclusion", "failure"); - process.exit(1); + throw new Error(`SDK execution error: ${error}`); } + const result: ClaudeRunResult = { + conclusion: "failure", + }; + // Write execution file try { await writeFile(EXECUTION_FILE, JSON.stringify(messages, null, 2)); console.log(`Log saved to ${EXECUTION_FILE}`); - core.setOutput("execution_file", EXECUTION_FILE); + result.executionFile = EXECUTION_FILE; } catch (error) { core.warning(`Failed to write execution file: ${error}`); } - // Extract and set session_id from system.init message + // Extract session_id from system.init message const initMessage = messages.find( (m) => m.type === "system" && "subtype" in m && m.subtype === "init", ); if (initMessage && "session_id" in initMessage && initMessage.session_id) { - core.setOutput("session_id", initMessage.session_id); - core.info(`Set session_id: ${initMessage.session_id}`); + result.sessionId = initMessage.session_id as string; + core.info(`Set session_id: ${result.sessionId}`); } if (!resultMessage) { - core.setOutput("conclusion", "failure"); core.error("No result message received from Claude"); - process.exit(1); + throw new Error("No result message received from Claude"); } const isSuccess = resultMessage.subtype === "success"; - core.setOutput("conclusion", isSuccess ? "success" : "failure"); + result.conclusion = isSuccess ? "success" : "failure"; // Handle structured output if (hasJsonSchema) { @@ -203,10 +212,7 @@ export async function runClaudeWithSdk( "structured_output" in resultMessage && resultMessage.structured_output ) { - const structuredOutputJson = JSON.stringify( - resultMessage.structured_output, - ); - core.setOutput("structured_output", structuredOutputJson); + result.structuredOutput = JSON.stringify(resultMessage.structured_output); core.info( `Set structured_output with ${Object.keys(resultMessage.structured_output as object).length} field(s)`, ); @@ -214,8 +220,10 @@ export async function runClaudeWithSdk( core.setFailed( `--json-schema was provided but Claude did not return structured_output. Result subtype: ${resultMessage.subtype}`, ); - core.setOutput("conclusion", "failure"); - process.exit(1); + result.conclusion = "failure"; + throw new Error( + `--json-schema was provided but Claude did not return structured_output. Result subtype: ${resultMessage.subtype}`, + ); } } @@ -223,6 +231,14 @@ export async function runClaudeWithSdk( if ("errors" in resultMessage && resultMessage.errors) { core.error(`Execution failed: ${resultMessage.errors.join(", ")}`); } - process.exit(1); + throw new Error( + `Claude execution failed: ${ + "errors" in resultMessage && resultMessage.errors + ? resultMessage.errors.join(", ") + : "unknown error" + }`, + ); } + + return result; } diff --git a/base-action/src/run-claude.ts b/base-action/src/run-claude.ts index e644cd5..b18b3f9 100644 --- a/base-action/src/run-claude.ts +++ b/base-action/src/run-claude.ts @@ -1,4 +1,5 @@ import { runClaudeWithSdk } from "./run-claude-sdk"; +import type { ClaudeRunResult } from "./run-claude-sdk"; import { parseSdkOptions } from "./parse-sdk-options"; export type ClaudeOptions = { @@ -15,7 +16,10 @@ export type ClaudeOptions = { showFullOutput?: string; }; -export async function runClaude(promptPath: string, options: ClaudeOptions) { +export async function runClaude( + promptPath: string, + options: ClaudeOptions, +): Promise { const parsedOptions = parseSdkOptions(options); return runClaudeWithSdk(promptPath, parsedOptions); } diff --git a/src/entrypoints/collect-inputs.ts b/src/entrypoints/collect-inputs.ts index 0d240a6..a463743 100644 --- a/src/entrypoints/collect-inputs.ts +++ b/src/entrypoints/collect-inputs.ts @@ -1,6 +1,4 @@ -import * as core from "@actions/core"; - -export function collectActionInputsPresence(): void { +export function collectActionInputsPresence(): string { const inputDefaults: Record = { 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; @@ -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 = {}; @@ -54,5 +50,5 @@ export function collectActionInputsPresence(): void { presentInputs[name] = isSet; } - core.setOutput("action_inputs_present", JSON.stringify(presentInputs)); + return JSON.stringify(presentInputs); } diff --git a/src/entrypoints/run.ts b/src/entrypoints/run.ts new file mode 100644 index 0000000..b6755f9 --- /dev/null +++ b/src/entrypoints/run.ts @@ -0,0 +1,361 @@ +#!/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 { 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"; +import { cleanupSshSigning } from "../github/operations/git-config"; +import { GITHUB_API_URL } from "../github/api/config"; + +// 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 { + const customExecutable = process.env.PATH_TO_CLAUDE_CODE_EXECUTABLE; + if (customExecutable) { + console.log(`Using custom Claude Code executable: ${customExecutable}`); + const claudeDir = customExecutable.substring( + 0, + customExecutable.lastIndexOf("/"), + ); + // 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((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 { + 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"); + } + } +} + +/** + * Revoke the GitHub App installation token. + */ +async function revokeAppToken(githubToken: string): Promise { + try { + const apiUrl = GITHUB_API_URL; + const response = await fetch(`${apiUrl}/installation/token`, { + method: "DELETE", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${githubToken}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + if (response.ok) { + console.log("App token revoked successfully"); + } else { + console.error( + `Failed to revoke app token: ${response.status} ${response.statusText}`, + ); + } + } catch (error) { + console.error("Error revoking app token:", error); + } +} + +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; + let useSshSigning = false; + let useOverrideToken = false; + let skippedDueToWorkflowValidation = false; + + try { + // Phase 1: Prepare + const actionInputsPresent = collectActionInputsPresence(); + context = parseGitHubContext(); + const mode = getMode(context); + + try { + githubToken = await setupGitHubToken(); + } catch (error) { + if (error instanceof WorkflowValidationSkipError) { + skippedDueToWorkflowValidation = true; + core.setOutput("skipped_due_to_workflow_validation_mismatch", "true"); + console.log("Exiting due to workflow validation skip"); + return; + } + throw error; + } + + useOverrideToken = !!process.env.OVERRIDE_GITHUB_TOKEN; + 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, + useOverrideToken, + ); + 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; + useSshSigning = !!context.inputs.sshSigningKey; + + // 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); + 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); + } + + // Cleanup SSH signing key + if (useSshSigning) { + try { + await cleanupSshSigning(); + } catch (error) { + console.error("Failed to cleanup SSH signing key:", error); + } + } + + // Revoke app token (only if we're using the app token, not an override) + if (githubToken && !useOverrideToken && !skippedDueToWorkflowValidation) { + await revokeAppToken(githubToken); + } + + // Set remaining action-level outputs + core.setOutput("branch_name", claudeBranch); + core.setOutput("github_token", githubToken); + } +} + +if (import.meta.main) { + run(); +} diff --git a/src/entrypoints/update-comment-link.ts b/src/entrypoints/update-comment-link.ts index 849f954..c7bd8d6 100644 --- a/src/entrypoints/update-comment-link.ts +++ b/src/entrypoints/update-comment-link.ts @@ -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 { + 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(); +} diff --git a/src/github/operations/branch.ts b/src/github/operations/branch.ts index aea1b9c..86197da 100644 --- a/src/github/operations/branch.ts +++ b/src/github/operations/branch.ts @@ -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, diff --git a/src/github/token.ts b/src/github/token.ts index 54948d1..96f2808 100644 --- a/src/github/token.ts +++ b/src/github/token.ts @@ -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 { 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 { - 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; } diff --git a/src/modes/agent/index.ts b/src/modes/agent/index.ts index 59b78b4..0e9376c 100644 --- a/src/modes/agent/index.ts +++ b/src/modes/agent/index.ts @@ -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, }; }, diff --git a/src/modes/tag/index.ts b/src/modes/tag/index.ts index 4e7c2a8..1135c51 100644 --- a/src/modes/tag/index.ts +++ b/src/modes/tag/index.ts @@ -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(), }; }, diff --git a/src/modes/types.ts b/src/modes/types.ts index 1f5069a..3d653cc 100644 --- a/src/modes/types.ts +++ b/src/modes/types.ts @@ -97,4 +97,5 @@ export type ModeResult = { currentBranch: string; }; mcpConfig: string; + claudeArgs: string; }; diff --git a/src/prepare/types.ts b/src/prepare/types.ts index c064275..72ece51 100644 --- a/src/prepare/types.ts +++ b/src/prepare/types.ts @@ -10,6 +10,7 @@ export type PrepareResult = { currentBranch: string; }; mcpConfig: string; + claudeArgs: string; }; export type PrepareOptions = { diff --git a/test/create-prompt.test.ts b/test/create-prompt.test.ts index 905a6b4..ca24681 100644 --- a/test/create-prompt.test.ts +++ b/test/create-prompt.test.ts @@ -31,6 +31,7 @@ describe("generatePrompt", () => { claudeBranch: undefined, }, mcpConfig: "{}", + claudeArgs: "", }), }; @@ -52,6 +53,7 @@ describe("generatePrompt", () => { claudeBranch: undefined, }, mcpConfig: "{}", + claudeArgs: "", }), }; diff --git a/test/modes/agent.test.ts b/test/modes/agent.test.ts index 25bf844..b722406 100644 --- a/test/modes/agent.test.ts +++ b/test/modes/agent.test.ts @@ -163,10 +163,8 @@ describe("Agent Mode", () => { }); // Verify claude_args includes user args (no MCP config in agent mode without allowed tools) - const callArgs = setOutputSpy.mock.calls[0]; - expect(callArgs[0]).toBe("claude_args"); - expect(callArgs[1]).toBe("--model claude-sonnet-4 --max-turns 10"); - expect(callArgs[1]).not.toContain("--mcp-config"); + expect(result.claudeArgs).toBe("--model claude-sonnet-4 --max-turns 10"); + expect(result.claudeArgs).not.toContain("--mcp-config"); // Verify return structure - should use "main" as fallback when no env vars set expect(result).toEqual({ @@ -177,6 +175,7 @@ describe("Agent Mode", () => { claudeBranch: undefined, }, mcpConfig: expect.any(String), + claudeArgs: "--model claude-sonnet-4 --max-turns 10", }); // Clean up @@ -269,7 +268,7 @@ describe("Agent Mode", () => { }, }, } as any; - await agentMode.prepare({ + const result = await agentMode.prepare({ context: contextWithPrompts, octokit: mockOctokit, githubToken: "test-token", @@ -279,9 +278,7 @@ describe("Agent Mode", () => { // but we can verify the method completes without errors // With our conditional MCP logic, agent mode with no allowed tools // should not include any MCP config - const callArgs = setOutputSpy.mock.calls[0]; - expect(callArgs[0]).toBe("claude_args"); // Should be empty or just whitespace when no MCP servers are included - expect(callArgs[1]).not.toContain("--mcp-config"); + expect(result.claudeArgs).not.toContain("--mcp-config"); }); }); diff --git a/test/pull-request-target.test.ts b/test/pull-request-target.test.ts index 48bfd19..7f4fd34 100644 --- a/test/pull-request-target.test.ts +++ b/test/pull-request-target.test.ts @@ -29,6 +29,7 @@ describe("pull_request_target event support", () => { claudeBranch: undefined, }, mcpConfig: "{}", + claudeArgs: "", }), }; @@ -313,6 +314,7 @@ describe("pull_request_target event support", () => { claudeBranch: undefined, }, mcpConfig: "{}", + claudeArgs: "", }), };