Compare commits

..

3 Commits

Author SHA1 Message Date
Ashwin Bhat
a3c96305c1 fix: move critical outputs to immediately after prepare step
Previously, outputs like GITHUB_TOKEN and branch_name were set after Claude execution completed. This caused issues if Claude failed, as downstream cleanup steps wouldn't have access to these outputs.

Now outputs are set immediately after prepare() completes, ensuring they're available even if Claude execution fails.
2025-08-06 08:24:21 -07:00
Ashwin Bhat
e3a4ac69fe fix: update step reference from claude-code to claude in format-turns
Fixed critical bug where format-turns step was still referencing the old
step name 'claude-code' instead of the new 'claude' step name.
2025-08-06 08:24:20 -07:00
Ashwin Bhat
923d1d0592 feat: merge prepare and run-claude steps into unified entrypoint
- Create run-claude-core.ts as the central Claude execution function
- Update action.yml to use single "Run Claude" step
- Create src/entrypoints/run.ts that combines prepare + run-claude logic
- Pass environment variables as object to Claude instead of setting process.env
- Update create-prompt to return tools (still exports env vars for compatibility)
- Add TODO comments for future refactoring to remove env var dependency
- Both action.yml and base-action continue to work correctly

This simplifies the action flow by removing intermediate steps and environment
variable passing between prepare and run-claude phases.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-06 08:24:20 -07:00
35 changed files with 683 additions and 922 deletions

View File

@@ -36,4 +36,4 @@ jobs:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
allowed_tools: "Bash(bun install),Bash(bun test:*),Bash(bun run format),Bash(bun typecheck)" allowed_tools: "Bash(bun install),Bash(bun test:*),Bash(bun run format),Bash(bun typecheck)"
custom_instructions: "You have also been granted tools for editing files and running bun commands (install, run, test, typecheck) for testing your changes: bun install, bun test, bun run format, bun typecheck." custom_instructions: "You have also been granted tools for editing files and running bun commands (install, run, test, typecheck) for testing your changes: bun install, bun test, bun run format, bun typecheck."
model: "claude-opus-4-1-20250805" model: "claude-opus-4-20250514"

View File

@@ -104,5 +104,3 @@ jobs:
mcp_config: /tmp/mcp-config/mcp-servers.json mcp_config: /tmp/mcp-config/mcp-servers.json
timeout_minutes: "5" timeout_minutes: "5"
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -14,19 +14,6 @@ A general-purpose [Claude Code](https://claude.ai/code) action for GitHub PRs an
- 📋 **Progress Tracking**: Visual progress indicators with checkboxes that dynamically update as Claude completes tasks - 📋 **Progress Tracking**: Visual progress indicators with checkboxes that dynamically update as Claude completes tasks
- 🏃 **Runs on Your Infrastructure**: The action executes entirely on your own GitHub runner (Anthropic API calls go to your chosen provider) - 🏃 **Runs on Your Infrastructure**: The action executes entirely on your own GitHub runner (Anthropic API calls go to your chosen provider)
## ⚠️ **BREAKING CHANGES COMING IN v1.0** ⚠️
**We're planning a major update that will significantly change how this action works.** The new version will:
- ✨ Automatically select the appropriate mode (no more `mode` input)
- 🔧 Simplify configuration with unified `prompt` and `claude_args`
- 🚀 Align more closely with the Claude Code SDK capabilities
- 💥 Remove multiple inputs like `direct_prompt`, `custom_instructions`, and others
**[→ Read the full v1.0 roadmap and provide feedback](https://github.com/anthropics/claude-code-action/discussions/428)**
---
## Quickstart ## Quickstart
The easiest way to set up this action is through [Claude Code](https://claude.ai/code) in the terminal. Just open `claude` and run `/install-github-app`. The easiest way to set up this action is through [Claude Code](https://claude.ai/code) in the terminal. Just open `claude` and run `/install-github-app`.

View File

@@ -10,7 +10,7 @@ Thank you for trying out the beta of our GitHub Action! This document outlines o
- **Support for workflow_dispatch and repository_dispatch events** - Dispatch Claude on events triggered via API from other workflows or from other services - **Support for workflow_dispatch and repository_dispatch events** - Dispatch Claude on events triggered via API from other workflows or from other services
- **Ability to disable commit signing** - Option to turn off GPG signing for environments where it's not required. This will enable Claude to use normal `git` bash commands for committing. This will likely become the default behavior once added. - **Ability to disable commit signing** - Option to turn off GPG signing for environments where it's not required. This will enable Claude to use normal `git` bash commands for committing. This will likely become the default behavior once added.
- **Better code review behavior** - Support inline comments on specific lines, provide higher quality reviews with more actionable feedback - **Better code review behavior** - Support inline comments on specific lines, provide higher quality reviews with more actionable feedback
- ~**Support triggering @claude from bot users** - Allow automation and bot accounts to invoke Claude~ - **Support triggering @claude from bot users** - Allow automation and bot accounts to invoke Claude
- **Customizable base prompts** - Full control over Claude's initial context with template variables like `$PR_COMMENTS`, `$PR_FILES`, etc. Users can replace our default prompt entirely while still accessing key contextual data - **Customizable base prompts** - Full control over Claude's initial context with template variables like `$PR_COMMENTS`, `$PR_FILES`, etc. Users can replace our default prompt entirely while still accessing key contextual data
--- ---

View File

@@ -23,10 +23,6 @@ inputs:
description: "The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format)" description: "The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format)"
required: false required: false
default: "claude/" default: "claude/"
allowed_bots:
description: "Comma-separated list of allowed bot usernames, or '*' to allow all bots. Empty string (default) allows no bots."
required: false
default: ""
# Mode configuration # Mode configuration
mode: mode:
@@ -122,10 +118,10 @@ inputs:
outputs: outputs:
execution_file: execution_file:
description: "Path to the Claude Code execution output file" description: "Path to the Claude Code execution output file"
value: ${{ steps.claude-code.outputs.execution_file }} value: ${{ steps.claude.outputs.execution_file }}
branch_name: branch_name:
description: "The branch created by Claude Code for this execution" description: "The branch created by Claude Code for this execution"
value: ${{ steps.prepare.outputs.CLAUDE_BRANCH }} value: ${{ steps.claude.outputs.CLAUDE_BRANCH }}
runs: runs:
using: "composite" using: "composite"
@@ -141,83 +137,61 @@ runs:
cd ${GITHUB_ACTION_PATH} cd ${GITHUB_ACTION_PATH}
bun install bun install
- name: Prepare action - name: Run Claude
id: prepare id: claude
shell: bash shell: bash
run: | run: |
bun run ${GITHUB_ACTION_PATH}/src/entrypoints/prepare.ts # Install base-action dependencies
echo "Installing base-action dependencies..."
cd ${GITHUB_ACTION_PATH}/base-action
bun install
echo "Base-action dependencies installed"
cd -
# Install Claude Code globally
bun install -g @anthropic-ai/claude-code@1.0.67
# Setup network restrictions if needed
if [[ "${{ inputs.experimental_allowed_domains }}" != "" ]]; then
chmod +x ${GITHUB_ACTION_PATH}/scripts/setup-network-restrictions.sh
${GITHUB_ACTION_PATH}/scripts/setup-network-restrictions.sh
fi
# Run the unified entrypoint
bun run ${GITHUB_ACTION_PATH}/src/entrypoints/run.ts
env: env:
# Mode and trigger configuration
MODE: ${{ inputs.mode }} MODE: ${{ inputs.mode }}
TRIGGER_PHRASE: ${{ inputs.trigger_phrase }} TRIGGER_PHRASE: ${{ inputs.trigger_phrase }}
ASSIGNEE_TRIGGER: ${{ inputs.assignee_trigger }} ASSIGNEE_TRIGGER: ${{ inputs.assignee_trigger }}
LABEL_TRIGGER: ${{ inputs.label_trigger }} LABEL_TRIGGER: ${{ inputs.label_trigger }}
BASE_BRANCH: ${{ inputs.base_branch }} BASE_BRANCH: ${{ inputs.base_branch }}
BRANCH_PREFIX: ${{ inputs.branch_prefix }} BRANCH_PREFIX: ${{ inputs.branch_prefix }}
ALLOWED_TOOLS: ${{ inputs.allowed_tools }}
DISALLOWED_TOOLS: ${{ inputs.disallowed_tools }}
CUSTOM_INSTRUCTIONS: ${{ inputs.custom_instructions }} CUSTOM_INSTRUCTIONS: ${{ inputs.custom_instructions }}
DIRECT_PROMPT: ${{ inputs.direct_prompt }} DIRECT_PROMPT: ${{ inputs.direct_prompt }}
OVERRIDE_PROMPT: ${{ inputs.override_prompt }} OVERRIDE_PROMPT: ${{ inputs.override_prompt }}
MCP_CONFIG: ${{ inputs.mcp_config }} MCP_CONFIG: ${{ inputs.mcp_config }}
OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }} OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }}
ALLOWED_BOTS: ${{ inputs.allowed_bots }}
GITHUB_RUN_ID: ${{ github.run_id }} GITHUB_RUN_ID: ${{ github.run_id }}
USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }} USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }}
DEFAULT_WORKFLOW_TOKEN: ${{ github.token }} DEFAULT_WORKFLOW_TOKEN: ${{ github.token }}
ADDITIONAL_PERMISSIONS: ${{ inputs.additional_permissions }} ADDITIONAL_PERMISSIONS: ${{ inputs.additional_permissions }}
USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }} USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }}
- name: Install Base Action Dependencies
if: steps.prepare.outputs.contains_trigger == 'true'
shell: bash
run: |
echo "Installing base-action dependencies..."
cd ${GITHUB_ACTION_PATH}/base-action
bun install
echo "Base-action dependencies installed"
cd -
# Install Claude Code globally
curl -fsSL https://claude.ai/install.sh | bash -s 1.0.83
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Setup Network Restrictions
if: steps.prepare.outputs.contains_trigger == 'true' && inputs.experimental_allowed_domains != ''
shell: bash
run: |
chmod +x ${GITHUB_ACTION_PATH}/scripts/setup-network-restrictions.sh
${GITHUB_ACTION_PATH}/scripts/setup-network-restrictions.sh
env:
EXPERIMENTAL_ALLOWED_DOMAINS: ${{ inputs.experimental_allowed_domains }} EXPERIMENTAL_ALLOWED_DOMAINS: ${{ inputs.experimental_allowed_domains }}
- name: Run Claude Code # Claude configuration
id: claude-code ALLOWED_TOOLS: ${{ inputs.allowed_tools }}
if: steps.prepare.outputs.contains_trigger == 'true' DISALLOWED_TOOLS: ${{ inputs.disallowed_tools }}
shell: bash MAX_TURNS: ${{ inputs.max_turns }}
run: | SETTINGS: ${{ inputs.settings }}
TIMEOUT_MINUTES: ${{ inputs.timeout_minutes }}
# Run the base-action CLAUDE_ENV: ${{ inputs.claude_env }}
bun run ${GITHUB_ACTION_PATH}/base-action/src/index.ts FALLBACK_MODEL: ${{ inputs.fallback_model }}
env:
# Base-action inputs
CLAUDE_CODE_ACTION: "1"
INPUT_PROMPT_FILE: ${{ runner.temp }}/claude-prompts/claude-prompt.txt
INPUT_ALLOWED_TOOLS: ${{ env.ALLOWED_TOOLS }}
INPUT_DISALLOWED_TOOLS: ${{ env.DISALLOWED_TOOLS }}
INPUT_MAX_TURNS: ${{ inputs.max_turns }}
INPUT_MCP_CONFIG: ${{ steps.prepare.outputs.mcp_config }}
INPUT_SETTINGS: ${{ inputs.settings }}
INPUT_SYSTEM_PROMPT: ""
INPUT_APPEND_SYSTEM_PROMPT: ${{ env.APPEND_SYSTEM_PROMPT }}
INPUT_TIMEOUT_MINUTES: ${{ inputs.timeout_minutes }}
INPUT_CLAUDE_ENV: ${{ inputs.claude_env }}
INPUT_FALLBACK_MODEL: ${{ inputs.fallback_model }}
INPUT_EXPERIMENTAL_SLASH_COMMANDS_DIR: ${{ github.action_path }}/slash-commands INPUT_EXPERIMENTAL_SLASH_COMMANDS_DIR: ${{ github.action_path }}/slash-commands
# Model configuration # Model configuration
MODEL: ${{ inputs.model }}
ANTHROPIC_MODEL: ${{ inputs.model || inputs.anthropic_model }} ANTHROPIC_MODEL: ${{ inputs.model || inputs.anthropic_model }}
GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }}
NODE_VERSION: ${{ env.NODE_VERSION }}
DETAILED_PERMISSION_MESSAGES: "1"
# Provider configuration # Provider configuration
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }} ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
@@ -245,35 +219,35 @@ runs:
VERTEX_REGION_CLAUDE_3_7_SONNET: ${{ env.VERTEX_REGION_CLAUDE_3_7_SONNET }} VERTEX_REGION_CLAUDE_3_7_SONNET: ${{ env.VERTEX_REGION_CLAUDE_3_7_SONNET }}
- name: Update comment with job link - name: Update comment with job link
if: steps.prepare.outputs.contains_trigger == 'true' && steps.prepare.outputs.claude_comment_id && always() if: steps.claude.outputs.contains_trigger == 'true' && steps.claude.outputs.claude_comment_id && always()
shell: bash shell: bash
run: | run: |
bun run ${GITHUB_ACTION_PATH}/src/entrypoints/update-comment-link.ts bun run ${GITHUB_ACTION_PATH}/src/entrypoints/update-comment-link.ts
env: env:
REPOSITORY: ${{ github.repository }} REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }} PR_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }}
CLAUDE_COMMENT_ID: ${{ steps.prepare.outputs.claude_comment_id }} CLAUDE_COMMENT_ID: ${{ steps.claude.outputs.claude_comment_id }}
GITHUB_RUN_ID: ${{ github.run_id }} GITHUB_RUN_ID: ${{ github.run_id }}
GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ steps.claude.outputs.GITHUB_TOKEN }}
GITHUB_EVENT_NAME: ${{ github.event_name }} GITHUB_EVENT_NAME: ${{ github.event_name }}
TRIGGER_COMMENT_ID: ${{ github.event.comment.id }} TRIGGER_COMMENT_ID: ${{ github.event.comment.id }}
CLAUDE_BRANCH: ${{ steps.prepare.outputs.CLAUDE_BRANCH }} CLAUDE_BRANCH: ${{ steps.claude.outputs.CLAUDE_BRANCH }}
IS_PR: ${{ github.event.issue.pull_request != null || github.event_name == 'pull_request_review_comment' }} IS_PR: ${{ github.event.issue.pull_request != null || github.event_name == 'pull_request_review_comment' }}
BASE_BRANCH: ${{ steps.prepare.outputs.BASE_BRANCH }} BASE_BRANCH: ${{ steps.claude.outputs.BASE_BRANCH }}
CLAUDE_SUCCESS: ${{ steps.claude-code.outputs.conclusion == 'success' }} CLAUDE_SUCCESS: ${{ steps.claude.outputs.conclusion == 'success' }}
OUTPUT_FILE: ${{ steps.claude-code.outputs.execution_file || '' }} OUTPUT_FILE: ${{ steps.claude.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 || '' }} 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_SUCCESS: ${{ steps.claude.outcome == 'success' }}
PREPARE_ERROR: ${{ steps.prepare.outputs.prepare_error || '' }} PREPARE_ERROR: ${{ steps.claude.outputs.prepare_error || '' }}
USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }} USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }}
USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }} USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }}
- name: Display Claude Code Report - name: Display Claude Code Report
if: steps.prepare.outputs.contains_trigger == 'true' && steps.claude-code.outputs.execution_file != '' if: steps.claude.outputs.contains_trigger == 'true' && steps.claude.outputs.execution_file != ''
shell: bash shell: bash
run: | run: |
# Try to format the turns, but if it fails, dump the raw JSON # 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 if bun run ${{ github.action_path }}/src/entrypoints/format-turns.ts "${{ steps.claude.outputs.execution_file }}" >> $GITHUB_STEP_SUMMARY 2>/dev/null; then
echo "Successfully formatted Claude Code report" echo "Successfully formatted Claude Code report"
else else
echo "## Claude Code Report (Raw Output)" >> $GITHUB_STEP_SUMMARY echo "## Claude Code Report (Raw Output)" >> $GITHUB_STEP_SUMMARY
@@ -281,17 +255,17 @@ runs:
echo "Failed to format output (please report). Here's the raw JSON:" >> $GITHUB_STEP_SUMMARY echo "Failed to format output (please report). Here's the raw JSON:" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY
echo '```json' >> $GITHUB_STEP_SUMMARY echo '```json' >> $GITHUB_STEP_SUMMARY
cat "${{ steps.claude-code.outputs.execution_file }}" >> $GITHUB_STEP_SUMMARY cat "${{ steps.claude.outputs.execution_file }}" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY
fi fi
- name: Revoke app token - name: Revoke app token
if: always() && inputs.github_token == '' && steps.prepare.outputs.skipped_due_to_workflow_validation_mismatch != 'true' if: always() && inputs.github_token == ''
shell: bash shell: bash
run: | run: |
curl -L \ curl -L \
-X DELETE \ -X DELETE \
-H "Accept: application/vnd.github+json" \ -H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${{ steps.prepare.outputs.GITHUB_TOKEN }}" \ -H "Authorization: Bearer ${{ steps.claude.outputs.GITHUB_TOKEN }}" \
-H "X-GitHub-Api-Version: 2022-11-28" \ -H "X-GitHub-Api-Version: 2022-11-28" \
${GITHUB_API_URL:-https://api.github.com}/installation/token ${GITHUB_API_URL:-https://api.github.com}/installation/token

View File

@@ -69,7 +69,7 @@ Add the following to your workflow file:
uses: anthropics/claude-code-base-action@beta uses: anthropics/claude-code-base-action@beta
with: with:
prompt: "Review and fix TypeScript errors" prompt: "Review and fix TypeScript errors"
model: "claude-opus-4-1-20250805" model: "claude-opus-4-20250514"
fallback_model: "claude-sonnet-4-20250514" fallback_model: "claude-sonnet-4-20250514"
allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool" allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool"
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
@@ -217,7 +217,7 @@ Provide the settings configuration directly as a JSON string:
prompt: "Your prompt here" prompt: "Your prompt here"
settings: | settings: |
{ {
"model": "claude-opus-4-1-20250805", "model": "claude-opus-4-20250514",
"env": { "env": {
"DEBUG": "true", "DEBUG": "true",
"API_URL": "https://api.example.com" "API_URL": "https://api.example.com"

View File

@@ -118,7 +118,7 @@ runs:
- name: Install Claude Code - name: Install Claude Code
shell: bash shell: bash
run: curl -fsSL https://claude.ai/install.sh | bash -s 1.0.83 run: bun install -g @anthropic-ai/claude-code@1.0.69
- name: Run Claude Code Action - name: Run Claude Code Action
shell: bash shell: bash

View File

@@ -2,7 +2,7 @@
import * as core from "@actions/core"; import * as core from "@actions/core";
import { preparePrompt } from "./prepare-prompt"; import { preparePrompt } from "./prepare-prompt";
import { runClaude } from "./run-claude"; import { runClaudeCore } from "./run-claude-core";
import { setupClaudeCodeSettings } from "./setup-claude-code-settings"; import { setupClaudeCodeSettings } from "./setup-claude-code-settings";
import { validateEnvironmentVariables } from "./validate-env"; import { validateEnvironmentVariables } from "./validate-env";
@@ -21,7 +21,9 @@ async function run() {
promptFile: process.env.INPUT_PROMPT_FILE || "", promptFile: process.env.INPUT_PROMPT_FILE || "",
}); });
await runClaude(promptConfig.path, { await runClaudeCore({
promptFile: promptConfig.path,
settings: process.env.INPUT_SETTINGS,
allowedTools: process.env.INPUT_ALLOWED_TOOLS, allowedTools: process.env.INPUT_ALLOWED_TOOLS,
disallowedTools: process.env.INPUT_DISALLOWED_TOOLS, disallowedTools: process.env.INPUT_DISALLOWED_TOOLS,
maxTurns: process.env.INPUT_MAX_TURNS, maxTurns: process.env.INPUT_MAX_TURNS,
@@ -31,6 +33,7 @@ async function run() {
claudeEnv: process.env.INPUT_CLAUDE_ENV, claudeEnv: process.env.INPUT_CLAUDE_ENV,
fallbackModel: process.env.INPUT_FALLBACK_MODEL, fallbackModel: process.env.INPUT_FALLBACK_MODEL,
model: process.env.ANTHROPIC_MODEL, model: process.env.ANTHROPIC_MODEL,
timeoutMinutes: process.env.INPUT_TIMEOUT_MINUTES,
}); });
} catch (error) { } catch (error) {
core.setFailed(`Action failed with error: ${error}`); core.setFailed(`Action failed with error: ${error}`);

View File

@@ -0,0 +1,366 @@
#!/usr/bin/env bun
import * as core from "@actions/core";
import { exec } from "child_process";
import { promisify } from "util";
import { unlink, writeFile, stat } from "fs/promises";
import { createWriteStream } from "fs";
import { spawn } from "child_process";
const execAsync = promisify(exec);
const PIPE_PATH = `${process.env.RUNNER_TEMP}/claude_prompt_pipe`;
const EXECUTION_FILE = `${process.env.RUNNER_TEMP}/claude-execution-output.json`;
const BASE_ARGS = ["-p", "--verbose", "--output-format", "stream-json"];
export type ClaudeOptions = {
allowedTools?: string;
disallowedTools?: string;
maxTurns?: string;
mcpConfig?: string;
systemPrompt?: string;
appendSystemPrompt?: string;
claudeEnv?: string;
fallbackModel?: string;
model?: string;
timeoutMinutes?: string;
};
export type RunClaudeConfig = {
promptFile: string;
settings?: string;
allowedTools?: string;
disallowedTools?: string;
maxTurns?: string;
mcpConfig?: string;
systemPrompt?: string;
appendSystemPrompt?: string;
claudeEnv?: string;
fallbackModel?: string;
model?: string;
timeoutMinutes?: string;
env?: Record<string, string>;
};
function parseCustomEnvVars(claudeEnv?: string): Record<string, string> {
if (!claudeEnv || claudeEnv.trim() === "") {
return {};
}
const customEnv: Record<string, string> = {};
// Split by lines and parse each line as KEY: VALUE
const lines = claudeEnv.split("\n");
for (const line of lines) {
const trimmedLine = line.trim();
if (trimmedLine === "" || trimmedLine.startsWith("#")) {
continue; // Skip empty lines and comments
}
const colonIndex = trimmedLine.indexOf(":");
if (colonIndex === -1) {
continue; // Skip lines without colons
}
const key = trimmedLine.substring(0, colonIndex).trim();
const value = trimmedLine.substring(colonIndex + 1).trim();
if (key) {
customEnv[key] = value;
}
}
return customEnv;
}
function prepareClaudeArgs(config: RunClaudeConfig): string[] {
const claudeArgs = [...BASE_ARGS];
if (config.allowedTools) {
claudeArgs.push("--allowedTools", config.allowedTools);
}
if (config.disallowedTools) {
claudeArgs.push("--disallowedTools", config.disallowedTools);
}
if (config.maxTurns) {
const maxTurnsNum = parseInt(config.maxTurns, 10);
if (isNaN(maxTurnsNum) || maxTurnsNum <= 0) {
throw new Error(
`maxTurns must be a positive number, got: ${config.maxTurns}`,
);
}
claudeArgs.push("--max-turns", config.maxTurns);
}
if (config.mcpConfig) {
claudeArgs.push("--mcp-config", config.mcpConfig);
}
if (config.systemPrompt) {
claudeArgs.push("--system-prompt", config.systemPrompt);
}
if (config.appendSystemPrompt) {
claudeArgs.push("--append-system-prompt", config.appendSystemPrompt);
}
if (config.fallbackModel) {
claudeArgs.push("--fallback-model", config.fallbackModel);
}
if (config.model) {
claudeArgs.push("--model", config.model);
}
if (config.timeoutMinutes) {
const timeoutMinutesNum = parseInt(config.timeoutMinutes, 10);
if (isNaN(timeoutMinutesNum) || timeoutMinutesNum <= 0) {
throw new Error(
`timeoutMinutes must be a positive number, got: ${config.timeoutMinutes}`,
);
}
}
return claudeArgs;
}
export function prepareRunConfig(
promptPath: string,
options: ClaudeOptions,
): { claudeArgs: string[]; promptPath: string; env: Record<string, string> } {
const config: RunClaudeConfig = {
promptFile: promptPath,
...options,
};
const claudeArgs = prepareClaudeArgs(config);
const customEnv = parseCustomEnvVars(config.claudeEnv);
const mergedEnv = {
...customEnv,
...(config.env || {}),
};
return {
claudeArgs,
promptPath,
env: mergedEnv,
};
}
export async function runClaudeCore(config: RunClaudeConfig) {
const claudeArgs = prepareClaudeArgs(config);
// Parse custom environment variables from claudeEnv
const customEnv = parseCustomEnvVars(config.claudeEnv);
// Merge with additional env vars passed in config
const mergedEnv = {
...customEnv,
...(config.env || {}),
};
// Create a named pipe
try {
await unlink(PIPE_PATH);
} catch (e) {
// Ignore if file doesn't exist
}
// Create the named pipe
await execAsync(`mkfifo "${PIPE_PATH}"`);
// Log prompt file size
let promptSize = "unknown";
try {
const stats = await stat(config.promptFile);
promptSize = stats.size.toString();
} catch (e) {
// Ignore error
}
console.log(`Prompt file size: ${promptSize} bytes`);
// Log custom environment variables if any
const totalEnvVars = Object.keys(mergedEnv).length;
if (totalEnvVars > 0) {
const envKeys = Object.keys(mergedEnv).join(", ");
console.log(`Custom environment variables (${totalEnvVars}): ${envKeys}`);
}
// Output to console
console.log(`Running Claude with prompt from file: ${config.promptFile}`);
// Start sending prompt to pipe in background
const catProcess = spawn("cat", [config.promptFile], {
stdio: ["ignore", "pipe", "inherit"],
});
const pipeStream = createWriteStream(PIPE_PATH);
catProcess.stdout.pipe(pipeStream);
catProcess.on("error", (error) => {
console.error("Error reading prompt file:", error);
pipeStream.destroy();
});
const claudeProcess = spawn("claude", claudeArgs, {
stdio: ["pipe", "pipe", "inherit"],
env: {
...process.env,
...mergedEnv,
},
});
// Handle Claude process errors
claudeProcess.on("error", (error) => {
console.error("Error spawning Claude process:", error);
pipeStream.destroy();
});
// Capture output for parsing execution metrics
let output = "";
claudeProcess.stdout.on("data", (data) => {
const text = data.toString();
// Try to parse as JSON and pretty print if it's on a single line
const lines = text.split("\n");
lines.forEach((line: string, index: number) => {
if (line.trim() === "") return;
try {
// Check if this line is a JSON object
const parsed = JSON.parse(line);
const prettyJson = JSON.stringify(parsed, null, 2);
process.stdout.write(prettyJson);
if (index < lines.length - 1 || text.endsWith("\n")) {
process.stdout.write("\n");
}
} catch (e) {
// Not a JSON object, print as is
process.stdout.write(line);
if (index < lines.length - 1 || text.endsWith("\n")) {
process.stdout.write("\n");
}
}
});
output += text;
});
// Handle stdout errors
claudeProcess.stdout.on("error", (error) => {
console.error("Error reading Claude stdout:", error);
});
// Pipe from named pipe to Claude
const pipeProcess = spawn("cat", [PIPE_PATH]);
pipeProcess.stdout.pipe(claudeProcess.stdin);
// Handle pipe process errors
pipeProcess.on("error", (error) => {
console.error("Error reading from named pipe:", error);
claudeProcess.kill("SIGTERM");
});
// Wait for Claude to finish with timeout
let timeoutMs = 10 * 60 * 1000; // Default 10 minutes
if (config.timeoutMinutes) {
timeoutMs = parseInt(config.timeoutMinutes, 10) * 60 * 1000;
} else if (process.env.INPUT_TIMEOUT_MINUTES) {
const envTimeout = parseInt(process.env.INPUT_TIMEOUT_MINUTES, 10);
if (isNaN(envTimeout) || envTimeout <= 0) {
throw new Error(
`INPUT_TIMEOUT_MINUTES must be a positive number, got: ${process.env.INPUT_TIMEOUT_MINUTES}`,
);
}
timeoutMs = envTimeout * 60 * 1000;
}
const exitCode = await new Promise<number>((resolve) => {
let resolved = false;
// Set a timeout for the process
const timeoutId = setTimeout(() => {
if (!resolved) {
console.error(
`Claude process timed out after ${timeoutMs / 1000} seconds`,
);
claudeProcess.kill("SIGTERM");
// Give it 5 seconds to terminate gracefully, then force kill
setTimeout(() => {
try {
claudeProcess.kill("SIGKILL");
} catch (e) {
// Process may already be dead
}
}, 5000);
resolved = true;
resolve(124); // Standard timeout exit code
}
}, timeoutMs);
claudeProcess.on("close", (code) => {
if (!resolved) {
clearTimeout(timeoutId);
resolved = true;
resolve(code || 0);
}
});
claudeProcess.on("error", (error) => {
if (!resolved) {
console.error("Claude process error:", error);
clearTimeout(timeoutId);
resolved = true;
resolve(1);
}
});
});
// Clean up processes
try {
catProcess.kill("SIGTERM");
} catch (e) {
// Process may already be dead
}
try {
pipeProcess.kill("SIGTERM");
} catch (e) {
// Process may already be dead
}
// Clean up pipe file
try {
await unlink(PIPE_PATH);
} catch (e) {
// Ignore errors during cleanup
}
// Set conclusion based on exit code
if (exitCode === 0) {
// Try to process the output and save execution metrics
try {
await writeFile("output.txt", output);
// Process output.txt into JSON and save to execution file
const { stdout: jsonOutput } = await execAsync("jq -s '.' output.txt");
await writeFile(EXECUTION_FILE, jsonOutput);
console.log(`Log saved to ${EXECUTION_FILE}`);
} catch (e) {
core.warning(`Failed to process output for execution metrics: ${e}`);
}
core.setOutput("conclusion", "success");
core.setOutput("execution_file", EXECUTION_FILE);
} else {
core.setOutput("conclusion", "failure");
// Still try to save execution file if we have output
if (output) {
try {
await writeFile("output.txt", output);
const { stdout: jsonOutput } = await execAsync("jq -s '.' output.txt");
await writeFile(EXECUTION_FILE, jsonOutput);
core.setOutput("execution_file", EXECUTION_FILE);
} catch (e) {
// Ignore errors when processing output during failure
}
}
process.exit(exitCode);
}
}

View File

@@ -1,332 +1,44 @@
#!/usr/bin/env bun
import * as core from "@actions/core"; import * as core from "@actions/core";
import { exec } from "child_process"; import { preparePrompt } from "./prepare-prompt";
import { promisify } from "util"; import { runClaudeCore } from "./run-claude-core";
import { unlink, writeFile, stat } from "fs/promises"; export { prepareRunConfig, type ClaudeOptions } from "./run-claude-core";
import { createWriteStream } from "fs"; import { setupClaudeCodeSettings } from "./setup-claude-code-settings";
import { spawn } from "child_process"; import { validateEnvironmentVariables } from "./validate-env";
const execAsync = promisify(exec); async function run() {
const PIPE_PATH = `${process.env.RUNNER_TEMP}/claude_prompt_pipe`;
const EXECUTION_FILE = `${process.env.RUNNER_TEMP}/claude-execution-output.json`;
const BASE_ARGS = ["-p", "--verbose", "--output-format", "stream-json"];
export type ClaudeOptions = {
allowedTools?: string;
disallowedTools?: string;
maxTurns?: string;
mcpConfig?: string;
systemPrompt?: string;
appendSystemPrompt?: string;
claudeEnv?: string;
fallbackModel?: string;
timeoutMinutes?: string;
model?: string;
};
type PreparedConfig = {
claudeArgs: string[];
promptPath: string;
env: Record<string, string>;
};
function parseCustomEnvVars(claudeEnv?: string): Record<string, string> {
if (!claudeEnv || claudeEnv.trim() === "") {
return {};
}
const customEnv: Record<string, string> = {};
// Split by lines and parse each line as KEY: VALUE
const lines = claudeEnv.split("\n");
for (const line of lines) {
const trimmedLine = line.trim();
if (trimmedLine === "" || trimmedLine.startsWith("#")) {
continue; // Skip empty lines and comments
}
const colonIndex = trimmedLine.indexOf(":");
if (colonIndex === -1) {
continue; // Skip lines without colons
}
const key = trimmedLine.substring(0, colonIndex).trim();
const value = trimmedLine.substring(colonIndex + 1).trim();
if (key) {
customEnv[key] = value;
}
}
return customEnv;
}
export function prepareRunConfig(
promptPath: string,
options: ClaudeOptions,
): PreparedConfig {
const claudeArgs = [...BASE_ARGS];
if (options.allowedTools) {
claudeArgs.push("--allowedTools", options.allowedTools);
}
if (options.disallowedTools) {
claudeArgs.push("--disallowedTools", options.disallowedTools);
}
if (options.maxTurns) {
const maxTurnsNum = parseInt(options.maxTurns, 10);
if (isNaN(maxTurnsNum) || maxTurnsNum <= 0) {
throw new Error(
`maxTurns must be a positive number, got: ${options.maxTurns}`,
);
}
claudeArgs.push("--max-turns", options.maxTurns);
}
if (options.mcpConfig) {
claudeArgs.push("--mcp-config", options.mcpConfig);
}
if (options.systemPrompt) {
claudeArgs.push("--system-prompt", options.systemPrompt);
}
if (options.appendSystemPrompt) {
claudeArgs.push("--append-system-prompt", options.appendSystemPrompt);
}
if (options.fallbackModel) {
claudeArgs.push("--fallback-model", options.fallbackModel);
}
if (options.model) {
claudeArgs.push("--model", options.model);
}
if (options.timeoutMinutes) {
const timeoutMinutesNum = parseInt(options.timeoutMinutes, 10);
if (isNaN(timeoutMinutesNum) || timeoutMinutesNum <= 0) {
throw new Error(
`timeoutMinutes must be a positive number, got: ${options.timeoutMinutes}`,
);
}
}
// Parse custom environment variables
const customEnv = parseCustomEnvVars(options.claudeEnv);
return {
claudeArgs,
promptPath,
env: customEnv,
};
}
export async function runClaude(promptPath: string, options: ClaudeOptions) {
const config = prepareRunConfig(promptPath, options);
// Create a named pipe
try { try {
await unlink(PIPE_PATH); validateEnvironmentVariables();
} catch (e) {
// Ignore if file doesn't exist
}
// Create the named pipe await setupClaudeCodeSettings(process.env.INPUT_SETTINGS);
await execAsync(`mkfifo "${PIPE_PATH}"`);
// Log prompt file size const promptConfig = await preparePrompt({
let promptSize = "unknown"; prompt: process.env.INPUT_PROMPT || "",
try { promptFile: process.env.INPUT_PROMPT_FILE || "",
const stats = await stat(config.promptPath);
promptSize = stats.size.toString();
} catch (e) {
// Ignore error
}
console.log(`Prompt file size: ${promptSize} bytes`);
// Log custom environment variables if any
if (Object.keys(config.env).length > 0) {
const envKeys = Object.keys(config.env).join(", ");
console.log(`Custom environment variables: ${envKeys}`);
}
// Output to console
console.log(`Running Claude with prompt from file: ${config.promptPath}`);
// Start sending prompt to pipe in background
const catProcess = spawn("cat", [config.promptPath], {
stdio: ["ignore", "pipe", "inherit"],
});
const pipeStream = createWriteStream(PIPE_PATH);
catProcess.stdout.pipe(pipeStream);
catProcess.on("error", (error) => {
console.error("Error reading prompt file:", error);
pipeStream.destroy();
}); });
const claudeProcess = spawn("claude", config.claudeArgs, { await runClaudeCore({
stdio: ["pipe", "pipe", "inherit"], promptFile: promptConfig.path,
env: { settings: process.env.INPUT_SETTINGS,
...process.env, allowedTools: process.env.INPUT_ALLOWED_TOOLS,
...config.env, disallowedTools: process.env.INPUT_DISALLOWED_TOOLS,
}, maxTurns: process.env.INPUT_MAX_TURNS,
mcpConfig: process.env.INPUT_MCP_CONFIG,
systemPrompt: process.env.INPUT_SYSTEM_PROMPT,
appendSystemPrompt: process.env.INPUT_APPEND_SYSTEM_PROMPT,
claudeEnv: process.env.INPUT_CLAUDE_ENV,
fallbackModel: process.env.INPUT_FALLBACK_MODEL,
model: process.env.ANTHROPIC_MODEL,
timeoutMinutes: process.env.INPUT_TIMEOUT_MINUTES,
}); });
console.log("yolo", process.env); } catch (error) {
core.setFailed(`Action failed with error: ${error}`);
// Handle Claude process errors
claudeProcess.on("error", (error) => {
console.error("Error spawning Claude process:", error);
pipeStream.destroy();
});
// Capture output for parsing execution metrics
let output = "";
claudeProcess.stdout.on("data", (data) => {
const text = data.toString();
// Try to parse as JSON and pretty print if it's on a single line
const lines = text.split("\n");
lines.forEach((line: string, index: number) => {
if (line.trim() === "") return;
try {
// Check if this line is a JSON object
const parsed = JSON.parse(line);
const prettyJson = JSON.stringify(parsed, null, 2);
process.stdout.write(prettyJson);
if (index < lines.length - 1 || text.endsWith("\n")) {
process.stdout.write("\n");
}
} catch (e) {
// Not a JSON object, print as is
process.stdout.write(line);
if (index < lines.length - 1 || text.endsWith("\n")) {
process.stdout.write("\n");
}
}
});
output += text;
});
// Handle stdout errors
claudeProcess.stdout.on("error", (error) => {
console.error("Error reading Claude stdout:", error);
});
// Pipe from named pipe to Claude
const pipeProcess = spawn("cat", [PIPE_PATH]);
pipeProcess.stdout.pipe(claudeProcess.stdin);
// Handle pipe process errors
pipeProcess.on("error", (error) => {
console.error("Error reading from named pipe:", error);
claudeProcess.kill("SIGTERM");
});
// Wait for Claude to finish with timeout
let timeoutMs = 10 * 60 * 1000; // Default 10 minutes
if (options.timeoutMinutes) {
timeoutMs = parseInt(options.timeoutMinutes, 10) * 60 * 1000;
} else if (process.env.INPUT_TIMEOUT_MINUTES) {
const envTimeout = parseInt(process.env.INPUT_TIMEOUT_MINUTES, 10);
if (isNaN(envTimeout) || envTimeout <= 0) {
throw new Error(
`INPUT_TIMEOUT_MINUTES must be a positive number, got: ${process.env.INPUT_TIMEOUT_MINUTES}`,
);
}
timeoutMs = envTimeout * 60 * 1000;
}
const exitCode = await new Promise<number>((resolve) => {
let resolved = false;
// Set a timeout for the process
const timeoutId = setTimeout(() => {
if (!resolved) {
console.error(
`Claude process timed out after ${timeoutMs / 1000} seconds`,
);
claudeProcess.kill("SIGTERM");
// Give it 5 seconds to terminate gracefully, then force kill
setTimeout(() => {
try {
claudeProcess.kill("SIGKILL");
} catch (e) {
// Process may already be dead
}
}, 5000);
resolved = true;
resolve(124); // Standard timeout exit code
}
}, timeoutMs);
claudeProcess.on("close", (code) => {
if (!resolved) {
clearTimeout(timeoutId);
resolved = true;
resolve(code || 0);
}
});
claudeProcess.on("error", (error) => {
if (!resolved) {
console.error("Claude process error:", error);
clearTimeout(timeoutId);
resolved = true;
resolve(1);
}
});
});
// Clean up processes
try {
catProcess.kill("SIGTERM");
} catch (e) {
// Process may already be dead
}
try {
pipeProcess.kill("SIGTERM");
} catch (e) {
// Process may already be dead
}
// Clean up pipe file
try {
await unlink(PIPE_PATH);
} catch (e) {
// Ignore errors during cleanup
}
// Set conclusion based on exit code
if (exitCode === 0) {
// Try to process the output and save execution metrics
try {
await writeFile("output.txt", output);
// Process output.txt into JSON and save to execution file
const { stdout: jsonOutput } = await execAsync("jq -s '.' output.txt");
await writeFile(EXECUTION_FILE, jsonOutput);
console.log(`Log saved to ${EXECUTION_FILE}`);
} catch (e) {
core.warning(`Failed to process output for execution metrics: ${e}`);
}
core.setOutput("conclusion", "success");
core.setOutput("execution_file", EXECUTION_FILE);
} else {
core.setOutput("conclusion", "failure"); core.setOutput("conclusion", "failure");
process.exit(1);
// Still try to save execution file if we have output
if (output) {
try {
await writeFile("output.txt", output);
const { stdout: jsonOutput } = await execAsync("jq -s '.' output.txt");
await writeFile(EXECUTION_FILE, jsonOutput);
core.setOutput("execution_file", EXECUTION_FILE);
} catch (e) {
// Ignore errors when processing output during failure
}
}
process.exit(exitCode);
} }
} }
if (import.meta.main) {
run();
}

View File

@@ -134,7 +134,7 @@ describe("setupClaudeCodeSettings", () => {
// Then, add new settings // Then, add new settings
const newSettings = JSON.stringify({ const newSettings = JSON.stringify({
newKey: "newValue", newKey: "newValue",
model: "claude-opus-4-1-20250805", model: "claude-opus-4-20250514",
}); });
await setupClaudeCodeSettings(newSettings, testHomeDir); await setupClaudeCodeSettings(newSettings, testHomeDir);
@@ -145,7 +145,7 @@ describe("setupClaudeCodeSettings", () => {
expect(settings.enableAllProjectMcpServers).toBe(true); expect(settings.enableAllProjectMcpServers).toBe(true);
expect(settings.existingKey).toBe("existingValue"); expect(settings.existingKey).toBe("existingValue");
expect(settings.newKey).toBe("newValue"); expect(settings.newKey).toBe("newValue");
expect(settings.model).toBe("claude-opus-4-1-20250805"); expect(settings.model).toBe("claude-opus-4-20250514");
}); });
test("should copy slash commands to .claude directory when path provided", async () => { test("should copy slash commands to .claude directory when path provided", async () => {

View File

@@ -207,8 +207,15 @@ Claude does **not** have access to execute arbitrary Bash commands by default. I
```yaml ```yaml
- uses: anthropics/claude-code-action@beta - uses: anthropics/claude-code-action@beta
with: with:
allowed_tools: "Bash(npm install),Bash(npm run test),Edit,Replace,NotebookEditCell" allowed_tools: |
disallowed_tools: "TaskOutput,KillTask" Bash(npm install)
Bash(npm run test)
Edit
Replace
NotebookEditCell
disallowed_tools: |
TaskOutput
KillTask
# ... other inputs # ... other inputs
``` ```
@@ -245,7 +252,7 @@ You can provide Claude Code settings to customize behavior such as model selecti
with: with:
settings: | settings: |
{ {
"model": "claude-opus-4-1-20250805", "model": "claude-opus-4-20250514",
"env": { "env": {
"DEBUG": "true", "DEBUG": "true",
"API_URL": "https://api.example.com" "API_URL": "https://api.example.com"

View File

@@ -3,7 +3,7 @@
## Access Control ## Access Control
- **Repository Access**: The action can only be triggered by users with write access to the repository - **Repository Access**: The action can only be triggered by users with write access to the repository
- **Bot User Control**: By default, GitHub Apps and bots cannot trigger this action for security reasons. Use the `allowed_bots` parameter to enable specific bots or all bots - **No Bot Triggers**: GitHub Apps and bots cannot trigger this action
- **Token Permissions**: The GitHub app receives only a short-lived token scoped specifically to the repository it's operating in - **Token Permissions**: The GitHub app receives only a short-lived token scoped specifically to the repository it's operating in
- **No Cross-Repository Access**: Each action invocation is limited to the repository where it was triggered - **No Cross-Repository Access**: Each action invocation is limited to the repository where it was triggered
- **Limited Scope**: The token cannot access other repositories or perform actions beyond the configured permissions - **Limited Scope**: The token cannot access other repositories or perform actions beyond the configured permissions

View File

@@ -42,8 +42,6 @@ jobs:
# Optional: grant additional permissions (requires corresponding GitHub token permissions) # Optional: grant additional permissions (requires corresponding GitHub token permissions)
# additional_permissions: | # additional_permissions: |
# actions: read # actions: read
# Optional: allow bot users to trigger the action
# allowed_bots: "dependabot[bot],renovate[bot]"
``` ```
## Inputs ## Inputs
@@ -78,7 +76,6 @@ jobs:
| `additional_permissions` | Additional permissions to enable. Currently supports 'actions: read' for viewing workflow results | No | "" | | `additional_permissions` | Additional permissions to enable. Currently supports 'actions: read' for viewing workflow results | No | "" |
| `experimental_allowed_domains` | Restrict network access to these domains only (newline-separated). | No | "" | | `experimental_allowed_domains` | Restrict network access to these domains only (newline-separated). | No | "" |
| `use_commit_signing` | Enable commit signing using GitHub's commit signature verification. When false, Claude uses standard git commands | No | `false` | | `use_commit_signing` | Enable commit signing using GitHub's commit signature verification. When false, Claude uses standard git commands | No | `false` |
| `allowed_bots` | Comma-separated list of allowed bot usernames, or '\*' to allow all bots. Empty string (default) allows no bots | No | "" |
\*Required when using direct Anthropic API (default and when not using Bedrock or Vertex) \*Required when using direct Anthropic API (default and when not using Bedrock or Vertex)

View File

@@ -1,4 +1,4 @@
name: Claude Code name: Claude PR Assistant
on: on:
issue_comment: issue_comment:
@@ -11,53 +11,38 @@ on:
types: [submitted] types: [submitted]
jobs: jobs:
claude: claude-code-action:
if: | if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) (github.event_name == 'issues' && contains(github.event.issue.body, '@claude'))
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: write contents: read
pull-requests: write pull-requests: read
issues: write issues: read
id-token: write id-token: write
actions: read # Required for Claude to read CI results on PRs
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 1 fetch-depth: 1
- name: Run Claude Code - name: Run Claude PR Action
id: claude
uses: anthropics/claude-code-action@beta uses: anthropics/claude-code-action@beta
with: with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
# Or use OAuth token instead:
# This is an optional setting that allows Claude to read CI results on PRs # claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
additional_permissions: | timeout_minutes: "60"
actions: read # mode: tag # Default: responds to @claude mentions
# Optional: Restrict network access to specific domains only
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4.1) # experimental_allowed_domains: |
# model: "claude-opus-4-1-20250805" # .anthropic.com
# .github.com
# Optional: Customize the trigger phrase (default: @claude) # api.github.com
# trigger_phrase: "/claude" # .githubusercontent.com
# bun.sh
# Optional: Trigger when specific user is assigned to an issue # registry.npmjs.org
# assignee_trigger: "claude-bot" # .blob.core.windows.net
# Optional: Allow Claude to run specific commands
# allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)"
# Optional: Add custom instructions for Claude to customize its behavior for your project
# custom_instructions: |
# Follow our coding standards
# Ensure all new code has tests
# Use TypeScript for new files
# Optional: Custom environment variables for Claude
# claude_env: |
# NODE_ENV: test

View File

@@ -811,12 +811,18 @@ f. If you are unable to complete certain steps, such as running a linter or test
return promptContent; return promptContent;
} }
export type CreatePromptResult = {
promptFile: string;
allowedTools: string;
disallowedTools: string;
};
export async function createPrompt( export async function createPrompt(
mode: Mode, mode: Mode,
modeContext: ModeContext, modeContext: ModeContext,
githubData: FetchDataResult, githubData: FetchDataResult,
context: ParsedGitHubContext, context: ParsedGitHubContext,
) { ): Promise<CreatePromptResult> {
try { try {
// Prepare the context for prompt generation // Prepare the context for prompt generation
let claudeCommentId: string = ""; let claudeCommentId: string = "";
@@ -836,7 +842,7 @@ export async function createPrompt(
modeContext.claudeBranch, modeContext.claudeBranch,
); );
await mkdir(`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts`, { await mkdir(`${process.env.RUNNER_TEMP}/claude-prompts`, {
recursive: true, recursive: true,
}); });
@@ -855,7 +861,7 @@ export async function createPrompt(
// Write the prompt file // Write the prompt file
await writeFile( await writeFile(
`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts/claude-prompt.txt`, `${process.env.RUNNER_TEMP}/claude-prompts/claude-prompt.txt`,
promptContent, promptContent,
); );
@@ -888,8 +894,17 @@ export async function createPrompt(
combinedAllowedTools, combinedAllowedTools,
); );
// TODO: Remove these environment variable exports once modes are updated to use return values
core.exportVariable("ALLOWED_TOOLS", allAllowedTools); core.exportVariable("ALLOWED_TOOLS", allAllowedTools);
core.exportVariable("DISALLOWED_TOOLS", allDisallowedTools); core.exportVariable("DISALLOWED_TOOLS", allDisallowedTools);
const promptFile = `${process.env.RUNNER_TEMP}/claude-prompts/claude-prompt.txt`;
return {
promptFile,
allowedTools: allAllowedTools,
disallowedTools: allDisallowedTools,
};
} catch (error) { } catch (error) {
core.setFailed(`Create prompt failed with error: ${error}`); core.setFailed(`Create prompt failed with error: ${error}`);
process.exit(1); process.exit(1);

145
src/entrypoints/run.ts Normal file
View File

@@ -0,0 +1,145 @@
#!/usr/bin/env bun
/**
* Unified entrypoint that combines prepare and run-claude steps
*/
import * as core from "@actions/core";
import { setupGitHubToken } from "../github/token";
import { checkWritePermissions } from "../github/validation/permissions";
import { createOctokit } from "../github/api/client";
import { parseGitHubContext, isEntityContext } from "../github/context";
import { getMode, isValidMode, DEFAULT_MODE } from "../modes/registry";
import type { ModeName } from "../modes/types";
import { prepare } from "../prepare";
import { runClaudeCore } from "../../base-action/src/run-claude-core";
import { validateEnvironmentVariables } from "../../base-action/src/validate-env";
import { setupClaudeCodeSettings } from "../../base-action/src/setup-claude-code-settings";
async function run() {
try {
// Step 1: Get mode first to determine authentication method
const modeInput = process.env.MODE || DEFAULT_MODE;
// Validate mode input
if (!isValidMode(modeInput)) {
throw new Error(`Invalid mode: ${modeInput}`);
}
const validatedMode: ModeName = modeInput;
// Step 2: Setup GitHub token based on mode
let githubToken: string;
if (validatedMode === "experimental-review") {
// For experimental-review mode, use the default GitHub Action token
githubToken = process.env.DEFAULT_WORKFLOW_TOKEN || "";
if (!githubToken) {
throw new Error(
"DEFAULT_WORKFLOW_TOKEN not found for experimental-review mode",
);
}
console.log("Using default GitHub Action token for review mode");
} else {
// For other modes, use the existing token exchange
githubToken = await setupGitHubToken();
}
const octokit = createOctokit(githubToken);
// Step 3: Parse GitHub context (once for all operations)
const context = parseGitHubContext();
// Step 4: Check write permissions (only for entity contexts)
if (isEntityContext(context)) {
const hasWritePermissions = await checkWritePermissions(
octokit.rest,
context,
);
if (!hasWritePermissions) {
throw new Error(
"Actor does not have write permissions to the repository",
);
}
}
// Step 5: Get mode and check trigger conditions
const mode = getMode(validatedMode, context);
const containsTrigger = mode.shouldTrigger(context);
// Set output for action.yml to check (in case it's still needed)
core.setOutput("contains_trigger", containsTrigger.toString());
if (!containsTrigger) {
console.log("No trigger found, skipping remaining steps");
return;
}
// Step 6: Use the modular prepare function
const prepareResult = await prepare({
context,
octokit,
mode,
githubToken,
});
// Set critical outputs immediately after prepare completes
// This ensures they're available for cleanup even if Claude fails
core.setOutput("GITHUB_TOKEN", githubToken);
core.setOutput("mcp_config", prepareResult.mcpConfig);
if (prepareResult.branchInfo.claudeBranch) {
core.setOutput("branch_name", prepareResult.branchInfo.claudeBranch);
core.setOutput("CLAUDE_BRANCH", prepareResult.branchInfo.claudeBranch);
}
core.setOutput("BASE_BRANCH", prepareResult.branchInfo.baseBranch);
if (prepareResult.commentId) {
core.setOutput("claude_comment_id", prepareResult.commentId.toString());
}
// Step 7: The mode.prepare() call already created the prompt and set up tools
// We need to get the allowed/disallowed tools from environment variables
// TODO: Update Mode interface to return tools from prepare() instead of relying on env vars
const allowedTools = process.env.ALLOWED_TOOLS || "";
const disallowedTools = process.env.DISALLOWED_TOOLS || "";
const promptFile = `${process.env.RUNNER_TEMP}/claude-prompts/claude-prompt.txt`;
// Step 8: Validate environment and setup Claude settings
validateEnvironmentVariables();
await setupClaudeCodeSettings(process.env.SETTINGS);
// Step 9: Run Claude Code
console.log("Running Claude Code...");
// Build environment object to pass to Claude
const claudeEnvObject: Record<string, string> = {
GITHUB_TOKEN: githubToken,
NODE_VERSION: process.env.NODE_VERSION || "18.x",
DETAILED_PERMISSION_MESSAGES: "1",
CLAUDE_CODE_ACTION: "1",
};
await runClaudeCore({
promptFile,
settings: process.env.SETTINGS,
allowedTools,
disallowedTools,
maxTurns: process.env.MAX_TURNS,
mcpConfig: prepareResult.mcpConfig,
systemPrompt: "",
appendSystemPrompt: "",
claudeEnv: process.env.CLAUDE_ENV,
fallbackModel: process.env.FALLBACK_MODEL,
model: process.env.ANTHROPIC_MODEL || process.env.MODEL,
timeoutMinutes: process.env.TIMEOUT_MINUTES || "30",
env: claudeEnvObject,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
core.setFailed(`Action failed with error: ${errorMessage}`);
// Also output the clean error message for the action to capture
core.setOutput("prepare_error", errorMessage);
core.setOutput("conclusion", "failure");
process.exit(1);
}
}
if (import.meta.main) {
run();
}

View File

@@ -77,7 +77,6 @@ type BaseContext = {
useStickyComment: boolean; useStickyComment: boolean;
additionalPermissions: Map<string, string>; additionalPermissions: Map<string, string>;
useCommitSigning: boolean; useCommitSigning: boolean;
allowedBots: string;
}; };
}; };
@@ -137,7 +136,6 @@ export function parseGitHubContext(): GitHubContext {
process.env.ADDITIONAL_PERMISSIONS ?? "", process.env.ADDITIONAL_PERMISSIONS ?? "",
), ),
useCommitSigning: process.env.USE_COMMIT_SIGNING === "true", useCommitSigning: process.env.USE_COMMIT_SIGNING === "true",
allowedBots: process.env.ALLOWED_BOTS ?? "",
}, },
}; };

View File

@@ -31,30 +31,8 @@ async function exchangeForAppToken(oidcToken: string): Promise<string> {
const responseJson = (await response.json()) as { const responseJson = (await response.json()) as {
error?: { error?: {
message?: string; message?: string;
details?: {
error_code?: string;
}; };
}; };
type?: string;
message?: string;
};
// Check for specific workflow validation error codes that should skip the action
const errorCode = responseJson.error?.details?.error_code;
if (errorCode === "workflow_not_found_on_default_branch") {
const message =
responseJson.message ??
responseJson.error?.message ??
"Workflow validation failed";
core.warning(`Skipping action due to workflow validation: ${message}`);
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);
}
console.error( console.error(
`App token exchange failed: ${response.status} ${response.statusText} - ${responseJson?.error?.message ?? "Unknown error"}`, `App token exchange failed: ${response.status} ${response.statusText} - ${responseJson?.error?.message ?? "Unknown error"}`,
); );
@@ -99,9 +77,8 @@ export async function setupGitHubToken(): Promise<string> {
core.setOutput("GITHUB_TOKEN", appToken); core.setOutput("GITHUB_TOKEN", appToken);
return appToken; return appToken;
} catch (error) { } catch (error) {
// Only set failed if we get here - workflow validation errors will exit(0) before this
core.setFailed( 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.`, `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); process.exit(1);
} }

View File

@@ -58,41 +58,6 @@ export function sanitizeContent(content: string): string {
content = stripMarkdownLinkTitles(content); content = stripMarkdownLinkTitles(content);
content = stripHiddenAttributes(content); content = stripHiddenAttributes(content);
content = normalizeHtmlEntities(content); content = normalizeHtmlEntities(content);
content = redactGitHubTokens(content);
return content;
}
export function redactGitHubTokens(content: string): string {
// GitHub Personal Access Tokens (classic): ghp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars)
content = content.replace(
/\bghp_[A-Za-z0-9]{36}\b/g,
"[REDACTED_GITHUB_TOKEN]",
);
// GitHub OAuth tokens: gho_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars)
content = content.replace(
/\bgho_[A-Za-z0-9]{36}\b/g,
"[REDACTED_GITHUB_TOKEN]",
);
// GitHub installation tokens: ghs_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars)
content = content.replace(
/\bghs_[A-Za-z0-9]{36}\b/g,
"[REDACTED_GITHUB_TOKEN]",
);
// GitHub refresh tokens: ghr_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars)
content = content.replace(
/\bghr_[A-Za-z0-9]{36}\b/g,
"[REDACTED_GITHUB_TOKEN]",
);
// GitHub fine-grained personal access tokens: github_pat_XXXXXXXXXX (up to 255 chars)
content = content.replace(
/\bgithub_pat_[A-Za-z0-9_]{11,221}\b/g,
"[REDACTED_GITHUB_TOKEN]",
);
return content; return content;
} }

View File

@@ -21,42 +21,9 @@ export async function checkHumanActor(
console.log(`Actor type: ${actorType}`); console.log(`Actor type: ${actorType}`);
// Check bot permissions if actor is not a User
if (actorType !== "User") { if (actorType !== "User") {
const allowedBots = githubContext.inputs.allowedBots;
// Check if all bots are allowed
if (allowedBots.trim() === "*") {
console.log(
`All bots are allowed, skipping human actor check for: ${githubContext.actor}`,
);
return;
}
// Parse allowed bots list
const allowedBotsList = allowedBots
.split(",")
.map((bot) =>
bot
.trim()
.toLowerCase()
.replace(/\[bot\]$/, ""),
)
.filter((bot) => bot.length > 0);
const botName = githubContext.actor.toLowerCase().replace(/\[bot\]$/, "");
// Check if specific bot is allowed
if (allowedBotsList.includes(botName)) {
console.log(
`Bot ${botName} is in allowed list, skipping human actor check`,
);
return;
}
// Bot not allowed
throw new Error( throw new Error(
`Workflow initiated by non-human actor: ${botName} (type: ${actorType}). Add bot to allowed_bots list or use '*' to allow all bots.`, `Workflow initiated by non-human actor: ${githubContext.actor} (type: ${actorType}).`,
); );
} }

View File

@@ -17,12 +17,6 @@ export async function checkWritePermissions(
try { try {
core.info(`Checking permissions for actor: ${actor}`); core.info(`Checking permissions for actor: ${actor}`);
// Check if the actor is a GitHub App (bot user)
if (actor.endsWith("[bot]")) {
core.info(`Actor is a GitHub App: ${actor}`);
return true;
}
// Check permissions directly using the permission endpoint // Check permissions directly using the permission endpoint
const response = await octokit.repos.getCollaboratorPermissionLevel({ const response = await octokit.repos.getCollaboratorPermissionLevel({
owner: repository.owner, owner: repository.owner,

View File

@@ -6,7 +6,6 @@ import { z } from "zod";
import { GITHUB_API_URL } from "../github/api/config"; import { GITHUB_API_URL } from "../github/api/config";
import { Octokit } from "@octokit/rest"; import { Octokit } from "@octokit/rest";
import { updateClaudeComment } from "../github/operations/comments/update-claude-comment"; import { updateClaudeComment } from "../github/operations/comments/update-claude-comment";
import { sanitizeContent } from "../github/utils/sanitizer";
// Get repository information from environment variables // Get repository information from environment variables
const REPO_OWNER = process.env.REPO_OWNER; const REPO_OWNER = process.env.REPO_OWNER;
@@ -55,13 +54,11 @@ server.tool(
const isPullRequestReviewComment = const isPullRequestReviewComment =
eventName === "pull_request_review_comment"; eventName === "pull_request_review_comment";
const sanitizedBody = sanitizeContent(body);
const result = await updateClaudeComment(octokit, { const result = await updateClaudeComment(octokit, {
owner, owner,
repo, repo,
commentId, commentId,
body: sanitizedBody, body,
isPullRequestReviewComment, isPullRequestReviewComment,
}); });

View File

@@ -3,7 +3,6 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod"; import { z } from "zod";
import { createOctokit } from "../github/api/client"; import { createOctokit } from "../github/api/client";
import { sanitizeContent } from "../github/utils/sanitizer";
// Get repository and PR information from environment variables // Get repository and PR information from environment variables
const REPO_OWNER = process.env.REPO_OWNER; const REPO_OWNER = process.env.REPO_OWNER;
@@ -42,14 +41,12 @@ server.tool(
), ),
line: z line: z
.number() .number()
.nonnegative()
.optional() .optional()
.describe( .describe(
"Line number for single-line comments (required if startLine is not provided)", "Line number for single-line comments (required if startLine is not provided)",
), ),
startLine: z startLine: z
.number() .number()
.nonnegative()
.optional() .optional()
.describe( .describe(
"Start line for multi-line comments (use with line parameter for the end line)", "Start line for multi-line comments (use with line parameter for the end line)",
@@ -82,9 +79,6 @@ server.tool(
const octokit = createOctokit(githubToken).rest; const octokit = createOctokit(githubToken).rest;
// Sanitize the comment body to remove any potential GitHub tokens
const sanitizedBody = sanitizeContent(body);
// Validate that either line or both startLine and line are provided // Validate that either line or both startLine and line are provided
if (!line && !startLine) { if (!line && !startLine) {
throw new Error( throw new Error(
@@ -108,7 +102,7 @@ server.tool(
owner, owner,
repo, repo,
pull_number, pull_number,
body: sanitizedBody, body,
path, path,
side: side || "RIGHT", side: side || "RIGHT",
commit_id: commit_id || pr.data.head.sha, commit_id: commit_id || pr.data.head.sha,

View File

@@ -45,7 +45,7 @@ export const agentMode: Mode = {
// TODO: handle by createPrompt (similar to tag and review modes) // TODO: handle by createPrompt (similar to tag and review modes)
// Create prompt directory // Create prompt directory
await mkdir(`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts`, { await mkdir(`${process.env.RUNNER_TEMP}/claude-prompts`, {
recursive: true, recursive: true,
}); });
// Write the prompt file - the base action requires a prompt_file parameter, // Write the prompt file - the base action requires a prompt_file parameter,
@@ -57,7 +57,7 @@ export const agentMode: Mode = {
context.inputs.directPrompt || context.inputs.directPrompt ||
`Repository: ${context.repository.owner}/${context.repository.repo}`; `Repository: ${context.repository.owner}/${context.repository.repo}`;
await writeFile( await writeFile(
`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts/claude-prompt.txt`, `${process.env.RUNNER_TEMP}/claude-prompts/claude-prompt.txt`,
promptContent, promptContent,
); );
@@ -80,8 +80,9 @@ export const agentMode: Mode = {
...context.inputs.disallowedTools, ...context.inputs.disallowedTools,
]; ];
core.exportVariable("ALLOWED_TOOLS", allowedTools.join(",")); // Export as INPUT_ prefixed variables for the base action
core.exportVariable("DISALLOWED_TOOLS", disallowedTools.join(",")); core.exportVariable("INPUT_ALLOWED_TOOLS", allowedTools.join(","));
core.exportVariable("INPUT_DISALLOWED_TOOLS", disallowedTools.join(","));
// Agent mode uses a minimal MCP configuration // Agent mode uses a minimal MCP configuration
// We don't need comment servers or PR-specific tools for automation // We don't need comment servers or PR-specific tools for automation

View File

@@ -103,9 +103,6 @@ export const reviewMode: Mode = {
? formatBody(contextData.body, imageUrlMap) ? formatBody(contextData.body, imageUrlMap)
: "No description provided"; : "No description provided";
// Using a variable for code blocks to avoid escaping backticks in the template string
const codeBlock = "```";
return `You are Claude, an AI assistant specialized in code reviews for GitHub pull requests. You are operating in REVIEW MODE, which means you should focus on providing thorough code review feedback using GitHub MCP tools for inline comments and suggestions. return `You are Claude, an AI assistant specialized in code reviews for GitHub pull requests. You are operating in REVIEW MODE, which means you should focus on providing thorough code review feedback using GitHub MCP tools for inline comments and suggestions.
<formatted_context> <formatted_context>
@@ -158,46 +155,17 @@ REVIEW MODE WORKFLOW:
- This provides the full context and latest state of the code - This provides the full context and latest state of the code
- Look at the changed_files section above to see which files were modified - Look at the changed_files section above to see which files were modified
2. Create review comments using GitHub MCP tools: 2. Add comments:
- Use Bash(gh issue comment:*) for general PR-level comments - use Bash(gh issue comment:*) to add top-level comments
- Use mcp__github_inline_comment__create_inline_comment for line-specific feedback (strongly preferred) - Use mcp__github_inline_comment__create_inline_comment to add inline comments (prefer this where possible)
- Parameters:
* path: The file path (e.g., "src/index.js")
* line: Line number for single-line comments
* startLine & line: For multi-line comments (startLine is the first line, line is the last)
* side: "LEFT" (old code) or "RIGHT" (new code)
* subjectType: "line" for line-level comments
* body: Your comment text
3. When creating inline comments with suggestions:
CRITICAL: GitHub's suggestion blocks REPLACE the ENTIRE line range you select
- For single-line comments: Use 'line' parameter only
- For multi-line comments: Use both 'startLine' and 'line' parameters
- The 'body' parameter should contain your comment and/or suggestion block
How to write code suggestions correctly:
a) To remove a line (e.g., removing console.log on line 22):
- Set line: 22
- Body: ${codeBlock}suggestion
${codeBlock}
(Empty suggestion block removes the line)
b) To modify a single line (e.g., fixing line 22):
- Set line: 22
- Body: ${codeBlock}suggestion
await this.emailInput.fill(email);
${codeBlock}
c) To replace multiple lines (e.g., lines 21-23):
- Set startLine: 21, line: 23
- Body must include ALL lines being replaced:
${codeBlock}suggestion
async typeEmail(email: string): Promise<void> {
await this.emailInput.fill(email);
}
${codeBlock}
COMMON MISTAKE TO AVOID:
Never duplicate code in suggestions. For example, DON'T do this:
${codeBlock}suggestion
async typeEmail(email: string): Promise<void> {
async typeEmail(email: string): Promise<void> { // WRONG: Duplicate signature!
await this.emailInput.fill(email);
}
${codeBlock}
REVIEW GUIDELINES: REVIEW GUIDELINES:
@@ -211,11 +179,13 @@ REVIEW GUIDELINES:
- Provide: - Provide:
* Specific, actionable feedback * Specific, actionable feedback
* Code suggestions using the exact format described above * Code suggestions when possible (following GitHub's format exactly)
* Clear explanations of issues found * Clear explanations of issues
* Constructive criticism with solutions * Constructive criticism
* Recognition of good practices * Recognition of good practices
* For complex changes: Create separate inline comments for each logical change * For complex changes that require multiple modifications:
- Create separate comments for each logical change
- Or explain the full solution in text without a suggestion block
- Communication: - Communication:
* All feedback goes through GitHub's review system * All feedback goes through GitHub's review system
@@ -272,6 +242,7 @@ This ensures users get value from the review even before checking individual inl
claudeBranch: branchInfo.claudeBranch, claudeBranch: branchInfo.claudeBranch,
}); });
// TODO: Capture and return the allowed/disallowed tools from createPrompt
await createPrompt(reviewMode, modeContext, githubData, context); await createPrompt(reviewMode, modeContext, githubData, context);
// Export tool environment variables for review mode // Export tool environment variables for review mode
@@ -297,8 +268,9 @@ This ensures users get value from the review even before checking individual inl
...context.inputs.disallowedTools, ...context.inputs.disallowedTools,
]; ];
core.exportVariable("ALLOWED_TOOLS", allowedTools.join(",")); // Export as INPUT_ prefixed variables for the base action
core.exportVariable("DISALLOWED_TOOLS", disallowedTools.join(",")); core.exportVariable("INPUT_ALLOWED_TOOLS", allowedTools.join(","));
core.exportVariable("INPUT_DISALLOWED_TOOLS", disallowedTools.join(","));
const additionalMcpConfig = process.env.MCP_CONFIG || ""; const additionalMcpConfig = process.env.MCP_CONFIG || "";
const mcpConfig = await prepareMcpConfig({ const mcpConfig = await prepareMcpConfig({

View File

@@ -98,6 +98,7 @@ export const tagMode: Mode = {
claudeBranch: branchInfo.claudeBranch, claudeBranch: branchInfo.claudeBranch,
}); });
// TODO: Capture and return the allowed/disallowed tools from createPrompt
await createPrompt(tagMode, modeContext, githubData, context); await createPrompt(tagMode, modeContext, githubData, context);
// Get MCP configuration // Get MCP configuration

View File

@@ -10,6 +10,7 @@ export type PrepareResult = {
currentBranch: string; currentBranch: string;
}; };
mcpConfig: string; mcpConfig: string;
// TODO: Add allowedTools and disallowedTools here once modes are updated
}; };
export type PrepareOptions = { export type PrepareOptions = {

View File

@@ -1,96 +0,0 @@
#!/usr/bin/env bun
import { describe, test, expect } from "bun:test";
import { checkHumanActor } from "../src/github/validation/actor";
import type { Octokit } from "@octokit/rest";
import { createMockContext } from "./mockContext";
function createMockOctokit(userType: string): Octokit {
return {
users: {
getByUsername: async () => ({
data: {
type: userType,
},
}),
},
} as unknown as Octokit;
}
describe("checkHumanActor", () => {
test("should pass for human actor", async () => {
const mockOctokit = createMockOctokit("User");
const context = createMockContext();
context.actor = "human-user";
await expect(
checkHumanActor(mockOctokit, context),
).resolves.toBeUndefined();
});
test("should throw error for bot actor when not allowed", async () => {
const mockOctokit = createMockOctokit("Bot");
const context = createMockContext();
context.actor = "test-bot[bot]";
context.inputs.allowedBots = "";
await expect(checkHumanActor(mockOctokit, context)).rejects.toThrow(
"Workflow initiated by non-human actor: test-bot (type: Bot). Add bot to allowed_bots list or use '*' to allow all bots.",
);
});
test("should pass for bot actor when all bots allowed", async () => {
const mockOctokit = createMockOctokit("Bot");
const context = createMockContext();
context.actor = "test-bot[bot]";
context.inputs.allowedBots = "*";
await expect(
checkHumanActor(mockOctokit, context),
).resolves.toBeUndefined();
});
test("should pass for specific bot when in allowed list", async () => {
const mockOctokit = createMockOctokit("Bot");
const context = createMockContext();
context.actor = "dependabot[bot]";
context.inputs.allowedBots = "dependabot[bot],renovate[bot]";
await expect(
checkHumanActor(mockOctokit, context),
).resolves.toBeUndefined();
});
test("should pass for specific bot when in allowed list (without [bot])", async () => {
const mockOctokit = createMockOctokit("Bot");
const context = createMockContext();
context.actor = "dependabot[bot]";
context.inputs.allowedBots = "dependabot,renovate";
await expect(
checkHumanActor(mockOctokit, context),
).resolves.toBeUndefined();
});
test("should throw error for bot not in allowed list", async () => {
const mockOctokit = createMockOctokit("Bot");
const context = createMockContext();
context.actor = "other-bot[bot]";
context.inputs.allowedBots = "dependabot[bot],renovate[bot]";
await expect(checkHumanActor(mockOctokit, context)).rejects.toThrow(
"Workflow initiated by non-human actor: other-bot (type: Bot). Add bot to allowed_bots list or use '*' to allow all bots.",
);
});
test("should throw error for bot not in allowed list (without [bot])", async () => {
const mockOctokit = createMockOctokit("Bot");
const context = createMockContext();
context.actor = "other-bot[bot]";
context.inputs.allowedBots = "dependabot,renovate";
await expect(checkHumanActor(mockOctokit, context)).rejects.toThrow(
"Workflow initiated by non-human actor: other-bot (type: Bot). Add bot to allowed_bots list or use '*' to allow all bots.",
);
});
});

View File

@@ -37,7 +37,6 @@ describe("prepareMcpConfig", () => {
useStickyComment: false, useStickyComment: false,
additionalPermissions: new Map(), additionalPermissions: new Map(),
useCommitSigning: false, useCommitSigning: false,
allowedBots: "",
}, },
}; };

View File

@@ -28,7 +28,6 @@ const defaultInputs = {
useStickyComment: false, useStickyComment: false,
additionalPermissions: new Map<string, string>(), additionalPermissions: new Map<string, string>(),
useCommitSigning: false, useCommitSigning: false,
allowedBots: "",
}; };
const defaultRepository = { const defaultRepository = {

View File

@@ -1,29 +1,15 @@
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test"; import { describe, test, expect, beforeEach } from "bun:test";
import { agentMode } from "../../src/modes/agent"; import { agentMode } from "../../src/modes/agent";
import type { GitHubContext } from "../../src/github/context"; import type { GitHubContext } from "../../src/github/context";
import { createMockContext, createMockAutomationContext } from "../mockContext"; import { createMockContext, createMockAutomationContext } from "../mockContext";
import * as core from "@actions/core";
describe("Agent Mode", () => { describe("Agent Mode", () => {
let mockContext: GitHubContext; let mockContext: GitHubContext;
let exportVariableSpy: any;
let setOutputSpy: any;
beforeEach(() => { beforeEach(() => {
mockContext = createMockAutomationContext({ mockContext = createMockAutomationContext({
eventName: "workflow_dispatch", eventName: "workflow_dispatch",
}); });
exportVariableSpy = spyOn(core, "exportVariable").mockImplementation(
() => {},
);
setOutputSpy = spyOn(core, "setOutput").mockImplementation(() => {});
});
afterEach(() => {
exportVariableSpy?.mockClear();
setOutputSpy?.mockClear();
exportVariableSpy?.mockRestore();
setOutputSpy?.mockRestore();
}); });
test("agent mode has correct properties", () => { test("agent mode has correct properties", () => {
@@ -70,67 +56,4 @@ describe("Agent Mode", () => {
expect(agentMode.shouldTrigger(context)).toBe(false); expect(agentMode.shouldTrigger(context)).toBe(false);
}); });
}); });
test("prepare method sets up tools environment variables correctly", async () => {
// Clear any previous calls before this test
exportVariableSpy.mockClear();
setOutputSpy.mockClear();
const contextWithCustomTools = createMockAutomationContext({
eventName: "workflow_dispatch",
});
contextWithCustomTools.inputs.allowedTools = ["CustomTool1", "CustomTool2"];
contextWithCustomTools.inputs.disallowedTools = ["BadTool"];
const mockOctokit = {} as any;
const result = await agentMode.prepare({
context: contextWithCustomTools,
octokit: mockOctokit,
githubToken: "test-token",
});
// Verify that both ALLOWED_TOOLS and DISALLOWED_TOOLS are set
expect(exportVariableSpy).toHaveBeenCalledWith(
"ALLOWED_TOOLS",
"Edit,MultiEdit,Glob,Grep,LS,Read,Write,CustomTool1,CustomTool2",
);
expect(exportVariableSpy).toHaveBeenCalledWith(
"DISALLOWED_TOOLS",
"WebSearch,WebFetch,BadTool",
);
// Verify MCP config is set
expect(setOutputSpy).toHaveBeenCalledWith("mcp_config", expect.any(String));
// Verify return structure
expect(result).toEqual({
commentId: undefined,
branchInfo: {
baseBranch: "",
currentBranch: "",
claudeBranch: undefined,
},
mcpConfig: expect.any(String),
});
});
test("prepare method creates prompt file with correct content", async () => {
const contextWithPrompts = createMockAutomationContext({
eventName: "workflow_dispatch",
});
contextWithPrompts.inputs.overridePrompt = "Custom override prompt";
contextWithPrompts.inputs.directPrompt =
"Direct prompt (should be ignored)";
const mockOctokit = {} as any;
await agentMode.prepare({
context: contextWithPrompts,
octokit: mockOctokit,
githubToken: "test-token",
});
// Note: We can't easily test file creation in this unit test,
// but we can verify the method completes without errors
expect(setOutputSpy).toHaveBeenCalledWith("mcp_config", expect.any(String));
});
}); });

View File

@@ -73,7 +73,6 @@ describe("checkWritePermissions", () => {
useStickyComment: false, useStickyComment: false,
additionalPermissions: new Map(), additionalPermissions: new Map(),
useCommitSigning: false, useCommitSigning: false,
allowedBots: "",
}, },
}); });
@@ -127,16 +126,6 @@ describe("checkWritePermissions", () => {
); );
}); });
test("should return true for bot user", async () => {
const mockOctokit = createMockOctokit("none");
const context = createContext();
context.actor = "test-bot[bot]";
const result = await checkWritePermissions(mockOctokit, context);
expect(result).toBe(true);
});
test("should throw error when permission check fails", async () => { test("should throw error when permission check fails", async () => {
const error = new Error("API error"); const error = new Error("API error");
const mockOctokit = { const mockOctokit = {

View File

@@ -7,7 +7,6 @@ import {
normalizeHtmlEntities, normalizeHtmlEntities,
sanitizeContent, sanitizeContent,
stripHtmlComments, stripHtmlComments,
redactGitHubTokens,
} from "../src/github/utils/sanitizer"; } from "../src/github/utils/sanitizer";
describe("stripInvisibleCharacters", () => { describe("stripInvisibleCharacters", () => {
@@ -243,109 +242,6 @@ describe("sanitizeContent", () => {
}); });
}); });
describe("redactGitHubTokens", () => {
it("should redact personal access tokens (ghp_)", () => {
const token = "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";
expect(redactGitHubTokens(`Token: ${token}`)).toBe(
"Token: [REDACTED_GITHUB_TOKEN]",
);
expect(redactGitHubTokens(`Here's a token: ${token} in text`)).toBe(
"Here's a token: [REDACTED_GITHUB_TOKEN] in text",
);
});
it("should redact OAuth tokens (gho_)", () => {
const token = "gho_16C7e42F292c6912E7710c838347Ae178B4a";
expect(redactGitHubTokens(`OAuth: ${token}`)).toBe(
"OAuth: [REDACTED_GITHUB_TOKEN]",
);
});
it("should redact installation tokens (ghs_)", () => {
const token = "ghs_xz7yzju2SZjGPa0dUNMAx0SH4xDOCS31LXQW";
expect(redactGitHubTokens(`Install token: ${token}`)).toBe(
"Install token: [REDACTED_GITHUB_TOKEN]",
);
});
it("should redact refresh tokens (ghr_)", () => {
const token = "ghr_1B4a2e77838347a253e56d7b5253e7d11667";
expect(redactGitHubTokens(`Refresh: ${token}`)).toBe(
"Refresh: [REDACTED_GITHUB_TOKEN]",
);
});
it("should redact fine-grained tokens (github_pat_)", () => {
const token =
"github_pat_11ABCDEFG0example5of9_2nVwvsylpmOLboQwTPTLewDcE621dQ0AAaBBCCDDEEFFHH";
expect(redactGitHubTokens(`Fine-grained: ${token}`)).toBe(
"Fine-grained: [REDACTED_GITHUB_TOKEN]",
);
});
it("should handle tokens in code blocks", () => {
const content = `\`\`\`bash
export GITHUB_TOKEN=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
\`\`\``;
const expected = `\`\`\`bash
export GITHUB_TOKEN=[REDACTED_GITHUB_TOKEN]
\`\`\``;
expect(redactGitHubTokens(content)).toBe(expected);
});
it("should handle multiple tokens in one text", () => {
const content =
"Token 1: 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 and token 2: gho_16C7e42F292c6912E7710c838347Ae178B4a";
expect(redactGitHubTokens(content)).toBe(
"Token 1: [REDACTED_GITHUB_TOKEN] and token 2: [REDACTED_GITHUB_TOKEN]",
);
});
it("should handle tokens in URLs", () => {
const content =
"https://api.github.com/user?access_token=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";
expect(redactGitHubTokens(content)).toBe(
"https://api.github.com/user?access_token=[REDACTED_GITHUB_TOKEN]",
);
});
it("should not redact partial matches or invalid tokens", () => {
const content =
"This is not a token: ghp_short or gho_toolong1234567890123456789012345678901234567890";
expect(redactGitHubTokens(content)).toBe(content);
});
it("should preserve normal text", () => {
const content = "Normal text with no tokens";
expect(redactGitHubTokens(content)).toBe(content);
});
it("should handle edge cases", () => {
expect(redactGitHubTokens("")).toBe("");
expect(redactGitHubTokens("ghp_")).toBe("ghp_");
expect(redactGitHubTokens("github_pat_short")).toBe("github_pat_short");
});
});
describe("sanitizeContent with token redaction", () => {
it("should redact tokens as part of full sanitization", () => {
const content = `
<!-- Hidden comment with token: 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 -->
Here's some text with a token: gho_16C7e42F292c6912E7710c838347Ae178B4a
And invisible chars: test\u200Btoken
`;
const sanitized = sanitizeContent(content);
expect(sanitized).not.toContain("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");
expect(sanitized).not.toContain("gho_16C7e42F292c6912E7710c838347Ae178B4a");
expect(sanitized).not.toContain("<!-- Hidden comment");
expect(sanitized).not.toContain("\u200B");
expect(sanitized).toContain("[REDACTED_GITHUB_TOKEN]");
expect(sanitized).toContain("Here's some text with a token:");
});
});
describe("stripHtmlComments (legacy)", () => { describe("stripHtmlComments (legacy)", () => {
it("should remove HTML comments", () => { it("should remove HTML comments", () => {
expect(stripHtmlComments("Hello <!-- example -->World")).toBe( expect(stripHtmlComments("Hello <!-- example -->World")).toBe(

View File

@@ -41,7 +41,6 @@ describe("checkContainsTrigger", () => {
useStickyComment: false, useStickyComment: false,
additionalPermissions: new Map(), additionalPermissions: new Map(),
useCommitSigning: false, useCommitSigning: false,
allowedBots: "",
}, },
}); });
expect(checkContainsTrigger(context)).toBe(true); expect(checkContainsTrigger(context)).toBe(true);
@@ -75,7 +74,6 @@ describe("checkContainsTrigger", () => {
useStickyComment: false, useStickyComment: false,
additionalPermissions: new Map(), additionalPermissions: new Map(),
useCommitSigning: false, useCommitSigning: false,
allowedBots: "",
}, },
}); });
expect(checkContainsTrigger(context)).toBe(false); expect(checkContainsTrigger(context)).toBe(false);
@@ -293,7 +291,6 @@ describe("checkContainsTrigger", () => {
useStickyComment: false, useStickyComment: false,
additionalPermissions: new Map(), additionalPermissions: new Map(),
useCommitSigning: false, useCommitSigning: false,
allowedBots: "",
}, },
}); });
expect(checkContainsTrigger(context)).toBe(true); expect(checkContainsTrigger(context)).toBe(true);
@@ -328,7 +325,6 @@ describe("checkContainsTrigger", () => {
useStickyComment: false, useStickyComment: false,
additionalPermissions: new Map(), additionalPermissions: new Map(),
useCommitSigning: false, useCommitSigning: false,
allowedBots: "",
}, },
}); });
expect(checkContainsTrigger(context)).toBe(true); expect(checkContainsTrigger(context)).toBe(true);
@@ -363,7 +359,6 @@ describe("checkContainsTrigger", () => {
useStickyComment: false, useStickyComment: false,
additionalPermissions: new Map(), additionalPermissions: new Map(),
useCommitSigning: false, useCommitSigning: false,
allowedBots: "",
}, },
}); });
expect(checkContainsTrigger(context)).toBe(false); expect(checkContainsTrigger(context)).toBe(false);