mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-23 23:14:13 +08:00
Compare commits
9 Commits
support-bo
...
ashwin/cla
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3c96305c1 | ||
|
|
e3a4ac69fe | ||
|
|
923d1d0592 | ||
|
|
15db2b3c79 | ||
|
|
188d526721 | ||
|
|
a519840051 | ||
|
|
85287e957d | ||
|
|
c6a07895d7 | ||
|
|
0c5d54472f |
110
action.yml
110
action.yml
@@ -118,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"
|
||||||
@@ -137,20 +137,36 @@ 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 }}
|
||||||
@@ -161,57 +177,21 @@ runs:
|
|||||||
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
|
|
||||||
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 }}
|
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 }}
|
||||||
@@ -239,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
|
||||||
@@ -275,7 +255,7 @@ 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
|
||||||
|
|
||||||
@@ -286,6 +266,6 @@ runs:
|
|||||||
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
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ runs:
|
|||||||
|
|
||||||
- name: Install Claude Code
|
- name: Install Claude Code
|
||||||
shell: bash
|
shell: bash
|
||||||
run: bun install -g @anthropic-ai/claude-code@1.0.68
|
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
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
366
base-action/src/run-claude-core.ts
Normal file
366
base-action/src/run-claude-core.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,331 +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, {
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
output += text;
|
await runClaudeCore({
|
||||||
});
|
promptFile: promptConfig.path,
|
||||||
|
settings: process.env.INPUT_SETTINGS,
|
||||||
// Handle stdout errors
|
allowedTools: process.env.INPUT_ALLOWED_TOOLS,
|
||||||
claudeProcess.stdout.on("error", (error) => {
|
disallowedTools: process.env.INPUT_DISALLOWED_TOOLS,
|
||||||
console.error("Error reading Claude stdout:", error);
|
maxTurns: process.env.INPUT_MAX_TURNS,
|
||||||
});
|
mcpConfig: process.env.INPUT_MCP_CONFIG,
|
||||||
|
systemPrompt: process.env.INPUT_SYSTEM_PROMPT,
|
||||||
// Pipe from named pipe to Claude
|
appendSystemPrompt: process.env.INPUT_APPEND_SYSTEM_PROMPT,
|
||||||
const pipeProcess = spawn("cat", [PIPE_PATH]);
|
claudeEnv: process.env.INPUT_CLAUDE_ENV,
|
||||||
pipeProcess.stdout.pipe(claudeProcess.stdin);
|
fallbackModel: process.env.INPUT_FALLBACK_MODEL,
|
||||||
|
model: process.env.ANTHROPIC_MODEL,
|
||||||
// Handle pipe process errors
|
timeoutMinutes: process.env.INPUT_TIMEOUT_MINUTES,
|
||||||
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) {
|
||||||
claudeProcess.on("error", (error) => {
|
core.setFailed(`Action failed with 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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ echo "Installing git hooks..."
|
|||||||
# Make sure hooks directory exists
|
# Make sure hooks directory exists
|
||||||
mkdir -p .git/hooks
|
mkdir -p .git/hooks
|
||||||
|
|
||||||
# Install pre-push hook
|
# Install pre-commit hook
|
||||||
cp scripts/pre-push .git/hooks/pre-push
|
cp scripts/pre-commit .git/hooks/pre-commit
|
||||||
chmod +x .git/hooks/pre-push
|
chmod +x .git/hooks/pre-commit
|
||||||
|
|
||||||
echo "Git hooks installed successfully!"
|
echo "Git hooks installed successfully!"
|
||||||
@@ -60,8 +60,6 @@ export function buildAllowedToolsString(
|
|||||||
"Bash(git diff:*)",
|
"Bash(git diff:*)",
|
||||||
"Bash(git log:*)",
|
"Bash(git log:*)",
|
||||||
"Bash(git rm:*)",
|
"Bash(git rm:*)",
|
||||||
"Bash(git config user.name:*)",
|
|
||||||
"Bash(git config user.email:*)",
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -813,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 = "";
|
||||||
@@ -890,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
145
src/entrypoints/run.ts
Normal 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();
|
||||||
|
}
|
||||||
@@ -3,11 +3,17 @@ import path from "path";
|
|||||||
import type { Octokits } from "../api/client";
|
import type { Octokits } from "../api/client";
|
||||||
import { GITHUB_SERVER_URL } from "../api/config";
|
import { GITHUB_SERVER_URL } from "../api/config";
|
||||||
|
|
||||||
|
const escapedUrl = GITHUB_SERVER_URL.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
const IMAGE_REGEX = new RegExp(
|
const IMAGE_REGEX = new RegExp(
|
||||||
`!\\[[^\\]]*\\]\\((${GITHUB_SERVER_URL.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\/user-attachments\\/assets\\/[^)]+)\\)`,
|
`!\\[[^\\]]*\\]\\((${escapedUrl}\\/user-attachments\\/assets\\/[^)]+)\\)`,
|
||||||
"g",
|
"g",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const HTML_IMG_REGEX = new RegExp(
|
||||||
|
`<img[^>]+src=["']([^"']*${escapedUrl}\\/user-attachments\\/assets\\/[^"']+)["'][^>]*>`,
|
||||||
|
"gi",
|
||||||
|
);
|
||||||
|
|
||||||
type IssueComment = {
|
type IssueComment = {
|
||||||
type: "issue_comment";
|
type: "issue_comment";
|
||||||
id: string;
|
id: string;
|
||||||
@@ -63,8 +69,16 @@ export async function downloadCommentImages(
|
|||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
for (const comment of comments) {
|
for (const comment of comments) {
|
||||||
const imageMatches = [...comment.body.matchAll(IMAGE_REGEX)];
|
// Extract URLs from Markdown format
|
||||||
const urls = imageMatches.map((match) => match[1] as string);
|
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])];
|
||||||
|
|
||||||
if (urls.length > 0) {
|
if (urls.length > 0) {
|
||||||
commentsWithImages.push({ comment, urls });
|
commentsWithImages.push({ comment, urls });
|
||||||
|
|||||||
178
src/mcp/github-inline-comment-server.ts
Normal file
178
src/mcp/github-inline-comment-server.ts
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
#!/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,6 +111,24 @@ 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
|
// Only add CI server if we have actions:read permission and we're in a PR context
|
||||||
const hasActionsReadPermission =
|
const hasActionsReadPermission =
|
||||||
context.inputs.additionalPermissions.get("actions") === "read";
|
context.inputs.additionalPermissions.get("actions") === "read";
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as core from "@actions/core";
|
import * as core from "@actions/core";
|
||||||
|
import { mkdir, writeFile } from "fs/promises";
|
||||||
import type { Mode, ModeOptions, ModeResult } from "../types";
|
import type { Mode, ModeOptions, ModeResult } from "../types";
|
||||||
import { isAutomationContext } from "../../github/context";
|
import { isAutomationContext } from "../../github/context";
|
||||||
import type { PreparedContext } from "../../create-prompt/types";
|
import type { PreparedContext } from "../../create-prompt/types";
|
||||||
@@ -42,7 +43,23 @@ export const agentMode: Mode = {
|
|||||||
async prepare({ context }: ModeOptions): Promise<ModeResult> {
|
async prepare({ context }: ModeOptions): Promise<ModeResult> {
|
||||||
// Agent mode handles automation events (workflow_dispatch, schedule) only
|
// Agent mode handles automation events (workflow_dispatch, schedule) only
|
||||||
|
|
||||||
// Agent mode doesn't need to create prompt files here - handled by createPrompt
|
// 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,
|
||||||
|
);
|
||||||
|
|
||||||
// Export tool environment variables for agent mode
|
// Export tool environment variables for agent mode
|
||||||
const baseTools = [
|
const baseTools = [
|
||||||
|
|||||||
@@ -60,20 +60,8 @@ export const reviewMode: Mode = {
|
|||||||
|
|
||||||
getAllowedTools() {
|
getAllowedTools() {
|
||||||
return [
|
return [
|
||||||
// Context tools - to know who the current user is
|
"Bash(gh issue comment:*)",
|
||||||
"mcp__github__get_me",
|
"mcp__github_inline_comment__create_inline_comment",
|
||||||
// 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",
|
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -163,17 +151,13 @@ REVIEW MODE WORKFLOW:
|
|||||||
|
|
||||||
1. First, understand the PR context:
|
1. First, understand the PR context:
|
||||||
- You are reviewing PR #${eventData.isPR && eventData.prNumber ? eventData.prNumber : "[PR number]"} in ${context.repository}
|
- 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
|
- 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
|
- 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 a pending review:
|
2. Add comments:
|
||||||
- Use mcp__github__create_pending_pull_request_review to start your review
|
- use Bash(gh issue comment:*) to add top-level comments
|
||||||
- This allows you to batch comments before submitting
|
- Use mcp__github_inline_comment__create_inline_comment to add inline comments (prefer this where possible)
|
||||||
|
|
||||||
3. Add inline comments:
|
|
||||||
- Use mcp__github__add_comment_to_pending_review for each issue or suggestion
|
|
||||||
- Parameters:
|
- Parameters:
|
||||||
* path: The file path (e.g., "src/index.js")
|
* path: The file path (e.g., "src/index.js")
|
||||||
* line: Line number for single-line comments
|
* line: Line number for single-line comments
|
||||||
@@ -182,49 +166,6 @@ REVIEW MODE WORKFLOW:
|
|||||||
* subjectType: "line" for line-level comments
|
* subjectType: "line" for line-level comments
|
||||||
* body: Your comment text
|
* 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:
|
REVIEW GUIDELINES:
|
||||||
|
|
||||||
@@ -301,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -1041,8 +1041,6 @@ describe("buildAllowedToolsString", () => {
|
|||||||
expect(result).toContain("Bash(git diff:*)");
|
expect(result).toContain("Bash(git diff:*)");
|
||||||
expect(result).toContain("Bash(git log:*)");
|
expect(result).toContain("Bash(git log:*)");
|
||||||
expect(result).toContain("Bash(git rm:*)");
|
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
|
// Comment tool from minimal server should be included
|
||||||
expect(result).toContain("mcp__github_comment__update_claude_comment");
|
expect(result).toContain("mcp__github_comment__update_claude_comment");
|
||||||
|
|||||||
@@ -662,4 +662,255 @@ describe("downloadCommentImages", () => {
|
|||||||
);
|
);
|
||||||
expect(result.get(imageUrl2)).toBeUndefined();
|
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",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user