mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-23 15:04:13 +08:00
Compare commits
14 Commits
claude/sla
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8341a564b0 | ||
|
|
2804b4174b | ||
|
|
2316a9a8db | ||
|
|
49cfcf8107 | ||
|
|
e208124d29 | ||
|
|
ba60ef7ba2 | ||
|
|
f3c892ca8d | ||
|
|
6e896a06bb | ||
|
|
a017b830c0 | ||
|
|
75f52e56b2 | ||
|
|
1bbc9e7ff7 | ||
|
|
625ea1519c | ||
|
|
a9171f0ced | ||
|
|
4778aeae4c |
37
.github/workflows/ci-all.yml
vendored
Normal file
37
.github/workflows/ci-all.yml
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
# Orchestrates all CI workflows - runs on PRs, pushes to main, and manual dispatch
|
||||
# Individual test workflows are called as reusable workflows
|
||||
name: CI All
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
uses: ./.github/workflows/ci.yml
|
||||
|
||||
test-base-action:
|
||||
uses: ./.github/workflows/test-base-action.yml
|
||||
secrets: inherit # Required for ANTHROPIC_API_KEY
|
||||
|
||||
test-custom-executables:
|
||||
uses: ./.github/workflows/test-custom-executables.yml
|
||||
secrets: inherit
|
||||
|
||||
test-mcp-servers:
|
||||
uses: ./.github/workflows/test-mcp-servers.yml
|
||||
secrets: inherit
|
||||
|
||||
test-settings:
|
||||
uses: ./.github/workflows/test-settings.yml
|
||||
secrets: inherit
|
||||
|
||||
test-structured-output:
|
||||
uses: ./.github/workflows/test-structured-output.yml
|
||||
secrets: inherit
|
||||
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@@ -1,9 +1,8 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
|
||||
106
.github/workflows/release.yml
vendored
106
.github/workflows/release.yml
vendored
@@ -8,10 +8,23 @@ on:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
workflow_run:
|
||||
workflows: ["CI All"]
|
||||
types:
|
||||
- completed
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
runs-on: ubuntu-latest
|
||||
# Run if: manual dispatch OR (CI All succeeded AND commit is a version bump)
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.head_branch == 'main' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
startsWith(github.event.workflow_run.head_commit.message, 'chore: bump Claude Code to'))
|
||||
environment: production
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -84,7 +97,8 @@ jobs:
|
||||
|
||||
update-major-tag:
|
||||
needs: create-release
|
||||
if: ${{ !inputs.dry_run }}
|
||||
# Skip for dry runs (workflow_run events are never dry runs)
|
||||
if: github.event_name == 'workflow_run' || !inputs.dry_run
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
permissions:
|
||||
@@ -109,48 +123,48 @@ jobs:
|
||||
|
||||
echo "Updated $major_version tag to point to $next_version"
|
||||
|
||||
release-base-action:
|
||||
needs: create-release
|
||||
if: ${{ !inputs.dry_run }}
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
steps:
|
||||
- name: Checkout base-action repo
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
repository: anthropics/claude-code-base-action
|
||||
token: ${{ secrets.CLAUDE_CODE_BASE_ACTION_PAT }}
|
||||
fetch-depth: 0
|
||||
|
||||
# - name: Create and push tag
|
||||
# run: |
|
||||
# next_version="${{ needs.create-release.outputs.next_version }}"
|
||||
|
||||
# git config user.name "github-actions[bot]"
|
||||
# git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
# # Create the version tag
|
||||
# git tag -a "$next_version" -m "Release $next_version - synced from claude-code-action"
|
||||
# git push origin "$next_version"
|
||||
|
||||
# # Update the beta tag
|
||||
# git tag -fa beta -m "Update beta tag to ${next_version}"
|
||||
# git push origin beta --force
|
||||
|
||||
# - name: Create GitHub release
|
||||
# env:
|
||||
# GH_TOKEN: ${{ secrets.CLAUDE_CODE_BASE_ACTION_PAT }}
|
||||
# run: |
|
||||
# next_version="${{ needs.create-release.outputs.next_version }}"
|
||||
|
||||
# # Create the release
|
||||
# gh release create "$next_version" \
|
||||
# --repo anthropics/claude-code-base-action \
|
||||
# --title "$next_version" \
|
||||
# --notes "Release $next_version - synced from anthropics/claude-code-action" \
|
||||
# --latest=false
|
||||
|
||||
# # Update beta release to be latest
|
||||
# gh release edit beta \
|
||||
# --repo anthropics/claude-code-base-action \
|
||||
# --latest
|
||||
# release-base-action:
|
||||
# needs: create-release
|
||||
# if: ${{ !inputs.dry_run }}
|
||||
# runs-on: ubuntu-latest
|
||||
# environment: production
|
||||
# steps:
|
||||
# - name: Checkout base-action repo
|
||||
# uses: actions/checkout@v5
|
||||
# with:
|
||||
# repository: anthropics/claude-code-base-action
|
||||
# token: ${{ secrets.CLAUDE_CODE_BASE_ACTION_PAT }}
|
||||
# fetch-depth: 0
|
||||
#
|
||||
# - name: Create and push tag
|
||||
# run: |
|
||||
# next_version="${{ needs.create-release.outputs.next_version }}"
|
||||
#
|
||||
# git config user.name "github-actions[bot]"
|
||||
# git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
#
|
||||
# # Create the version tag
|
||||
# git tag -a "$next_version" -m "Release $next_version - synced from claude-code-action"
|
||||
# git push origin "$next_version"
|
||||
#
|
||||
# # Update the beta tag
|
||||
# git tag -fa beta -m "Update beta tag to ${next_version}"
|
||||
# git push origin beta --force
|
||||
#
|
||||
# - name: Create GitHub release
|
||||
# env:
|
||||
# GH_TOKEN: ${{ secrets.CLAUDE_CODE_BASE_ACTION_PAT }}
|
||||
# run: |
|
||||
# next_version="${{ needs.create-release.outputs.next_version }}"
|
||||
#
|
||||
# # Create the release
|
||||
# gh release create "$next_version" \
|
||||
# --repo anthropics/claude-code-base-action \
|
||||
# --title "$next_version" \
|
||||
# --notes "Release $next_version - synced from anthropics/claude-code-action" \
|
||||
# --latest=false
|
||||
#
|
||||
# # Update beta release to be latest
|
||||
# gh release edit beta \
|
||||
# --repo anthropics/claude-code-base-action \
|
||||
# --latest
|
||||
|
||||
62
.github/workflows/test-base-action.yml
vendored
62
.github/workflows/test-base-action.yml
vendored
@@ -1,9 +1,6 @@
|
||||
name: Test Claude Code Action
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -11,6 +8,7 @@ on:
|
||||
description: "Test prompt for Claude"
|
||||
required: false
|
||||
default: "List the files in the current directory starting with 'package'"
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
test-inline-prompt:
|
||||
@@ -118,61 +116,3 @@ jobs:
|
||||
echo "❌ Execution log file not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
test-agent-sdk:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Test with Agent SDK
|
||||
id: sdk-test
|
||||
uses: ./base-action
|
||||
env:
|
||||
USE_AGENT_SDK: "true"
|
||||
with:
|
||||
prompt: ${{ github.event.inputs.test_prompt || 'List the files in the current directory starting with "package"' }}
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
allowed_tools: "LS,Read"
|
||||
|
||||
- name: Verify SDK output
|
||||
run: |
|
||||
OUTPUT_FILE="${{ steps.sdk-test.outputs.execution_file }}"
|
||||
CONCLUSION="${{ steps.sdk-test.outputs.conclusion }}"
|
||||
|
||||
echo "Conclusion: $CONCLUSION"
|
||||
echo "Output file: $OUTPUT_FILE"
|
||||
|
||||
if [ "$CONCLUSION" = "success" ]; then
|
||||
echo "✅ Action completed successfully with Agent SDK"
|
||||
else
|
||||
echo "❌ Action failed with Agent SDK"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -f "$OUTPUT_FILE" ]; then
|
||||
if [ -s "$OUTPUT_FILE" ]; then
|
||||
echo "✅ Execution log file created successfully with content"
|
||||
echo "Validating JSON format:"
|
||||
if jq . "$OUTPUT_FILE" > /dev/null 2>&1; then
|
||||
echo "✅ Output is valid JSON"
|
||||
# Verify SDK output contains total_cost_usd (SDK field name)
|
||||
if jq -e '.[] | select(.type == "result") | .total_cost_usd' "$OUTPUT_FILE" > /dev/null 2>&1; then
|
||||
echo "✅ SDK output contains total_cost_usd field"
|
||||
else
|
||||
echo "❌ SDK output missing total_cost_usd field"
|
||||
exit 1
|
||||
fi
|
||||
echo "Content preview:"
|
||||
head -c 500 "$OUTPUT_FILE"
|
||||
else
|
||||
echo "❌ Output is not valid JSON"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "❌ Execution log file is empty"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "❌ Execution log file not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
name: Test Custom Executables
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
test-custom-executables:
|
||||
|
||||
4
.github/workflows/test-mcp-servers.yml
vendored
4
.github/workflows/test-mcp-servers.yml
vendored
@@ -1,11 +1,9 @@
|
||||
name: Test MCP Servers
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
test-mcp-integration:
|
||||
|
||||
4
.github/workflows/test-settings.yml
vendored
4
.github/workflows/test-settings.yml
vendored
@@ -1,11 +1,9 @@
|
||||
name: Test Settings Feature
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
test-settings-inline-allow:
|
||||
|
||||
4
.github/workflows/test-structured-output.yml
vendored
4
.github/workflows/test-structured-output.yml
vendored
@@ -1,11 +1,9 @@
|
||||
name: Test Structured Outputs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -148,9 +148,9 @@ runs:
|
||||
steps:
|
||||
- name: Install Bun
|
||||
if: inputs.path_to_bun_executable == ''
|
||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # https://github.com/oven-sh/setup-bun/releases/tag/v2.0.2
|
||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # https://github.com/oven-sh/setup-bun/releases/tag/v2.1.2
|
||||
with:
|
||||
bun-version: 1.2.11
|
||||
bun-version: 1.3.6
|
||||
|
||||
- name: Setup Custom Bun Path
|
||||
if: inputs.path_to_bun_executable != ''
|
||||
@@ -213,7 +213,7 @@ runs:
|
||||
|
||||
# Install Claude Code if no custom executable is provided
|
||||
if [ -z "$PATH_TO_CLAUDE_CODE_EXECUTABLE" ]; then
|
||||
CLAUDE_CODE_VERSION="2.1.4"
|
||||
CLAUDE_CODE_VERSION="2.1.17"
|
||||
echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..."
|
||||
for attempt in 1 2 3; do
|
||||
echo "Installation attempt $attempt..."
|
||||
|
||||
@@ -27,7 +27,6 @@ This is a GitHub Action that allows running Claude Code within GitHub workflows.
|
||||
### Key Design Patterns
|
||||
|
||||
- Uses Bun runtime for development and execution
|
||||
- Named pipes for IPC between prompt input and Claude process
|
||||
- JSON streaming output format for execution logs
|
||||
- Composite action pattern to orchestrate multiple steps
|
||||
- Provider-agnostic design supporting Anthropic API, AWS Bedrock, and Google Vertex AI
|
||||
@@ -54,7 +53,6 @@ This is a GitHub Action that allows running Claude Code within GitHub workflows.
|
||||
|
||||
## Important Technical Details
|
||||
|
||||
- Uses `mkfifo` to create named pipes for prompt input
|
||||
- Outputs execution logs as JSON to `/tmp/claude-execution-output.json`
|
||||
- Timeout enforcement via `timeout` command wrapper
|
||||
- Strict TypeScript configuration with Bun-specific settings
|
||||
|
||||
@@ -97,9 +97,9 @@ runs:
|
||||
|
||||
- name: Install Bun
|
||||
if: inputs.path_to_bun_executable == ''
|
||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # https://github.com/oven-sh/setup-bun/releases/tag/v2.0.2
|
||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # https://github.com/oven-sh/setup-bun/releases/tag/v2.1.2
|
||||
with:
|
||||
bun-version: 1.2.11
|
||||
bun-version: 1.3.6
|
||||
|
||||
- name: Setup Custom Bun Path
|
||||
if: inputs.path_to_bun_executable != ''
|
||||
@@ -124,7 +124,7 @@ runs:
|
||||
PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
|
||||
run: |
|
||||
if [ -z "$PATH_TO_CLAUDE_CODE_EXECUTABLE" ]; then
|
||||
CLAUDE_CODE_VERSION="2.1.4"
|
||||
CLAUDE_CODE_VERSION="2.1.17"
|
||||
echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..."
|
||||
for attempt in 1 2 3; do
|
||||
echo "Installation attempt $attempt..."
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"name": "@anthropic-ai/claude-code-base-action",
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.10.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.4",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.17",
|
||||
"shell-quote": "^1.8.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -27,7 +27,7 @@
|
||||
|
||||
"@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.4", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-linuxmusl-arm64": "^0.33.5", "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-5RpMO8aLEwuAd8h7/QHMCKzdVSihZCtHGnouPp+Isvc7zPzQXKb6GvUitkbs3wIBgIbXA/vXQmIi126uw9qo0A=="],
|
||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.17", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-linuxmusl-arm64": "^0.33.5", "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-cWEZ3fhPG6beVlZkXPAGYwqoR5zbELPracg+eKQ9UUqlf9m5UmUaaasGSXdVVxgDkjZfl8yoPsHnicuL2GIB1A=="],
|
||||
|
||||
"@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="],
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.10.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.4",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.17",
|
||||
"shell-quote": "^1.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -36,7 +36,6 @@ async function run() {
|
||||
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,
|
||||
pathToClaudeCodeExecutable:
|
||||
|
||||
@@ -212,7 +212,7 @@ export function parseSdkOptions(options: ClaudeOptions): ParsedSdkOptions {
|
||||
if (process.env.INPUT_ACTION_INPUTS_PRESENT) {
|
||||
env.GITHUB_ACTION_INPUTS = process.env.INPUT_ACTION_INPUTS_PRESENT;
|
||||
}
|
||||
// Ensure SDK path uses the same entrypoint as the CLI path
|
||||
// Set the entrypoint for Claude Code to identify this as the GitHub Action
|
||||
env.CLAUDE_CODE_ENTRYPOINT = "claude-code-github-action";
|
||||
|
||||
// Build system prompt option - default to claude_code preset
|
||||
|
||||
@@ -178,6 +178,15 @@ export async function runClaudeWithSdk(
|
||||
core.warning(`Failed to write execution file: ${error}`);
|
||||
}
|
||||
|
||||
// Extract and set session_id from system.init message
|
||||
const initMessage = messages.find(
|
||||
(m) => m.type === "system" && "subtype" in m && m.subtype === "init",
|
||||
);
|
||||
if (initMessage && "session_id" in initMessage && initMessage.session_id) {
|
||||
core.setOutput("session_id", initMessage.session_id);
|
||||
core.info(`Set session_id: ${initMessage.session_id}`);
|
||||
}
|
||||
|
||||
if (!resultMessage) {
|
||||
core.setOutput("conclusion", "failure");
|
||||
core.error("No result message received from Claude");
|
||||
|
||||
@@ -1,72 +1,6 @@
|
||||
import * as core from "@actions/core";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { unlink, writeFile, stat, readFile } from "fs/promises";
|
||||
import { createWriteStream } from "fs";
|
||||
import { spawn } from "child_process";
|
||||
import { parse as parseShellArgs } from "shell-quote";
|
||||
import { runClaudeWithSdk } from "./run-claude-sdk";
|
||||
import { parseSdkOptions } from "./parse-sdk-options";
|
||||
|
||||
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 = ["--verbose", "--output-format", "stream-json"];
|
||||
|
||||
/**
|
||||
* Sanitizes JSON output to remove sensitive information when full output is disabled
|
||||
* Returns a safe summary message or null if the message should be completely suppressed
|
||||
*/
|
||||
function sanitizeJsonOutput(
|
||||
jsonObj: any,
|
||||
showFullOutput: boolean,
|
||||
): string | null {
|
||||
if (showFullOutput) {
|
||||
// In full output mode, return the full JSON
|
||||
return JSON.stringify(jsonObj, null, 2);
|
||||
}
|
||||
|
||||
// In non-full-output mode, provide minimal safe output
|
||||
const type = jsonObj.type;
|
||||
const subtype = jsonObj.subtype;
|
||||
|
||||
// System initialization - safe to show
|
||||
if (type === "system" && subtype === "init") {
|
||||
return JSON.stringify(
|
||||
{
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
message: "Claude Code initialized",
|
||||
model: jsonObj.model || "unknown",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
// Result messages - Always show the final result
|
||||
if (type === "result") {
|
||||
// These messages contain the final result and should always be visible
|
||||
return JSON.stringify(
|
||||
{
|
||||
type: "result",
|
||||
subtype: jsonObj.subtype,
|
||||
is_error: jsonObj.is_error,
|
||||
duration_ms: jsonObj.duration_ms,
|
||||
num_turns: jsonObj.num_turns,
|
||||
total_cost_usd: jsonObj.total_cost_usd,
|
||||
permission_denials: jsonObj.permission_denials,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
// For any other message types, suppress completely in non-full-output mode
|
||||
return null;
|
||||
}
|
||||
|
||||
export type ClaudeOptions = {
|
||||
claudeArgs?: string;
|
||||
model?: string;
|
||||
@@ -77,363 +11,11 @@ export type ClaudeOptions = {
|
||||
mcpConfig?: string;
|
||||
systemPrompt?: string;
|
||||
appendSystemPrompt?: string;
|
||||
claudeEnv?: string;
|
||||
fallbackModel?: string;
|
||||
showFullOutput?: string;
|
||||
};
|
||||
|
||||
type PreparedConfig = {
|
||||
claudeArgs: string[];
|
||||
promptPath: string;
|
||||
env: Record<string, string>;
|
||||
};
|
||||
|
||||
export function prepareRunConfig(
|
||||
promptPath: string,
|
||||
options: ClaudeOptions,
|
||||
): PreparedConfig {
|
||||
// Build Claude CLI arguments:
|
||||
// 1. Prompt flag (always first)
|
||||
// 2. User's claudeArgs (full control)
|
||||
// 3. BASE_ARGS (always last, cannot be overridden)
|
||||
|
||||
const claudeArgs = ["-p"];
|
||||
|
||||
// Parse and add user's custom Claude arguments
|
||||
if (options.claudeArgs?.trim()) {
|
||||
const parsed = parseShellArgs(options.claudeArgs);
|
||||
const customArgs = parsed.filter(
|
||||
(arg): arg is string => typeof arg === "string",
|
||||
);
|
||||
claudeArgs.push(...customArgs);
|
||||
}
|
||||
|
||||
// BASE_ARGS are always appended last (cannot be overridden)
|
||||
claudeArgs.push(...BASE_ARGS);
|
||||
|
||||
const customEnv: Record<string, string> = {};
|
||||
|
||||
if (process.env.INPUT_ACTION_INPUTS_PRESENT) {
|
||||
customEnv.GITHUB_ACTION_INPUTS = process.env.INPUT_ACTION_INPUTS_PRESENT;
|
||||
}
|
||||
|
||||
return {
|
||||
claudeArgs,
|
||||
promptPath,
|
||||
env: customEnv,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses session_id from execution file and sets GitHub Action output
|
||||
* Exported for testing
|
||||
*/
|
||||
export async function parseAndSetSessionId(
|
||||
executionFile: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const content = await readFile(executionFile, "utf-8");
|
||||
const messages = JSON.parse(content) as {
|
||||
type: string;
|
||||
subtype?: string;
|
||||
session_id?: string;
|
||||
}[];
|
||||
|
||||
// Find the system.init message which contains session_id
|
||||
const initMessage = messages.find(
|
||||
(m) => m.type === "system" && m.subtype === "init",
|
||||
);
|
||||
|
||||
if (initMessage?.session_id) {
|
||||
core.setOutput("session_id", initMessage.session_id);
|
||||
core.info(`Set session_id: ${initMessage.session_id}`);
|
||||
}
|
||||
} catch (error) {
|
||||
// Don't fail the action if session_id extraction fails
|
||||
core.warning(`Failed to extract session_id: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses structured_output from execution file and sets GitHub Action outputs
|
||||
* Only runs if --json-schema was explicitly provided in claude_args
|
||||
* Exported for testing
|
||||
*/
|
||||
export async function parseAndSetStructuredOutputs(
|
||||
executionFile: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const content = await readFile(executionFile, "utf-8");
|
||||
const messages = JSON.parse(content) as {
|
||||
type: string;
|
||||
structured_output?: Record<string, unknown>;
|
||||
}[];
|
||||
|
||||
// Search backwards - result is typically last or second-to-last message
|
||||
const result = messages.findLast(
|
||||
(m) => m.type === "result" && m.structured_output,
|
||||
);
|
||||
|
||||
if (!result?.structured_output) {
|
||||
throw new Error(
|
||||
`--json-schema was provided but Claude did not return structured_output.\n` +
|
||||
`Found ${messages.length} messages. Result exists: ${!!result}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
// Set the complete structured output as a single JSON string
|
||||
// This works around GitHub Actions limitation that composite actions can't have dynamic outputs
|
||||
const structuredOutputJson = JSON.stringify(result.structured_output);
|
||||
core.setOutput("structured_output", structuredOutputJson);
|
||||
core.info(
|
||||
`Set structured_output with ${Object.keys(result.structured_output).length} field(s)`,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw error; // Preserve original error and stack trace
|
||||
}
|
||||
throw new Error(`Failed to parse structured outputs: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runClaude(promptPath: string, options: ClaudeOptions) {
|
||||
// Feature flag: use SDK path by default, set USE_AGENT_SDK=false to use CLI
|
||||
const useAgentSdk = process.env.USE_AGENT_SDK !== "false";
|
||||
console.log(
|
||||
`Using ${useAgentSdk ? "Agent SDK" : "CLI"} path (USE_AGENT_SDK=${process.env.USE_AGENT_SDK ?? "unset"})`,
|
||||
);
|
||||
|
||||
if (useAgentSdk) {
|
||||
const parsedOptions = parseSdkOptions(options);
|
||||
return runClaudeWithSdk(promptPath, parsedOptions);
|
||||
}
|
||||
|
||||
const config = prepareRunConfig(promptPath, options);
|
||||
|
||||
// Detect if --json-schema is present in claude args
|
||||
const hasJsonSchema = options.claudeArgs?.includes("--json-schema") ?? false;
|
||||
|
||||
// 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.promptPath);
|
||||
promptSize = stats.size.toString();
|
||||
} catch (e) {
|
||||
// Ignore error
|
||||
}
|
||||
|
||||
console.log(`Prompt file size: ${promptSize} bytes`);
|
||||
|
||||
// Log custom environment variables if any
|
||||
const customEnvKeys = Object.keys(config.env).filter(
|
||||
(key) => key !== "CLAUDE_ACTION_INPUTS_PRESENT",
|
||||
);
|
||||
if (customEnvKeys.length > 0) {
|
||||
console.log(`Custom environment variables: ${customEnvKeys.join(", ")}`);
|
||||
}
|
||||
|
||||
// Log custom arguments if any
|
||||
if (options.claudeArgs && options.claudeArgs.trim() !== "") {
|
||||
console.log(`Custom Claude arguments: ${options.claudeArgs}`);
|
||||
}
|
||||
|
||||
// Output to console
|
||||
console.log(`Running Claude with prompt from file: ${config.promptPath}`);
|
||||
console.log(`Full command: claude ${config.claudeArgs.join(" ")}`);
|
||||
|
||||
// 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();
|
||||
});
|
||||
|
||||
// Use custom executable path if provided, otherwise default to "claude"
|
||||
const claudeExecutable = options.pathToClaudeCodeExecutable || "claude";
|
||||
|
||||
const claudeProcess = spawn(claudeExecutable, 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();
|
||||
});
|
||||
|
||||
// Determine if full output should be shown
|
||||
// Show full output if explicitly set to "true" OR if GitHub Actions debug mode is enabled
|
||||
const isDebugMode = process.env.ACTIONS_STEP_DEBUG === "true";
|
||||
let showFullOutput = options.showFullOutput === "true" || isDebugMode;
|
||||
|
||||
if (isDebugMode && options.showFullOutput !== "false") {
|
||||
console.log("Debug mode detected - showing full output");
|
||||
showFullOutput = true;
|
||||
} else if (!showFullOutput) {
|
||||
console.log("Running Claude Code (full output hidden for security)...");
|
||||
console.log(
|
||||
"Rerun in debug mode or enable `show_full_output: true` in your workflow file for full output.",
|
||||
);
|
||||
}
|
||||
|
||||
// Capture output for parsing execution metrics
|
||||
let output = "";
|
||||
claudeProcess.stdout.on("data", (data) => {
|
||||
const text = data.toString();
|
||||
|
||||
// Try to parse as JSON and handle based on verbose setting
|
||||
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 sanitizedOutput = sanitizeJsonOutput(parsed, showFullOutput);
|
||||
|
||||
if (sanitizedOutput) {
|
||||
process.stdout.write(sanitizedOutput);
|
||||
if (index < lines.length - 1 || text.endsWith("\n")) {
|
||||
process.stdout.write("\n");
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Not a JSON object
|
||||
if (showFullOutput) {
|
||||
// In full output mode, print as is
|
||||
process.stdout.write(line);
|
||||
if (index < lines.length - 1 || text.endsWith("\n")) {
|
||||
process.stdout.write("\n");
|
||||
}
|
||||
}
|
||||
// In non-full-output mode, suppress non-JSON output
|
||||
}
|
||||
});
|
||||
|
||||
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
|
||||
const exitCode = await new Promise<number>((resolve) => {
|
||||
claudeProcess.on("close", (code) => {
|
||||
resolve(code || 0);
|
||||
});
|
||||
|
||||
claudeProcess.on("error", (error) => {
|
||||
console.error("Claude process error:", error);
|
||||
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
|
||||
// Increase maxBuffer from Node.js default of 1MB to 10MB to handle large Claude outputs
|
||||
const { stdout: jsonOutput } = await execAsync("jq -s '.' output.txt", {
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
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("execution_file", EXECUTION_FILE);
|
||||
|
||||
// Extract and set session_id
|
||||
await parseAndSetSessionId(EXECUTION_FILE);
|
||||
|
||||
// Parse and set structured outputs only if user provided --json-schema in claude_args
|
||||
if (hasJsonSchema) {
|
||||
try {
|
||||
await parseAndSetStructuredOutputs(EXECUTION_FILE);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
core.setFailed(errorMessage);
|
||||
core.setOutput("conclusion", "failure");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Set conclusion to success if we reached here
|
||||
core.setOutput("conclusion", "success");
|
||||
} else {
|
||||
core.setOutput("conclusion", "failure");
|
||||
|
||||
// Still try to save execution file if we have output
|
||||
if (output) {
|
||||
try {
|
||||
await writeFile("output.txt", output);
|
||||
// Increase maxBuffer from Node.js default of 1MB to 10MB to handle large Claude outputs
|
||||
const { stdout: jsonOutput } = await execAsync("jq -s '.' output.txt", {
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
await writeFile(EXECUTION_FILE, jsonOutput);
|
||||
core.setOutput("execution_file", EXECUTION_FILE);
|
||||
} catch (e) {
|
||||
// Ignore errors when processing output during failure
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(exitCode);
|
||||
}
|
||||
const parsedOptions = parseSdkOptions(options);
|
||||
return runClaudeWithSdk(promptPath, parsedOptions);
|
||||
}
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { prepareRunConfig, type ClaudeOptions } from "../src/run-claude";
|
||||
|
||||
describe("prepareRunConfig", () => {
|
||||
test("should prepare config with basic arguments", () => {
|
||||
const options: ClaudeOptions = {};
|
||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
||||
|
||||
expect(prepared.claudeArgs).toEqual([
|
||||
"-p",
|
||||
"--verbose",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
]);
|
||||
});
|
||||
|
||||
test("should include promptPath", () => {
|
||||
const options: ClaudeOptions = {};
|
||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
||||
|
||||
expect(prepared.promptPath).toBe("/tmp/test-prompt.txt");
|
||||
});
|
||||
|
||||
test("should use provided prompt path", () => {
|
||||
const options: ClaudeOptions = {};
|
||||
const prepared = prepareRunConfig("/custom/prompt/path.txt", options);
|
||||
|
||||
expect(prepared.promptPath).toBe("/custom/prompt/path.txt");
|
||||
});
|
||||
|
||||
describe("claudeArgs handling", () => {
|
||||
test("should parse and include custom claude arguments", () => {
|
||||
const options: ClaudeOptions = {
|
||||
claudeArgs: "--max-turns 10 --model claude-3-opus-20240229",
|
||||
};
|
||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
||||
|
||||
expect(prepared.claudeArgs).toEqual([
|
||||
"-p",
|
||||
"--max-turns",
|
||||
"10",
|
||||
"--model",
|
||||
"claude-3-opus-20240229",
|
||||
"--verbose",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
]);
|
||||
});
|
||||
|
||||
test("should handle empty claudeArgs", () => {
|
||||
const options: ClaudeOptions = {
|
||||
claudeArgs: "",
|
||||
};
|
||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
||||
|
||||
expect(prepared.claudeArgs).toEqual([
|
||||
"-p",
|
||||
"--verbose",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
]);
|
||||
});
|
||||
|
||||
test("should handle claudeArgs with quoted strings", () => {
|
||||
const options: ClaudeOptions = {
|
||||
claudeArgs: '--system-prompt "You are a helpful assistant"',
|
||||
};
|
||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
||||
|
||||
expect(prepared.claudeArgs).toEqual([
|
||||
"-p",
|
||||
"--system-prompt",
|
||||
"You are a helpful assistant",
|
||||
"--verbose",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
]);
|
||||
});
|
||||
|
||||
test("should include json-schema flag when provided", () => {
|
||||
const options: ClaudeOptions = {
|
||||
claudeArgs:
|
||||
'--json-schema \'{"type":"object","properties":{"result":{"type":"boolean"}}}\'',
|
||||
};
|
||||
|
||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
||||
|
||||
expect(prepared.claudeArgs).toContain("--json-schema");
|
||||
expect(prepared.claudeArgs).toContain(
|
||||
'{"type":"object","properties":{"result":{"type":"boolean"}}}',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,227 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { describe, test, expect, afterEach, beforeEach, spyOn } from "bun:test";
|
||||
import { writeFile, unlink } from "fs/promises";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import {
|
||||
parseAndSetStructuredOutputs,
|
||||
parseAndSetSessionId,
|
||||
} from "../src/run-claude";
|
||||
import * as core from "@actions/core";
|
||||
|
||||
// Mock execution file path
|
||||
const TEST_EXECUTION_FILE = join(tmpdir(), "test-execution-output.json");
|
||||
|
||||
// Helper to create mock execution file with structured output
|
||||
async function createMockExecutionFile(
|
||||
structuredOutput?: Record<string, unknown>,
|
||||
includeResult: boolean = true,
|
||||
): Promise<void> {
|
||||
const messages: any[] = [
|
||||
{ type: "system", subtype: "init" },
|
||||
{ type: "turn", content: "test" },
|
||||
];
|
||||
|
||||
if (includeResult) {
|
||||
messages.push({
|
||||
type: "result",
|
||||
cost_usd: 0.01,
|
||||
duration_ms: 1000,
|
||||
structured_output: structuredOutput,
|
||||
});
|
||||
}
|
||||
|
||||
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
|
||||
}
|
||||
|
||||
// Spy on core functions
|
||||
let setOutputSpy: any;
|
||||
let infoSpy: any;
|
||||
let warningSpy: any;
|
||||
|
||||
beforeEach(() => {
|
||||
setOutputSpy = spyOn(core, "setOutput").mockImplementation(() => {});
|
||||
infoSpy = spyOn(core, "info").mockImplementation(() => {});
|
||||
warningSpy = spyOn(core, "warning").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe("parseAndSetStructuredOutputs", () => {
|
||||
afterEach(async () => {
|
||||
setOutputSpy?.mockRestore();
|
||||
infoSpy?.mockRestore();
|
||||
warningSpy?.mockRestore();
|
||||
try {
|
||||
await unlink(TEST_EXECUTION_FILE);
|
||||
} catch {
|
||||
// Ignore if file doesn't exist
|
||||
}
|
||||
});
|
||||
|
||||
test("should set structured_output with valid data", async () => {
|
||||
await createMockExecutionFile({
|
||||
is_flaky: true,
|
||||
confidence: 0.85,
|
||||
summary: "Test looks flaky",
|
||||
});
|
||||
|
||||
await parseAndSetStructuredOutputs(TEST_EXECUTION_FILE);
|
||||
|
||||
expect(setOutputSpy).toHaveBeenCalledWith(
|
||||
"structured_output",
|
||||
'{"is_flaky":true,"confidence":0.85,"summary":"Test looks flaky"}',
|
||||
);
|
||||
expect(infoSpy).toHaveBeenCalledWith(
|
||||
"Set structured_output with 3 field(s)",
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle arrays and nested objects", async () => {
|
||||
await createMockExecutionFile({
|
||||
items: ["a", "b", "c"],
|
||||
config: { key: "value", nested: { deep: true } },
|
||||
});
|
||||
|
||||
await parseAndSetStructuredOutputs(TEST_EXECUTION_FILE);
|
||||
|
||||
const callArgs = setOutputSpy.mock.calls[0];
|
||||
expect(callArgs[0]).toBe("structured_output");
|
||||
const parsed = JSON.parse(callArgs[1]);
|
||||
expect(parsed).toEqual({
|
||||
items: ["a", "b", "c"],
|
||||
config: { key: "value", nested: { deep: true } },
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle special characters in field names", async () => {
|
||||
await createMockExecutionFile({
|
||||
"test-result": "passed",
|
||||
"item.count": 10,
|
||||
"user@email": "test",
|
||||
});
|
||||
|
||||
await parseAndSetStructuredOutputs(TEST_EXECUTION_FILE);
|
||||
|
||||
const callArgs = setOutputSpy.mock.calls[0];
|
||||
const parsed = JSON.parse(callArgs[1]);
|
||||
expect(parsed["test-result"]).toBe("passed");
|
||||
expect(parsed["item.count"]).toBe(10);
|
||||
expect(parsed["user@email"]).toBe("test");
|
||||
});
|
||||
|
||||
test("should throw error when result exists but structured_output is undefined", async () => {
|
||||
const messages = [
|
||||
{ type: "system", subtype: "init" },
|
||||
{ type: "result", cost_usd: 0.01, duration_ms: 1000 },
|
||||
];
|
||||
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
|
||||
|
||||
await expect(
|
||||
parseAndSetStructuredOutputs(TEST_EXECUTION_FILE),
|
||||
).rejects.toThrow(
|
||||
"--json-schema was provided but Claude did not return structured_output",
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw error when no result message exists", async () => {
|
||||
const messages = [
|
||||
{ type: "system", subtype: "init" },
|
||||
{ type: "turn", content: "test" },
|
||||
];
|
||||
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
|
||||
|
||||
await expect(
|
||||
parseAndSetStructuredOutputs(TEST_EXECUTION_FILE),
|
||||
).rejects.toThrow(
|
||||
"--json-schema was provided but Claude did not return structured_output",
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw error with malformed JSON", async () => {
|
||||
await writeFile(TEST_EXECUTION_FILE, "{ invalid json");
|
||||
|
||||
await expect(
|
||||
parseAndSetStructuredOutputs(TEST_EXECUTION_FILE),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("should throw error when file does not exist", async () => {
|
||||
await expect(
|
||||
parseAndSetStructuredOutputs("/nonexistent/file.json"),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("should handle empty structured_output object", async () => {
|
||||
await createMockExecutionFile({});
|
||||
|
||||
await parseAndSetStructuredOutputs(TEST_EXECUTION_FILE);
|
||||
|
||||
expect(setOutputSpy).toHaveBeenCalledWith("structured_output", "{}");
|
||||
expect(infoSpy).toHaveBeenCalledWith(
|
||||
"Set structured_output with 0 field(s)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseAndSetSessionId", () => {
|
||||
afterEach(async () => {
|
||||
setOutputSpy?.mockRestore();
|
||||
infoSpy?.mockRestore();
|
||||
warningSpy?.mockRestore();
|
||||
try {
|
||||
await unlink(TEST_EXECUTION_FILE);
|
||||
} catch {
|
||||
// Ignore if file doesn't exist
|
||||
}
|
||||
});
|
||||
|
||||
test("should extract session_id from system.init message", async () => {
|
||||
const messages = [
|
||||
{ type: "system", subtype: "init", session_id: "test-session-123" },
|
||||
{ type: "result", cost_usd: 0.01 },
|
||||
];
|
||||
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
|
||||
|
||||
await parseAndSetSessionId(TEST_EXECUTION_FILE);
|
||||
|
||||
expect(setOutputSpy).toHaveBeenCalledWith("session_id", "test-session-123");
|
||||
expect(infoSpy).toHaveBeenCalledWith("Set session_id: test-session-123");
|
||||
});
|
||||
|
||||
test("should handle missing session_id gracefully", async () => {
|
||||
const messages = [
|
||||
{ type: "system", subtype: "init" },
|
||||
{ type: "result", cost_usd: 0.01 },
|
||||
];
|
||||
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
|
||||
|
||||
await parseAndSetSessionId(TEST_EXECUTION_FILE);
|
||||
|
||||
expect(setOutputSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle missing system.init message gracefully", async () => {
|
||||
const messages = [{ type: "result", cost_usd: 0.01 }];
|
||||
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
|
||||
|
||||
await parseAndSetSessionId(TEST_EXECUTION_FILE);
|
||||
|
||||
expect(setOutputSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle malformed JSON gracefully with warning", async () => {
|
||||
await writeFile(TEST_EXECUTION_FILE, "{ invalid json");
|
||||
|
||||
await parseAndSetSessionId(TEST_EXECUTION_FILE);
|
||||
|
||||
expect(setOutputSpy).not.toHaveBeenCalled();
|
||||
expect(warningSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle non-existent file gracefully with warning", async () => {
|
||||
await parseAndSetSessionId("/nonexistent/file.json");
|
||||
|
||||
expect(setOutputSpy).not.toHaveBeenCalled();
|
||||
expect(warningSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
4
bun.lock
4
bun.lock
@@ -7,7 +7,7 @@
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.10.1",
|
||||
"@actions/github": "^6.0.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.4",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.17",
|
||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||
"@octokit/graphql": "^8.2.2",
|
||||
"@octokit/rest": "^21.1.1",
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
"@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.4", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-linuxmusl-arm64": "^0.33.5", "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-5RpMO8aLEwuAd8h7/QHMCKzdVSihZCtHGnouPp+Isvc7zPzQXKb6GvUitkbs3wIBgIbXA/vXQmIi126uw9qo0A=="],
|
||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.17", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-linuxmusl-arm64": "^0.33.5", "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-cWEZ3fhPG6beVlZkXPAGYwqoR5zbELPracg+eKQ9UUqlf9m5UmUaaasGSXdVVxgDkjZfl8yoPsHnicuL2GIB1A=="],
|
||||
|
||||
"@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="],
|
||||
|
||||
|
||||
@@ -13,6 +13,16 @@
|
||||
- **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
|
||||
|
||||
## Pull Request Creation
|
||||
|
||||
In its default configuration, **Claude does not create pull requests automatically** when responding to `@claude` mentions. Instead:
|
||||
|
||||
- Claude commits code changes to a new branch
|
||||
- Claude provides a **link to the GitHub PR creation page** in its response
|
||||
- **The user must click the link and create the PR themselves**, ensuring human oversight before any code is proposed for merging
|
||||
|
||||
This design ensures that users retain full control over what pull requests are created and can review the changes before initiating the PR workflow.
|
||||
|
||||
## ⚠️ Prompt Injection Risks
|
||||
|
||||
**Beware of potential hidden markdown when tagging Claude on untrusted content.** External contributors may include hidden instructions through HTML comments, invisible characters, hidden attributes, or other techniques. The action sanitizes content by stripping HTML comments, invisible characters, markdown image alt text, hidden HTML attributes, and HTML entities, but new bypass techniques may emerge. We recommend reviewing the raw content of all input coming from external contributors before allowing Claude to process it.
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.10.1",
|
||||
"@actions/github": "^6.0.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.4",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.17",
|
||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||
"@octokit/graphql": "^8.2.2",
|
||||
"@octokit/rest": "^21.1.1",
|
||||
|
||||
@@ -82,8 +82,13 @@ export async function setupSshSigning(sshSigningKey: string): Promise<void> {
|
||||
const sshDir = join(homedir(), ".ssh");
|
||||
await mkdir(sshDir, { recursive: true, mode: 0o700 });
|
||||
|
||||
// Ensure key ends with newline (required for ssh-keygen to parse it)
|
||||
const normalizedKey = sshSigningKey.endsWith("\n")
|
||||
? sshSigningKey
|
||||
: sshSigningKey + "\n";
|
||||
|
||||
// Write the signing key atomically with secure permissions (600)
|
||||
await writeFile(SSH_SIGNING_KEY_PATH, sshSigningKey, { mode: 0o600 });
|
||||
await writeFile(SSH_SIGNING_KEY_PATH, normalizedKey, { mode: 0o600 });
|
||||
console.log(`✓ SSH signing key written to ${SSH_SIGNING_KEY_PATH}`);
|
||||
|
||||
// Configure git to use SSH signing
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
*/
|
||||
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
import type { ParsedGitHubContext } from "../context";
|
||||
import type { GitHubContext } from "../context";
|
||||
|
||||
export async function checkHumanActor(
|
||||
octokit: Octokit,
|
||||
githubContext: ParsedGitHubContext,
|
||||
githubContext: GitHubContext,
|
||||
) {
|
||||
// Fetch user information from GitHub API
|
||||
const { data: userData } = await octokit.users.getByUsername({
|
||||
|
||||
@@ -10,11 +10,6 @@ import {
|
||||
isPullRequestReviewCommentEvent,
|
||||
} from "../context";
|
||||
import type { ParsedGitHubContext } from "../context";
|
||||
import {
|
||||
detectActionableSuggestion,
|
||||
isCommentActionableForAutofix,
|
||||
type ActionableSuggestionResult,
|
||||
} from "../../utils/detect-actionable-suggestion";
|
||||
|
||||
export function checkContainsTrigger(context: ParsedGitHubContext): boolean {
|
||||
const {
|
||||
@@ -151,89 +146,3 @@ export async function checkTriggerAction(context: ParsedGitHubContext) {
|
||||
core.setOutput("contains_trigger", containsTrigger.toString());
|
||||
return containsTrigger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the context contains an actionable suggestion that can be automatically fixed.
|
||||
* This is useful for autofix workflows that want to respond to code review suggestions,
|
||||
* even when they come from bot accounts like claude[bot].
|
||||
*
|
||||
* @param context - The parsed GitHub context
|
||||
* @returns Detection result with confidence level and reason
|
||||
*/
|
||||
export function checkContainsActionableSuggestion(
|
||||
context: ParsedGitHubContext,
|
||||
): ActionableSuggestionResult {
|
||||
// Extract comment body based on event type
|
||||
let commentBody: string | undefined;
|
||||
|
||||
if (isPullRequestReviewCommentEvent(context)) {
|
||||
commentBody = context.payload.comment.body;
|
||||
} else if (isIssueCommentEvent(context)) {
|
||||
commentBody = context.payload.comment.body;
|
||||
} else if (isPullRequestReviewEvent(context)) {
|
||||
commentBody = context.payload.review.body ?? undefined;
|
||||
}
|
||||
|
||||
return detectActionableSuggestion(commentBody);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced trigger check that also considers actionable suggestions.
|
||||
* This function first checks for the standard trigger phrase, and if not found,
|
||||
* optionally checks for actionable suggestions when `checkSuggestions` is true.
|
||||
*
|
||||
* @param context - The parsed GitHub context
|
||||
* @param checkSuggestions - Whether to also check for actionable suggestions (default: false)
|
||||
* @returns Whether the action should be triggered
|
||||
*/
|
||||
export function checkContainsTriggerOrActionableSuggestion(
|
||||
context: ParsedGitHubContext,
|
||||
checkSuggestions: boolean = false,
|
||||
): boolean {
|
||||
// First, check for standard trigger
|
||||
if (checkContainsTrigger(context)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If checkSuggestions is enabled, also check for actionable suggestions
|
||||
if (checkSuggestions) {
|
||||
const suggestionResult = checkContainsActionableSuggestion(context);
|
||||
if (suggestionResult.isActionable) {
|
||||
console.log(
|
||||
`Comment contains actionable suggestion: ${suggestionResult.reason} (confidence: ${suggestionResult.confidence})`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a PR comment is actionable for autofix purposes.
|
||||
* This is a convenience function for workflows that want to automatically
|
||||
* apply suggestions from code review comments.
|
||||
*
|
||||
* @param context - The parsed GitHub context
|
||||
* @returns Whether the comment should be treated as actionable for autofix
|
||||
*/
|
||||
export function checkIsActionableForAutofix(
|
||||
context: ParsedGitHubContext,
|
||||
): boolean {
|
||||
// Only applicable to PR review comment events
|
||||
if (!isPullRequestReviewCommentEvent(context)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const commentBody = context.payload.comment.body;
|
||||
const authorUsername = context.payload.comment.user?.login;
|
||||
|
||||
return isCommentActionableForAutofix(commentBody, authorUsername);
|
||||
}
|
||||
|
||||
// Re-export the types and functions from the utility module for convenience
|
||||
export {
|
||||
detectActionableSuggestion,
|
||||
isCommentActionableForAutofix,
|
||||
type ActionableSuggestionResult,
|
||||
} from "../../utils/detect-actionable-suggestion";
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
configureGitAuth,
|
||||
setupSshSigning,
|
||||
} from "../../github/operations/git-config";
|
||||
import { checkHumanActor } from "../../github/validation/actor";
|
||||
import type { GitHubContext } from "../../github/context";
|
||||
import { isEntityContext } from "../../github/context";
|
||||
|
||||
@@ -80,7 +81,14 @@ export const agentMode: Mode = {
|
||||
return false;
|
||||
},
|
||||
|
||||
async prepare({ context, githubToken }: ModeOptions): Promise<ModeResult> {
|
||||
async prepare({
|
||||
context,
|
||||
octokit,
|
||||
githubToken,
|
||||
}: ModeOptions): Promise<ModeResult> {
|
||||
// Check if actor is human (prevents bot-triggered loops)
|
||||
await checkHumanActor(octokit.rest, context);
|
||||
|
||||
// Configure git authentication for agent mode (same as tag mode)
|
||||
// SSH signing takes precedence if provided
|
||||
const useSshSigning = !!context.inputs.sshSigningKey;
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
/**
|
||||
* Detects if a PR comment contains actionable suggestions that can be automatically fixed.
|
||||
*
|
||||
* This module identifies:
|
||||
* 1. GitHub inline committable suggestions (```suggestion blocks)
|
||||
* 2. Clear bug fix suggestions with specific patterns
|
||||
* 3. Code fix recommendations with explicit changes
|
||||
*/
|
||||
|
||||
/**
|
||||
* Patterns that indicate a comment contains a GitHub inline committable suggestion.
|
||||
* These are code blocks that GitHub renders with a "Commit suggestion" button.
|
||||
*/
|
||||
const COMMITTABLE_SUGGESTION_PATTERN = /```suggestion\b[\s\S]*?```/i;
|
||||
|
||||
/**
|
||||
* Patterns that indicate a clear, actionable bug fix suggestion.
|
||||
* These phrases typically precede concrete fix recommendations.
|
||||
*/
|
||||
const BUG_FIX_PATTERNS = [
|
||||
// Direct fix suggestions
|
||||
/\bshould\s+(?:be|use|return|change\s+to)\b/i,
|
||||
/\bchange\s+(?:this\s+)?to\b/i,
|
||||
/\breplace\s+(?:this\s+)?with\b/i,
|
||||
/\buse\s+(?:this\s+)?instead\b/i,
|
||||
/\binstead\s+of\s+.*?,?\s*use\b/i,
|
||||
|
||||
// Bug identification with fix
|
||||
/\b(?:bug|issue|error|problem):\s*.*(?:fix|change|update|replace)/i,
|
||||
/\bfix(?:ed)?\s+by\s+(?:chang|replac|updat)/i,
|
||||
/\bto\s+fix\s+(?:this|the)\b/i,
|
||||
|
||||
// Explicit code changes
|
||||
/\bthe\s+(?:correct|proper|right)\s+(?:code|syntax|value|approach)\s+(?:is|would\s+be)\b/i,
|
||||
/\bshould\s+(?:read|look\s+like)\b/i,
|
||||
|
||||
// Missing/wrong patterns
|
||||
/\bmissing\s+(?:a\s+)?(?:semicolon|bracket|parenthesis|quote|import|return|await)\b/i,
|
||||
/\bextra\s+(?:semicolon|bracket|parenthesis|quote)\b/i,
|
||||
/\bwrong\s+(?:type|value|variable|import|parameter)\b/i,
|
||||
/\btypo\s+(?:in|here)\b/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* Patterns that suggest code alternatives (less strong than direct fixes but still actionable).
|
||||
*/
|
||||
const CODE_ALTERNATIVE_PATTERNS = [
|
||||
/```[\w]*\n[\s\S]+?\n```/, // Any code block (might contain the fix)
|
||||
/\b(?:try|consider)\s+(?:using|changing|replacing)\b/i,
|
||||
/\bhere'?s?\s+(?:the|a)\s+(?:fix|solution|correction)\b/i,
|
||||
/\b(?:correct|fixed|updated)\s+(?:version|code|implementation)\b/i,
|
||||
];
|
||||
|
||||
export interface ActionableSuggestionResult {
|
||||
/** Whether the comment contains an actionable suggestion */
|
||||
isActionable: boolean;
|
||||
/** Whether the comment contains a GitHub inline committable suggestion */
|
||||
hasCommittableSuggestion: boolean;
|
||||
/** Whether the comment contains clear bug fix language */
|
||||
hasBugFixSuggestion: boolean;
|
||||
/** Whether the comment contains code alternatives */
|
||||
hasCodeAlternative: boolean;
|
||||
/** Confidence level: 'high', 'medium', or 'low' */
|
||||
confidence: "high" | "medium" | "low";
|
||||
/** Reason for the determination */
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects if a comment contains actionable suggestions that can be automatically fixed.
|
||||
*
|
||||
* @param commentBody - The body of the PR comment to analyze
|
||||
* @returns Object with detection results and confidence level
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const result = detectActionableSuggestion("```suggestion\nfixed code\n```");
|
||||
* // { isActionable: true, hasCommittableSuggestion: true, confidence: 'high', ... }
|
||||
*
|
||||
* const result2 = detectActionableSuggestion("You should use `const` instead of `let` here");
|
||||
* // { isActionable: true, hasBugFixSuggestion: true, confidence: 'medium', ... }
|
||||
* ```
|
||||
*/
|
||||
export function detectActionableSuggestion(
|
||||
commentBody: string | undefined | null,
|
||||
): ActionableSuggestionResult {
|
||||
if (!commentBody) {
|
||||
return {
|
||||
isActionable: false,
|
||||
hasCommittableSuggestion: false,
|
||||
hasBugFixSuggestion: false,
|
||||
hasCodeAlternative: false,
|
||||
confidence: "low",
|
||||
reason: "Empty or missing comment body",
|
||||
};
|
||||
}
|
||||
|
||||
// Check for GitHub inline committable suggestion (highest confidence)
|
||||
const hasCommittableSuggestion =
|
||||
COMMITTABLE_SUGGESTION_PATTERN.test(commentBody);
|
||||
if (hasCommittableSuggestion) {
|
||||
return {
|
||||
isActionable: true,
|
||||
hasCommittableSuggestion: true,
|
||||
hasBugFixSuggestion: false,
|
||||
hasCodeAlternative: false,
|
||||
confidence: "high",
|
||||
reason: "Contains GitHub inline committable suggestion (```suggestion)",
|
||||
};
|
||||
}
|
||||
|
||||
// Check for clear bug fix patterns (medium-high confidence)
|
||||
const matchedBugFixPattern = BUG_FIX_PATTERNS.find((pattern) =>
|
||||
pattern.test(commentBody),
|
||||
);
|
||||
if (matchedBugFixPattern) {
|
||||
// Higher confidence if also contains a code block
|
||||
const hasCodeBlock = CODE_ALTERNATIVE_PATTERNS[0].test(commentBody);
|
||||
return {
|
||||
isActionable: true,
|
||||
hasCommittableSuggestion: false,
|
||||
hasBugFixSuggestion: true,
|
||||
hasCodeAlternative: hasCodeBlock,
|
||||
confidence: hasCodeBlock ? "high" : "medium",
|
||||
reason: hasCodeBlock
|
||||
? "Contains clear bug fix suggestion with code example"
|
||||
: "Contains clear bug fix suggestion",
|
||||
};
|
||||
}
|
||||
|
||||
// Check for code alternatives (medium confidence)
|
||||
const matchedAlternativePattern = CODE_ALTERNATIVE_PATTERNS.find((pattern) =>
|
||||
pattern.test(commentBody),
|
||||
);
|
||||
if (matchedAlternativePattern) {
|
||||
return {
|
||||
isActionable: true,
|
||||
hasCommittableSuggestion: false,
|
||||
hasBugFixSuggestion: false,
|
||||
hasCodeAlternative: true,
|
||||
confidence: "medium",
|
||||
reason: "Contains code alternative or fix suggestion",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isActionable: false,
|
||||
hasCommittableSuggestion: false,
|
||||
hasBugFixSuggestion: false,
|
||||
hasCodeAlternative: false,
|
||||
confidence: "low",
|
||||
reason: "No actionable suggestion patterns detected",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a comment should be treated as actionable for autofix purposes,
|
||||
* even if it comes from a bot account like claude[bot].
|
||||
*
|
||||
* This is particularly useful for workflows that want to automatically apply
|
||||
* suggestions from code review comments.
|
||||
*
|
||||
* @param commentBody - The body of the PR comment
|
||||
* @param authorUsername - The username of the comment author
|
||||
* @returns Whether the comment should be treated as actionable
|
||||
*/
|
||||
export function isCommentActionableForAutofix(
|
||||
commentBody: string | undefined | null,
|
||||
authorUsername?: string,
|
||||
): boolean {
|
||||
const result = detectActionableSuggestion(commentBody);
|
||||
|
||||
// If it's already clearly actionable (high confidence), return true
|
||||
if (result.confidence === "high") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// For medium confidence, be more lenient
|
||||
if (result.confidence === "medium" && result.isActionable) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the suggested code from a GitHub inline committable suggestion block.
|
||||
*
|
||||
* @param commentBody - The body of the PR comment
|
||||
* @returns The suggested code content, or null if no suggestion block found
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const code = extractSuggestionCode("```suggestion\nconst x = 1;\n```");
|
||||
* // "const x = 1;"
|
||||
* ```
|
||||
*/
|
||||
export function extractSuggestionCode(
|
||||
commentBody: string | undefined | null,
|
||||
): string | null {
|
||||
if (!commentBody) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = commentBody.match(/```suggestion\b\n?([\s\S]*?)```/i);
|
||||
if (match && match[1] !== undefined) {
|
||||
return match[1].trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,309 +0,0 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
detectActionableSuggestion,
|
||||
isCommentActionableForAutofix,
|
||||
extractSuggestionCode,
|
||||
} from "../src/utils/detect-actionable-suggestion";
|
||||
|
||||
describe("detectActionableSuggestion", () => {
|
||||
describe("GitHub inline committable suggestions", () => {
|
||||
it("should detect suggestion blocks with high confidence", () => {
|
||||
const comment = `Here's a fix:
|
||||
\`\`\`suggestion
|
||||
const x = 1;
|
||||
\`\`\``;
|
||||
const result = detectActionableSuggestion(comment);
|
||||
expect(result.isActionable).toBe(true);
|
||||
expect(result.hasCommittableSuggestion).toBe(true);
|
||||
expect(result.confidence).toBe("high");
|
||||
expect(result.reason).toContain("committable suggestion");
|
||||
});
|
||||
|
||||
it("should detect suggestion blocks with multiple lines", () => {
|
||||
const comment = `\`\`\`suggestion
|
||||
function foo() {
|
||||
return bar();
|
||||
}
|
||||
\`\`\``;
|
||||
const result = detectActionableSuggestion(comment);
|
||||
expect(result.isActionable).toBe(true);
|
||||
expect(result.hasCommittableSuggestion).toBe(true);
|
||||
expect(result.confidence).toBe("high");
|
||||
});
|
||||
|
||||
it("should detect suggestion blocks case-insensitively", () => {
|
||||
const comment = `\`\`\`SUGGESTION
|
||||
const x = 1;
|
||||
\`\`\``;
|
||||
const result = detectActionableSuggestion(comment);
|
||||
expect(result.isActionable).toBe(true);
|
||||
expect(result.hasCommittableSuggestion).toBe(true);
|
||||
});
|
||||
|
||||
it("should not confuse regular code blocks with suggestion blocks", () => {
|
||||
const comment = `\`\`\`javascript
|
||||
const x = 1;
|
||||
\`\`\``;
|
||||
const result = detectActionableSuggestion(comment);
|
||||
expect(result.hasCommittableSuggestion).toBe(false);
|
||||
// But it should still detect the code alternative
|
||||
expect(result.hasCodeAlternative).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bug fix suggestions", () => {
|
||||
it('should detect "should be" patterns', () => {
|
||||
const comment = "This should be `const` instead of `let`";
|
||||
const result = detectActionableSuggestion(comment);
|
||||
expect(result.isActionable).toBe(true);
|
||||
expect(result.hasBugFixSuggestion).toBe(true);
|
||||
expect(result.confidence).toBe("medium");
|
||||
});
|
||||
|
||||
it('should detect "change to" patterns', () => {
|
||||
const comment = "Change this to use async/await";
|
||||
const result = detectActionableSuggestion(comment);
|
||||
expect(result.isActionable).toBe(true);
|
||||
expect(result.hasBugFixSuggestion).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect "replace with" patterns', () => {
|
||||
const comment = "Replace this with Array.from()";
|
||||
const result = detectActionableSuggestion(comment);
|
||||
expect(result.isActionable).toBe(true);
|
||||
expect(result.hasBugFixSuggestion).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect "use instead" patterns', () => {
|
||||
const comment = "Use this instead of the deprecated method";
|
||||
const result = detectActionableSuggestion(comment);
|
||||
expect(result.isActionable).toBe(true);
|
||||
expect(result.hasBugFixSuggestion).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect "instead of X, use Y" patterns', () => {
|
||||
const comment = "Instead of forEach, use map";
|
||||
const result = detectActionableSuggestion(comment);
|
||||
expect(result.isActionable).toBe(true);
|
||||
expect(result.hasBugFixSuggestion).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect "to fix this" patterns', () => {
|
||||
const comment = "To fix this, you need to add the await keyword";
|
||||
const result = detectActionableSuggestion(comment);
|
||||
expect(result.isActionable).toBe(true);
|
||||
expect(result.hasBugFixSuggestion).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect "the correct code is" patterns', () => {
|
||||
const comment = "The correct code would be: return null;";
|
||||
const result = detectActionableSuggestion(comment);
|
||||
expect(result.isActionable).toBe(true);
|
||||
expect(result.hasBugFixSuggestion).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect "missing semicolon" patterns', () => {
|
||||
const comment = "Missing a semicolon at the end";
|
||||
const result = detectActionableSuggestion(comment);
|
||||
expect(result.isActionable).toBe(true);
|
||||
expect(result.hasBugFixSuggestion).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect "typo" patterns', () => {
|
||||
const comment = "Typo here: teh should be the";
|
||||
const result = detectActionableSuggestion(comment);
|
||||
expect(result.isActionable).toBe(true);
|
||||
expect(result.hasBugFixSuggestion).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect "wrong type" patterns', () => {
|
||||
const comment = "Wrong type here, should be string not number";
|
||||
const result = detectActionableSuggestion(comment);
|
||||
expect(result.isActionable).toBe(true);
|
||||
expect(result.hasBugFixSuggestion).toBe(true);
|
||||
});
|
||||
|
||||
it("should have high confidence when bug fix suggestion includes code block", () => {
|
||||
const comment = `You should use const here:
|
||||
\`\`\`javascript
|
||||
const x = 1;
|
||||
\`\`\``;
|
||||
const result = detectActionableSuggestion(comment);
|
||||
expect(result.isActionable).toBe(true);
|
||||
expect(result.hasBugFixSuggestion).toBe(true);
|
||||
expect(result.hasCodeAlternative).toBe(true);
|
||||
expect(result.confidence).toBe("high");
|
||||
});
|
||||
});
|
||||
|
||||
describe("code alternatives", () => {
|
||||
it('should detect "try using" patterns', () => {
|
||||
const comment = "Try using Array.map() instead";
|
||||
const result = detectActionableSuggestion(comment);
|
||||
expect(result.isActionable).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect "here\'s the fix" patterns', () => {
|
||||
const comment = "Here's the fix for this issue";
|
||||
const result = detectActionableSuggestion(comment);
|
||||
expect(result.isActionable).toBe(true);
|
||||
});
|
||||
|
||||
it("should detect code blocks as potential alternatives", () => {
|
||||
const comment = `Try this approach:
|
||||
\`\`\`
|
||||
const result = [];
|
||||
\`\`\``;
|
||||
const result = detectActionableSuggestion(comment);
|
||||
expect(result.isActionable).toBe(true);
|
||||
expect(result.hasCodeAlternative).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("non-actionable comments", () => {
|
||||
it("should not flag general questions", () => {
|
||||
const comment = "Why is this returning undefined?";
|
||||
const result = detectActionableSuggestion(comment);
|
||||
expect(result.isActionable).toBe(false);
|
||||
expect(result.confidence).toBe("low");
|
||||
});
|
||||
|
||||
it("should not flag simple observations", () => {
|
||||
const comment = "This looks interesting";
|
||||
const result = detectActionableSuggestion(comment);
|
||||
expect(result.isActionable).toBe(false);
|
||||
});
|
||||
|
||||
it("should not flag approval comments", () => {
|
||||
const comment = "LGTM! :+1:";
|
||||
const result = detectActionableSuggestion(comment);
|
||||
expect(result.isActionable).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle empty comments", () => {
|
||||
const result = detectActionableSuggestion("");
|
||||
expect(result.isActionable).toBe(false);
|
||||
expect(result.reason).toContain("Empty");
|
||||
});
|
||||
|
||||
it("should handle null comments", () => {
|
||||
const result = detectActionableSuggestion(null);
|
||||
expect(result.isActionable).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle undefined comments", () => {
|
||||
const result = detectActionableSuggestion(undefined);
|
||||
expect(result.isActionable).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle comments with both suggestion block and bug fix language", () => {
|
||||
const comment = `This should be fixed. Here's the suggestion:
|
||||
\`\`\`suggestion
|
||||
const x = 1;
|
||||
\`\`\``;
|
||||
const result = detectActionableSuggestion(comment);
|
||||
// Suggestion block takes precedence (high confidence)
|
||||
expect(result.isActionable).toBe(true);
|
||||
expect(result.hasCommittableSuggestion).toBe(true);
|
||||
expect(result.confidence).toBe("high");
|
||||
});
|
||||
|
||||
it("should handle very long comments", () => {
|
||||
const longContent = "a".repeat(10000);
|
||||
const comment = `${longContent}
|
||||
\`\`\`suggestion
|
||||
const x = 1;
|
||||
\`\`\``;
|
||||
const result = detectActionableSuggestion(comment);
|
||||
expect(result.isActionable).toBe(true);
|
||||
expect(result.hasCommittableSuggestion).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle comments with special characters", () => {
|
||||
const comment =
|
||||
"You should be using `const` here! @#$%^&* Change this to `let`";
|
||||
const result = detectActionableSuggestion(comment);
|
||||
expect(result.isActionable).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isCommentActionableForAutofix", () => {
|
||||
it("should return true for high confidence suggestions", () => {
|
||||
const comment = `\`\`\`suggestion
|
||||
const x = 1;
|
||||
\`\`\``;
|
||||
expect(isCommentActionableForAutofix(comment)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for medium confidence suggestions", () => {
|
||||
const comment = "You should use const here instead of let";
|
||||
expect(isCommentActionableForAutofix(comment)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for non-actionable comments", () => {
|
||||
const comment = "This looks fine to me";
|
||||
expect(isCommentActionableForAutofix(comment)).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle bot authors correctly", () => {
|
||||
const comment = `\`\`\`suggestion
|
||||
const x = 1;
|
||||
\`\`\``;
|
||||
// Should still return true even for bot authors
|
||||
expect(isCommentActionableForAutofix(comment, "claude[bot]")).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle empty comments", () => {
|
||||
expect(isCommentActionableForAutofix("")).toBe(false);
|
||||
expect(isCommentActionableForAutofix(null)).toBe(false);
|
||||
expect(isCommentActionableForAutofix(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractSuggestionCode", () => {
|
||||
it("should extract code from suggestion block", () => {
|
||||
const comment = `Here's a fix:
|
||||
\`\`\`suggestion
|
||||
const x = 1;
|
||||
\`\`\``;
|
||||
expect(extractSuggestionCode(comment)).toBe("const x = 1;");
|
||||
});
|
||||
|
||||
it("should extract multi-line code from suggestion block", () => {
|
||||
const comment = `\`\`\`suggestion
|
||||
function foo() {
|
||||
return bar();
|
||||
}
|
||||
\`\`\``;
|
||||
expect(extractSuggestionCode(comment)).toBe(
|
||||
"function foo() {\n return bar();\n}",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle empty suggestion blocks", () => {
|
||||
const comment = `\`\`\`suggestion
|
||||
\`\`\``;
|
||||
expect(extractSuggestionCode(comment)).toBe("");
|
||||
});
|
||||
|
||||
it("should return null for comments without suggestion blocks", () => {
|
||||
const comment = "Just a regular comment";
|
||||
expect(extractSuggestionCode(comment)).toBe(null);
|
||||
});
|
||||
|
||||
it("should return null for empty comments", () => {
|
||||
expect(extractSuggestionCode("")).toBe(null);
|
||||
expect(extractSuggestionCode(null)).toBe(null);
|
||||
expect(extractSuggestionCode(undefined)).toBe(null);
|
||||
});
|
||||
|
||||
it("should not extract from regular code blocks", () => {
|
||||
const comment = `\`\`\`javascript
|
||||
const x = 1;
|
||||
\`\`\``;
|
||||
expect(extractSuggestionCode(comment)).toBe(null);
|
||||
});
|
||||
});
|
||||
@@ -145,12 +145,12 @@ describe("Agent Mode", () => {
|
||||
users: {
|
||||
getAuthenticated: mock(() =>
|
||||
Promise.resolve({
|
||||
data: { login: "test-user", id: 12345 },
|
||||
data: { login: "test-user", id: 12345, type: "User" },
|
||||
}),
|
||||
),
|
||||
getByUsername: mock(() =>
|
||||
Promise.resolve({
|
||||
data: { login: "test-user", id: 12345 },
|
||||
data: { login: "test-user", id: 12345, type: "User" },
|
||||
}),
|
||||
),
|
||||
},
|
||||
@@ -187,6 +187,65 @@ describe("Agent Mode", () => {
|
||||
process.env.GITHUB_REF_NAME = originalRefName;
|
||||
});
|
||||
|
||||
test("prepare method rejects bot actors without allowed_bots", async () => {
|
||||
const contextWithPrompts = createMockAutomationContext({
|
||||
eventName: "workflow_dispatch",
|
||||
});
|
||||
contextWithPrompts.actor = "claude[bot]";
|
||||
contextWithPrompts.inputs.allowedBots = "";
|
||||
|
||||
const mockOctokit = {
|
||||
rest: {
|
||||
users: {
|
||||
getByUsername: mock(() =>
|
||||
Promise.resolve({
|
||||
data: { login: "claude[bot]", id: 12345, type: "Bot" },
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
await expect(
|
||||
agentMode.prepare({
|
||||
context: contextWithPrompts,
|
||||
octokit: mockOctokit,
|
||||
githubToken: "test-token",
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"Workflow initiated by non-human actor: claude (type: Bot)",
|
||||
);
|
||||
});
|
||||
|
||||
test("prepare method allows bot actors when in allowed_bots list", async () => {
|
||||
const contextWithPrompts = createMockAutomationContext({
|
||||
eventName: "workflow_dispatch",
|
||||
});
|
||||
contextWithPrompts.actor = "dependabot[bot]";
|
||||
contextWithPrompts.inputs.allowedBots = "dependabot";
|
||||
|
||||
const mockOctokit = {
|
||||
rest: {
|
||||
users: {
|
||||
getByUsername: mock(() =>
|
||||
Promise.resolve({
|
||||
data: { login: "dependabot[bot]", id: 12345, type: "Bot" },
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
// Should not throw - bot is in allowed list
|
||||
await expect(
|
||||
agentMode.prepare({
|
||||
context: contextWithPrompts,
|
||||
octokit: mockOctokit,
|
||||
githubToken: "test-token",
|
||||
}),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
test("prepare method creates prompt file with correct content", async () => {
|
||||
const contextWithPrompts = createMockAutomationContext({
|
||||
eventName: "workflow_dispatch",
|
||||
@@ -199,12 +258,12 @@ describe("Agent Mode", () => {
|
||||
users: {
|
||||
getAuthenticated: mock(() =>
|
||||
Promise.resolve({
|
||||
data: { login: "test-user", id: 12345 },
|
||||
data: { login: "test-user", id: 12345, type: "User" },
|
||||
}),
|
||||
),
|
||||
getByUsername: mock(() =>
|
||||
Promise.resolve({
|
||||
data: { login: "test-user", id: 12345 },
|
||||
data: { login: "test-user", id: 12345, type: "User" },
|
||||
}),
|
||||
),
|
||||
},
|
||||
|
||||
@@ -55,6 +55,47 @@ describe("SSH Signing", () => {
|
||||
expect(permissions).toBe(0o600);
|
||||
});
|
||||
|
||||
test("should normalize key to have trailing newline", async () => {
|
||||
// ssh-keygen requires a trailing newline to parse the key
|
||||
const keyWithoutNewline =
|
||||
"-----BEGIN OPENSSH PRIVATE KEY-----\ntest-key-content\n-----END OPENSSH PRIVATE KEY-----";
|
||||
const keyWithNewline = keyWithoutNewline + "\n";
|
||||
|
||||
// Create directory
|
||||
await mkdir(testSshDir, { recursive: true, mode: 0o700 });
|
||||
|
||||
// Normalize the key (same logic as setupSshSigning)
|
||||
const normalizedKey = keyWithoutNewline.endsWith("\n")
|
||||
? keyWithoutNewline
|
||||
: keyWithoutNewline + "\n";
|
||||
|
||||
await writeFile(testKeyPath, normalizedKey, { mode: 0o600 });
|
||||
|
||||
// Verify the written key ends with newline
|
||||
const keyContent = await readFile(testKeyPath, "utf-8");
|
||||
expect(keyContent).toBe(keyWithNewline);
|
||||
expect(keyContent.endsWith("\n")).toBe(true);
|
||||
});
|
||||
|
||||
test("should not add extra newline if key already has one", async () => {
|
||||
const keyWithNewline =
|
||||
"-----BEGIN OPENSSH PRIVATE KEY-----\ntest-key-content\n-----END OPENSSH PRIVATE KEY-----\n";
|
||||
|
||||
await mkdir(testSshDir, { recursive: true, mode: 0o700 });
|
||||
|
||||
// Normalize the key (same logic as setupSshSigning)
|
||||
const normalizedKey = keyWithNewline.endsWith("\n")
|
||||
? keyWithNewline
|
||||
: keyWithNewline + "\n";
|
||||
|
||||
await writeFile(testKeyPath, normalizedKey, { mode: 0o600 });
|
||||
|
||||
// Verify no double newline
|
||||
const keyContent = await readFile(testKeyPath, "utf-8");
|
||||
expect(keyContent).toBe(keyWithNewline);
|
||||
expect(keyContent.endsWith("\n\n")).toBe(false);
|
||||
});
|
||||
|
||||
test("should create .ssh directory with secure permissions", async () => {
|
||||
// Clean up first
|
||||
await rm(testSshDir, { recursive: true, force: true });
|
||||
|
||||
Reference in New Issue
Block a user