diff --git a/CLAUDE.md b/CLAUDE.md index 7834fc2d6..e05c2ee9c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,136 +1,44 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Development Tools - -- Runtime: Bun 1.2.11 -- TypeScript with strict configuration - -## Common Development Tasks - -### Available npm/bun scripts from package.json: +## Commands ```bash -# Test -bun test - -# Formatting -bun run format # Format code with prettier -bun run format:check # Check code formatting - -# Type checking -bun run typecheck # Run TypeScript type checker +bun test # Run tests +bun run typecheck # TypeScript type checking +bun run format # Format with prettier +bun run format:check # Check formatting ``` -## Architecture Overview - -This is a GitHub Action that enables Claude to interact with GitHub PRs and issues. The action operates in two main phases: - -### Phase 1: Preparation (`src/entrypoints/prepare.ts`) - -1. **Authentication Setup**: Establishes GitHub token via OIDC or GitHub App -2. **Permission Validation**: Verifies actor has write permissions -3. **Trigger Detection**: Uses mode-specific logic to determine if Claude should respond -4. **Context Creation**: Prepares GitHub context and initial tracking comment - -### Phase 2: Execution (`base-action/`) - -The `base-action/` directory contains the core Claude Code execution logic, which serves a dual purpose: - -- **Standalone Action**: Published separately as `@anthropic-ai/claude-code-base-action` for direct use -- **Inner Logic**: Used internally by this GitHub Action after preparation phase completes - -Execution steps: +## What This Is -1. **MCP Server Setup**: Installs and configures GitHub MCP server for tool access -2. **Prompt Generation**: Creates context-rich prompts from GitHub data -3. **Claude Integration**: Executes via multiple providers (Anthropic API, AWS Bedrock, Google Vertex AI) -4. **Result Processing**: Updates comments and creates branches/PRs as needed - -### Key Architectural Components - -#### Mode System (`src/modes/`) - -- **Tag Mode** (`tag/`): Responds to `@claude` mentions and issue assignments -- **Agent Mode** (`agent/`): Direct execution when explicit prompt is provided -- Extensible registry pattern in `modes/registry.ts` - -#### GitHub Integration (`src/github/`) - -- **Context Parsing** (`context.ts`): Unified GitHub event handling -- **Data Fetching** (`data/fetcher.ts`): Retrieves PR/issue data via GraphQL/REST -- **Data Formatting** (`data/formatter.ts`): Converts GitHub data to Claude-readable format -- **Branch Operations** (`operations/branch.ts`): Handles branch creation and cleanup -- **Comment Management** (`operations/comments/`): Creates and updates tracking comments - -#### MCP Server Integration (`src/mcp/`) - -- **GitHub Actions Server** (`github-actions-server.ts`): Workflow and CI access -- **GitHub Comment Server** (`github-comment-server.ts`): Comment operations -- **GitHub File Operations** (`github-file-ops-server.ts`): File system access -- Auto-installation and configuration in `install-mcp-server.ts` - -#### Authentication & Security (`src/github/`) - -- **Token Management** (`token.ts`): OIDC token exchange and GitHub App authentication -- **Permission Validation** (`validation/permissions.ts`): Write access verification -- **Actor Validation** (`validation/actor.ts`): Human vs bot detection - -### Project Structure - -``` -src/ -├── entrypoints/ # Action entry points -│ ├── prepare.ts # Main preparation logic -│ ├── update-comment-link.ts # Post-execution comment updates -│ └── format-turns.ts # Claude conversation formatting -├── github/ # GitHub integration layer -│ ├── api/ # REST/GraphQL clients -│ ├── data/ # Data fetching and formatting -│ ├── operations/ # Branch, comment, git operations -│ ├── validation/ # Permission and trigger validation -│ └── utils/ # Image downloading, sanitization -├── modes/ # Execution modes -│ ├── tag/ # @claude mention mode -│ ├── agent/ # Automation mode -│ └── registry.ts # Mode selection logic -├── mcp/ # MCP server implementations -├── prepare/ # Preparation orchestration -└── utils/ # Shared utilities -``` +A GitHub Action that lets Claude respond to `@claude` mentions on issues/PRs (tag mode) or run tasks via `prompt` input (agent mode). Mode is auto-detected: if `prompt` is provided, it's agent mode; if triggered by a comment/issue event with `@claude`, it's tag mode. See `src/modes/registry.ts`. -## Important Implementation Notes +## How It Runs -### Authentication Flow +Single entrypoint: `src/entrypoints/run.ts` orchestrates everything — prepare (auth, permissions, trigger check, branch/comment creation), install Claude Code CLI, execute Claude via `base-action/` functions (imported directly, not subprocess), then cleanup (update tracking comment, write step summary). SSH signing cleanup and token revocation are separate `always()` steps in `action.yml`. -- Uses GitHub OIDC token exchange for secure authentication -- Supports custom GitHub Apps via `APP_ID` and `APP_PRIVATE_KEY` -- Falls back to official Claude GitHub App if no custom app provided +`base-action/` is also published standalone as `@anthropic-ai/claude-code-base-action`. Don't break its public API. It reads config from `INPUT_`-prefixed env vars (set by `action.yml`), not from action inputs directly. -### MCP Server Architecture +## Key Concepts -- Each MCP server has specific GitHub API access patterns -- Servers are auto-installed in `~/.claude/mcp/github-{type}-server/` -- Configuration merged with user-provided MCP config via `mcp_config` input +**Auth priority**: `github_token` input (user-provided) > GitHub App OIDC token (default). The `claude_code_oauth_token` and `anthropic_api_key` are for the Claude API, not GitHub. Token setup lives in `src/github/token.ts`. -### Mode System Design +**Mode lifecycle**: Modes implement `shouldTrigger()` → `prepare()` → `prepareContext()` → `getSystemPrompt()`. The registry in `src/modes/registry.ts` picks the mode based on event type and inputs. To add a new mode, implement the `Mode` type from `src/modes/types.ts` and register it. -- Modes implement `Mode` interface with `shouldTrigger()` and `prepare()` methods -- Registry validates mode compatibility with GitHub event types -- Agent mode triggers when explicit prompt is provided +**Prompt construction**: `src/prepare/` builds the prompt by fetching GitHub data (`src/github/data/fetcher.ts`), formatting it as markdown (`src/github/data/formatter.ts`), and writing it to a temp file. The prompt includes issue/PR body, comments, diff, and CI status. This is the most important part of the action — it's what Claude sees. -### Comment Threading +## Things That Will Bite You -- Single tracking comment updated throughout execution -- Progress indicated via dynamic checkboxes -- Links to job runs and created branches/PRs -- Sticky comment option for consolidated PR comments +- **Strict TypeScript**: `noUnusedLocals` and `noUnusedParameters` are enabled. Typecheck will fail on unused variables. +- **Discriminated unions for GitHub context**: `GitHubContext` is a union type — call `isEntityContext(context)` before accessing entity-specific fields like `context.issue` or `context.pullRequest`. +- **Token lifecycle matters**: The GitHub App token is obtained early and revoked in a separate `always()` step in `action.yml`. If you move token revocation into `run.ts`, it won't run if the process crashes. Same for SSH signing cleanup. +- **Error phase attribution**: The catch block in `run.ts` uses `prepareCompleted` to distinguish prepare failures from execution failures. The tracking comment shows different messages for each. +- **`action.yml` outputs reference step IDs**: Outputs like `execution_file`, `branch_name`, `github_token` reference `steps.run.outputs.*`. If you rename the step ID, update the outputs section too. +- **Integration testing** happens in a separate repo (`install-test`), not here. The tests in this repo are unit tests. ## Code Conventions -- Use Bun-specific TypeScript configuration with `moduleResolution: "bundler"` -- Strict TypeScript with `noUnusedLocals` and `noUnusedParameters` enabled -- Prefer explicit error handling with detailed error messages -- Use discriminated unions for GitHub context types -- Implement retry logic for GitHub API operations via `utils/retry.ts` +- Runtime is Bun, not Node. Use `bun test`, not `jest`. +- `moduleResolution: "bundler"` — imports don't need `.js` extensions. +- GitHub API calls should use retry logic (`src/utils/retry.ts`). +- MCP servers are auto-installed at runtime to `~/.claude/mcp/github-{type}-server/`. diff --git a/action.yml b/action.yml index a0880cf74..6418040a8 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 }} @@ -324,49 +271,6 @@ runs: 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 @@ -374,12 +278,12 @@ runs: 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' + if: always() && inputs.github_token == '' && steps.run.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 "Authorization: Bearer ${{ steps.run.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 b10f1cacf..970e79d1c 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 16ee3c4fb..d67c6c413 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 e644cd5b0..b18b3f938 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 0d240a698..a4637438e 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 000000000..871fa1915 --- /dev/null +++ b/src/entrypoints/run.ts @@ -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 { + 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((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"); + } + } +} + +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(); +} diff --git a/src/entrypoints/update-comment-link.ts b/src/entrypoints/update-comment-link.ts index 849f954c8..c7bd8d637 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,230 +12,258 @@ 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"; -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; +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; +}; - const context = parseGitHubContext(); +export async function updateCommentLink( + params: UpdateCommentLinkParams, +): Promise { + const { + commentId, + claudeBranch, + baseBranch, + triggerUsername, + context, + octokit, + useCommitSigning, + } = params; - // 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 { owner, repo } = context.repository; + const serverUrl = GITHUB_SERVER_URL; + const jobUrl = `${serverUrl}/${owner}/${repo}/actions/runs/${process.env.GITHUB_RUN_ID}`; - const octokit = createOctokit(githubToken); + let comment; + let isPRReviewComment = false; - const serverUrl = GITHUB_SERVER_URL; - const jobUrl = `${serverUrl}/${owner}/${repo}/actions/runs/${process.env.GITHUB_RUN_ID}`; + 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"); + } - let comment; - let isPRReviewComment = false; + // 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 { - // 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"); - } + 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"); + } - // 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 + 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: 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"); - } + const { data: comparison } = + await octokit.rest.repos.compareCommitsWithBasehead({ + owner, + repo, + basehead: `${baseBranch}...${claudeBranch}`, + }); - throw finalError; + // 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 + } } + } - const currentBody = comment.body ?? ""; + // 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; - // 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 (!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 ( - comparison.total_commits > 0 || - (comparison.files && comparison.files.length > 0) + lastElement.type === "result" && + "total_cost_usd" in lastElement && + "duration_ms" in lastElement ) { - 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})`; + executionDetails = { + total_cost_usd: lastElement.total_cost_usd, + duration_ms: lastElement.duration_ms, + duration_api_ms: lastElement.duration_api_ms, + }; } - } catch (error) { - console.error("Error checking for changes in branch:", error); - // Don't fail the entire update if we can't check for changes } } + + actionFailed = !params.claudeSuccess; + } catch (error) { + console.error("Error reading output file:", error); + actionFailed = !params.claudeSuccess; } + } - // 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, - }; - } - } - } + // Prepare input for updateCommentBody function + const commentInput: CommentUpdateInput = { + currentBody, + actionFailed, + executionDetails, + jobUrl, + branchLink, + prLink, + branchName: shouldDeleteBranch || !branchLink ? undefined : claudeBranch, + triggerUsername, + errorDetails, + }; - // 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"; - } - } + const updatedBody = updateCommentBody(commentInput); - // 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; + } +} - 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 context = parseGitHubContext(); + if (!isEntityContext(context)) { + throw new Error("update-comment-link requires an entity context"); } + const githubToken = process.env.GITHUB_TOKEN!; + const octokit = createOctokit(githubToken); + + 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) { console.error("Error updating comment with job link:", 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 aea1b9ce2..86197da96 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 54948d190..96f280889 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; - } + 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"); + console.log("Requesting OIDC token..."); + const oidcToken = await retryWithBackoff(() => getOidcToken()); + console.log("OIDC token successfully obtained"); - const permissions = parseAdditionalPermissions(); + 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("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); - } + console.log("Using GITHUB_TOKEN from OIDC"); + return appToken; } diff --git a/src/modes/agent/index.ts b/src/modes/agent/index.ts index 59b78b405..0e9376c61 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 4e7c2a8f7..1135c517c 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 1f5069a50..3d653ccd3 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 c064275b5..72ece515b 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 905a6b4c6..ca2468175 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 25bf844c8..b7224069b 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 48bfd1934..7f4fd342d 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: "", }), };