mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-23 15:04:13 +08:00
Compare commits
30 Commits
v1.0.22
...
feature/me
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8e8fdc051 | ||
|
|
e5b07416ea | ||
|
|
b89827f8d1 | ||
|
|
7145c3e051 | ||
|
|
db4548b597 | ||
|
|
0d19335299 | ||
|
|
95be46676d | ||
|
|
f98c1a5aa8 | ||
|
|
b0c32b65f9 | ||
|
|
d7b6d50442 | ||
|
|
f375cabfab | ||
|
|
9acae263e7 | ||
|
|
67bf0594ce | ||
|
|
b58533dbe0 | ||
|
|
bda9bf08de | ||
|
|
79b343c094 | ||
|
|
609c388361 | ||
|
|
f0c8eb2980 | ||
|
|
68a0348c20 | ||
|
|
dc06a34646 | ||
|
|
a3bb51dac1 | ||
|
|
6610520549 | ||
|
|
e2eb96f51d | ||
|
|
05c95aed79 | ||
|
|
bb4a3f68f7 | ||
|
|
2acd1f7011 | ||
|
|
469fc9c1a4 | ||
|
|
90da6b6e15 | ||
|
|
752ba96ea1 | ||
|
|
66bf95c07f |
132
.github/workflows/bump-claude-code-version.yml
vendored
132
.github/workflows/bump-claude-code-version.yml
vendored
@@ -1,132 +0,0 @@
|
|||||||
name: Bump Claude Code Version
|
|
||||||
|
|
||||||
on:
|
|
||||||
repository_dispatch:
|
|
||||||
types: [bump_claude_code_version]
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
version:
|
|
||||||
description: "Claude Code version to bump to"
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
bump-version:
|
|
||||||
name: Bump Claude Code Version
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
environment: release
|
|
||||||
timeout-minutes: 5
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.RELEASE_PAT }}
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Get version from event payload
|
|
||||||
id: get_version
|
|
||||||
run: |
|
|
||||||
# Get version from either repository_dispatch or workflow_dispatch
|
|
||||||
if [ "${{ github.event_name }}" = "repository_dispatch" ]; then
|
|
||||||
NEW_VERSION="${CLIENT_PAYLOAD_VERSION}"
|
|
||||||
else
|
|
||||||
NEW_VERSION="${INPUT_VERSION}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Sanitize the version to avoid issues enabled by problematic characters
|
|
||||||
NEW_VERSION=$(echo "$NEW_VERSION" | tr -d '`;$(){}[]|&<>' | tr -s ' ' '-')
|
|
||||||
|
|
||||||
if [ -z "$NEW_VERSION" ]; then
|
|
||||||
echo "Error: version not provided"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_ENV
|
|
||||||
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
|
|
||||||
env:
|
|
||||||
INPUT_VERSION: ${{ inputs.version }}
|
|
||||||
CLIENT_PAYLOAD_VERSION: ${{ github.event.client_payload.version }}
|
|
||||||
|
|
||||||
- name: Create branch and update base-action/action.yml
|
|
||||||
run: |
|
|
||||||
# Variables
|
|
||||||
TIMESTAMP=$(date +'%Y%m%d-%H%M%S')
|
|
||||||
BRANCH_NAME="bump-claude-code-${{ env.NEW_VERSION }}-$TIMESTAMP"
|
|
||||||
|
|
||||||
echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
# Get the default branch
|
|
||||||
DEFAULT_BRANCH=$(gh api repos/${GITHUB_REPOSITORY} --jq '.default_branch')
|
|
||||||
echo "DEFAULT_BRANCH=$DEFAULT_BRANCH" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
# Get the latest commit SHA from the default branch
|
|
||||||
BASE_SHA=$(gh api repos/${GITHUB_REPOSITORY}/git/refs/heads/$DEFAULT_BRANCH --jq '.object.sha')
|
|
||||||
|
|
||||||
# Create a new branch
|
|
||||||
gh api \
|
|
||||||
--method POST \
|
|
||||||
repos/${GITHUB_REPOSITORY}/git/refs \
|
|
||||||
-f ref="refs/heads/$BRANCH_NAME" \
|
|
||||||
-f sha="$BASE_SHA"
|
|
||||||
|
|
||||||
# Get the current base-action/action.yml content
|
|
||||||
ACTION_CONTENT=$(gh api repos/${GITHUB_REPOSITORY}/contents/base-action/action.yml?ref=$DEFAULT_BRANCH --jq '.content' | base64 -d)
|
|
||||||
|
|
||||||
# Update the Claude Code version in the npm install command
|
|
||||||
UPDATED_CONTENT=$(echo "$ACTION_CONTENT" | sed -E "s/(npm install -g @anthropic-ai\/claude-code@)[0-9]+\.[0-9]+\.[0-9]+/\1${{ env.NEW_VERSION }}/")
|
|
||||||
|
|
||||||
# Verify the change would be made
|
|
||||||
if ! echo "$UPDATED_CONTENT" | grep -q "@anthropic-ai/claude-code@${{ env.NEW_VERSION }}"; then
|
|
||||||
echo "Error: Failed to update Claude Code version in content"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Get the current SHA of base-action/action.yml for the update API call
|
|
||||||
FILE_SHA=$(gh api repos/${GITHUB_REPOSITORY}/contents/base-action/action.yml?ref=$DEFAULT_BRANCH --jq '.sha')
|
|
||||||
|
|
||||||
# Create the updated base-action/action.yml content in base64
|
|
||||||
echo "$UPDATED_CONTENT" | base64 > action.yml.b64
|
|
||||||
|
|
||||||
# Commit the updated base-action/action.yml via GitHub API
|
|
||||||
gh api \
|
|
||||||
--method PUT \
|
|
||||||
repos/${GITHUB_REPOSITORY}/contents/base-action/action.yml \
|
|
||||||
-f message="chore: bump Claude Code version to ${{ env.NEW_VERSION }}" \
|
|
||||||
-F content=@action.yml.b64 \
|
|
||||||
-f sha="$FILE_SHA" \
|
|
||||||
-f branch="$BRANCH_NAME"
|
|
||||||
|
|
||||||
echo "Successfully created branch and updated Claude Code version to ${{ env.NEW_VERSION }}"
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.RELEASE_PAT }}
|
|
||||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
|
||||||
|
|
||||||
- name: Create Pull Request
|
|
||||||
run: |
|
|
||||||
# Determine trigger type for PR body
|
|
||||||
if [ "${{ github.event_name }}" = "repository_dispatch" ]; then
|
|
||||||
TRIGGER_INFO="repository dispatch event"
|
|
||||||
else
|
|
||||||
TRIGGER_INFO="manual workflow dispatch by @${GITHUB_ACTOR}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create PR body with proper YAML escape
|
|
||||||
printf -v PR_BODY "## Bump Claude Code to ${{ env.NEW_VERSION }}\n\nThis PR updates the Claude Code version in base-action/action.yml to ${{ env.NEW_VERSION }}.\n\n### Changes\n- Updated Claude Code version from current to \`${{ env.NEW_VERSION }}\`\n\n### Triggered by\n- $TRIGGER_INFO\n\n🤖 This PR was automatically created by the bump-claude-code-version workflow."
|
|
||||||
|
|
||||||
echo "Creating PR with gh pr create command"
|
|
||||||
PR_URL=$(gh pr create \
|
|
||||||
--repo "${GITHUB_REPOSITORY}" \
|
|
||||||
--title "chore: bump Claude Code version to ${{ env.NEW_VERSION }}" \
|
|
||||||
--body "$PR_BODY" \
|
|
||||||
--base "${DEFAULT_BRANCH}" \
|
|
||||||
--head "${BRANCH_NAME}")
|
|
||||||
|
|
||||||
echo "PR created successfully: $PR_URL"
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.RELEASE_PAT }}
|
|
||||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
|
||||||
GITHUB_ACTOR: ${{ github.actor }}
|
|
||||||
DEFAULT_BRANCH: ${{ env.DEFAULT_BRANCH }}
|
|
||||||
BRANCH_NAME: ${{ env.BRANCH_NAME }}
|
|
||||||
2
.github/workflows/claude.yml
vendored
2
.github/workflows/claude.yml
vendored
@@ -36,4 +36,4 @@ jobs:
|
|||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
claude_args: |
|
claude_args: |
|
||||||
--allowedTools "Bash(bun install),Bash(bun test:*),Bash(bun run format),Bash(bun typecheck)"
|
--allowedTools "Bash(bun install),Bash(bun test:*),Bash(bun run format),Bash(bun typecheck)"
|
||||||
--model "claude-opus-4-1-20250805"
|
--model "claude-opus-4-5"
|
||||||
|
|||||||
4
.github/workflows/sync-base-action.yml
vendored
4
.github/workflows/sync-base-action.yml
vendored
@@ -94,5 +94,5 @@ jobs:
|
|||||||
echo "✅ Successfully synced \`base-action\` directory to [anthropics/claude-code-base-action](https://github.com/anthropics/claude-code-base-action)" >> $GITHUB_STEP_SUMMARY
|
echo "✅ Successfully synced \`base-action\` directory to [anthropics/claude-code-base-action](https://github.com/anthropics/claude-code-base-action)" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "- **Source commit**: [\`${GITHUB_SHA:0:7}\`](https://github.com/anthropics/claude-code-action/commit/${GITHUB_SHA})" >> $GITHUB_STEP_SUMMARY
|
echo "- **Source commit**: [\`${GITHUB_SHA:0:7}\`](https://github.com/anthropics/claude-code-action/commit/${GITHUB_SHA})" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "- **Triggered by**: ${{ github.event_name }}" >> $GITHUB_STEP_SUMMARY
|
echo "- **Triggered by**: $GITHUB_EVENT_NAME" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "- **Actor**: @${{ github.actor }}" >> $GITHUB_STEP_SUMMARY
|
echo "- **Actor**: @$GITHUB_ACTOR" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|||||||
58
.github/workflows/test-base-action.yml
vendored
58
.github/workflows/test-base-action.yml
vendored
@@ -118,3 +118,61 @@ jobs:
|
|||||||
echo "❌ Execution log file not found"
|
echo "❌ Execution log file not found"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
||||||
|
|||||||
133
action.yml
133
action.yml
@@ -120,13 +120,16 @@ outputs:
|
|||||||
value: ${{ steps.claude-code.outputs.execution_file }}
|
value: ${{ steps.claude-code.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-code.outputs.CLAUDE_BRANCH }}
|
||||||
github_token:
|
github_token:
|
||||||
description: "The GitHub token used by the action (Claude App token if available)"
|
description: "The GitHub token used by the action (Claude App token if available)"
|
||||||
value: ${{ steps.prepare.outputs.github_token }}
|
value: ${{ steps.claude-code.outputs.GITHUB_TOKEN }}
|
||||||
structured_output:
|
structured_output:
|
||||||
description: "JSON string containing all structured output fields when --json-schema is provided in claude_args. Use fromJSON() to parse: fromJSON(steps.id.outputs.structured_output).field_name"
|
description: "JSON string containing all structured output fields when --json-schema is provided in claude_args. Use fromJSON() to parse: fromJSON(steps.id.outputs.structured_output).field_name"
|
||||||
value: ${{ steps.claude-code.outputs.structured_output }}
|
value: ${{ steps.claude-code.outputs.structured_output }}
|
||||||
|
session_id:
|
||||||
|
description: "The Claude Code session ID that can be used with --resume to continue this conversation"
|
||||||
|
value: ${{ steps.claude-code.outputs.session_id }}
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
using: "composite"
|
using: "composite"
|
||||||
@@ -140,24 +143,66 @@ runs:
|
|||||||
- name: Setup Custom Bun Path
|
- name: Setup Custom Bun Path
|
||||||
if: inputs.path_to_bun_executable != ''
|
if: inputs.path_to_bun_executable != ''
|
||||||
shell: bash
|
shell: bash
|
||||||
|
env:
|
||||||
|
PATH_TO_BUN_EXECUTABLE: ${{ inputs.path_to_bun_executable }}
|
||||||
run: |
|
run: |
|
||||||
echo "Using custom Bun executable: ${{ inputs.path_to_bun_executable }}"
|
echo "Using custom Bun executable: $PATH_TO_BUN_EXECUTABLE"
|
||||||
# Add the directory containing the custom executable to PATH
|
# Add the directory containing the custom executable to PATH
|
||||||
BUN_DIR=$(dirname "${{ inputs.path_to_bun_executable }}")
|
BUN_DIR=$(dirname "$PATH_TO_BUN_EXECUTABLE")
|
||||||
echo "$BUN_DIR" >> "$GITHUB_PATH"
|
echo "$BUN_DIR" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
shell: bash
|
shell: bash
|
||||||
|
env:
|
||||||
|
PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
|
||||||
run: |
|
run: |
|
||||||
|
# Install main action dependencies
|
||||||
cd ${GITHUB_ACTION_PATH}
|
cd ${GITHUB_ACTION_PATH}
|
||||||
bun install
|
bun install
|
||||||
|
|
||||||
- name: Prepare action
|
# Install base-action dependencies
|
||||||
id: prepare
|
echo "Installing base-action dependencies..."
|
||||||
|
cd ${GITHUB_ACTION_PATH}/base-action
|
||||||
|
bun install
|
||||||
|
echo "Base-action dependencies installed"
|
||||||
|
cd -
|
||||||
|
|
||||||
|
# Install Claude Code if no custom executable is provided
|
||||||
|
if [ -z "$PATH_TO_CLAUDE_CODE_EXECUTABLE" ]; then
|
||||||
|
CLAUDE_CODE_VERSION="2.0.74"
|
||||||
|
echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..."
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
echo "Installation attempt $attempt..."
|
||||||
|
if command -v timeout &> /dev/null; then
|
||||||
|
timeout 120 bash -c "curl -fsSL https://claude.ai/install.sh | bash -s -- $CLAUDE_CODE_VERSION" && break
|
||||||
|
else
|
||||||
|
curl -fsSL https://claude.ai/install.sh | bash -s -- "$CLAUDE_CODE_VERSION" && break
|
||||||
|
fi
|
||||||
|
if [ $attempt -eq 3 ]; then
|
||||||
|
echo "Failed to install Claude Code after 3 attempts"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Installation failed, retrying..."
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
echo "Claude Code installed successfully"
|
||||||
|
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||||
|
else
|
||||||
|
echo "Using custom Claude Code executable: $PATH_TO_CLAUDE_CODE_EXECUTABLE"
|
||||||
|
# Add the directory containing the custom executable to PATH
|
||||||
|
CLAUDE_DIR=$(dirname "$PATH_TO_CLAUDE_CODE_EXECUTABLE")
|
||||||
|
echo "$CLAUDE_DIR" >> "$GITHUB_PATH"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Unified step: prepare + setup + run Claude
|
||||||
|
- name: Run Claude Code
|
||||||
|
id: claude-code
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
bun run ${GITHUB_ACTION_PATH}/src/entrypoints/prepare.ts
|
bun run ${GITHUB_ACTION_PATH}/src/entrypoints/run.ts
|
||||||
env:
|
env:
|
||||||
|
# Action configuration
|
||||||
|
CLAUDE_CODE_ACTION: "1"
|
||||||
MODE: ${{ inputs.mode }}
|
MODE: ${{ inputs.mode }}
|
||||||
PROMPT: ${{ inputs.prompt }}
|
PROMPT: ${{ inputs.prompt }}
|
||||||
TRIGGER_PHRASE: ${{ inputs.trigger_phrase }}
|
TRIGGER_PHRASE: ${{ inputs.trigger_phrase }}
|
||||||
@@ -179,67 +224,16 @@ runs:
|
|||||||
CLAUDE_ARGS: ${{ inputs.claude_args }}
|
CLAUDE_ARGS: ${{ inputs.claude_args }}
|
||||||
ALL_INPUTS: ${{ toJson(inputs) }}
|
ALL_INPUTS: ${{ toJson(inputs) }}
|
||||||
|
|
||||||
- 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 if no custom executable is provided
|
|
||||||
if [ -z "${{ inputs.path_to_claude_code_executable }}" ]; then
|
|
||||||
CLAUDE_CODE_VERSION="2.0.55"
|
|
||||||
echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..."
|
|
||||||
for attempt in 1 2 3; do
|
|
||||||
echo "Installation attempt $attempt..."
|
|
||||||
if command -v timeout &> /dev/null; then
|
|
||||||
timeout 120 bash -c "curl -fsSL https://claude.ai/install.sh | bash -s -- $CLAUDE_CODE_VERSION" && break
|
|
||||||
else
|
|
||||||
curl -fsSL https://claude.ai/install.sh | bash -s -- "$CLAUDE_CODE_VERSION" && break
|
|
||||||
fi
|
|
||||||
if [ $attempt -eq 3 ]; then
|
|
||||||
echo "Failed to install Claude Code after 3 attempts"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "Installation failed, retrying..."
|
|
||||||
sleep 5
|
|
||||||
done
|
|
||||||
echo "Claude Code installed successfully"
|
|
||||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
|
||||||
else
|
|
||||||
echo "Using custom Claude Code executable: ${{ inputs.path_to_claude_code_executable }}"
|
|
||||||
# Add the directory containing the custom executable to PATH
|
|
||||||
CLAUDE_DIR=$(dirname "${{ inputs.path_to_claude_code_executable }}")
|
|
||||||
echo "$CLAUDE_DIR" >> "$GITHUB_PATH"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Run Claude Code
|
|
||||||
id: claude-code
|
|
||||||
if: steps.prepare.outputs.contains_trigger == 'true'
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
|
|
||||||
# Run the base-action
|
|
||||||
bun run ${GITHUB_ACTION_PATH}/base-action/src/index.ts
|
|
||||||
env:
|
|
||||||
# Base-action inputs
|
# Base-action inputs
|
||||||
CLAUDE_CODE_ACTION: "1"
|
|
||||||
INPUT_PROMPT_FILE: ${{ runner.temp }}/claude-prompts/claude-prompt.txt
|
|
||||||
INPUT_SETTINGS: ${{ inputs.settings }}
|
INPUT_SETTINGS: ${{ inputs.settings }}
|
||||||
INPUT_CLAUDE_ARGS: ${{ steps.prepare.outputs.claude_args }}
|
INPUT_CLAUDE_ARGS: ${{ inputs.claude_args }}
|
||||||
INPUT_EXPERIMENTAL_SLASH_COMMANDS_DIR: ${{ github.action_path }}/slash-commands
|
INPUT_EXPERIMENTAL_SLASH_COMMANDS_DIR: ${{ github.action_path }}/slash-commands
|
||||||
INPUT_ACTION_INPUTS_PRESENT: ${{ steps.prepare.outputs.action_inputs_present }}
|
|
||||||
INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
|
INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
|
||||||
INPUT_PATH_TO_BUN_EXECUTABLE: ${{ inputs.path_to_bun_executable }}
|
INPUT_PATH_TO_BUN_EXECUTABLE: ${{ inputs.path_to_bun_executable }}
|
||||||
INPUT_SHOW_FULL_OUTPUT: ${{ inputs.show_full_output }}
|
INPUT_SHOW_FULL_OUTPUT: ${{ inputs.show_full_output }}
|
||||||
INPUT_PLUGINS: ${{ inputs.plugins }}
|
INPUT_PLUGINS: ${{ inputs.plugins }}
|
||||||
INPUT_PLUGIN_MARKETPLACES: ${{ inputs.plugin_marketplaces }}
|
INPUT_PLUGIN_MARKETPLACES: ${{ inputs.plugin_marketplaces }}
|
||||||
|
|
||||||
# Model configuration
|
|
||||||
GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }}
|
|
||||||
NODE_VERSION: ${{ env.NODE_VERSION }}
|
NODE_VERSION: ${{ env.NODE_VERSION }}
|
||||||
DETAILED_PERMISSION_MESSAGES: "1"
|
DETAILED_PERMISSION_MESSAGES: "1"
|
||||||
|
|
||||||
@@ -279,32 +273,33 @@ runs:
|
|||||||
ANTHROPIC_DEFAULT_OPUS_MODEL: ${{ env.ANTHROPIC_DEFAULT_OPUS_MODEL }}
|
ANTHROPIC_DEFAULT_OPUS_MODEL: ${{ env.ANTHROPIC_DEFAULT_OPUS_MODEL }}
|
||||||
|
|
||||||
- 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-code.outputs.contains_trigger == 'true' && steps.claude-code.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-code.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-code.outputs.GITHUB_TOKEN }}
|
||||||
|
GH_TOKEN: ${{ steps.claude-code.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-code.outputs.CLAUDE_BRANCH }}
|
||||||
IS_PR: ${{ github.event.issue.pull_request != null || github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review_comment' }}
|
IS_PR: ${{ github.event.issue.pull_request != null || github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review_comment' }}
|
||||||
BASE_BRANCH: ${{ steps.prepare.outputs.BASE_BRANCH }}
|
BASE_BRANCH: ${{ steps.claude-code.outputs.BASE_BRANCH }}
|
||||||
CLAUDE_SUCCESS: ${{ steps.claude-code.outputs.conclusion == 'success' }}
|
CLAUDE_SUCCESS: ${{ steps.claude-code.outputs.conclusion == 'success' }}
|
||||||
OUTPUT_FILE: ${{ steps.claude-code.outputs.execution_file || '' }}
|
OUTPUT_FILE: ${{ steps.claude-code.outputs.execution_file || '' }}
|
||||||
TRIGGER_USERNAME: ${{ github.event.comment.user.login || github.event.issue.user.login || github.event.pull_request.user.login || github.event.sender.login || github.triggering_actor || github.actor || '' }}
|
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-code.outcome == 'success' }}
|
||||||
PREPARE_ERROR: ${{ steps.prepare.outputs.prepare_error || '' }}
|
PREPARE_ERROR: ${{ steps.claude-code.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 }}
|
||||||
TRACK_PROGRESS: ${{ inputs.track_progress }}
|
TRACK_PROGRESS: ${{ inputs.track_progress }}
|
||||||
|
|
||||||
- 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-code.outputs.contains_trigger == 'true' && steps.claude-code.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
|
||||||
@@ -321,12 +316,12 @@ runs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Revoke app token
|
- name: Revoke app token
|
||||||
if: always() && inputs.github_token == '' && steps.prepare.outputs.skipped_due_to_workflow_validation_mismatch != 'true'
|
if: always() && inputs.github_token == '' && steps.claude-code.outputs.skipped_due_to_workflow_validation_mismatch != 'true'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
curl -L \
|
curl -L \
|
||||||
-X DELETE \
|
-X DELETE \
|
||||||
-H "Accept: application/vnd.github+json" \
|
-H "Accept: application/vnd.github+json" \
|
||||||
-H "Authorization: Bearer ${{ steps.prepare.outputs.GITHUB_TOKEN }}" \
|
-H "Authorization: Bearer ${{ steps.claude-code.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
|
||||||
|
|||||||
@@ -82,6 +82,9 @@ outputs:
|
|||||||
structured_output:
|
structured_output:
|
||||||
description: "JSON string containing all structured output fields when --json-schema is provided in claude_args (use fromJSON() or jq to parse)"
|
description: "JSON string containing all structured output fields when --json-schema is provided in claude_args (use fromJSON() or jq to parse)"
|
||||||
value: ${{ steps.run_claude.outputs.structured_output }}
|
value: ${{ steps.run_claude.outputs.structured_output }}
|
||||||
|
session_id:
|
||||||
|
description: "The Claude Code session ID that can be used with --resume to continue this conversation"
|
||||||
|
value: ${{ steps.run_claude.outputs.session_id }}
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
using: "composite"
|
using: "composite"
|
||||||
@@ -101,10 +104,12 @@ runs:
|
|||||||
- name: Setup Custom Bun Path
|
- name: Setup Custom Bun Path
|
||||||
if: inputs.path_to_bun_executable != ''
|
if: inputs.path_to_bun_executable != ''
|
||||||
shell: bash
|
shell: bash
|
||||||
|
env:
|
||||||
|
PATH_TO_BUN_EXECUTABLE: ${{ inputs.path_to_bun_executable }}
|
||||||
run: |
|
run: |
|
||||||
echo "Using custom Bun executable: ${{ inputs.path_to_bun_executable }}"
|
echo "Using custom Bun executable: $PATH_TO_BUN_EXECUTABLE"
|
||||||
# Add the directory containing the custom executable to PATH
|
# Add the directory containing the custom executable to PATH
|
||||||
BUN_DIR=$(dirname "${{ inputs.path_to_bun_executable }}")
|
BUN_DIR=$(dirname "$PATH_TO_BUN_EXECUTABLE")
|
||||||
echo "$BUN_DIR" >> "$GITHUB_PATH"
|
echo "$BUN_DIR" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
@@ -115,9 +120,11 @@ runs:
|
|||||||
|
|
||||||
- name: Install Claude Code
|
- name: Install Claude Code
|
||||||
shell: bash
|
shell: bash
|
||||||
|
env:
|
||||||
|
PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
|
||||||
run: |
|
run: |
|
||||||
if [ -z "${{ inputs.path_to_claude_code_executable }}" ]; then
|
if [ -z "$PATH_TO_CLAUDE_CODE_EXECUTABLE" ]; then
|
||||||
CLAUDE_CODE_VERSION="2.0.55"
|
CLAUDE_CODE_VERSION="2.0.74"
|
||||||
echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..."
|
echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..."
|
||||||
for attempt in 1 2 3; do
|
for attempt in 1 2 3; do
|
||||||
echo "Installation attempt $attempt..."
|
echo "Installation attempt $attempt..."
|
||||||
@@ -135,9 +142,9 @@ runs:
|
|||||||
done
|
done
|
||||||
echo "Claude Code installed successfully"
|
echo "Claude Code installed successfully"
|
||||||
else
|
else
|
||||||
echo "Using custom Claude Code executable: ${{ inputs.path_to_claude_code_executable }}"
|
echo "Using custom Claude Code executable: $PATH_TO_CLAUDE_CODE_EXECUTABLE"
|
||||||
# Add the directory containing the custom executable to PATH
|
# Add the directory containing the custom executable to PATH
|
||||||
CLAUDE_DIR=$(dirname "${{ inputs.path_to_claude_code_executable }}")
|
CLAUDE_DIR=$(dirname "$PATH_TO_CLAUDE_CODE_EXECUTABLE")
|
||||||
echo "$CLAUDE_DIR" >> "$GITHUB_PATH"
|
echo "$CLAUDE_DIR" >> "$GITHUB_PATH"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
{
|
{
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 0,
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@anthropic-ai/claude-code-base-action",
|
"name": "@anthropic-ai/claude-code-base-action",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "^1.10.1",
|
"@actions/core": "^1.10.1",
|
||||||
|
"@anthropic-ai/claude-agent-sdk": "^0.1.74",
|
||||||
"shell-quote": "^1.8.3",
|
"shell-quote": "^1.8.3",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -25,8 +27,40 @@
|
|||||||
|
|
||||||
"@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="],
|
"@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.1.74", "", { "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": "^3.24.1 || ^4.0.0" } }, "sha512-d6H3Oo625WAG3BrBFKJsuSshi4f0amc0kTJTm83LRPPFxn9kfq58FX4Oxxt+RUD9N3QumW9sQSEDnri20/F4qQ=="],
|
||||||
|
|
||||||
"@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="],
|
"@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="],
|
||||||
|
|
||||||
|
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="],
|
||||||
|
|
||||||
|
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.0.5", "", { "os": "linux", "cpu": "arm" }, "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw=="],
|
||||||
|
|
||||||
|
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.0.5" }, "os": "linux", "cpu": "arm" }, "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ=="],
|
||||||
|
|
||||||
|
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA=="],
|
||||||
|
|
||||||
|
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA=="],
|
||||||
|
|
||||||
|
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g=="],
|
||||||
|
|
||||||
|
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw=="],
|
||||||
|
|
||||||
|
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
|
||||||
|
|
||||||
"@types/bun": ["@types/bun@1.2.19", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="],
|
"@types/bun": ["@types/bun@1.2.19", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="],
|
||||||
|
|
||||||
"@types/node": ["@types/node@20.19.9", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-cuVNgarYWZqxRJDQHEB58GEONhOK79QVR/qYx4S7kcUObQvUwvFnYxJuuHUKm2aieN9X3yZB4LZsuYNU1Qphsw=="],
|
"@types/node": ["@types/node@20.19.9", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-cuVNgarYWZqxRJDQHEB58GEONhOK79QVR/qYx4S7kcUObQvUwvFnYxJuuHUKm2aieN9X3yZB4LZsuYNU1Qphsw=="],
|
||||||
@@ -50,5 +84,7 @@
|
|||||||
"undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
|
"undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
|
||||||
|
|
||||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||||
|
|
||||||
|
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "^1.10.1",
|
"@actions/core": "^1.10.1",
|
||||||
|
"@anthropic-ai/claude-agent-sdk": "^0.1.74",
|
||||||
"shell-quote": "^1.8.3"
|
"shell-quote": "^1.8.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
13
base-action/src/lib.ts
Normal file
13
base-action/src/lib.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Library exports for the base-action.
|
||||||
|
* These functions can be imported directly by the main action
|
||||||
|
* to avoid file/output-based communication between steps.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { runClaudeWithSdk, runClaudeWithSdkFromFile } from "./run-claude-sdk";
|
||||||
|
export type { RunClaudeResult, PromptInput } from "./run-claude-sdk";
|
||||||
|
export { setupClaudeCodeSettings } from "./setup-claude-code-settings";
|
||||||
|
export { installPlugins } from "./install-plugins";
|
||||||
|
export { parseSdkOptions } from "./parse-sdk-options";
|
||||||
|
export type { ClaudeOptions } from "./run-claude";
|
||||||
|
export type { ParsedSdkOptions } from "./parse-sdk-options";
|
||||||
269
base-action/src/parse-sdk-options.ts
Normal file
269
base-action/src/parse-sdk-options.ts
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
import { parse as parseShellArgs } from "shell-quote";
|
||||||
|
import type { ClaudeOptions } from "./run-claude";
|
||||||
|
import type { Options as SdkOptions } from "@anthropic-ai/claude-agent-sdk";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of parsing ClaudeOptions for SDK usage
|
||||||
|
*/
|
||||||
|
export type ParsedSdkOptions = {
|
||||||
|
sdkOptions: SdkOptions;
|
||||||
|
showFullOutput: boolean;
|
||||||
|
hasJsonSchema: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Flags that should accumulate multiple values instead of overwriting
|
||||||
|
// Include both camelCase and hyphenated variants for CLI compatibility
|
||||||
|
const ACCUMULATING_FLAGS = new Set([
|
||||||
|
"allowedTools",
|
||||||
|
"allowed-tools",
|
||||||
|
"disallowedTools",
|
||||||
|
"disallowed-tools",
|
||||||
|
"mcp-config",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Delimiter used to join accumulated flag values
|
||||||
|
const ACCUMULATE_DELIMITER = "\x00";
|
||||||
|
|
||||||
|
type McpConfig = {
|
||||||
|
mcpServers?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge multiple MCP config values into a single config.
|
||||||
|
* Each config can be a JSON string or a file path.
|
||||||
|
* For JSON strings, mcpServers objects are merged.
|
||||||
|
* For file paths, they are kept as-is (user's file takes precedence and is used last).
|
||||||
|
*/
|
||||||
|
function mergeMcpConfigs(configValues: string[]): string {
|
||||||
|
const merged: McpConfig = { mcpServers: {} };
|
||||||
|
let lastFilePath: string | null = null;
|
||||||
|
|
||||||
|
for (const config of configValues) {
|
||||||
|
const trimmed = config.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
|
||||||
|
// Check if it's a JSON string (starts with {) or a file path
|
||||||
|
if (trimmed.startsWith("{")) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed) as McpConfig;
|
||||||
|
if (parsed.mcpServers) {
|
||||||
|
Object.assign(merged.mcpServers!, parsed.mcpServers);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If JSON parsing fails, treat as file path
|
||||||
|
lastFilePath = trimmed;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// It's a file path - store it to handle separately
|
||||||
|
lastFilePath = trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have file paths, we need to keep the merged JSON and let the file
|
||||||
|
// be handled separately. Since we can only return one value, merge what we can.
|
||||||
|
// If there's a file path, we need a different approach - read the file at runtime.
|
||||||
|
// For now, if there's a file path, we'll stringify the merged config.
|
||||||
|
// The action prepends its config as JSON, so we can safely merge inline JSON configs.
|
||||||
|
|
||||||
|
// If no inline configs were found (all file paths), return the last file path
|
||||||
|
if (Object.keys(merged.mcpServers!).length === 0 && lastFilePath) {
|
||||||
|
return lastFilePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: If user passes a file path, we cannot merge it at parse time since
|
||||||
|
// we don't have access to the file system here. The action's built-in MCP
|
||||||
|
// servers are always passed as inline JSON, so they will be merged.
|
||||||
|
// If user also passes inline JSON, it will be merged.
|
||||||
|
// If user passes a file path, they should ensure it includes all needed servers.
|
||||||
|
|
||||||
|
return JSON.stringify(merged);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse claudeArgs string into extraArgs record for SDK pass-through
|
||||||
|
* The SDK/CLI will handle --mcp-config, --json-schema, etc.
|
||||||
|
* For allowedTools and disallowedTools, multiple occurrences are accumulated (null-char joined).
|
||||||
|
* Accumulating flags also consume all consecutive non-flag values
|
||||||
|
* (e.g., --allowed-tools "Tool1" "Tool2" "Tool3" captures all three).
|
||||||
|
*/
|
||||||
|
function parseClaudeArgsToExtraArgs(
|
||||||
|
claudeArgs?: string,
|
||||||
|
): Record<string, string | null> {
|
||||||
|
if (!claudeArgs?.trim()) return {};
|
||||||
|
|
||||||
|
const result: Record<string, string | null> = {};
|
||||||
|
const args = parseShellArgs(claudeArgs).filter(
|
||||||
|
(arg): arg is string => typeof arg === "string",
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
const arg = args[i];
|
||||||
|
if (arg?.startsWith("--")) {
|
||||||
|
const flag = arg.slice(2);
|
||||||
|
const nextArg = args[i + 1];
|
||||||
|
|
||||||
|
// Check if next arg is a value (not another flag)
|
||||||
|
if (nextArg && !nextArg.startsWith("--")) {
|
||||||
|
// For accumulating flags, consume all consecutive non-flag values
|
||||||
|
// This handles: --allowed-tools "Tool1" "Tool2" "Tool3"
|
||||||
|
if (ACCUMULATING_FLAGS.has(flag)) {
|
||||||
|
const values: string[] = [];
|
||||||
|
while (i + 1 < args.length && !args[i + 1]?.startsWith("--")) {
|
||||||
|
i++;
|
||||||
|
values.push(args[i]!);
|
||||||
|
}
|
||||||
|
const joinedValues = values.join(ACCUMULATE_DELIMITER);
|
||||||
|
if (result[flag]) {
|
||||||
|
result[flag] =
|
||||||
|
`${result[flag]}${ACCUMULATE_DELIMITER}${joinedValues}`;
|
||||||
|
} else {
|
||||||
|
result[flag] = joinedValues;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result[flag] = nextArg;
|
||||||
|
i++; // Skip the value
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result[flag] = null; // Boolean flag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse ClaudeOptions into SDK-compatible options
|
||||||
|
* Uses extraArgs for CLI pass-through instead of duplicating option parsing
|
||||||
|
*/
|
||||||
|
export function parseSdkOptions(options: ClaudeOptions): ParsedSdkOptions {
|
||||||
|
// Determine output verbosity
|
||||||
|
const isDebugMode = process.env.ACTIONS_STEP_DEBUG === "true";
|
||||||
|
const showFullOutput = options.showFullOutput === "true" || isDebugMode;
|
||||||
|
|
||||||
|
// Parse claudeArgs into extraArgs for CLI pass-through
|
||||||
|
const extraArgs = parseClaudeArgsToExtraArgs(options.claudeArgs);
|
||||||
|
|
||||||
|
// Detect if --json-schema is present (for hasJsonSchema flag)
|
||||||
|
const hasJsonSchema = "json-schema" in extraArgs;
|
||||||
|
|
||||||
|
// Extract and merge allowedTools from all sources:
|
||||||
|
// 1. From extraArgs (parsed from claudeArgs - contains tag mode's tools)
|
||||||
|
// - Check both camelCase (--allowedTools) and hyphenated (--allowed-tools) variants
|
||||||
|
// 2. From options.allowedTools (direct input - may be undefined)
|
||||||
|
// This prevents duplicate flags being overwritten when claudeArgs contains --allowedTools
|
||||||
|
const allowedToolsValues = [
|
||||||
|
extraArgs["allowedTools"],
|
||||||
|
extraArgs["allowed-tools"],
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(ACCUMULATE_DELIMITER);
|
||||||
|
const extraArgsAllowedTools = allowedToolsValues
|
||||||
|
? allowedToolsValues
|
||||||
|
.split(ACCUMULATE_DELIMITER)
|
||||||
|
.flatMap((v) => v.split(","))
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const directAllowedTools = options.allowedTools
|
||||||
|
? options.allowedTools.split(",").map((t) => t.trim())
|
||||||
|
: [];
|
||||||
|
const mergedAllowedTools = [
|
||||||
|
...new Set([...extraArgsAllowedTools, ...directAllowedTools]),
|
||||||
|
];
|
||||||
|
delete extraArgs["allowedTools"];
|
||||||
|
delete extraArgs["allowed-tools"];
|
||||||
|
|
||||||
|
// Same for disallowedTools - check both camelCase and hyphenated variants
|
||||||
|
const disallowedToolsValues = [
|
||||||
|
extraArgs["disallowedTools"],
|
||||||
|
extraArgs["disallowed-tools"],
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(ACCUMULATE_DELIMITER);
|
||||||
|
const extraArgsDisallowedTools = disallowedToolsValues
|
||||||
|
? disallowedToolsValues
|
||||||
|
.split(ACCUMULATE_DELIMITER)
|
||||||
|
.flatMap((v) => v.split(","))
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const directDisallowedTools = options.disallowedTools
|
||||||
|
? options.disallowedTools.split(",").map((t) => t.trim())
|
||||||
|
: [];
|
||||||
|
const mergedDisallowedTools = [
|
||||||
|
...new Set([...extraArgsDisallowedTools, ...directDisallowedTools]),
|
||||||
|
];
|
||||||
|
delete extraArgs["disallowedTools"];
|
||||||
|
delete extraArgs["disallowed-tools"];
|
||||||
|
|
||||||
|
// Merge multiple --mcp-config values by combining their mcpServers objects
|
||||||
|
// The action prepends its config (github_comment, github_ci, etc.) as inline JSON,
|
||||||
|
// and users may provide their own config as inline JSON or file path
|
||||||
|
if (extraArgs["mcp-config"]) {
|
||||||
|
const mcpConfigValues = extraArgs["mcp-config"].split(ACCUMULATE_DELIMITER);
|
||||||
|
if (mcpConfigValues.length > 1) {
|
||||||
|
extraArgs["mcp-config"] = mergeMcpConfigs(mcpConfigValues);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build custom environment
|
||||||
|
const env: Record<string, string | undefined> = { ...process.env };
|
||||||
|
if (process.env.INPUT_ACTION_INPUTS_PRESENT) {
|
||||||
|
env.GITHUB_ACTION_INPUTS = process.env.INPUT_ACTION_INPUTS_PRESENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build system prompt option - default to claude_code preset
|
||||||
|
let systemPrompt: SdkOptions["systemPrompt"];
|
||||||
|
if (options.systemPrompt) {
|
||||||
|
systemPrompt = options.systemPrompt;
|
||||||
|
} else if (options.appendSystemPrompt) {
|
||||||
|
systemPrompt = {
|
||||||
|
type: "preset",
|
||||||
|
preset: "claude_code",
|
||||||
|
append: options.appendSystemPrompt,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Default to claude_code preset when no custom prompt is specified
|
||||||
|
systemPrompt = {
|
||||||
|
type: "preset",
|
||||||
|
preset: "claude_code",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build SDK options - use merged tools from both direct options and claudeArgs
|
||||||
|
const sdkOptions: SdkOptions = {
|
||||||
|
// Direct options from ClaudeOptions inputs
|
||||||
|
model: options.model,
|
||||||
|
maxTurns: options.maxTurns ? parseInt(options.maxTurns, 10) : undefined,
|
||||||
|
allowedTools:
|
||||||
|
mergedAllowedTools.length > 0 ? mergedAllowedTools : undefined,
|
||||||
|
disallowedTools:
|
||||||
|
mergedDisallowedTools.length > 0 ? mergedDisallowedTools : undefined,
|
||||||
|
systemPrompt,
|
||||||
|
fallbackModel: options.fallbackModel,
|
||||||
|
pathToClaudeCodeExecutable: options.pathToClaudeCodeExecutable,
|
||||||
|
|
||||||
|
// Pass through claudeArgs as extraArgs - CLI handles --mcp-config, --json-schema, etc.
|
||||||
|
// Note: allowedTools and disallowedTools have been removed from extraArgs to prevent duplicates
|
||||||
|
extraArgs,
|
||||||
|
env,
|
||||||
|
|
||||||
|
// Load settings from sources - prefer user's --setting-sources if provided, otherwise use all sources
|
||||||
|
// This ensures users can override the default behavior (e.g., --setting-sources user to avoid in-repo configs)
|
||||||
|
settingSources: extraArgs["setting-sources"]
|
||||||
|
? (extraArgs["setting-sources"].split(
|
||||||
|
",",
|
||||||
|
) as SdkOptions["settingSources"])
|
||||||
|
: ["user", "project", "local"],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove setting-sources from extraArgs to avoid passing it twice
|
||||||
|
delete extraArgs["setting-sources"];
|
||||||
|
|
||||||
|
return {
|
||||||
|
sdkOptions,
|
||||||
|
showFullOutput,
|
||||||
|
hasJsonSchema,
|
||||||
|
};
|
||||||
|
}
|
||||||
245
base-action/src/run-claude-sdk.ts
Normal file
245
base-action/src/run-claude-sdk.ts
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
import * as core from "@actions/core";
|
||||||
|
import { readFile, writeFile } from "fs/promises";
|
||||||
|
import { query } from "@anthropic-ai/claude-agent-sdk";
|
||||||
|
import type {
|
||||||
|
SDKMessage,
|
||||||
|
SDKResultMessage,
|
||||||
|
} from "@anthropic-ai/claude-agent-sdk";
|
||||||
|
import type { ParsedSdkOptions } from "./parse-sdk-options";
|
||||||
|
|
||||||
|
const EXECUTION_FILE = `${process.env.RUNNER_TEMP}/claude-execution-output.json`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of running Claude via SDK
|
||||||
|
*/
|
||||||
|
export type RunClaudeResult = {
|
||||||
|
success: boolean;
|
||||||
|
executionFile: string;
|
||||||
|
conclusion: "success" | "failure";
|
||||||
|
structuredOutput?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitizes SDK output to match CLI sanitization behavior
|
||||||
|
*/
|
||||||
|
function sanitizeSdkOutput(
|
||||||
|
message: SDKMessage,
|
||||||
|
showFullOutput: boolean,
|
||||||
|
): string | null {
|
||||||
|
if (showFullOutput) {
|
||||||
|
return JSON.stringify(message, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// System initialization - safe to show
|
||||||
|
if (message.type === "system" && message.subtype === "init") {
|
||||||
|
return JSON.stringify(
|
||||||
|
{
|
||||||
|
type: "system",
|
||||||
|
subtype: "init",
|
||||||
|
message: "Claude Code initialized",
|
||||||
|
model: "model" in message ? message.model : "unknown",
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result messages - show sanitized summary
|
||||||
|
if (message.type === "result") {
|
||||||
|
const resultMsg = message as SDKResultMessage;
|
||||||
|
return JSON.stringify(
|
||||||
|
{
|
||||||
|
type: "result",
|
||||||
|
subtype: resultMsg.subtype,
|
||||||
|
is_error: resultMsg.is_error,
|
||||||
|
duration_ms: resultMsg.duration_ms,
|
||||||
|
num_turns: resultMsg.num_turns,
|
||||||
|
total_cost_usd: resultMsg.total_cost_usd,
|
||||||
|
permission_denials: resultMsg.permission_denials,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suppress other message types in non-full-output mode
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input for runClaudeWithSdk - either a prompt string or file path
|
||||||
|
*/
|
||||||
|
export type PromptInput =
|
||||||
|
| { type: "string"; prompt: string }
|
||||||
|
| { type: "file"; promptPath: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run Claude using the Agent SDK.
|
||||||
|
*
|
||||||
|
* @param promptInput - Either a direct prompt string or path to prompt file
|
||||||
|
* @param parsedOptions - Parsed SDK options
|
||||||
|
* @param options - Additional options
|
||||||
|
* @param options.setOutputs - Whether to set GitHub Action outputs (default: true for backwards compat)
|
||||||
|
* @returns Result of the execution
|
||||||
|
*/
|
||||||
|
export async function runClaudeWithSdk(
|
||||||
|
promptInput: PromptInput,
|
||||||
|
{ sdkOptions, showFullOutput, hasJsonSchema }: ParsedSdkOptions,
|
||||||
|
{ setOutputs = true }: { setOutputs?: boolean } = {},
|
||||||
|
): Promise<RunClaudeResult> {
|
||||||
|
// Get prompt from string or file
|
||||||
|
const prompt =
|
||||||
|
promptInput.type === "string"
|
||||||
|
? promptInput.prompt
|
||||||
|
: await readFile(promptInput.promptPath, "utf-8");
|
||||||
|
|
||||||
|
if (!showFullOutput) {
|
||||||
|
console.log(
|
||||||
|
"Running Claude Code via SDK (full output hidden for security)...",
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
"Rerun in debug mode or enable `show_full_output: true` in your workflow file for full output.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (promptInput.type === "file") {
|
||||||
|
console.log(
|
||||||
|
`Running Claude with prompt from file: ${promptInput.promptPath}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(`Running Claude with prompt string (${prompt.length} chars)`);
|
||||||
|
}
|
||||||
|
// Log SDK options without env (which could contain sensitive data)
|
||||||
|
const { env, ...optionsToLog } = sdkOptions;
|
||||||
|
console.log("SDK options:", JSON.stringify(optionsToLog, null, 2));
|
||||||
|
|
||||||
|
const messages: SDKMessage[] = [];
|
||||||
|
let resultMessage: SDKResultMessage | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const message of query({ prompt, options: sdkOptions })) {
|
||||||
|
messages.push(message);
|
||||||
|
|
||||||
|
const sanitized = sanitizeSdkOutput(message, showFullOutput);
|
||||||
|
if (sanitized) {
|
||||||
|
console.log(sanitized);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === "result") {
|
||||||
|
resultMessage = message as SDKResultMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SDK execution error:", error);
|
||||||
|
if (setOutputs) {
|
||||||
|
core.setOutput("conclusion", "failure");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
executionFile: EXECUTION_FILE,
|
||||||
|
conclusion: "failure",
|
||||||
|
error: String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write execution file
|
||||||
|
try {
|
||||||
|
await writeFile(EXECUTION_FILE, JSON.stringify(messages, null, 2));
|
||||||
|
console.log(`Log saved to ${EXECUTION_FILE}`);
|
||||||
|
if (setOutputs) {
|
||||||
|
core.setOutput("execution_file", EXECUTION_FILE);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
core.warning(`Failed to write execution file: ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resultMessage) {
|
||||||
|
if (setOutputs) {
|
||||||
|
core.setOutput("conclusion", "failure");
|
||||||
|
}
|
||||||
|
core.error("No result message received from Claude");
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
executionFile: EXECUTION_FILE,
|
||||||
|
conclusion: "failure",
|
||||||
|
error: "No result message received from Claude",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSuccess = resultMessage.subtype === "success";
|
||||||
|
if (setOutputs) {
|
||||||
|
core.setOutput("conclusion", isSuccess ? "success" : "failure");
|
||||||
|
}
|
||||||
|
|
||||||
|
let structuredOutput: string | undefined;
|
||||||
|
|
||||||
|
// Handle structured output
|
||||||
|
if (hasJsonSchema) {
|
||||||
|
if (
|
||||||
|
isSuccess &&
|
||||||
|
"structured_output" in resultMessage &&
|
||||||
|
resultMessage.structured_output
|
||||||
|
) {
|
||||||
|
structuredOutput = JSON.stringify(resultMessage.structured_output);
|
||||||
|
if (setOutputs) {
|
||||||
|
core.setOutput("structured_output", structuredOutput);
|
||||||
|
}
|
||||||
|
core.info(
|
||||||
|
`Set structured_output with ${Object.keys(resultMessage.structured_output as object).length} field(s)`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const errorMsg = `--json-schema was provided but Claude did not return structured_output. Result subtype: ${resultMessage.subtype}`;
|
||||||
|
if (setOutputs) {
|
||||||
|
core.setFailed(errorMsg);
|
||||||
|
core.setOutput("conclusion", "failure");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
executionFile: EXECUTION_FILE,
|
||||||
|
conclusion: "failure",
|
||||||
|
error: errorMsg,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSuccess) {
|
||||||
|
const errors =
|
||||||
|
"errors" in resultMessage && resultMessage.errors
|
||||||
|
? resultMessage.errors.join(", ")
|
||||||
|
: "Unknown error";
|
||||||
|
core.error(`Execution failed: ${errors}`);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
executionFile: EXECUTION_FILE,
|
||||||
|
conclusion: "failure",
|
||||||
|
error: errors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
executionFile: EXECUTION_FILE,
|
||||||
|
conclusion: "success",
|
||||||
|
structuredOutput,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper for backwards compatibility - reads prompt from file path and exits on failure
|
||||||
|
*/
|
||||||
|
export async function runClaudeWithSdkFromFile(
|
||||||
|
promptPath: string,
|
||||||
|
parsedOptions: ParsedSdkOptions,
|
||||||
|
): Promise<void> {
|
||||||
|
const result = await runClaudeWithSdk(
|
||||||
|
{ type: "file", promptPath },
|
||||||
|
parsedOptions,
|
||||||
|
{ setOutputs: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import { unlink, writeFile, stat, readFile } from "fs/promises";
|
|||||||
import { createWriteStream } from "fs";
|
import { createWriteStream } from "fs";
|
||||||
import { spawn } from "child_process";
|
import { spawn } from "child_process";
|
||||||
import { parse as parseShellArgs } from "shell-quote";
|
import { parse as parseShellArgs } from "shell-quote";
|
||||||
|
import { runClaudeWithSdkFromFile } from "./run-claude-sdk";
|
||||||
|
import { parseSdkOptions } from "./parse-sdk-options";
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
@@ -122,6 +124,36 @@ export function prepareRunConfig(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
* Parses structured_output from execution file and sets GitHub Action outputs
|
||||||
* Only runs if --json-schema was explicitly provided in claude_args
|
* Only runs if --json-schema was explicitly provided in claude_args
|
||||||
@@ -165,6 +197,17 @@ export async function parseAndSetStructuredOutputs(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function runClaude(promptPath: string, options: ClaudeOptions) {
|
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 runClaudeWithSdkFromFile(promptPath, parsedOptions);
|
||||||
|
}
|
||||||
|
|
||||||
const config = prepareRunConfig(promptPath, options);
|
const config = prepareRunConfig(promptPath, options);
|
||||||
|
|
||||||
// Detect if --json-schema is present in claude args
|
// Detect if --json-schema is present in claude args
|
||||||
@@ -355,6 +398,9 @@ export async function runClaude(promptPath: string, options: ClaudeOptions) {
|
|||||||
|
|
||||||
core.setOutput("execution_file", EXECUTION_FILE);
|
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
|
// Parse and set structured outputs only if user provided --json-schema in claude_args
|
||||||
if (hasJsonSchema) {
|
if (hasJsonSchema) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
315
base-action/test/parse-sdk-options.test.ts
Normal file
315
base-action/test/parse-sdk-options.test.ts
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import { parseSdkOptions } from "../src/parse-sdk-options";
|
||||||
|
import type { ClaudeOptions } from "../src/run-claude";
|
||||||
|
|
||||||
|
describe("parseSdkOptions", () => {
|
||||||
|
describe("allowedTools merging", () => {
|
||||||
|
test("should extract allowedTools from claudeArgs", () => {
|
||||||
|
const options: ClaudeOptions = {
|
||||||
|
claudeArgs: '--allowedTools "Edit,Read,Write"',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parseSdkOptions(options);
|
||||||
|
|
||||||
|
expect(result.sdkOptions.allowedTools).toEqual(["Edit", "Read", "Write"]);
|
||||||
|
expect(result.sdkOptions.extraArgs?.["allowedTools"]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should extract allowedTools from claudeArgs with MCP tools", () => {
|
||||||
|
const options: ClaudeOptions = {
|
||||||
|
claudeArgs:
|
||||||
|
'--allowedTools "Edit,Read,mcp__github_comment__update_claude_comment"',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parseSdkOptions(options);
|
||||||
|
|
||||||
|
expect(result.sdkOptions.allowedTools).toEqual([
|
||||||
|
"Edit",
|
||||||
|
"Read",
|
||||||
|
"mcp__github_comment__update_claude_comment",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should accumulate multiple --allowedTools flags from claudeArgs", () => {
|
||||||
|
// This simulates tag mode adding its tools, then user adding their own
|
||||||
|
const options: ClaudeOptions = {
|
||||||
|
claudeArgs:
|
||||||
|
'--allowedTools "Edit,Read,mcp__github_comment__update_claude_comment" --model "claude-3" --allowedTools "Bash(npm install),mcp__github__get_issue"',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parseSdkOptions(options);
|
||||||
|
|
||||||
|
expect(result.sdkOptions.allowedTools).toEqual([
|
||||||
|
"Edit",
|
||||||
|
"Read",
|
||||||
|
"mcp__github_comment__update_claude_comment",
|
||||||
|
"Bash(npm install)",
|
||||||
|
"mcp__github__get_issue",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should merge allowedTools from both claudeArgs and direct options", () => {
|
||||||
|
const options: ClaudeOptions = {
|
||||||
|
claudeArgs: '--allowedTools "Edit,Read"',
|
||||||
|
allowedTools: "Write,Glob",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parseSdkOptions(options);
|
||||||
|
|
||||||
|
expect(result.sdkOptions.allowedTools).toEqual([
|
||||||
|
"Edit",
|
||||||
|
"Read",
|
||||||
|
"Write",
|
||||||
|
"Glob",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should deduplicate allowedTools when merging", () => {
|
||||||
|
const options: ClaudeOptions = {
|
||||||
|
claudeArgs: '--allowedTools "Edit,Read"',
|
||||||
|
allowedTools: "Edit,Write",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parseSdkOptions(options);
|
||||||
|
|
||||||
|
expect(result.sdkOptions.allowedTools).toEqual(["Edit", "Read", "Write"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should use only direct options when claudeArgs has no allowedTools", () => {
|
||||||
|
const options: ClaudeOptions = {
|
||||||
|
claudeArgs: '--model "claude-3-5-sonnet"',
|
||||||
|
allowedTools: "Edit,Read",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parseSdkOptions(options);
|
||||||
|
|
||||||
|
expect(result.sdkOptions.allowedTools).toEqual(["Edit", "Read"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return undefined allowedTools when neither source has it", () => {
|
||||||
|
const options: ClaudeOptions = {
|
||||||
|
claudeArgs: '--model "claude-3-5-sonnet"',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parseSdkOptions(options);
|
||||||
|
|
||||||
|
expect(result.sdkOptions.allowedTools).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should remove allowedTools from extraArgs after extraction", () => {
|
||||||
|
const options: ClaudeOptions = {
|
||||||
|
claudeArgs: '--allowedTools "Edit,Read" --model "claude-3-5-sonnet"',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parseSdkOptions(options);
|
||||||
|
|
||||||
|
expect(result.sdkOptions.extraArgs?.["allowedTools"]).toBeUndefined();
|
||||||
|
expect(result.sdkOptions.extraArgs?.["model"]).toBe("claude-3-5-sonnet");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle hyphenated --allowed-tools flag", () => {
|
||||||
|
const options: ClaudeOptions = {
|
||||||
|
claudeArgs: '--allowed-tools "Edit,Read,Write"',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parseSdkOptions(options);
|
||||||
|
|
||||||
|
expect(result.sdkOptions.allowedTools).toEqual(["Edit", "Read", "Write"]);
|
||||||
|
expect(result.sdkOptions.extraArgs?.["allowed-tools"]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should accumulate multiple --allowed-tools flags (hyphenated)", () => {
|
||||||
|
// This is the exact scenario from issue #746
|
||||||
|
const options: ClaudeOptions = {
|
||||||
|
claudeArgs:
|
||||||
|
'--allowed-tools "Bash(git log:*)" "Bash(git diff:*)" "Bash(git fetch:*)" "Bash(gh pr:*)"',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parseSdkOptions(options);
|
||||||
|
|
||||||
|
expect(result.sdkOptions.allowedTools).toEqual([
|
||||||
|
"Bash(git log:*)",
|
||||||
|
"Bash(git diff:*)",
|
||||||
|
"Bash(git fetch:*)",
|
||||||
|
"Bash(gh pr:*)",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle mixed camelCase and hyphenated allowedTools flags", () => {
|
||||||
|
const options: ClaudeOptions = {
|
||||||
|
claudeArgs: '--allowedTools "Edit,Read" --allowed-tools "Write,Glob"',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parseSdkOptions(options);
|
||||||
|
|
||||||
|
// Both should be merged - note: order depends on which key is found first
|
||||||
|
expect(result.sdkOptions.allowedTools).toContain("Edit");
|
||||||
|
expect(result.sdkOptions.allowedTools).toContain("Read");
|
||||||
|
expect(result.sdkOptions.allowedTools).toContain("Write");
|
||||||
|
expect(result.sdkOptions.allowedTools).toContain("Glob");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("disallowedTools merging", () => {
|
||||||
|
test("should extract disallowedTools from claudeArgs", () => {
|
||||||
|
const options: ClaudeOptions = {
|
||||||
|
claudeArgs: '--disallowedTools "Bash,Write"',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parseSdkOptions(options);
|
||||||
|
|
||||||
|
expect(result.sdkOptions.disallowedTools).toEqual(["Bash", "Write"]);
|
||||||
|
expect(result.sdkOptions.extraArgs?.["disallowedTools"]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should merge disallowedTools from both sources", () => {
|
||||||
|
const options: ClaudeOptions = {
|
||||||
|
claudeArgs: '--disallowedTools "Bash"',
|
||||||
|
disallowedTools: "Write",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parseSdkOptions(options);
|
||||||
|
|
||||||
|
expect(result.sdkOptions.disallowedTools).toEqual(["Bash", "Write"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mcp-config merging", () => {
|
||||||
|
test("should pass through single mcp-config in extraArgs", () => {
|
||||||
|
const options: ClaudeOptions = {
|
||||||
|
claudeArgs: `--mcp-config '{"mcpServers":{"server1":{"command":"cmd1"}}}'`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parseSdkOptions(options);
|
||||||
|
|
||||||
|
expect(result.sdkOptions.extraArgs?.["mcp-config"]).toBe(
|
||||||
|
'{"mcpServers":{"server1":{"command":"cmd1"}}}',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should merge multiple mcp-config flags with inline JSON", () => {
|
||||||
|
// Simulates action prepending its config, then user providing their own
|
||||||
|
const options: ClaudeOptions = {
|
||||||
|
claudeArgs: `--mcp-config '{"mcpServers":{"github_comment":{"command":"node","args":["server.js"]}}}' --mcp-config '{"mcpServers":{"user_server":{"command":"custom","args":["run"]}}}'`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parseSdkOptions(options);
|
||||||
|
|
||||||
|
const mcpConfig = JSON.parse(
|
||||||
|
result.sdkOptions.extraArgs?.["mcp-config"] as string,
|
||||||
|
);
|
||||||
|
expect(mcpConfig.mcpServers).toHaveProperty("github_comment");
|
||||||
|
expect(mcpConfig.mcpServers).toHaveProperty("user_server");
|
||||||
|
expect(mcpConfig.mcpServers.github_comment.command).toBe("node");
|
||||||
|
expect(mcpConfig.mcpServers.user_server.command).toBe("custom");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should merge three mcp-config flags", () => {
|
||||||
|
const options: ClaudeOptions = {
|
||||||
|
claudeArgs: `--mcp-config '{"mcpServers":{"server1":{"command":"cmd1"}}}' --mcp-config '{"mcpServers":{"server2":{"command":"cmd2"}}}' --mcp-config '{"mcpServers":{"server3":{"command":"cmd3"}}}'`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parseSdkOptions(options);
|
||||||
|
|
||||||
|
const mcpConfig = JSON.parse(
|
||||||
|
result.sdkOptions.extraArgs?.["mcp-config"] as string,
|
||||||
|
);
|
||||||
|
expect(mcpConfig.mcpServers).toHaveProperty("server1");
|
||||||
|
expect(mcpConfig.mcpServers).toHaveProperty("server2");
|
||||||
|
expect(mcpConfig.mcpServers).toHaveProperty("server3");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle mcp-config file path when no inline JSON exists", () => {
|
||||||
|
const options: ClaudeOptions = {
|
||||||
|
claudeArgs: `--mcp-config /tmp/user-mcp-config.json`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parseSdkOptions(options);
|
||||||
|
|
||||||
|
expect(result.sdkOptions.extraArgs?.["mcp-config"]).toBe(
|
||||||
|
"/tmp/user-mcp-config.json",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should merge inline JSON configs when file path is also present", () => {
|
||||||
|
// When action provides inline JSON and user provides a file path,
|
||||||
|
// the inline JSON configs should be merged (file paths cannot be merged at parse time)
|
||||||
|
const options: ClaudeOptions = {
|
||||||
|
claudeArgs: `--mcp-config '{"mcpServers":{"github_comment":{"command":"node"}}}' --mcp-config '{"mcpServers":{"github_ci":{"command":"node"}}}' --mcp-config /tmp/user-config.json`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parseSdkOptions(options);
|
||||||
|
|
||||||
|
// The inline JSON configs should be merged
|
||||||
|
const mcpConfig = JSON.parse(
|
||||||
|
result.sdkOptions.extraArgs?.["mcp-config"] as string,
|
||||||
|
);
|
||||||
|
expect(mcpConfig.mcpServers).toHaveProperty("github_comment");
|
||||||
|
expect(mcpConfig.mcpServers).toHaveProperty("github_ci");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle mcp-config with other flags", () => {
|
||||||
|
const options: ClaudeOptions = {
|
||||||
|
claudeArgs: `--mcp-config '{"mcpServers":{"server1":{}}}' --model claude-3-5-sonnet --mcp-config '{"mcpServers":{"server2":{}}}'`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parseSdkOptions(options);
|
||||||
|
|
||||||
|
const mcpConfig = JSON.parse(
|
||||||
|
result.sdkOptions.extraArgs?.["mcp-config"] as string,
|
||||||
|
);
|
||||||
|
expect(mcpConfig.mcpServers).toHaveProperty("server1");
|
||||||
|
expect(mcpConfig.mcpServers).toHaveProperty("server2");
|
||||||
|
expect(result.sdkOptions.extraArgs?.["model"]).toBe("claude-3-5-sonnet");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle real-world scenario: action config + user config", () => {
|
||||||
|
// This is the exact scenario from the bug report
|
||||||
|
const actionConfig = JSON.stringify({
|
||||||
|
mcpServers: {
|
||||||
|
github_comment: {
|
||||||
|
command: "node",
|
||||||
|
args: ["github-comment-server.js"],
|
||||||
|
},
|
||||||
|
github_ci: { command: "node", args: ["github-ci-server.js"] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const userConfig = JSON.stringify({
|
||||||
|
mcpServers: {
|
||||||
|
my_custom_server: { command: "python", args: ["server.py"] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const options: ClaudeOptions = {
|
||||||
|
claudeArgs: `--mcp-config '${actionConfig}' --mcp-config '${userConfig}'`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parseSdkOptions(options);
|
||||||
|
|
||||||
|
const mcpConfig = JSON.parse(
|
||||||
|
result.sdkOptions.extraArgs?.["mcp-config"] as string,
|
||||||
|
);
|
||||||
|
// All servers should be present
|
||||||
|
expect(mcpConfig.mcpServers).toHaveProperty("github_comment");
|
||||||
|
expect(mcpConfig.mcpServers).toHaveProperty("github_ci");
|
||||||
|
expect(mcpConfig.mcpServers).toHaveProperty("my_custom_server");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("other extraArgs passthrough", () => {
|
||||||
|
test("should pass through json-schema in extraArgs", () => {
|
||||||
|
const options: ClaudeOptions = {
|
||||||
|
claudeArgs: `--json-schema '{"type":"object"}'`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parseSdkOptions(options);
|
||||||
|
|
||||||
|
expect(result.sdkOptions.extraArgs?.["json-schema"]).toBe(
|
||||||
|
'{"type":"object"}',
|
||||||
|
);
|
||||||
|
expect(result.hasJsonSchema).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,7 +4,10 @@ import { describe, test, expect, afterEach, beforeEach, spyOn } from "bun:test";
|
|||||||
import { writeFile, unlink } from "fs/promises";
|
import { writeFile, unlink } from "fs/promises";
|
||||||
import { tmpdir } from "os";
|
import { tmpdir } from "os";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import { parseAndSetStructuredOutputs } from "../src/run-claude";
|
import {
|
||||||
|
parseAndSetStructuredOutputs,
|
||||||
|
parseAndSetSessionId,
|
||||||
|
} from "../src/run-claude";
|
||||||
import * as core from "@actions/core";
|
import * as core from "@actions/core";
|
||||||
|
|
||||||
// Mock execution file path
|
// Mock execution file path
|
||||||
@@ -35,16 +38,19 @@ async function createMockExecutionFile(
|
|||||||
// Spy on core functions
|
// Spy on core functions
|
||||||
let setOutputSpy: any;
|
let setOutputSpy: any;
|
||||||
let infoSpy: any;
|
let infoSpy: any;
|
||||||
|
let warningSpy: any;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setOutputSpy = spyOn(core, "setOutput").mockImplementation(() => {});
|
setOutputSpy = spyOn(core, "setOutput").mockImplementation(() => {});
|
||||||
infoSpy = spyOn(core, "info").mockImplementation(() => {});
|
infoSpy = spyOn(core, "info").mockImplementation(() => {});
|
||||||
|
warningSpy = spyOn(core, "warning").mockImplementation(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("parseAndSetStructuredOutputs", () => {
|
describe("parseAndSetStructuredOutputs", () => {
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
setOutputSpy?.mockRestore();
|
setOutputSpy?.mockRestore();
|
||||||
infoSpy?.mockRestore();
|
infoSpy?.mockRestore();
|
||||||
|
warningSpy?.mockRestore();
|
||||||
try {
|
try {
|
||||||
await unlink(TEST_EXECUTION_FILE);
|
await unlink(TEST_EXECUTION_FILE);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -156,3 +162,66 @@ describe("parseAndSetStructuredOutputs", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
34
bun.lock
34
bun.lock
@@ -1,11 +1,13 @@
|
|||||||
{
|
{
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 0,
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@anthropic-ai/claude-code-action",
|
"name": "@anthropic-ai/claude-code-action",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "^1.10.1",
|
"@actions/core": "^1.10.1",
|
||||||
"@actions/github": "^6.0.1",
|
"@actions/github": "^6.0.1",
|
||||||
|
"@anthropic-ai/claude-agent-sdk": "^0.1.74",
|
||||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||||
"@octokit/graphql": "^8.2.2",
|
"@octokit/graphql": "^8.2.2",
|
||||||
"@octokit/rest": "^21.1.1",
|
"@octokit/rest": "^21.1.1",
|
||||||
@@ -35,8 +37,40 @@
|
|||||||
|
|
||||||
"@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="],
|
"@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.1.74", "", { "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": "^3.24.1 || ^4.0.0" } }, "sha512-d6H3Oo625WAG3BrBFKJsuSshi4f0amc0kTJTm83LRPPFxn9kfq58FX4Oxxt+RUD9N3QumW9sQSEDnri20/F4qQ=="],
|
||||||
|
|
||||||
"@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="],
|
"@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="],
|
||||||
|
|
||||||
|
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="],
|
||||||
|
|
||||||
|
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.0.5", "", { "os": "linux", "cpu": "arm" }, "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw=="],
|
||||||
|
|
||||||
|
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.0.5" }, "os": "linux", "cpu": "arm" }, "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ=="],
|
||||||
|
|
||||||
|
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA=="],
|
||||||
|
|
||||||
|
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA=="],
|
||||||
|
|
||||||
|
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g=="],
|
||||||
|
|
||||||
|
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw=="],
|
||||||
|
|
||||||
|
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
|
||||||
|
|
||||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.16.0", "", { "dependencies": { "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-8ofX7gkZcLj9H9rSd50mCgm3SSF8C7XoclxJuLoV0Cz3rEQ1tv9MZRYYvJtm9n1BiEQQMzSmE/w2AEkNacLYfg=="],
|
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.16.0", "", { "dependencies": { "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-8ofX7gkZcLj9H9rSd50mCgm3SSF8C7XoclxJuLoV0Cz3rEQ1tv9MZRYYvJtm9n1BiEQQMzSmE/w2AEkNacLYfg=="],
|
||||||
|
|
||||||
"@octokit/auth-token": ["@octokit/auth-token@4.0.0", "", {}, "sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA=="],
|
"@octokit/auth-token": ["@octokit/auth-token@4.0.0", "", {}, "sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA=="],
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ You can authenticate with Claude using any of these four methods:
|
|||||||
3. Google Vertex AI with OIDC authentication
|
3. Google Vertex AI with OIDC authentication
|
||||||
4. Microsoft Foundry with OIDC authentication
|
4. Microsoft Foundry with OIDC authentication
|
||||||
|
|
||||||
For detailed setup instructions for AWS Bedrock and Google Vertex AI, see the [official documentation](https://docs.anthropic.com/en/docs/claude-code/github-actions#using-with-aws-bedrock-%26-google-vertex-ai).
|
For detailed setup instructions for AWS Bedrock and Google Vertex AI, see the [official documentation](https://code.claude.com/docs/en/github-actions#for-aws-bedrock:).
|
||||||
|
|
||||||
**Note**:
|
**Note**:
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "^1.10.1",
|
"@actions/core": "^1.10.1",
|
||||||
"@actions/github": "^6.0.1",
|
"@actions/github": "^6.0.1",
|
||||||
|
"@anthropic-ai/claude-agent-sdk": "^0.1.74",
|
||||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||||
"@octokit/graphql": "^8.2.2",
|
"@octokit/graphql": "^8.2.2",
|
||||||
"@octokit/rest": "^21.1.1",
|
"@octokit/rest": "^21.1.1",
|
||||||
|
|||||||
@@ -192,11 +192,6 @@ export function prepareContext(
|
|||||||
if (!isPR) {
|
if (!isPR) {
|
||||||
throw new Error("IS_PR must be true for pull_request_review event");
|
throw new Error("IS_PR must be true for pull_request_review event");
|
||||||
}
|
}
|
||||||
if (!commentBody) {
|
|
||||||
throw new Error(
|
|
||||||
"COMMENT_BODY is required for pull_request_review event",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
eventData = {
|
eventData = {
|
||||||
eventName: "pull_request_review",
|
eventName: "pull_request_review",
|
||||||
isPR: true,
|
isPR: true,
|
||||||
@@ -464,6 +459,123 @@ export function generatePrompt(
|
|||||||
return mode.generatePrompt(context, githubData, useCommitSigning);
|
return mode.generatePrompt(context, githubData, useCommitSigning);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a simplified prompt for tag mode (opt-in via USE_SIMPLE_PROMPT env var)
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
function generateSimplePrompt(
|
||||||
|
context: PreparedContext,
|
||||||
|
githubData: FetchDataResult,
|
||||||
|
useCommitSigning: boolean = false,
|
||||||
|
): string {
|
||||||
|
const {
|
||||||
|
contextData,
|
||||||
|
comments,
|
||||||
|
changedFilesWithSHA,
|
||||||
|
reviewData,
|
||||||
|
imageUrlMap,
|
||||||
|
} = githubData;
|
||||||
|
const { eventData } = context;
|
||||||
|
|
||||||
|
const { triggerContext } = getEventTypeAndContext(context);
|
||||||
|
|
||||||
|
const formattedContext = formatContext(contextData, eventData.isPR);
|
||||||
|
const formattedComments = formatComments(comments, imageUrlMap);
|
||||||
|
const formattedReviewComments = eventData.isPR
|
||||||
|
? formatReviewComments(reviewData, imageUrlMap)
|
||||||
|
: "";
|
||||||
|
const formattedChangedFiles = eventData.isPR
|
||||||
|
? formatChangedFilesWithSHA(changedFilesWithSHA)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const hasImages = imageUrlMap && imageUrlMap.size > 0;
|
||||||
|
const imagesInfo = hasImages
|
||||||
|
? `\n\n<images_info>
|
||||||
|
Images from comments have been saved to disk. Paths are in the formatted content above. Use Read tool to view them.
|
||||||
|
</images_info>`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const formattedBody = contextData?.body
|
||||||
|
? formatBody(contextData.body, imageUrlMap)
|
||||||
|
: "No description provided";
|
||||||
|
|
||||||
|
const entityType = eventData.isPR ? "pull request" : "issue";
|
||||||
|
const jobUrl = `${GITHUB_SERVER_URL}/${context.repository}/actions/runs/${process.env.GITHUB_RUN_ID}`;
|
||||||
|
|
||||||
|
let promptContent = `You were tagged on a GitHub ${entityType} via "${context.triggerPhrase}". Read the request and decide how to help.
|
||||||
|
|
||||||
|
<context>
|
||||||
|
${formattedContext}
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<${eventData.isPR ? "pr" : "issue"}_body>
|
||||||
|
${formattedBody}
|
||||||
|
</${eventData.isPR ? "pr" : "issue"}_body>
|
||||||
|
|
||||||
|
<comments>
|
||||||
|
${formattedComments || "No comments"}
|
||||||
|
</comments>
|
||||||
|
${
|
||||||
|
eventData.isPR
|
||||||
|
? `
|
||||||
|
<review_comments>
|
||||||
|
${formattedReviewComments || "No review comments"}
|
||||||
|
</review_comments>
|
||||||
|
|
||||||
|
<changed_files>
|
||||||
|
${formattedChangedFiles || "No files changed"}
|
||||||
|
</changed_files>`
|
||||||
|
: ""
|
||||||
|
}${imagesInfo}
|
||||||
|
|
||||||
|
<metadata>
|
||||||
|
repository: ${context.repository}
|
||||||
|
${eventData.isPR && eventData.prNumber ? `pr_number: ${eventData.prNumber}` : ""}
|
||||||
|
${!eventData.isPR && eventData.issueNumber ? `issue_number: ${eventData.issueNumber}` : ""}
|
||||||
|
trigger: ${triggerContext}
|
||||||
|
triggered_by: ${context.triggerUsername ?? "Unknown"}
|
||||||
|
claude_comment_id: ${context.claudeCommentId}
|
||||||
|
</metadata>
|
||||||
|
${
|
||||||
|
(eventData.eventName === "issue_comment" ||
|
||||||
|
eventData.eventName === "pull_request_review_comment" ||
|
||||||
|
eventData.eventName === "pull_request_review") &&
|
||||||
|
eventData.commentBody
|
||||||
|
? `
|
||||||
|
<trigger_comment>
|
||||||
|
${sanitizeContent(eventData.commentBody)}
|
||||||
|
</trigger_comment>`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
Your request is in <trigger_comment> above${eventData.eventName === "issues" ? ` (or the ${entityType} body for assigned/labeled events)` : ""}.
|
||||||
|
|
||||||
|
Decide what's being asked:
|
||||||
|
1. **Question or code review** - Answer directly or provide feedback
|
||||||
|
2. **Code change** - Implement the change, commit, and push
|
||||||
|
|
||||||
|
Communication:
|
||||||
|
- Your ONLY visible output is your GitHub comment - update it with progress and results
|
||||||
|
- Use mcp__github_comment__update_claude_comment to update (only "body" param needed)
|
||||||
|
- Use checklist format for tasks: - [ ] incomplete, - [x] complete
|
||||||
|
- Use ### headers (not #)
|
||||||
|
${getCommitInstructions(eventData, githubData, context, useCommitSigning)}
|
||||||
|
${
|
||||||
|
eventData.claudeBranch
|
||||||
|
? `
|
||||||
|
When done with changes, provide a PR link:
|
||||||
|
[Create a PR](${GITHUB_SERVER_URL}/${context.repository}/compare/${eventData.baseBranch}...${eventData.claudeBranch}?quick_pull=1&title=<url-encoded-title>&body=<url-encoded-body>)
|
||||||
|
Use THREE dots (...) between branches. URL-encode all parameters.`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
Always include at the bottom:
|
||||||
|
- Job link: [View job run](${jobUrl})
|
||||||
|
- Follow the repo's CLAUDE.md file for project-specific guidelines`;
|
||||||
|
|
||||||
|
return promptContent;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates the default prompt for tag mode
|
* Generates the default prompt for tag mode
|
||||||
* @internal
|
* @internal
|
||||||
@@ -473,6 +585,10 @@ export function generateDefaultPrompt(
|
|||||||
githubData: FetchDataResult,
|
githubData: FetchDataResult,
|
||||||
useCommitSigning: boolean = false,
|
useCommitSigning: boolean = false,
|
||||||
): string {
|
): string {
|
||||||
|
// Use simplified prompt if opted in
|
||||||
|
if (process.env.USE_SIMPLE_PROMPT === "true") {
|
||||||
|
return generateSimplePrompt(context, githubData, useCommitSigning);
|
||||||
|
}
|
||||||
const {
|
const {
|
||||||
contextData,
|
contextData,
|
||||||
comments,
|
comments,
|
||||||
@@ -725,6 +841,78 @@ f. If you are unable to complete certain steps, such as running a linter or test
|
|||||||
return promptContent;
|
return promptContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of generating prompt content
|
||||||
|
*/
|
||||||
|
export type PromptResult = {
|
||||||
|
promptContent: string;
|
||||||
|
allowedTools: string;
|
||||||
|
disallowedTools: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate prompt content and tool configurations.
|
||||||
|
* This function can be used directly without side effects (no file writes, no env vars).
|
||||||
|
*/
|
||||||
|
export function generatePromptContent(
|
||||||
|
mode: Mode,
|
||||||
|
modeContext: ModeContext,
|
||||||
|
githubData: FetchDataResult,
|
||||||
|
context: ParsedGitHubContext,
|
||||||
|
): PromptResult {
|
||||||
|
// Prepare the context for prompt generation
|
||||||
|
let claudeCommentId: string = "";
|
||||||
|
if (mode.name === "tag") {
|
||||||
|
if (!modeContext.commentId) {
|
||||||
|
throw new Error(
|
||||||
|
`${mode.name} mode requires a comment ID for prompt generation`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
claudeCommentId = modeContext.commentId.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const preparedContext = prepareContext(
|
||||||
|
context,
|
||||||
|
claudeCommentId,
|
||||||
|
modeContext.baseBranch,
|
||||||
|
modeContext.claudeBranch,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generate the prompt directly
|
||||||
|
const promptContent = generatePrompt(
|
||||||
|
preparedContext,
|
||||||
|
githubData,
|
||||||
|
context.inputs.useCommitSigning,
|
||||||
|
mode,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get mode-specific tools
|
||||||
|
const modeAllowedTools = mode.getAllowedTools();
|
||||||
|
const modeDisallowedTools = mode.getDisallowedTools();
|
||||||
|
|
||||||
|
const hasActionsReadPermission = false;
|
||||||
|
const allowedTools = buildAllowedToolsString(
|
||||||
|
modeAllowedTools,
|
||||||
|
hasActionsReadPermission,
|
||||||
|
context.inputs.useCommitSigning,
|
||||||
|
);
|
||||||
|
const disallowedTools = buildDisallowedToolsString(
|
||||||
|
modeDisallowedTools,
|
||||||
|
modeAllowedTools,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
promptContent,
|
||||||
|
allowedTools,
|
||||||
|
disallowedTools,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create prompt and write to file.
|
||||||
|
* This is the legacy function that writes files and sets environment variables.
|
||||||
|
* For the unified step, use generatePromptContent() instead.
|
||||||
|
*/
|
||||||
export async function createPrompt(
|
export async function createPrompt(
|
||||||
mode: Mode,
|
mode: Mode,
|
||||||
modeContext: ModeContext,
|
modeContext: ModeContext,
|
||||||
@@ -732,66 +920,30 @@ export async function createPrompt(
|
|||||||
context: ParsedGitHubContext,
|
context: ParsedGitHubContext,
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
// Prepare the context for prompt generation
|
const result = generatePromptContent(
|
||||||
let claudeCommentId: string = "";
|
|
||||||
if (mode.name === "tag") {
|
|
||||||
if (!modeContext.commentId) {
|
|
||||||
throw new Error(
|
|
||||||
`${mode.name} mode requires a comment ID for prompt generation`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
claudeCommentId = modeContext.commentId.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
const preparedContext = prepareContext(
|
|
||||||
context,
|
|
||||||
claudeCommentId,
|
|
||||||
modeContext.baseBranch,
|
|
||||||
modeContext.claudeBranch,
|
|
||||||
);
|
|
||||||
|
|
||||||
await mkdir(`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts`, {
|
|
||||||
recursive: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Generate the prompt directly
|
|
||||||
const promptContent = generatePrompt(
|
|
||||||
preparedContext,
|
|
||||||
githubData,
|
|
||||||
context.inputs.useCommitSigning,
|
|
||||||
mode,
|
mode,
|
||||||
|
modeContext,
|
||||||
|
githubData,
|
||||||
|
context,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Log the final prompt to console
|
// Log the final prompt to console
|
||||||
console.log("===== FINAL PROMPT =====");
|
console.log("===== FINAL PROMPT =====");
|
||||||
console.log(promptContent);
|
console.log(result.promptContent);
|
||||||
console.log("=======================");
|
console.log("=======================");
|
||||||
|
|
||||||
// Write the prompt file
|
// Write the prompt file
|
||||||
|
await mkdir(`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts`, {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
await writeFile(
|
await writeFile(
|
||||||
`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts/claude-prompt.txt`,
|
`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts/claude-prompt.txt`,
|
||||||
promptContent,
|
result.promptContent,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Set allowed tools
|
// Set environment variables
|
||||||
const hasActionsReadPermission = false;
|
core.exportVariable("ALLOWED_TOOLS", result.allowedTools);
|
||||||
|
core.exportVariable("DISALLOWED_TOOLS", result.disallowedTools);
|
||||||
// Get mode-specific tools
|
|
||||||
const modeAllowedTools = mode.getAllowedTools();
|
|
||||||
const modeDisallowedTools = mode.getDisallowedTools();
|
|
||||||
|
|
||||||
const allAllowedTools = buildAllowedToolsString(
|
|
||||||
modeAllowedTools,
|
|
||||||
hasActionsReadPermission,
|
|
||||||
context.inputs.useCommitSigning,
|
|
||||||
);
|
|
||||||
const allDisallowedTools = buildDisallowedToolsString(
|
|
||||||
modeDisallowedTools,
|
|
||||||
modeAllowedTools,
|
|
||||||
);
|
|
||||||
|
|
||||||
core.exportVariable("ALLOWED_TOOLS", allAllowedTools);
|
|
||||||
core.exportVariable("DISALLOWED_TOOLS", 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);
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ type PullRequestReviewEvent = {
|
|||||||
eventName: "pull_request_review";
|
eventName: "pull_request_review";
|
||||||
isPR: true;
|
isPR: true;
|
||||||
prNumber: string;
|
prNumber: string;
|
||||||
commentBody: string;
|
commentBody?: string; // May be absent for approvals without comments
|
||||||
claudeBranch?: string;
|
claudeBranch?: string;
|
||||||
baseBranch?: string;
|
baseBranch?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
186
src/entrypoints/run.ts
Normal file
186
src/entrypoints/run.ts
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified entry point for the Claude action.
|
||||||
|
*
|
||||||
|
* This combines the prepare and run phases into a single step,
|
||||||
|
* passing data directly in-memory instead of via files and outputs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 } from "../modes/registry";
|
||||||
|
import { prepare } from "../prepare";
|
||||||
|
import { collectActionInputsPresence } from "./collect-inputs";
|
||||||
|
import {
|
||||||
|
runClaudeWithSdk,
|
||||||
|
setupClaudeCodeSettings,
|
||||||
|
installPlugins,
|
||||||
|
parseSdkOptions,
|
||||||
|
} from "../../base-action/src/lib";
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
try {
|
||||||
|
// ============================================
|
||||||
|
// PHASE 1: PREPARE
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
collectActionInputsPresence();
|
||||||
|
|
||||||
|
// Parse GitHub context first to enable mode detection
|
||||||
|
const context = parseGitHubContext();
|
||||||
|
|
||||||
|
// Auto-detect mode based on context
|
||||||
|
const mode = getMode(context);
|
||||||
|
|
||||||
|
// Setup GitHub token
|
||||||
|
const githubToken = await setupGitHubToken();
|
||||||
|
const octokit = createOctokit(githubToken);
|
||||||
|
|
||||||
|
// Check write permissions (only for entity contexts)
|
||||||
|
if (isEntityContext(context)) {
|
||||||
|
const githubTokenProvided = !!process.env.OVERRIDE_GITHUB_TOKEN;
|
||||||
|
const hasWritePermissions = await checkWritePermissions(
|
||||||
|
octokit.rest,
|
||||||
|
context,
|
||||||
|
context.inputs.allowedNonWriteUsers,
|
||||||
|
githubTokenProvided,
|
||||||
|
);
|
||||||
|
if (!hasWritePermissions) {
|
||||||
|
throw new Error(
|
||||||
|
"Actor does not have write permissions to the repository",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check trigger conditions
|
||||||
|
const containsTrigger = mode.shouldTrigger(context);
|
||||||
|
|
||||||
|
// Debug logging
|
||||||
|
console.log(`Mode: ${mode.name}`);
|
||||||
|
console.log(`Context prompt: ${context.inputs?.prompt || "NO PROMPT"}`);
|
||||||
|
console.log(`Trigger result: ${containsTrigger}`);
|
||||||
|
|
||||||
|
// Set output for action.yml to check
|
||||||
|
core.setOutput("contains_trigger", containsTrigger.toString());
|
||||||
|
|
||||||
|
if (!containsTrigger) {
|
||||||
|
console.log("No trigger found, skipping remaining steps");
|
||||||
|
core.setOutput("GITHUB_TOKEN", githubToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run mode.prepare() - returns prompt, commentId, branchInfo, etc.
|
||||||
|
const prepareResult = await prepare({
|
||||||
|
context,
|
||||||
|
octokit,
|
||||||
|
mode,
|
||||||
|
githubToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set outputs that may be needed by subsequent steps
|
||||||
|
core.setOutput("GITHUB_TOKEN", githubToken);
|
||||||
|
if (prepareResult.commentId) {
|
||||||
|
core.setOutput("claude_comment_id", prepareResult.commentId.toString());
|
||||||
|
}
|
||||||
|
core.setOutput(
|
||||||
|
"CLAUDE_BRANCH",
|
||||||
|
prepareResult.branchInfo.claudeBranch || "",
|
||||||
|
);
|
||||||
|
core.setOutput("BASE_BRANCH", prepareResult.branchInfo.baseBranch);
|
||||||
|
|
||||||
|
// Get system prompt from mode if available
|
||||||
|
let appendSystemPrompt: string | undefined;
|
||||||
|
if (mode.getSystemPrompt) {
|
||||||
|
const modeContext = mode.prepareContext(context, {
|
||||||
|
commentId: prepareResult.commentId,
|
||||||
|
baseBranch: prepareResult.branchInfo.baseBranch,
|
||||||
|
claudeBranch: prepareResult.branchInfo.claudeBranch,
|
||||||
|
});
|
||||||
|
appendSystemPrompt = mode.getSystemPrompt(modeContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// PHASE 2: SETUP
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// Setup Claude Code settings
|
||||||
|
await setupClaudeCodeSettings(
|
||||||
|
process.env.INPUT_SETTINGS,
|
||||||
|
undefined, // homeDir
|
||||||
|
);
|
||||||
|
|
||||||
|
// Install Claude Code plugins if specified
|
||||||
|
await installPlugins(
|
||||||
|
process.env.INPUT_PLUGIN_MARKETPLACES,
|
||||||
|
process.env.INPUT_PLUGINS,
|
||||||
|
process.env.INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// PHASE 3: EXECUTE
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// Get prompt content from prepare result
|
||||||
|
const promptContent = prepareResult.promptContent;
|
||||||
|
if (!promptContent) {
|
||||||
|
throw new Error("No prompt content generated by prepare phase");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("===== PROMPT CONTENT =====");
|
||||||
|
console.log(`Prompt length: ${promptContent.length} chars`);
|
||||||
|
console.log("==========================");
|
||||||
|
|
||||||
|
// Build SDK options from environment and prepare result
|
||||||
|
const sdkOptions = parseSdkOptions({
|
||||||
|
claudeArgs: process.env.INPUT_CLAUDE_ARGS,
|
||||||
|
allowedTools:
|
||||||
|
prepareResult.allowedTools || process.env.INPUT_ALLOWED_TOOLS,
|
||||||
|
disallowedTools:
|
||||||
|
prepareResult.disallowedTools || process.env.INPUT_DISALLOWED_TOOLS,
|
||||||
|
maxTurns: process.env.INPUT_MAX_TURNS,
|
||||||
|
mcpConfig: process.env.INPUT_MCP_CONFIG,
|
||||||
|
systemPrompt: process.env.INPUT_SYSTEM_PROMPT,
|
||||||
|
appendSystemPrompt:
|
||||||
|
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:
|
||||||
|
process.env.INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE,
|
||||||
|
showFullOutput: process.env.INPUT_SHOW_FULL_OUTPUT,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Run Claude with prompt string directly
|
||||||
|
const execResult = await runClaudeWithSdk(
|
||||||
|
{ type: "string", prompt: promptContent },
|
||||||
|
sdkOptions,
|
||||||
|
{ setOutputs: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set additional outputs
|
||||||
|
core.setOutput("execution_file", execResult.executionFile);
|
||||||
|
core.setOutput("conclusion", execResult.conclusion);
|
||||||
|
if (execResult.structuredOutput) {
|
||||||
|
core.setOutput("structured_output", execResult.structuredOutput);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!execResult.success) {
|
||||||
|
core.setFailed(`Claude execution failed: ${execResult.error}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
core.setFailed(`Run step failed with error: ${errorMessage}`);
|
||||||
|
core.setOutput("prepare_error", errorMessage);
|
||||||
|
core.setOutput("conclusion", "failure");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.main) {
|
||||||
|
run();
|
||||||
|
}
|
||||||
@@ -152,7 +152,7 @@ async function run() {
|
|||||||
|
|
||||||
// Check if action failed and read output file for execution details
|
// Check if action failed and read output file for execution details
|
||||||
let executionDetails: {
|
let executionDetails: {
|
||||||
cost_usd?: number;
|
total_cost_usd?: number;
|
||||||
duration_ms?: number;
|
duration_ms?: number;
|
||||||
duration_api_ms?: number;
|
duration_api_ms?: number;
|
||||||
} | null = null;
|
} | null = null;
|
||||||
@@ -179,11 +179,11 @@ async function run() {
|
|||||||
const lastElement = outputData[outputData.length - 1];
|
const lastElement = outputData[outputData.length - 1];
|
||||||
if (
|
if (
|
||||||
lastElement.type === "result" &&
|
lastElement.type === "result" &&
|
||||||
"cost_usd" in lastElement &&
|
"total_cost_usd" in lastElement &&
|
||||||
"duration_ms" in lastElement
|
"duration_ms" in lastElement
|
||||||
) {
|
) {
|
||||||
executionDetails = {
|
executionDetails = {
|
||||||
cost_usd: lastElement.cost_usd,
|
total_cost_usd: lastElement.total_cost_usd,
|
||||||
duration_ms: lastElement.duration_ms,
|
duration_ms: lastElement.duration_ms,
|
||||||
duration_api_ms: lastElement.duration_api_ms,
|
duration_api_ms: lastElement.duration_api_ms,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,13 +6,112 @@
|
|||||||
* - For Issues: Create a new branch
|
* - For Issues: Create a new branch
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { $ } from "bun";
|
import { execFileSync } from "child_process";
|
||||||
import * as core from "@actions/core";
|
import * as core from "@actions/core";
|
||||||
import type { ParsedGitHubContext } from "../context";
|
import type { ParsedGitHubContext } from "../context";
|
||||||
import type { GitHubPullRequest } from "../types";
|
import type { GitHubPullRequest } from "../types";
|
||||||
import type { Octokits } from "../api/client";
|
import type { Octokits } from "../api/client";
|
||||||
import type { FetchDataResult } from "../data/fetcher";
|
import type { FetchDataResult } from "../data/fetcher";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a git branch name against a strict whitelist pattern.
|
||||||
|
* This prevents command injection by ensuring only safe characters are used.
|
||||||
|
*
|
||||||
|
* Valid branch names:
|
||||||
|
* - Start with alphanumeric character (not dash, to prevent option injection)
|
||||||
|
* - Contain only alphanumeric, forward slash, hyphen, underscore, or period
|
||||||
|
* - Do not start or end with a period
|
||||||
|
* - Do not end with a slash
|
||||||
|
* - Do not contain '..' (path traversal)
|
||||||
|
* - Do not contain '//' (consecutive slashes)
|
||||||
|
* - Do not end with '.lock'
|
||||||
|
* - Do not contain '@{'
|
||||||
|
* - Do not contain control characters or special git characters (~^:?*[\])
|
||||||
|
*/
|
||||||
|
export function validateBranchName(branchName: string): void {
|
||||||
|
// Check for empty or whitespace-only names
|
||||||
|
if (!branchName || branchName.trim().length === 0) {
|
||||||
|
throw new Error("Branch name cannot be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for leading dash (prevents option injection like --help, -x)
|
||||||
|
if (branchName.startsWith("-")) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid branch name: "${branchName}". Branch names cannot start with a dash.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for control characters and special git characters (~^:?*[\])
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
if (/[\x00-\x1F\x7F ~^:?*[\]\\]/.test(branchName)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid branch name: "${branchName}". Branch names cannot contain control characters, spaces, or special git characters (~^:?*[\\]).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strict whitelist pattern: alphanumeric start, then alphanumeric/slash/hyphen/underscore/period
|
||||||
|
const validPattern = /^[a-zA-Z0-9][a-zA-Z0-9/_.-]*$/;
|
||||||
|
|
||||||
|
if (!validPattern.test(branchName)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid branch name: "${branchName}". Branch names must start with an alphanumeric character and contain only alphanumeric characters, forward slashes, hyphens, underscores, or periods.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for leading/trailing periods
|
||||||
|
if (branchName.startsWith(".") || branchName.endsWith(".")) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid branch name: "${branchName}". Branch names cannot start or end with a period.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for trailing slash
|
||||||
|
if (branchName.endsWith("/")) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid branch name: "${branchName}". Branch names cannot end with a slash.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for consecutive slashes
|
||||||
|
if (branchName.includes("//")) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid branch name: "${branchName}". Branch names cannot contain consecutive slashes.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional git-specific validations
|
||||||
|
if (branchName.includes("..")) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid branch name: "${branchName}". Branch names cannot contain '..'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (branchName.endsWith(".lock")) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid branch name: "${branchName}". Branch names cannot end with '.lock'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (branchName.includes("@{")) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid branch name: "${branchName}". Branch names cannot contain '@{'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes a git command safely using execFileSync to avoid shell interpolation.
|
||||||
|
*
|
||||||
|
* Security: execFileSync passes arguments directly to the git binary without
|
||||||
|
* invoking a shell, preventing command injection attacks where malicious input
|
||||||
|
* could be interpreted as shell commands (e.g., branch names containing `;`, `|`, `&&`).
|
||||||
|
*
|
||||||
|
* @param args - Git command arguments (e.g., ["checkout", "branch-name"])
|
||||||
|
*/
|
||||||
|
function execGit(args: string[]): void {
|
||||||
|
execFileSync("git", args, { stdio: "inherit" });
|
||||||
|
}
|
||||||
|
|
||||||
export type BranchInfo = {
|
export type BranchInfo = {
|
||||||
baseBranch: string;
|
baseBranch: string;
|
||||||
claudeBranch?: string;
|
claudeBranch?: string;
|
||||||
@@ -53,14 +152,19 @@ export async function setupBranch(
|
|||||||
`PR #${entityNumber}: ${commitCount} commits, using fetch depth ${fetchDepth}`,
|
`PR #${entityNumber}: ${commitCount} commits, using fetch depth ${fetchDepth}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Validate branch names before use to prevent command injection
|
||||||
|
validateBranchName(branchName);
|
||||||
|
|
||||||
// Execute git commands to checkout PR branch (dynamic depth based on PR size)
|
// Execute git commands to checkout PR branch (dynamic depth based on PR size)
|
||||||
await $`git fetch origin --depth=${fetchDepth} ${branchName}`;
|
// Using execFileSync instead of shell template literals for security
|
||||||
await $`git checkout ${branchName} --`;
|
execGit(["fetch", "origin", `--depth=${fetchDepth}`, branchName]);
|
||||||
|
execGit(["checkout", branchName, "--"]);
|
||||||
|
|
||||||
console.log(`Successfully checked out PR branch for PR #${entityNumber}`);
|
console.log(`Successfully checked out PR branch for PR #${entityNumber}`);
|
||||||
|
|
||||||
// For open PRs, we need to get the base branch of the PR
|
// For open PRs, we need to get the base branch of the PR
|
||||||
const baseBranch = prData.baseRefName;
|
const baseBranch = prData.baseRefName;
|
||||||
|
validateBranchName(baseBranch);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
baseBranch,
|
baseBranch,
|
||||||
@@ -118,8 +222,9 @@ export async function setupBranch(
|
|||||||
|
|
||||||
// Ensure we're on the source branch
|
// Ensure we're on the source branch
|
||||||
console.log(`Fetching and checking out source branch: ${sourceBranch}`);
|
console.log(`Fetching and checking out source branch: ${sourceBranch}`);
|
||||||
await $`git fetch origin ${sourceBranch} --depth=1`;
|
validateBranchName(sourceBranch);
|
||||||
await $`git checkout ${sourceBranch}`;
|
execGit(["fetch", "origin", sourceBranch, "--depth=1"]);
|
||||||
|
execGit(["checkout", sourceBranch, "--"]);
|
||||||
|
|
||||||
// Set outputs for GitHub Actions
|
// Set outputs for GitHub Actions
|
||||||
core.setOutput("CLAUDE_BRANCH", newBranch);
|
core.setOutput("CLAUDE_BRANCH", newBranch);
|
||||||
@@ -138,11 +243,13 @@ export async function setupBranch(
|
|||||||
|
|
||||||
// Fetch and checkout the source branch first to ensure we branch from the correct base
|
// Fetch and checkout the source branch first to ensure we branch from the correct base
|
||||||
console.log(`Fetching and checking out source branch: ${sourceBranch}`);
|
console.log(`Fetching and checking out source branch: ${sourceBranch}`);
|
||||||
await $`git fetch origin ${sourceBranch} --depth=1`;
|
validateBranchName(sourceBranch);
|
||||||
await $`git checkout ${sourceBranch}`;
|
validateBranchName(newBranch);
|
||||||
|
execGit(["fetch", "origin", sourceBranch, "--depth=1"]);
|
||||||
|
execGit(["checkout", sourceBranch, "--"]);
|
||||||
|
|
||||||
// Create and checkout the new branch from the source branch
|
// Create and checkout the new branch from the source branch
|
||||||
await $`git checkout -b ${newBranch}`;
|
execGit(["checkout", "-b", newBranch]);
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`Successfully created and checked out local branch: ${newBranch}`,
|
`Successfully created and checked out local branch: ${newBranch}`,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { GITHUB_SERVER_URL } from "../api/config";
|
import { GITHUB_SERVER_URL } from "../api/config";
|
||||||
|
|
||||||
export type ExecutionDetails = {
|
export type ExecutionDetails = {
|
||||||
cost_usd?: number;
|
total_cost_usd?: number;
|
||||||
duration_ms?: number;
|
duration_ms?: number;
|
||||||
duration_api_ms?: number;
|
duration_api_ms?: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -95,16 +95,15 @@ export const agentMode: Mode = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create prompt directory
|
// Generate prompt content - use the user's prompt directly
|
||||||
await mkdir(`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts`, {
|
|
||||||
recursive: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Write the prompt file - use the user's prompt directly
|
|
||||||
const promptContent =
|
const promptContent =
|
||||||
context.inputs.prompt ||
|
context.inputs.prompt ||
|
||||||
`Repository: ${context.repository.owner}/${context.repository.repo}`;
|
`Repository: ${context.repository.owner}/${context.repository.repo}`;
|
||||||
|
|
||||||
|
// Also write file for backwards compatibility with current flow
|
||||||
|
await mkdir(`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts`, {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
await writeFile(
|
await writeFile(
|
||||||
`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts/claude-prompt.txt`,
|
`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts/claude-prompt.txt`,
|
||||||
promptContent,
|
promptContent,
|
||||||
@@ -162,6 +161,8 @@ export const agentMode: Mode = {
|
|||||||
claudeBranch: claudeBranch,
|
claudeBranch: claudeBranch,
|
||||||
},
|
},
|
||||||
mcpConfig: ourMcpConfig,
|
mcpConfig: ourMcpConfig,
|
||||||
|
promptContent,
|
||||||
|
// Agent mode doesn't use the same allowed/disallowed tools mechanism as tag mode
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ import {
|
|||||||
fetchGitHubData,
|
fetchGitHubData,
|
||||||
extractTriggerTimestamp,
|
extractTriggerTimestamp,
|
||||||
} from "../../github/data/fetcher";
|
} from "../../github/data/fetcher";
|
||||||
import { createPrompt, generateDefaultPrompt } from "../../create-prompt";
|
import {
|
||||||
|
createPrompt,
|
||||||
|
generateDefaultPrompt,
|
||||||
|
generatePromptContent,
|
||||||
|
} from "../../create-prompt";
|
||||||
import { isEntityContext } from "../../github/context";
|
import { isEntityContext } from "../../github/context";
|
||||||
import type { PreparedContext } from "../../create-prompt/types";
|
import type { PreparedContext } from "../../create-prompt/types";
|
||||||
import type { FetchDataResult } from "../../github/data/fetcher";
|
import type { FetchDataResult } from "../../github/data/fetcher";
|
||||||
@@ -104,13 +108,22 @@ export const tagMode: Mode = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create prompt file
|
// Create prompt
|
||||||
const modeContext = this.prepareContext(context, {
|
const modeContext = this.prepareContext(context, {
|
||||||
commentId,
|
commentId,
|
||||||
baseBranch: branchInfo.baseBranch,
|
baseBranch: branchInfo.baseBranch,
|
||||||
claudeBranch: branchInfo.claudeBranch,
|
claudeBranch: branchInfo.claudeBranch,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Generate prompt content - returns data instead of writing file
|
||||||
|
const promptResult = generatePromptContent(
|
||||||
|
tagMode,
|
||||||
|
modeContext,
|
||||||
|
githubData,
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Also write file for backwards compatibility with current flow
|
||||||
await createPrompt(tagMode, modeContext, githubData, context);
|
await createPrompt(tagMode, modeContext, githubData, context);
|
||||||
|
|
||||||
const userClaudeArgs = process.env.CLAUDE_ARGS || "";
|
const userClaudeArgs = process.env.CLAUDE_ARGS || "";
|
||||||
@@ -188,6 +201,9 @@ export const tagMode: Mode = {
|
|||||||
commentId,
|
commentId,
|
||||||
branchInfo,
|
branchInfo,
|
||||||
mcpConfig: ourMcpConfig,
|
mcpConfig: ourMcpConfig,
|
||||||
|
promptContent: promptResult.promptContent,
|
||||||
|
allowedTools: promptResult.allowedTools,
|
||||||
|
disallowedTools: promptResult.disallowedTools,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -97,4 +97,10 @@ export type ModeResult = {
|
|||||||
currentBranch: string;
|
currentBranch: string;
|
||||||
};
|
};
|
||||||
mcpConfig: string;
|
mcpConfig: string;
|
||||||
|
/** Generated prompt content for Claude */
|
||||||
|
promptContent?: string;
|
||||||
|
/** Comma-separated list of allowed tools */
|
||||||
|
allowedTools?: string;
|
||||||
|
/** Comma-separated list of disallowed tools */
|
||||||
|
disallowedTools?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ export type PrepareResult = {
|
|||||||
currentBranch: string;
|
currentBranch: string;
|
||||||
};
|
};
|
||||||
mcpConfig: string;
|
mcpConfig: string;
|
||||||
|
/** Generated prompt content for Claude */
|
||||||
|
promptContent?: string;
|
||||||
|
/** Comma-separated list of allowed tools */
|
||||||
|
allowedTools?: string;
|
||||||
|
/** Comma-separated list of disallowed tools */
|
||||||
|
disallowedTools?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PrepareOptions = {
|
export type PrepareOptions = {
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ describe("updateCommentBody", () => {
|
|||||||
const input = {
|
const input = {
|
||||||
...baseInput,
|
...baseInput,
|
||||||
executionDetails: {
|
executionDetails: {
|
||||||
cost_usd: 0.13382595,
|
total_cost_usd: 0.13382595,
|
||||||
duration_ms: 31033,
|
duration_ms: 31033,
|
||||||
duration_api_ms: 31034,
|
duration_api_ms: 31034,
|
||||||
},
|
},
|
||||||
@@ -301,7 +301,7 @@ describe("updateCommentBody", () => {
|
|||||||
const input = {
|
const input = {
|
||||||
...baseInput,
|
...baseInput,
|
||||||
executionDetails: {
|
executionDetails: {
|
||||||
cost_usd: 0.25,
|
total_cost_usd: 0.25,
|
||||||
},
|
},
|
||||||
triggerUsername: "testuser",
|
triggerUsername: "testuser",
|
||||||
};
|
};
|
||||||
@@ -322,7 +322,7 @@ describe("updateCommentBody", () => {
|
|||||||
branchName: "claude-branch-123",
|
branchName: "claude-branch-123",
|
||||||
prLink: "\n[Create a PR](https://github.com/owner/repo/pr-url)",
|
prLink: "\n[Create a PR](https://github.com/owner/repo/pr-url)",
|
||||||
executionDetails: {
|
executionDetails: {
|
||||||
cost_usd: 0.01,
|
total_cost_usd: 0.01,
|
||||||
duration_ms: 65000, // 1 minute 5 seconds
|
duration_ms: 65000, // 1 minute 5 seconds
|
||||||
},
|
},
|
||||||
triggerUsername: "trigger-user",
|
triggerUsername: "trigger-user",
|
||||||
|
|||||||
2
test/fixtures/sample-turns.json
vendored
2
test/fixtures/sample-turns.json
vendored
@@ -189,7 +189,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "result",
|
"type": "result",
|
||||||
"cost_usd": 0.0347,
|
"total_cost_usd": 0.0347,
|
||||||
"duration_ms": 18750,
|
"duration_ms": 18750,
|
||||||
"result": "Successfully removed debug print statement from file and added review comment to document the change."
|
"result": "Successfully removed debug print statement from file and added review comment to document the change."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -401,6 +401,53 @@ export const mockPullRequestReviewContext: ParsedGitHubContext = {
|
|||||||
inputs: { ...defaultInputs, triggerPhrase: "@claude" },
|
inputs: { ...defaultInputs, triggerPhrase: "@claude" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const mockPullRequestReviewWithoutCommentContext: ParsedGitHubContext = {
|
||||||
|
runId: "1234567890",
|
||||||
|
eventName: "pull_request_review",
|
||||||
|
eventAction: "dismissed",
|
||||||
|
repository: defaultRepository,
|
||||||
|
actor: "senior-developer",
|
||||||
|
payload: {
|
||||||
|
action: "submitted",
|
||||||
|
review: {
|
||||||
|
id: 11122233,
|
||||||
|
body: null, // Simulating approval without comment
|
||||||
|
user: {
|
||||||
|
login: "senior-developer",
|
||||||
|
id: 44444,
|
||||||
|
avatar_url: "https://avatars.githubusercontent.com/u/44444",
|
||||||
|
html_url: "https://github.com/senior-developer",
|
||||||
|
},
|
||||||
|
state: "approved",
|
||||||
|
html_url:
|
||||||
|
"https://github.com/test-owner/test-repo/pull/321#pullrequestreview-11122233",
|
||||||
|
submitted_at: "2024-01-15T15:30:00Z",
|
||||||
|
},
|
||||||
|
pull_request: {
|
||||||
|
number: 321,
|
||||||
|
title: "Refactor: Improve error handling in API layer",
|
||||||
|
body: "This PR improves error handling across all API endpoints",
|
||||||
|
user: {
|
||||||
|
login: "backend-developer",
|
||||||
|
id: 33333,
|
||||||
|
avatar_url: "https://avatars.githubusercontent.com/u/33333",
|
||||||
|
html_url: "https://github.com/backend-developer",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
repository: {
|
||||||
|
name: "test-repo",
|
||||||
|
full_name: "test-owner/test-repo",
|
||||||
|
private: false,
|
||||||
|
owner: {
|
||||||
|
login: "test-owner",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as PullRequestReviewEvent,
|
||||||
|
entityNumber: 321,
|
||||||
|
isPR: true,
|
||||||
|
inputs: { ...defaultInputs, triggerPhrase: "@claude" },
|
||||||
|
};
|
||||||
|
|
||||||
export const mockPullRequestReviewCommentContext: ParsedGitHubContext = {
|
export const mockPullRequestReviewCommentContext: ParsedGitHubContext = {
|
||||||
runId: "1234567890",
|
runId: "1234567890",
|
||||||
eventName: "pull_request_review_comment",
|
eventName: "pull_request_review_comment",
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ describe("Agent Mode", () => {
|
|||||||
claudeBranch: undefined,
|
claudeBranch: undefined,
|
||||||
},
|
},
|
||||||
mcpConfig: expect.any(String),
|
mcpConfig: expect.any(String),
|
||||||
|
promptContent: expect.any(String),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clean up
|
// Clean up
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
mockPullRequestCommentContext,
|
mockPullRequestCommentContext,
|
||||||
mockPullRequestReviewContext,
|
mockPullRequestReviewContext,
|
||||||
mockPullRequestReviewCommentContext,
|
mockPullRequestReviewCommentContext,
|
||||||
|
mockPullRequestReviewWithoutCommentContext,
|
||||||
} from "./mockContext";
|
} from "./mockContext";
|
||||||
|
|
||||||
const BASE_ENV = {
|
const BASE_ENV = {
|
||||||
@@ -126,6 +127,24 @@ describe("parseEnvVarsWithContext", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("pull_request_review event without comment", () => {
|
||||||
|
test("should parse pull_request_review event correctly", () => {
|
||||||
|
process.env = BASE_ENV;
|
||||||
|
const result = prepareContext(
|
||||||
|
mockPullRequestReviewWithoutCommentContext,
|
||||||
|
"12345",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.eventData.eventName).toBe("pull_request_review");
|
||||||
|
expect(result.eventData.isPR).toBe(true);
|
||||||
|
expect(result.triggerUsername).toBe("senior-developer");
|
||||||
|
if (result.eventData.eventName === "pull_request_review") {
|
||||||
|
expect(result.eventData.prNumber).toBe("321");
|
||||||
|
expect(result.eventData.commentBody).toBe("");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("pull_request_review_comment event", () => {
|
describe("pull_request_review_comment event", () => {
|
||||||
test("should parse pull_request_review_comment event correctly", () => {
|
test("should parse pull_request_review_comment event correctly", () => {
|
||||||
process.env = BASE_ENV;
|
process.env = BASE_ENV;
|
||||||
|
|||||||
201
test/validate-branch-name.test.ts
Normal file
201
test/validate-branch-name.test.ts
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
import { validateBranchName } from "../src/github/operations/branch";
|
||||||
|
|
||||||
|
describe("validateBranchName", () => {
|
||||||
|
describe("valid branch names", () => {
|
||||||
|
it("should accept simple alphanumeric names", () => {
|
||||||
|
expect(() => validateBranchName("main")).not.toThrow();
|
||||||
|
expect(() => validateBranchName("feature123")).not.toThrow();
|
||||||
|
expect(() => validateBranchName("Branch1")).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept names with hyphens", () => {
|
||||||
|
expect(() => validateBranchName("feature-branch")).not.toThrow();
|
||||||
|
expect(() => validateBranchName("fix-bug-123")).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept names with underscores", () => {
|
||||||
|
expect(() => validateBranchName("feature_branch")).not.toThrow();
|
||||||
|
expect(() => validateBranchName("fix_bug_123")).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept names with forward slashes", () => {
|
||||||
|
expect(() => validateBranchName("feature/new-thing")).not.toThrow();
|
||||||
|
expect(() => validateBranchName("user/feature/branch")).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept names with periods", () => {
|
||||||
|
expect(() => validateBranchName("v1.0.0")).not.toThrow();
|
||||||
|
expect(() => validateBranchName("release.1.2.3")).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept typical branch name formats", () => {
|
||||||
|
expect(() =>
|
||||||
|
validateBranchName("claude/issue-123-20250101-1234"),
|
||||||
|
).not.toThrow();
|
||||||
|
expect(() => validateBranchName("refs/heads/main")).not.toThrow();
|
||||||
|
expect(() => validateBranchName("bugfix/JIRA-1234")).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("command injection attempts", () => {
|
||||||
|
it("should reject shell command substitution with $()", () => {
|
||||||
|
expect(() => validateBranchName("$(whoami)")).toThrow();
|
||||||
|
expect(() => validateBranchName("branch-$(rm -rf /)")).toThrow();
|
||||||
|
expect(() => validateBranchName("test$(cat /etc/passwd)")).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject shell command substitution with backticks", () => {
|
||||||
|
expect(() => validateBranchName("`whoami`")).toThrow();
|
||||||
|
expect(() => validateBranchName("branch-`rm -rf /`")).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject command chaining with semicolons", () => {
|
||||||
|
expect(() => validateBranchName("branch; rm -rf /")).toThrow();
|
||||||
|
expect(() => validateBranchName("test;whoami")).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject command chaining with &&", () => {
|
||||||
|
expect(() => validateBranchName("branch && rm -rf /")).toThrow();
|
||||||
|
expect(() => validateBranchName("test&&whoami")).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject command chaining with ||", () => {
|
||||||
|
expect(() => validateBranchName("branch || rm -rf /")).toThrow();
|
||||||
|
expect(() => validateBranchName("test||whoami")).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject pipe characters", () => {
|
||||||
|
expect(() => validateBranchName("branch | cat")).toThrow();
|
||||||
|
expect(() => validateBranchName("test|grep password")).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject redirection operators", () => {
|
||||||
|
expect(() => validateBranchName("branch > /etc/passwd")).toThrow();
|
||||||
|
expect(() => validateBranchName("branch < input")).toThrow();
|
||||||
|
expect(() => validateBranchName("branch >> file")).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("option injection attempts", () => {
|
||||||
|
it("should reject branch names starting with dash", () => {
|
||||||
|
expect(() => validateBranchName("-x")).toThrow(
|
||||||
|
/cannot start with a dash/,
|
||||||
|
);
|
||||||
|
expect(() => validateBranchName("--help")).toThrow(
|
||||||
|
/cannot start with a dash/,
|
||||||
|
);
|
||||||
|
expect(() => validateBranchName("-")).toThrow(/cannot start with a dash/);
|
||||||
|
expect(() => validateBranchName("--version")).toThrow(
|
||||||
|
/cannot start with a dash/,
|
||||||
|
);
|
||||||
|
expect(() => validateBranchName("-rf")).toThrow(
|
||||||
|
/cannot start with a dash/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("path traversal attempts", () => {
|
||||||
|
it("should reject double dot sequences", () => {
|
||||||
|
expect(() => validateBranchName("../../../etc")).toThrow();
|
||||||
|
expect(() => validateBranchName("branch/../secret")).toThrow(/'\.\.'$/);
|
||||||
|
expect(() => validateBranchName("a..b")).toThrow(/'\.\.'$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("git-specific invalid patterns", () => {
|
||||||
|
it("should reject @{ sequence", () => {
|
||||||
|
expect(() => validateBranchName("branch@{1}")).toThrow(/@{/);
|
||||||
|
expect(() => validateBranchName("HEAD@{yesterday}")).toThrow(/@{/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject .lock suffix", () => {
|
||||||
|
expect(() => validateBranchName("branch.lock")).toThrow(/\.lock/);
|
||||||
|
expect(() => validateBranchName("feature.lock")).toThrow(/\.lock/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject consecutive slashes", () => {
|
||||||
|
expect(() => validateBranchName("feature//branch")).toThrow(
|
||||||
|
/consecutive slashes/,
|
||||||
|
);
|
||||||
|
expect(() => validateBranchName("a//b//c")).toThrow(
|
||||||
|
/consecutive slashes/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject trailing slashes", () => {
|
||||||
|
expect(() => validateBranchName("feature/")).toThrow(
|
||||||
|
/cannot end with a slash/,
|
||||||
|
);
|
||||||
|
expect(() => validateBranchName("branch/")).toThrow(
|
||||||
|
/cannot end with a slash/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject leading periods", () => {
|
||||||
|
expect(() => validateBranchName(".hidden")).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject trailing periods", () => {
|
||||||
|
expect(() => validateBranchName("branch.")).toThrow(
|
||||||
|
/cannot start or end with a period/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject special git refspec characters", () => {
|
||||||
|
expect(() => validateBranchName("branch~1")).toThrow();
|
||||||
|
expect(() => validateBranchName("branch^2")).toThrow();
|
||||||
|
expect(() => validateBranchName("branch:ref")).toThrow();
|
||||||
|
expect(() => validateBranchName("branch?")).toThrow();
|
||||||
|
expect(() => validateBranchName("branch*")).toThrow();
|
||||||
|
expect(() => validateBranchName("branch[0]")).toThrow();
|
||||||
|
expect(() => validateBranchName("branch\\path")).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("control characters and special characters", () => {
|
||||||
|
it("should reject null bytes", () => {
|
||||||
|
expect(() => validateBranchName("branch\x00name")).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject other control characters", () => {
|
||||||
|
expect(() => validateBranchName("branch\x01name")).toThrow();
|
||||||
|
expect(() => validateBranchName("branch\x1Fname")).toThrow();
|
||||||
|
expect(() => validateBranchName("branch\x7Fname")).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject spaces", () => {
|
||||||
|
expect(() => validateBranchName("branch name")).toThrow();
|
||||||
|
expect(() => validateBranchName("feature branch")).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject newlines and tabs", () => {
|
||||||
|
expect(() => validateBranchName("branch\nname")).toThrow();
|
||||||
|
expect(() => validateBranchName("branch\tname")).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("empty and whitespace", () => {
|
||||||
|
it("should reject empty strings", () => {
|
||||||
|
expect(() => validateBranchName("")).toThrow(/cannot be empty/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject whitespace-only strings", () => {
|
||||||
|
expect(() => validateBranchName(" ")).toThrow();
|
||||||
|
expect(() => validateBranchName("\t\n")).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("edge cases", () => {
|
||||||
|
it("should accept single alphanumeric character", () => {
|
||||||
|
expect(() => validateBranchName("a")).not.toThrow();
|
||||||
|
expect(() => validateBranchName("1")).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject single special characters", () => {
|
||||||
|
expect(() => validateBranchName(".")).toThrow();
|
||||||
|
expect(() => validateBranchName("/")).toThrow();
|
||||||
|
expect(() => validateBranchName("-")).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user