mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-23 15:04:13 +08:00
Compare commits
9 Commits
ashwin/cla
...
support-bo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd0b40e98e | ||
|
|
28cc702619 | ||
|
|
15f62ad7ce | ||
|
|
d0f57ec761 | ||
|
|
e82e97e178 | ||
|
|
bf34e22e43 | ||
|
|
d46f8d940d | ||
|
|
3d56fc960a | ||
|
|
529716dcad |
@@ -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
|
||||
- **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
|
||||
- **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
|
||||
|
||||
---
|
||||
|
||||
115
action.yml
115
action.yml
@@ -23,6 +23,10 @@ inputs:
|
||||
description: "The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format)"
|
||||
required: false
|
||||
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:
|
||||
@@ -118,10 +122,10 @@ inputs:
|
||||
outputs:
|
||||
execution_file:
|
||||
description: "Path to the Claude Code execution output file"
|
||||
value: ${{ steps.claude.outputs.execution_file }}
|
||||
value: ${{ steps.claude-code.outputs.execution_file }}
|
||||
branch_name:
|
||||
description: "The branch created by Claude Code for this execution"
|
||||
value: ${{ steps.claude.outputs.CLAUDE_BRANCH }}
|
||||
value: ${{ steps.prepare.outputs.CLAUDE_BRANCH }}
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
@@ -137,61 +141,82 @@ runs:
|
||||
cd ${GITHUB_ACTION_PATH}
|
||||
bun install
|
||||
|
||||
- name: Run Claude
|
||||
id: claude
|
||||
- name: Prepare action
|
||||
id: prepare
|
||||
shell: bash
|
||||
run: |
|
||||
# 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
|
||||
bun run ${GITHUB_ACTION_PATH}/src/entrypoints/prepare.ts
|
||||
env:
|
||||
# Mode and trigger configuration
|
||||
MODE: ${{ inputs.mode }}
|
||||
TRIGGER_PHRASE: ${{ inputs.trigger_phrase }}
|
||||
ASSIGNEE_TRIGGER: ${{ inputs.assignee_trigger }}
|
||||
LABEL_TRIGGER: ${{ inputs.label_trigger }}
|
||||
BASE_BRANCH: ${{ inputs.base_branch }}
|
||||
BRANCH_PREFIX: ${{ inputs.branch_prefix }}
|
||||
ALLOWED_TOOLS: ${{ inputs.allowed_tools }}
|
||||
DISALLOWED_TOOLS: ${{ inputs.disallowed_tools }}
|
||||
CUSTOM_INSTRUCTIONS: ${{ inputs.custom_instructions }}
|
||||
DIRECT_PROMPT: ${{ inputs.direct_prompt }}
|
||||
OVERRIDE_PROMPT: ${{ inputs.override_prompt }}
|
||||
MCP_CONFIG: ${{ inputs.mcp_config }}
|
||||
OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }}
|
||||
ALLOWED_BOTS: ${{ inputs.allowed_bots }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }}
|
||||
DEFAULT_WORKFLOW_TOKEN: ${{ github.token }}
|
||||
ADDITIONAL_PERMISSIONS: ${{ inputs.additional_permissions }}
|
||||
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
|
||||
bun install -g @anthropic-ai/claude-code@1.0.68
|
||||
|
||||
- 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 }}
|
||||
|
||||
# Claude configuration
|
||||
ALLOWED_TOOLS: ${{ inputs.allowed_tools }}
|
||||
DISALLOWED_TOOLS: ${{ inputs.disallowed_tools }}
|
||||
MAX_TURNS: ${{ inputs.max_turns }}
|
||||
SETTINGS: ${{ inputs.settings }}
|
||||
TIMEOUT_MINUTES: ${{ inputs.timeout_minutes }}
|
||||
CLAUDE_ENV: ${{ inputs.claude_env }}
|
||||
FALLBACK_MODEL: ${{ inputs.fallback_model }}
|
||||
- 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_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
|
||||
|
||||
# Model configuration
|
||||
MODEL: ${{ inputs.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
|
||||
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
|
||||
@@ -219,35 +244,35 @@ runs:
|
||||
VERTEX_REGION_CLAUDE_3_7_SONNET: ${{ env.VERTEX_REGION_CLAUDE_3_7_SONNET }}
|
||||
|
||||
- name: Update comment with job link
|
||||
if: steps.claude.outputs.contains_trigger == 'true' && steps.claude.outputs.claude_comment_id && always()
|
||||
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.claude.outputs.claude_comment_id }}
|
||||
CLAUDE_COMMENT_ID: ${{ steps.prepare.outputs.claude_comment_id }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
GITHUB_TOKEN: ${{ steps.claude.outputs.GITHUB_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }}
|
||||
GITHUB_EVENT_NAME: ${{ github.event_name }}
|
||||
TRIGGER_COMMENT_ID: ${{ github.event.comment.id }}
|
||||
CLAUDE_BRANCH: ${{ steps.claude.outputs.CLAUDE_BRANCH }}
|
||||
CLAUDE_BRANCH: ${{ steps.prepare.outputs.CLAUDE_BRANCH }}
|
||||
IS_PR: ${{ github.event.issue.pull_request != null || github.event_name == 'pull_request_review_comment' }}
|
||||
BASE_BRANCH: ${{ steps.claude.outputs.BASE_BRANCH }}
|
||||
CLAUDE_SUCCESS: ${{ steps.claude.outputs.conclusion == 'success' }}
|
||||
OUTPUT_FILE: ${{ steps.claude.outputs.execution_file || '' }}
|
||||
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.claude.outcome == 'success' }}
|
||||
PREPARE_ERROR: ${{ steps.claude.outputs.prepare_error || '' }}
|
||||
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 }}
|
||||
|
||||
- name: Display Claude Code Report
|
||||
if: steps.claude.outputs.contains_trigger == 'true' && steps.claude.outputs.execution_file != ''
|
||||
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.outputs.execution_file }}" >> $GITHUB_STEP_SUMMARY 2>/dev/null; then
|
||||
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
|
||||
@@ -255,7 +280,7 @@ runs:
|
||||
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.outputs.execution_file }}" >> $GITHUB_STEP_SUMMARY
|
||||
cat "${{ steps.claude-code.outputs.execution_file }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
@@ -266,6 +291,6 @@ runs:
|
||||
curl -L \
|
||||
-X DELETE \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: Bearer ${{ steps.claude.outputs.GITHUB_TOKEN }}" \
|
||||
-H "Authorization: Bearer ${{ steps.prepare.outputs.GITHUB_TOKEN }}" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
${GITHUB_API_URL:-https://api.github.com}/installation/token
|
||||
|
||||
@@ -118,7 +118,7 @@ runs:
|
||||
|
||||
- name: Install Claude Code
|
||||
shell: bash
|
||||
run: bun install -g @anthropic-ai/claude-code@1.0.69
|
||||
run: bun install -g @anthropic-ai/claude-code@1.0.68
|
||||
|
||||
- name: Run Claude Code Action
|
||||
shell: bash
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as core from "@actions/core";
|
||||
import { preparePrompt } from "./prepare-prompt";
|
||||
import { runClaudeCore } from "./run-claude-core";
|
||||
import { runClaude } from "./run-claude";
|
||||
import { setupClaudeCodeSettings } from "./setup-claude-code-settings";
|
||||
import { validateEnvironmentVariables } from "./validate-env";
|
||||
|
||||
@@ -21,9 +21,7 @@ async function run() {
|
||||
promptFile: process.env.INPUT_PROMPT_FILE || "",
|
||||
});
|
||||
|
||||
await runClaudeCore({
|
||||
promptFile: promptConfig.path,
|
||||
settings: process.env.INPUT_SETTINGS,
|
||||
await runClaude(promptConfig.path, {
|
||||
allowedTools: process.env.INPUT_ALLOWED_TOOLS,
|
||||
disallowedTools: process.env.INPUT_DISALLOWED_TOOLS,
|
||||
maxTurns: process.env.INPUT_MAX_TURNS,
|
||||
@@ -33,7 +31,6 @@ async function run() {
|
||||
claudeEnv: process.env.INPUT_CLAUDE_ENV,
|
||||
fallbackModel: process.env.INPUT_FALLBACK_MODEL,
|
||||
model: process.env.ANTHROPIC_MODEL,
|
||||
timeoutMinutes: process.env.INPUT_TIMEOUT_MINUTES,
|
||||
});
|
||||
} catch (error) {
|
||||
core.setFailed(`Action failed with error: ${error}`);
|
||||
|
||||
@@ -1,366 +0,0 @@
|
||||
#!/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);
|
||||
}
|
||||
}
|
||||
@@ -1,44 +1,331 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import * as core from "@actions/core";
|
||||
import { preparePrompt } from "./prepare-prompt";
|
||||
import { runClaudeCore } from "./run-claude-core";
|
||||
export { prepareRunConfig, type ClaudeOptions } from "./run-claude-core";
|
||||
import { setupClaudeCodeSettings } from "./setup-claude-code-settings";
|
||||
import { validateEnvironmentVariables } from "./validate-env";
|
||||
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";
|
||||
|
||||
async function run() {
|
||||
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;
|
||||
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 {
|
||||
validateEnvironmentVariables();
|
||||
await unlink(PIPE_PATH);
|
||||
} catch (e) {
|
||||
// Ignore if file doesn't exist
|
||||
}
|
||||
|
||||
await setupClaudeCodeSettings(process.env.INPUT_SETTINGS);
|
||||
// Create the named pipe
|
||||
await execAsync(`mkfifo "${PIPE_PATH}"`);
|
||||
|
||||
const promptConfig = await preparePrompt({
|
||||
prompt: process.env.INPUT_PROMPT || "",
|
||||
promptFile: process.env.INPUT_PROMPT_FILE || "",
|
||||
// Log prompt file size
|
||||
let promptSize = "unknown";
|
||||
try {
|
||||
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, {
|
||||
stdio: ["pipe", "pipe", "inherit"],
|
||||
env: {
|
||||
...process.env,
|
||||
...config.env,
|
||||
},
|
||||
});
|
||||
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await runClaudeCore({
|
||||
promptFile: promptConfig.path,
|
||||
settings: process.env.INPUT_SETTINGS,
|
||||
allowedTools: process.env.INPUT_ALLOWED_TOOLS,
|
||||
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,
|
||||
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);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
core.setFailed(`Action failed with error: ${error}`);
|
||||
|
||||
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");
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
## Access Control
|
||||
|
||||
- **Repository Access**: The action can only be triggered by users with write access to the repository
|
||||
- **No Bot Triggers**: GitHub Apps and bots cannot trigger this action
|
||||
- **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
|
||||
- **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
|
||||
- **Limited Scope**: The token cannot access other repositories or perform actions beyond the configured permissions
|
||||
|
||||
@@ -42,6 +42,8 @@ jobs:
|
||||
# Optional: grant additional permissions (requires corresponding GitHub token permissions)
|
||||
# additional_permissions: |
|
||||
# actions: read
|
||||
# Optional: allow bot users to trigger the action
|
||||
# allowed_bots: "dependabot[bot],renovate[bot]"
|
||||
```
|
||||
|
||||
## Inputs
|
||||
@@ -76,6 +78,7 @@ jobs:
|
||||
| `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 | "" |
|
||||
| `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)
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ echo "Installing git hooks..."
|
||||
# Make sure hooks directory exists
|
||||
mkdir -p .git/hooks
|
||||
|
||||
# Install pre-commit hook
|
||||
cp scripts/pre-commit .git/hooks/pre-commit
|
||||
chmod +x .git/hooks/pre-commit
|
||||
# Install pre-push hook
|
||||
cp scripts/pre-push .git/hooks/pre-push
|
||||
chmod +x .git/hooks/pre-push
|
||||
|
||||
echo "Git hooks installed successfully!"
|
||||
@@ -60,6 +60,8 @@ export function buildAllowedToolsString(
|
||||
"Bash(git diff:*)",
|
||||
"Bash(git log:*)",
|
||||
"Bash(git rm:*)",
|
||||
"Bash(git config user.name:*)",
|
||||
"Bash(git config user.email:*)",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -811,18 +813,12 @@ f. If you are unable to complete certain steps, such as running a linter or test
|
||||
return promptContent;
|
||||
}
|
||||
|
||||
export type CreatePromptResult = {
|
||||
promptFile: string;
|
||||
allowedTools: string;
|
||||
disallowedTools: string;
|
||||
};
|
||||
|
||||
export async function createPrompt(
|
||||
mode: Mode,
|
||||
modeContext: ModeContext,
|
||||
githubData: FetchDataResult,
|
||||
context: ParsedGitHubContext,
|
||||
): Promise<CreatePromptResult> {
|
||||
) {
|
||||
try {
|
||||
// Prepare the context for prompt generation
|
||||
let claudeCommentId: string = "";
|
||||
@@ -894,17 +890,8 @@ export async function createPrompt(
|
||||
combinedAllowedTools,
|
||||
);
|
||||
|
||||
// TODO: Remove these environment variable exports once modes are updated to use return values
|
||||
core.exportVariable("ALLOWED_TOOLS", allAllowedTools);
|
||||
core.exportVariable("DISALLOWED_TOOLS", allDisallowedTools);
|
||||
|
||||
const promptFile = `${process.env.RUNNER_TEMP}/claude-prompts/claude-prompt.txt`;
|
||||
|
||||
return {
|
||||
promptFile,
|
||||
allowedTools: allAllowedTools,
|
||||
disallowedTools: allDisallowedTools,
|
||||
};
|
||||
} catch (error) {
|
||||
core.setFailed(`Create prompt failed with error: ${error}`);
|
||||
process.exit(1);
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
#!/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();
|
||||
}
|
||||
@@ -77,6 +77,7 @@ type BaseContext = {
|
||||
useStickyComment: boolean;
|
||||
additionalPermissions: Map<string, string>;
|
||||
useCommitSigning: boolean;
|
||||
allowedBots: string;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -136,6 +137,7 @@ export function parseGitHubContext(): GitHubContext {
|
||||
process.env.ADDITIONAL_PERMISSIONS ?? "",
|
||||
),
|
||||
useCommitSigning: process.env.USE_COMMIT_SIGNING === "true",
|
||||
allowedBots: process.env.ALLOWED_BOTS ?? "",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -3,17 +3,11 @@ import path from "path";
|
||||
import type { Octokits } from "../api/client";
|
||||
import { GITHUB_SERVER_URL } from "../api/config";
|
||||
|
||||
const escapedUrl = GITHUB_SERVER_URL.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const IMAGE_REGEX = new RegExp(
|
||||
`!\\[[^\\]]*\\]\\((${escapedUrl}\\/user-attachments\\/assets\\/[^)]+)\\)`,
|
||||
`!\\[[^\\]]*\\]\\((${GITHUB_SERVER_URL.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\/user-attachments\\/assets\\/[^)]+)\\)`,
|
||||
"g",
|
||||
);
|
||||
|
||||
const HTML_IMG_REGEX = new RegExp(
|
||||
`<img[^>]+src=["']([^"']*${escapedUrl}\\/user-attachments\\/assets\\/[^"']+)["'][^>]*>`,
|
||||
"gi",
|
||||
);
|
||||
|
||||
type IssueComment = {
|
||||
type: "issue_comment";
|
||||
id: string;
|
||||
@@ -69,16 +63,8 @@ export async function downloadCommentImages(
|
||||
}> = [];
|
||||
|
||||
for (const comment of comments) {
|
||||
// Extract URLs from Markdown format
|
||||
const markdownMatches = [...comment.body.matchAll(IMAGE_REGEX)];
|
||||
const markdownUrls = markdownMatches.map((match) => match[1] as string);
|
||||
|
||||
// Extract URLs from HTML format
|
||||
const htmlMatches = [...comment.body.matchAll(HTML_IMG_REGEX)];
|
||||
const htmlUrls = htmlMatches.map((match) => match[1] as string);
|
||||
|
||||
// Combine and deduplicate URLs
|
||||
const urls = [...new Set([...markdownUrls, ...htmlUrls])];
|
||||
const imageMatches = [...comment.body.matchAll(IMAGE_REGEX)];
|
||||
const urls = imageMatches.map((match) => match[1] as string);
|
||||
|
||||
if (urls.length > 0) {
|
||||
commentsWithImages.push({ comment, urls });
|
||||
|
||||
@@ -21,9 +21,42 @@ export async function checkHumanActor(
|
||||
|
||||
console.log(`Actor type: ${actorType}`);
|
||||
|
||||
// Check bot permissions if actor is not a 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(
|
||||
`Workflow initiated by non-human actor: ${githubContext.actor} (type: ${actorType}).`,
|
||||
`Workflow initiated by non-human actor: ${botName} (type: ${actorType}). Add bot to allowed_bots list or use '*' to allow all bots.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,12 @@ export async function checkWritePermissions(
|
||||
try {
|
||||
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
|
||||
const response = await octokit.repos.getCollaboratorPermissionLevel({
|
||||
owner: repository.owner,
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { z } from "zod";
|
||||
import { createOctokit } from "../github/api/client";
|
||||
|
||||
// Get repository and PR information from environment variables
|
||||
const REPO_OWNER = process.env.REPO_OWNER;
|
||||
const REPO_NAME = process.env.REPO_NAME;
|
||||
const PR_NUMBER = process.env.PR_NUMBER;
|
||||
|
||||
if (!REPO_OWNER || !REPO_NAME || !PR_NUMBER) {
|
||||
console.error(
|
||||
"Error: REPO_OWNER, REPO_NAME, and PR_NUMBER environment variables are required",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// GitHub Inline Comment MCP Server - Provides inline PR comment functionality
|
||||
// Provides an inline comment tool without exposing full PR review capabilities, so that
|
||||
// Claude can't accidentally approve a PR
|
||||
const server = new McpServer({
|
||||
name: "GitHub Inline Comment Server",
|
||||
version: "0.0.1",
|
||||
});
|
||||
|
||||
server.tool(
|
||||
"create_inline_comment",
|
||||
"Create an inline comment on a specific line or lines in a PR file",
|
||||
{
|
||||
path: z
|
||||
.string()
|
||||
.describe("The file path to comment on (e.g., 'src/index.js')"),
|
||||
body: z
|
||||
.string()
|
||||
.describe(
|
||||
"The comment text (supports markdown and GitHub code suggestion blocks). " +
|
||||
"For code suggestions, use: ```suggestion\\nreplacement code\\n```. " +
|
||||
"IMPORTANT: The suggestion block will REPLACE the ENTIRE line range (single line or startLine to line). " +
|
||||
"Ensure the replacement is syntactically complete and valid - it must work as a drop-in replacement for the selected lines.",
|
||||
),
|
||||
line: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Line number for single-line comments (required if startLine is not provided)",
|
||||
),
|
||||
startLine: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Start line for multi-line comments (use with line parameter for the end line)",
|
||||
),
|
||||
side: z
|
||||
.enum(["LEFT", "RIGHT"])
|
||||
.optional()
|
||||
.default("RIGHT")
|
||||
.describe(
|
||||
"Side of the diff to comment on: LEFT (old code) or RIGHT (new code)",
|
||||
),
|
||||
commit_id: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Specific commit SHA to comment on (defaults to latest commit)",
|
||||
),
|
||||
},
|
||||
async ({ path, body, line, startLine, side, commit_id }) => {
|
||||
try {
|
||||
const githubToken = process.env.GITHUB_TOKEN;
|
||||
|
||||
if (!githubToken) {
|
||||
throw new Error("GITHUB_TOKEN environment variable is required");
|
||||
}
|
||||
|
||||
const owner = REPO_OWNER;
|
||||
const repo = REPO_NAME;
|
||||
const pull_number = parseInt(PR_NUMBER, 10);
|
||||
|
||||
const octokit = createOctokit(githubToken).rest;
|
||||
|
||||
// Validate that either line or both startLine and line are provided
|
||||
if (!line && !startLine) {
|
||||
throw new Error(
|
||||
"Either 'line' for single-line comments or both 'startLine' and 'line' for multi-line comments must be provided",
|
||||
);
|
||||
}
|
||||
|
||||
// If only line is provided, it's a single-line comment
|
||||
// If both startLine and line are provided, it's a multi-line comment
|
||||
const isSingleLine = !startLine;
|
||||
|
||||
const pr = await octokit.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number,
|
||||
});
|
||||
|
||||
const params: Parameters<
|
||||
typeof octokit.rest.pulls.createReviewComment
|
||||
>[0] = {
|
||||
owner,
|
||||
repo,
|
||||
pull_number,
|
||||
body,
|
||||
path,
|
||||
side: side || "RIGHT",
|
||||
commit_id: commit_id || pr.data.head.sha,
|
||||
};
|
||||
|
||||
if (isSingleLine) {
|
||||
// Single-line comment
|
||||
params.line = line;
|
||||
} else {
|
||||
// Multi-line comment
|
||||
params.start_line = startLine;
|
||||
params.start_side = side || "RIGHT";
|
||||
params.line = line;
|
||||
}
|
||||
|
||||
const result = await octokit.rest.pulls.createReviewComment(params);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(
|
||||
{
|
||||
success: true,
|
||||
comment_id: result.data.id,
|
||||
html_url: result.data.html_url,
|
||||
path: result.data.path,
|
||||
line: result.data.line || result.data.original_line,
|
||||
message: `Inline comment created successfully on ${path}${isSingleLine ? ` at line ${line}` : ` from line ${startLine} to ${line}`}`,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
// Provide more helpful error messages for common issues
|
||||
let helpMessage = "";
|
||||
if (errorMessage.includes("Validation Failed")) {
|
||||
helpMessage =
|
||||
"\n\nThis usually means the line number doesn't exist in the diff or the file path is incorrect. Make sure you're commenting on lines that are part of the PR's changes.";
|
||||
} else if (errorMessage.includes("Not Found")) {
|
||||
helpMessage =
|
||||
"\n\nThis usually means the PR number, repository, or file path is incorrect.";
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error creating inline comment: ${errorMessage}${helpMessage}`,
|
||||
},
|
||||
],
|
||||
error: errorMessage,
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
async function runServer() {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
process.on("exit", () => {
|
||||
server.close();
|
||||
});
|
||||
}
|
||||
|
||||
runServer().catch(console.error);
|
||||
@@ -111,24 +111,6 @@ export async function prepareMcpConfig(
|
||||
};
|
||||
}
|
||||
|
||||
// Include inline comment server for experimental review mode
|
||||
if (context.inputs.mode === "experimental-review" && context.isPR) {
|
||||
baseMcpConfig.mcpServers.github_inline_comment = {
|
||||
command: "bun",
|
||||
args: [
|
||||
"run",
|
||||
`${process.env.GITHUB_ACTION_PATH}/src/mcp/github-inline-comment-server.ts`,
|
||||
],
|
||||
env: {
|
||||
GITHUB_TOKEN: githubToken,
|
||||
REPO_OWNER: owner,
|
||||
REPO_NAME: repo,
|
||||
PR_NUMBER: context.entityNumber?.toString() || "",
|
||||
GITHUB_API_URL: GITHUB_API_URL,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Only add CI server if we have actions:read permission and we're in a PR context
|
||||
const hasActionsReadPermission =
|
||||
context.inputs.additionalPermissions.get("actions") === "read";
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import * as core from "@actions/core";
|
||||
import { mkdir, writeFile } from "fs/promises";
|
||||
import type { Mode, ModeOptions, ModeResult } from "../types";
|
||||
import { isAutomationContext } from "../../github/context";
|
||||
import type { PreparedContext } from "../../create-prompt/types";
|
||||
@@ -43,23 +42,7 @@ export const agentMode: Mode = {
|
||||
async prepare({ context }: ModeOptions): Promise<ModeResult> {
|
||||
// Agent mode handles automation events (workflow_dispatch, schedule) only
|
||||
|
||||
// TODO: handle by createPrompt (similar to tag and review modes)
|
||||
// Create prompt directory
|
||||
await mkdir(`${process.env.RUNNER_TEMP}/claude-prompts`, {
|
||||
recursive: true,
|
||||
});
|
||||
// Write the prompt file - the base action requires a prompt_file parameter,
|
||||
// so we must create this file even though agent mode typically uses
|
||||
// override_prompt or direct_prompt. If neither is provided, we write
|
||||
// a minimal prompt with just the repository information.
|
||||
const promptContent =
|
||||
context.inputs.overridePrompt ||
|
||||
context.inputs.directPrompt ||
|
||||
`Repository: ${context.repository.owner}/${context.repository.repo}`;
|
||||
await writeFile(
|
||||
`${process.env.RUNNER_TEMP}/claude-prompts/claude-prompt.txt`,
|
||||
promptContent,
|
||||
);
|
||||
// Agent mode doesn't need to create prompt files here - handled by createPrompt
|
||||
|
||||
// Export tool environment variables for agent mode
|
||||
const baseTools = [
|
||||
|
||||
@@ -60,8 +60,20 @@ export const reviewMode: Mode = {
|
||||
|
||||
getAllowedTools() {
|
||||
return [
|
||||
"Bash(gh issue comment:*)",
|
||||
"mcp__github_inline_comment__create_inline_comment",
|
||||
// Context tools - to know who the current user is
|
||||
"mcp__github__get_me",
|
||||
// Core review tools
|
||||
"mcp__github__create_pending_pull_request_review",
|
||||
"mcp__github__add_comment_to_pending_review",
|
||||
"mcp__github__submit_pending_pull_request_review",
|
||||
"mcp__github__delete_pending_pull_request_review",
|
||||
"mcp__github__create_and_submit_pull_request_review",
|
||||
// Comment tools
|
||||
"mcp__github__add_issue_comment",
|
||||
// PR information tools
|
||||
"mcp__github__get_pull_request",
|
||||
"mcp__github__get_pull_request_reviews",
|
||||
"mcp__github__get_pull_request_status",
|
||||
];
|
||||
},
|
||||
|
||||
@@ -151,13 +163,17 @@ REVIEW MODE WORKFLOW:
|
||||
|
||||
1. First, understand the PR context:
|
||||
- You are reviewing PR #${eventData.isPR && eventData.prNumber ? eventData.prNumber : "[PR number]"} in ${context.repository}
|
||||
- Use mcp__github__get_pull_request to get PR metadata
|
||||
- Use the Read, Grep, and Glob tools to examine the modified files directly from disk
|
||||
- This provides the full context and latest state of the code
|
||||
- Look at the changed_files section above to see which files were modified
|
||||
|
||||
2. Add comments:
|
||||
- use Bash(gh issue comment:*) to add top-level comments
|
||||
- Use mcp__github_inline_comment__create_inline_comment to add inline comments (prefer this where possible)
|
||||
2. Create a pending review:
|
||||
- Use mcp__github__create_pending_pull_request_review to start your review
|
||||
- This allows you to batch comments before submitting
|
||||
|
||||
3. Add inline comments:
|
||||
- Use mcp__github__add_comment_to_pending_review for each issue or suggestion
|
||||
- Parameters:
|
||||
* path: The file path (e.g., "src/index.js")
|
||||
* line: Line number for single-line comments
|
||||
@@ -166,6 +182,49 @@ REVIEW MODE WORKFLOW:
|
||||
* subjectType: "line" for line-level comments
|
||||
* body: Your comment text
|
||||
|
||||
- When to use multi-line comments:
|
||||
* When replacing multiple consecutive lines
|
||||
* When the fix requires changes across several lines
|
||||
* Example: To replace lines 19-20, use startLine: 19, line: 20
|
||||
|
||||
- For code suggestions, use this EXACT format in the body:
|
||||
\`\`\`suggestion
|
||||
corrected code here
|
||||
\`\`\`
|
||||
|
||||
CRITICAL: GitHub suggestion blocks must ONLY contain the replacement for the specific line(s) being commented on:
|
||||
- For single-line comments: Replace ONLY that line
|
||||
- For multi-line comments: Replace ONLY the lines in the range
|
||||
- Do NOT include surrounding context or function signatures
|
||||
- Do NOT suggest changes that span beyond the commented lines
|
||||
|
||||
Example for line 19 \`var name = user.name;\`:
|
||||
WRONG:
|
||||
\\\`\\\`\\\`suggestion
|
||||
function processUser(user) {
|
||||
if (!user) throw new Error('Invalid user');
|
||||
const name = user.name;
|
||||
\\\`\\\`\\\`
|
||||
|
||||
CORRECT:
|
||||
\\\`\\\`\\\`suggestion
|
||||
const name = user.name;
|
||||
\\\`\\\`\\\`
|
||||
|
||||
For validation suggestions, comment on the function declaration line or create separate comments for each concern.
|
||||
|
||||
4. Submit your review:
|
||||
- Use mcp__github__submit_pending_pull_request_review
|
||||
- Parameters:
|
||||
* event: "COMMENT" (general feedback), "REQUEST_CHANGES" (issues found), or "APPROVE" (if appropriate)
|
||||
* body: Write a comprehensive review summary that includes:
|
||||
- Overview of what was reviewed (files, scope, focus areas)
|
||||
- Summary of all issues found (with counts by severity if applicable)
|
||||
- Key recommendations and action items
|
||||
- Highlights of good practices observed
|
||||
- Overall assessment and recommendation
|
||||
- The body should be detailed and informative since it's the main review content
|
||||
- Structure the body with clear sections using markdown headers
|
||||
|
||||
REVIEW GUIDELINES:
|
||||
|
||||
@@ -242,7 +301,6 @@ This ensures users get value from the review even before checking individual inl
|
||||
claudeBranch: branchInfo.claudeBranch,
|
||||
});
|
||||
|
||||
// TODO: Capture and return the allowed/disallowed tools from createPrompt
|
||||
await createPrompt(reviewMode, modeContext, githubData, context);
|
||||
|
||||
// Export tool environment variables for review mode
|
||||
|
||||
@@ -98,7 +98,6 @@ export const tagMode: Mode = {
|
||||
claudeBranch: branchInfo.claudeBranch,
|
||||
});
|
||||
|
||||
// TODO: Capture and return the allowed/disallowed tools from createPrompt
|
||||
await createPrompt(tagMode, modeContext, githubData, context);
|
||||
|
||||
// Get MCP configuration
|
||||
|
||||
@@ -10,7 +10,6 @@ export type PrepareResult = {
|
||||
currentBranch: string;
|
||||
};
|
||||
mcpConfig: string;
|
||||
// TODO: Add allowedTools and disallowedTools here once modes are updated
|
||||
};
|
||||
|
||||
export type PrepareOptions = {
|
||||
|
||||
96
test/actor.test.ts
Normal file
96
test/actor.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
#!/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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1041,6 +1041,8 @@ describe("buildAllowedToolsString", () => {
|
||||
expect(result).toContain("Bash(git diff:*)");
|
||||
expect(result).toContain("Bash(git log:*)");
|
||||
expect(result).toContain("Bash(git rm:*)");
|
||||
expect(result).toContain("Bash(git config user.name:*)");
|
||||
expect(result).toContain("Bash(git config user.email:*)");
|
||||
|
||||
// Comment tool from minimal server should be included
|
||||
expect(result).toContain("mcp__github_comment__update_claude_comment");
|
||||
|
||||
@@ -662,255 +662,4 @@ describe("downloadCommentImages", () => {
|
||||
);
|
||||
expect(result.get(imageUrl2)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should detect and download images from HTML img tags", async () => {
|
||||
const mockOctokit = createMockOctokit();
|
||||
const imageUrl =
|
||||
"https://github.com/user-attachments/assets/html-image.png";
|
||||
const signedUrl =
|
||||
"https://private-user-images.githubusercontent.com/html.png?jwt=token";
|
||||
|
||||
// Mock octokit response
|
||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
||||
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
body_html: `<img src="${signedUrl}">`,
|
||||
},
|
||||
});
|
||||
|
||||
// Mock fetch for image download
|
||||
const mockArrayBuffer = new ArrayBuffer(8);
|
||||
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: async () => mockArrayBuffer,
|
||||
} as Response);
|
||||
|
||||
const comments: CommentWithImages[] = [
|
||||
{
|
||||
type: "issue_comment",
|
||||
id: "777",
|
||||
body: `Here's an HTML image: <img src="${imageUrl}" alt="test">`,
|
||||
},
|
||||
];
|
||||
|
||||
const result = await downloadCommentImages(
|
||||
mockOctokit,
|
||||
"owner",
|
||||
"repo",
|
||||
comments,
|
||||
);
|
||||
|
||||
expect(mockOctokit.rest.issues.getComment).toHaveBeenCalledWith({
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
comment_id: 777,
|
||||
mediaType: { format: "full+json" },
|
||||
});
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(signedUrl);
|
||||
expect(fsWriteFileSpy).toHaveBeenCalledWith(
|
||||
"/tmp/github-images/image-1704067200000-0.png",
|
||||
Buffer.from(mockArrayBuffer),
|
||||
);
|
||||
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.get(imageUrl)).toBe(
|
||||
"/tmp/github-images/image-1704067200000-0.png",
|
||||
);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
"Found 1 image(s) in issue_comment 777",
|
||||
);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(`Downloading ${imageUrl}...`);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
"✓ Saved: /tmp/github-images/image-1704067200000-0.png",
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle HTML img tags with different quote styles", async () => {
|
||||
const mockOctokit = createMockOctokit();
|
||||
const imageUrl1 =
|
||||
"https://github.com/user-attachments/assets/single-quote.jpg";
|
||||
const imageUrl2 =
|
||||
"https://github.com/user-attachments/assets/double-quote.png";
|
||||
const signedUrl1 =
|
||||
"https://private-user-images.githubusercontent.com/single.jpg?jwt=token1";
|
||||
const signedUrl2 =
|
||||
"https://private-user-images.githubusercontent.com/double.png?jwt=token2";
|
||||
|
||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
||||
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
body_html: `<img src="${signedUrl1}"><img src="${signedUrl2}">`,
|
||||
},
|
||||
});
|
||||
|
||||
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: async () => new ArrayBuffer(8),
|
||||
} as Response);
|
||||
|
||||
const comments: CommentWithImages[] = [
|
||||
{
|
||||
type: "issue_comment",
|
||||
id: "888",
|
||||
body: `Single quote: <img src='${imageUrl1}' alt="test"> and double quote: <img src="${imageUrl2}" alt="test">`,
|
||||
},
|
||||
];
|
||||
|
||||
const result = await downloadCommentImages(
|
||||
mockOctokit,
|
||||
"owner",
|
||||
"repo",
|
||||
comments,
|
||||
);
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
expect(result.size).toBe(2);
|
||||
expect(result.get(imageUrl1)).toBe(
|
||||
"/tmp/github-images/image-1704067200000-0.jpg",
|
||||
);
|
||||
expect(result.get(imageUrl2)).toBe(
|
||||
"/tmp/github-images/image-1704067200000-1.png",
|
||||
);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
"Found 2 image(s) in issue_comment 888",
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle mixed Markdown and HTML images", async () => {
|
||||
const mockOctokit = createMockOctokit();
|
||||
const markdownUrl =
|
||||
"https://github.com/user-attachments/assets/markdown.png";
|
||||
const htmlUrl = "https://github.com/user-attachments/assets/html.jpg";
|
||||
const signedUrl1 =
|
||||
"https://private-user-images.githubusercontent.com/md.png?jwt=token1";
|
||||
const signedUrl2 =
|
||||
"https://private-user-images.githubusercontent.com/html.jpg?jwt=token2";
|
||||
|
||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
||||
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
body_html: `<img src="${signedUrl1}"><img src="${signedUrl2}">`,
|
||||
},
|
||||
});
|
||||
|
||||
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: async () => new ArrayBuffer(8),
|
||||
} as Response);
|
||||
|
||||
const comments: CommentWithImages[] = [
|
||||
{
|
||||
type: "issue_comment",
|
||||
id: "999",
|
||||
body: `Markdown:  and HTML: <img src="${htmlUrl}" alt="test">`,
|
||||
},
|
||||
];
|
||||
|
||||
const result = await downloadCommentImages(
|
||||
mockOctokit,
|
||||
"owner",
|
||||
"repo",
|
||||
comments,
|
||||
);
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
expect(result.size).toBe(2);
|
||||
expect(result.get(markdownUrl)).toBe(
|
||||
"/tmp/github-images/image-1704067200000-0.png",
|
||||
);
|
||||
expect(result.get(htmlUrl)).toBe(
|
||||
"/tmp/github-images/image-1704067200000-1.jpg",
|
||||
);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
"Found 2 image(s) in issue_comment 999",
|
||||
);
|
||||
});
|
||||
|
||||
test("should deduplicate identical URLs from Markdown and HTML", async () => {
|
||||
const mockOctokit = createMockOctokit();
|
||||
const imageUrl = "https://github.com/user-attachments/assets/duplicate.png";
|
||||
const signedUrl =
|
||||
"https://private-user-images.githubusercontent.com/dup.png?jwt=token";
|
||||
|
||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
||||
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
body_html: `<img src="${signedUrl}">`,
|
||||
},
|
||||
});
|
||||
|
||||
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: async () => new ArrayBuffer(8),
|
||||
} as Response);
|
||||
|
||||
const comments: CommentWithImages[] = [
|
||||
{
|
||||
type: "issue_comment",
|
||||
id: "1000",
|
||||
body: `Same image twice:  and <img src="${imageUrl}" alt="test">`,
|
||||
},
|
||||
];
|
||||
|
||||
const result = await downloadCommentImages(
|
||||
mockOctokit,
|
||||
"owner",
|
||||
"repo",
|
||||
comments,
|
||||
);
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1); // Only downloaded once
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.get(imageUrl)).toBe(
|
||||
"/tmp/github-images/image-1704067200000-0.png",
|
||||
);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
"Found 1 image(s) in issue_comment 1000",
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle HTML img tags with additional attributes", async () => {
|
||||
const mockOctokit = createMockOctokit();
|
||||
const imageUrl =
|
||||
"https://github.com/user-attachments/assets/complex-tag.webp";
|
||||
const signedUrl =
|
||||
"https://private-user-images.githubusercontent.com/complex.webp?jwt=token";
|
||||
|
||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
||||
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
body_html: `<img src="${signedUrl}">`,
|
||||
},
|
||||
});
|
||||
|
||||
fetchSpy = spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: async () => new ArrayBuffer(8),
|
||||
} as Response);
|
||||
|
||||
const comments: CommentWithImages[] = [
|
||||
{
|
||||
type: "issue_comment",
|
||||
id: "1001",
|
||||
body: `Complex tag: <img class="image" src="${imageUrl}" alt="test image" width="100" height="200">`,
|
||||
},
|
||||
];
|
||||
|
||||
const result = await downloadCommentImages(
|
||||
mockOctokit,
|
||||
"owner",
|
||||
"repo",
|
||||
comments,
|
||||
);
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.get(imageUrl)).toBe(
|
||||
"/tmp/github-images/image-1704067200000-0.webp",
|
||||
);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
"Found 1 image(s) in issue_comment 1001",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,6 +37,7 @@ describe("prepareMcpConfig", () => {
|
||||
useStickyComment: false,
|
||||
additionalPermissions: new Map(),
|
||||
useCommitSigning: false,
|
||||
allowedBots: "",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ const defaultInputs = {
|
||||
useStickyComment: false,
|
||||
additionalPermissions: new Map<string, string>(),
|
||||
useCommitSigning: false,
|
||||
allowedBots: "",
|
||||
};
|
||||
|
||||
const defaultRepository = {
|
||||
|
||||
@@ -73,6 +73,7 @@ describe("checkWritePermissions", () => {
|
||||
useStickyComment: false,
|
||||
additionalPermissions: new Map(),
|
||||
useCommitSigning: false,
|
||||
allowedBots: "",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -126,6 +127,16 @@ 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 () => {
|
||||
const error = new Error("API error");
|
||||
const mockOctokit = {
|
||||
|
||||
@@ -41,6 +41,7 @@ describe("checkContainsTrigger", () => {
|
||||
useStickyComment: false,
|
||||
additionalPermissions: new Map(),
|
||||
useCommitSigning: false,
|
||||
allowedBots: "",
|
||||
},
|
||||
});
|
||||
expect(checkContainsTrigger(context)).toBe(true);
|
||||
@@ -74,6 +75,7 @@ describe("checkContainsTrigger", () => {
|
||||
useStickyComment: false,
|
||||
additionalPermissions: new Map(),
|
||||
useCommitSigning: false,
|
||||
allowedBots: "",
|
||||
},
|
||||
});
|
||||
expect(checkContainsTrigger(context)).toBe(false);
|
||||
@@ -291,6 +293,7 @@ describe("checkContainsTrigger", () => {
|
||||
useStickyComment: false,
|
||||
additionalPermissions: new Map(),
|
||||
useCommitSigning: false,
|
||||
allowedBots: "",
|
||||
},
|
||||
});
|
||||
expect(checkContainsTrigger(context)).toBe(true);
|
||||
@@ -325,6 +328,7 @@ describe("checkContainsTrigger", () => {
|
||||
useStickyComment: false,
|
||||
additionalPermissions: new Map(),
|
||||
useCommitSigning: false,
|
||||
allowedBots: "",
|
||||
},
|
||||
});
|
||||
expect(checkContainsTrigger(context)).toBe(true);
|
||||
@@ -359,6 +363,7 @@ describe("checkContainsTrigger", () => {
|
||||
useStickyComment: false,
|
||||
additionalPermissions: new Map(),
|
||||
useCommitSigning: false,
|
||||
allowedBots: "",
|
||||
},
|
||||
});
|
||||
expect(checkContainsTrigger(context)).toBe(false);
|
||||
|
||||
Reference in New Issue
Block a user