mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-23 23:14:13 +08:00
Compare commits
11 Commits
v1.0.29
...
inigo/stru
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
606f6af1c6 | ||
|
|
6bc261bb35 | ||
|
|
84265a4271 | ||
|
|
9d3bab5bc7 | ||
|
|
bf8f85ca9d | ||
|
|
f551cdf070 | ||
|
|
ec3a934da7 | ||
|
|
8cd2cc1236 | ||
|
|
dcee434ef2 | ||
|
|
e93583852d | ||
|
|
e600a516c7 |
132
.github/workflows/bump-claude-code-version.yml
vendored
Normal file
132
.github/workflows/bump-claude-code-version.yml
vendored
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
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-review.yml
vendored
2
.github/workflows/claude-review.yml
vendored
@@ -2,7 +2,7 @@ name: PR Review
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [opened]
|
types: [opened, synchronize, ready_for_review, reopened]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
review:
|
review:
|
||||||
|
|||||||
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-5"
|
--model "claude-opus-4-1-20250805"
|
||||||
|
|||||||
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,61 +118,3 @@ 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
|
|
||||||
|
|||||||
72
.github/workflows/test-structured-output.yml
vendored
72
.github/workflows/test-structured-output.yml
vendored
@@ -30,10 +30,19 @@ jobs:
|
|||||||
- number_field: 42
|
- number_field: 42
|
||||||
- boolean_true: true
|
- boolean_true: true
|
||||||
- boolean_false: false
|
- boolean_false: false
|
||||||
|
json_schema: |
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"text_field": {"type": "string"},
|
||||||
|
"number_field": {"type": "number"},
|
||||||
|
"boolean_true": {"type": "boolean"},
|
||||||
|
"boolean_false": {"type": "boolean"}
|
||||||
|
},
|
||||||
|
"required": ["text_field", "number_field", "boolean_true", "boolean_false"]
|
||||||
|
}
|
||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
claude_args: |
|
claude_args: "--allowedTools Bash"
|
||||||
--allowedTools Bash
|
|
||||||
--json-schema '{"type":"object","properties":{"text_field":{"type":"string"},"number_field":{"type":"number"},"boolean_true":{"type":"boolean"},"boolean_false":{"type":"boolean"}},"required":["text_field","number_field","boolean_true","boolean_false"]}'
|
|
||||||
|
|
||||||
- name: Verify outputs
|
- name: Verify outputs
|
||||||
run: |
|
run: |
|
||||||
@@ -88,10 +97,21 @@ jobs:
|
|||||||
- items: ["apple", "banana", "cherry"]
|
- items: ["apple", "banana", "cherry"]
|
||||||
- config: {"key": "value", "count": 3}
|
- config: {"key": "value", "count": 3}
|
||||||
- empty_array: []
|
- empty_array: []
|
||||||
|
json_schema: |
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"}
|
||||||
|
},
|
||||||
|
"config": {"type": "object"},
|
||||||
|
"empty_array": {"type": "array"}
|
||||||
|
},
|
||||||
|
"required": ["items", "config", "empty_array"]
|
||||||
|
}
|
||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
claude_args: |
|
claude_args: "--allowedTools Bash"
|
||||||
--allowedTools Bash
|
|
||||||
--json-schema '{"type":"object","properties":{"items":{"type":"array","items":{"type":"string"}},"config":{"type":"object"},"empty_array":{"type":"array"}},"required":["items","config","empty_array"]}'
|
|
||||||
|
|
||||||
- name: Verify JSON stringification
|
- name: Verify JSON stringification
|
||||||
run: |
|
run: |
|
||||||
@@ -140,10 +160,19 @@ jobs:
|
|||||||
- empty_string: ""
|
- empty_string: ""
|
||||||
- negative: -5
|
- negative: -5
|
||||||
- decimal: 3.14
|
- decimal: 3.14
|
||||||
|
json_schema: |
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"zero": {"type": "number"},
|
||||||
|
"empty_string": {"type": "string"},
|
||||||
|
"negative": {"type": "number"},
|
||||||
|
"decimal": {"type": "number"}
|
||||||
|
},
|
||||||
|
"required": ["zero", "empty_string", "negative", "decimal"]
|
||||||
|
}
|
||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
claude_args: |
|
claude_args: "--allowedTools Bash"
|
||||||
--allowedTools Bash
|
|
||||||
--json-schema '{"type":"object","properties":{"zero":{"type":"number"},"empty_string":{"type":"string"},"negative":{"type":"number"},"decimal":{"type":"number"}},"required":["zero","empty_string","negative","decimal"]}'
|
|
||||||
|
|
||||||
- name: Verify edge cases
|
- name: Verify edge cases
|
||||||
run: |
|
run: |
|
||||||
@@ -194,10 +223,17 @@ jobs:
|
|||||||
prompt: |
|
prompt: |
|
||||||
Run: echo "test"
|
Run: echo "test"
|
||||||
Return EXACTLY: {test-result: "passed", item_count: 10}
|
Return EXACTLY: {test-result: "passed", item_count: 10}
|
||||||
|
json_schema: |
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"test-result": {"type": "string"},
|
||||||
|
"item_count": {"type": "number"}
|
||||||
|
},
|
||||||
|
"required": ["test-result", "item_count"]
|
||||||
|
}
|
||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
claude_args: |
|
claude_args: "--allowedTools Bash"
|
||||||
--allowedTools Bash
|
|
||||||
--json-schema '{"type":"object","properties":{"test-result":{"type":"string"},"item_count":{"type":"number"}},"required":["test-result","item_count"]}'
|
|
||||||
|
|
||||||
- name: Verify sanitized names work
|
- name: Verify sanitized names work
|
||||||
run: |
|
run: |
|
||||||
@@ -232,10 +268,16 @@ jobs:
|
|||||||
uses: ./base-action
|
uses: ./base-action
|
||||||
with:
|
with:
|
||||||
prompt: "Run: echo 'complete'. Return: {done: true}"
|
prompt: "Run: echo 'complete'. Return: {done: true}"
|
||||||
|
json_schema: |
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"done": {"type": "boolean"}
|
||||||
|
},
|
||||||
|
"required": ["done"]
|
||||||
|
}
|
||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
claude_args: |
|
claude_args: "--allowedTools Bash"
|
||||||
--allowedTools Bash
|
|
||||||
--json-schema '{"type":"object","properties":{"done":{"type":"boolean"}},"required":["done"]}'
|
|
||||||
|
|
||||||
- name: Verify execution file contains structured_output
|
- name: Verify execution file contains structured_output
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# Claude Code Action
|
# Claude Code Action
|
||||||
|
|
||||||
A general-purpose [Claude Code](https://claude.ai/code) action for GitHub PRs and issues that can answer questions and implement code changes. This action intelligently detects when to activate based on your workflow context—whether responding to @claude mentions, issue assignments, or executing automation tasks with explicit prompts. It supports multiple authentication methods including Anthropic direct API, Amazon Bedrock, Google Vertex AI, and Microsoft Foundry.
|
A general-purpose [Claude Code](https://claude.ai/code) action for GitHub PRs and issues that can answer questions and implement code changes. This action intelligently detects when to activate based on your workflow context—whether responding to @claude mentions, issue assignments, or executing automation tasks with explicit prompts. It supports multiple authentication methods including Anthropic direct API, Amazon Bedrock, and Google Vertex AI.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ This command will guide you through setting up the GitHub app and required secre
|
|||||||
**Note**:
|
**Note**:
|
||||||
|
|
||||||
- You must be a repository admin to install the GitHub app and add secrets
|
- You must be a repository admin to install the GitHub app and add secrets
|
||||||
- This quickstart method is only available for direct Anthropic API users. For AWS Bedrock, Google Vertex AI, or Microsoft Foundry setup, see [docs/cloud-providers.md](./docs/cloud-providers.md).
|
- This quickstart method is only available for direct Anthropic API users. For AWS Bedrock or Google Vertex AI setup, see [docs/cloud-providers.md](./docs/cloud-providers.md).
|
||||||
|
|
||||||
## 📚 Solutions & Use Cases
|
## 📚 Solutions & Use Cases
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ Each solution includes complete working examples, configuration details, and exp
|
|||||||
- [Custom Automations](./docs/custom-automations.md) - Examples of automated workflows and custom prompts
|
- [Custom Automations](./docs/custom-automations.md) - Examples of automated workflows and custom prompts
|
||||||
- [Configuration](./docs/configuration.md) - MCP servers, permissions, environment variables, and advanced settings
|
- [Configuration](./docs/configuration.md) - MCP servers, permissions, environment variables, and advanced settings
|
||||||
- [Experimental Features](./docs/experimental.md) - Execution modes and network restrictions
|
- [Experimental Features](./docs/experimental.md) - Execution modes and network restrictions
|
||||||
- [Cloud Providers](./docs/cloud-providers.md) - AWS Bedrock, Google Vertex AI, and Microsoft Foundry setup
|
- [Cloud Providers](./docs/cloud-providers.md) - AWS Bedrock and Google Vertex AI setup
|
||||||
- [Capabilities & Limitations](./docs/capabilities-and-limitations.md) - What Claude can and cannot do
|
- [Capabilities & Limitations](./docs/capabilities-and-limitations.md) - What Claude can and cannot do
|
||||||
- [Security](./docs/security.md) - Access control, permissions, and commit signing
|
- [Security](./docs/security.md) - Access control, permissions, and commit signing
|
||||||
- [FAQ](./docs/faq.md) - Common questions and troubleshooting
|
- [FAQ](./docs/faq.md) - Common questions and troubleshooting
|
||||||
|
|||||||
94
action.yml
94
action.yml
@@ -23,10 +23,6 @@ inputs:
|
|||||||
description: "The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format)"
|
description: "The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format)"
|
||||||
required: false
|
required: false
|
||||||
default: "claude/"
|
default: "claude/"
|
||||||
branch_name_template:
|
|
||||||
description: "Template for branch naming. Available variables: {{prefix}}, {{entityType}}, {{entityNumber}}, {{timestamp}}, {{sha}}, {{label}}, {{description}}. {{label}} will be first label from the issue/PR, or {{entityType}} as a fallback. {{description}} will be the first 5 words of the issue/PR title in kebab-case. Default: '{{prefix}}{{entityType}}-{{entityNumber}}-{{timestamp}}'"
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
allowed_bots:
|
allowed_bots:
|
||||||
description: "Comma-separated list of allowed bot usernames, or '*' to allow all bots. Empty string (default) allows no bots."
|
description: "Comma-separated list of allowed bot usernames, or '*' to allow all bots. Empty string (default) allows no bots."
|
||||||
required: false
|
required: false
|
||||||
@@ -48,7 +44,7 @@ inputs:
|
|||||||
|
|
||||||
# Auth configuration
|
# Auth configuration
|
||||||
anthropic_api_key:
|
anthropic_api_key:
|
||||||
description: "Anthropic API key (required for direct API, not needed for Bedrock/Vertex/Foundry)"
|
description: "Anthropic API key (required for direct API, not needed for Bedrock/Vertex)"
|
||||||
required: false
|
required: false
|
||||||
claude_code_oauth_token:
|
claude_code_oauth_token:
|
||||||
description: "Claude Code OAuth token (alternative to anthropic_api_key)"
|
description: "Claude Code OAuth token (alternative to anthropic_api_key)"
|
||||||
@@ -64,10 +60,6 @@ inputs:
|
|||||||
description: "Use Google Vertex AI with OIDC authentication instead of direct Anthropic API"
|
description: "Use Google Vertex AI with OIDC authentication instead of direct Anthropic API"
|
||||||
required: false
|
required: false
|
||||||
default: "false"
|
default: "false"
|
||||||
use_foundry:
|
|
||||||
description: "Use Microsoft Foundry with OIDC authentication instead of direct Anthropic API"
|
|
||||||
required: false
|
|
||||||
default: "false"
|
|
||||||
|
|
||||||
claude_args:
|
claude_args:
|
||||||
description: "Additional arguments to pass directly to Claude CLI"
|
description: "Additional arguments to pass directly to Claude CLI"
|
||||||
@@ -85,10 +77,6 @@ inputs:
|
|||||||
description: "Enable commit signing using GitHub's commit signature verification. When false, Claude uses standard git commands"
|
description: "Enable commit signing using GitHub's commit signature verification. When false, Claude uses standard git commands"
|
||||||
required: false
|
required: false
|
||||||
default: "false"
|
default: "false"
|
||||||
ssh_signing_key:
|
|
||||||
description: "SSH private key for signing commits. When provided, git will be configured to use SSH signing. Takes precedence over use_commit_signing."
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
bot_id:
|
bot_id:
|
||||||
description: "GitHub user ID to use for git operations (defaults to Claude's bot ID)"
|
description: "GitHub user ID to use for git operations (defaults to Claude's bot ID)"
|
||||||
required: false
|
required: false
|
||||||
@@ -101,10 +89,10 @@ inputs:
|
|||||||
description: "Force tag mode with tracking comments for pull_request and issue events. Only applicable to pull_request (opened, synchronize, ready_for_review, reopened) and issue (opened, edited, labeled, assigned) events."
|
description: "Force tag mode with tracking comments for pull_request and issue events. Only applicable to pull_request (opened, synchronize, ready_for_review, reopened) and issue (opened, edited, labeled, assigned) events."
|
||||||
required: false
|
required: false
|
||||||
default: "false"
|
default: "false"
|
||||||
include_fix_links:
|
experimental_allowed_domains:
|
||||||
description: "Include 'Fix this' links in PR code review feedback that open Claude Code with context to fix the identified issue"
|
description: "Restrict network access to these domains only (newline-separated). If not set, no restrictions are applied. Provider domains are auto-detected."
|
||||||
required: false
|
required: false
|
||||||
default: "true"
|
default: ""
|
||||||
path_to_claude_code_executable:
|
path_to_claude_code_executable:
|
||||||
description: "Optional path to a custom Claude Code executable. If provided, skips automatic installation and uses this executable instead. WARNING: Using an older version may cause problems if the action begins taking advantage of new Claude Code features. This input is typically not needed unless you're debugging something specific or have unique needs in your environment."
|
description: "Optional path to a custom Claude Code executable. If provided, skips automatic installation and uses this executable instead. WARNING: Using an older version may cause problems if the action begins taking advantage of new Claude Code features. This input is typically not needed unless you're debugging something specific or have unique needs in your environment."
|
||||||
required: false
|
required: false
|
||||||
@@ -125,6 +113,10 @@ inputs:
|
|||||||
description: "Newline-separated list of Claude Code plugin marketplace Git URLs to install from (e.g., 'https://github.com/user/marketplace1.git\nhttps://github.com/user/marketplace2.git')"
|
description: "Newline-separated list of Claude Code plugin marketplace Git URLs to install from (e.g., 'https://github.com/user/marketplace1.git\nhttps://github.com/user/marketplace2.git')"
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
|
json_schema:
|
||||||
|
description: "JSON schema for structured output validation. When provided, Claude will return validated JSON matching this schema. All fields are available in the structured_output output as a JSON string (use fromJSON() or jq to access fields)."
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
|
||||||
outputs:
|
outputs:
|
||||||
execution_file:
|
execution_file:
|
||||||
@@ -137,11 +129,8 @@ outputs:
|
|||||||
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.prepare.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 input is provided. 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"
|
||||||
@@ -155,12 +144,10 @@ 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: $PATH_TO_BUN_EXECUTABLE"
|
echo "Using custom Bun executable: ${{ inputs.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 "$PATH_TO_BUN_EXECUTABLE")
|
BUN_DIR=$(dirname "${{ inputs.path_to_bun_executable }}")
|
||||||
echo "$BUN_DIR" >> "$GITHUB_PATH"
|
echo "$BUN_DIR" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
@@ -182,7 +169,6 @@ runs:
|
|||||||
LABEL_TRIGGER: ${{ inputs.label_trigger }}
|
LABEL_TRIGGER: ${{ inputs.label_trigger }}
|
||||||
BASE_BRANCH: ${{ inputs.base_branch }}
|
BASE_BRANCH: ${{ inputs.base_branch }}
|
||||||
BRANCH_PREFIX: ${{ inputs.branch_prefix }}
|
BRANCH_PREFIX: ${{ inputs.branch_prefix }}
|
||||||
BRANCH_NAME_TEMPLATE: ${{ inputs.branch_name_template }}
|
|
||||||
OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }}
|
OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }}
|
||||||
ALLOWED_BOTS: ${{ inputs.allowed_bots }}
|
ALLOWED_BOTS: ${{ inputs.allowed_bots }}
|
||||||
ALLOWED_NON_WRITE_USERS: ${{ inputs.allowed_non_write_users }}
|
ALLOWED_NON_WRITE_USERS: ${{ inputs.allowed_non_write_users }}
|
||||||
@@ -190,20 +176,17 @@ runs:
|
|||||||
USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }}
|
USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }}
|
||||||
DEFAULT_WORKFLOW_TOKEN: ${{ github.token }}
|
DEFAULT_WORKFLOW_TOKEN: ${{ github.token }}
|
||||||
USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }}
|
USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }}
|
||||||
SSH_SIGNING_KEY: ${{ inputs.ssh_signing_key }}
|
|
||||||
BOT_ID: ${{ inputs.bot_id }}
|
BOT_ID: ${{ inputs.bot_id }}
|
||||||
BOT_NAME: ${{ inputs.bot_name }}
|
BOT_NAME: ${{ inputs.bot_name }}
|
||||||
TRACK_PROGRESS: ${{ inputs.track_progress }}
|
TRACK_PROGRESS: ${{ inputs.track_progress }}
|
||||||
INCLUDE_FIX_LINKS: ${{ inputs.include_fix_links }}
|
|
||||||
ADDITIONAL_PERMISSIONS: ${{ inputs.additional_permissions }}
|
ADDITIONAL_PERMISSIONS: ${{ inputs.additional_permissions }}
|
||||||
CLAUDE_ARGS: ${{ inputs.claude_args }}
|
CLAUDE_ARGS: ${{ inputs.claude_args }}
|
||||||
|
JSON_SCHEMA: ${{ inputs.json_schema }}
|
||||||
ALL_INPUTS: ${{ toJson(inputs) }}
|
ALL_INPUTS: ${{ toJson(inputs) }}
|
||||||
|
|
||||||
- name: Install Base Action Dependencies
|
- name: Install Base Action Dependencies
|
||||||
if: steps.prepare.outputs.contains_trigger == 'true'
|
if: steps.prepare.outputs.contains_trigger == 'true'
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
|
||||||
PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
|
|
||||||
run: |
|
run: |
|
||||||
echo "Installing base-action dependencies..."
|
echo "Installing base-action dependencies..."
|
||||||
cd ${GITHUB_ACTION_PATH}/base-action
|
cd ${GITHUB_ACTION_PATH}/base-action
|
||||||
@@ -212,33 +195,26 @@ runs:
|
|||||||
cd -
|
cd -
|
||||||
|
|
||||||
# Install Claude Code if no custom executable is provided
|
# Install Claude Code if no custom executable is provided
|
||||||
if [ -z "$PATH_TO_CLAUDE_CODE_EXECUTABLE" ]; then
|
if [ -z "${{ inputs.path_to_claude_code_executable }}" ]; then
|
||||||
CLAUDE_CODE_VERSION="2.1.1"
|
echo "Installing Claude Code..."
|
||||||
echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..."
|
curl -fsSL https://claude.ai/install.sh | bash -s 2.0.42
|
||||||
for attempt in 1 2 3; do
|
|
||||||
echo "Installation attempt $attempt..."
|
|
||||||
if command -v timeout &> /dev/null; then
|
|
||||||
# Use --foreground to kill entire process group on timeout, --kill-after to send SIGKILL if SIGTERM fails
|
|
||||||
timeout --foreground --kill-after=10 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"
|
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||||
else
|
else
|
||||||
echo "Using custom Claude Code executable: $PATH_TO_CLAUDE_CODE_EXECUTABLE"
|
echo "Using custom Claude Code executable: ${{ inputs.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 "$PATH_TO_CLAUDE_CODE_EXECUTABLE")
|
CLAUDE_DIR=$(dirname "${{ inputs.path_to_claude_code_executable }}")
|
||||||
echo "$CLAUDE_DIR" >> "$GITHUB_PATH"
|
echo "$CLAUDE_DIR" >> "$GITHUB_PATH"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
- name: Setup Network Restrictions
|
||||||
|
if: steps.prepare.outputs.contains_trigger == 'true' && inputs.experimental_allowed_domains != ''
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
chmod +x ${GITHUB_ACTION_PATH}/scripts/setup-network-restrictions.sh
|
||||||
|
${GITHUB_ACTION_PATH}/scripts/setup-network-restrictions.sh
|
||||||
|
env:
|
||||||
|
EXPERIMENTAL_ALLOWED_DOMAINS: ${{ inputs.experimental_allowed_domains }}
|
||||||
|
|
||||||
- name: Run Claude Code
|
- name: Run Claude Code
|
||||||
id: claude-code
|
id: claude-code
|
||||||
if: steps.prepare.outputs.contains_trigger == 'true'
|
if: steps.prepare.outputs.contains_trigger == 'true'
|
||||||
@@ -260,10 +236,10 @@ runs:
|
|||||||
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 }}
|
||||||
|
JSON_SCHEMA: ${{ inputs.json_schema }}
|
||||||
|
|
||||||
# Model configuration
|
# Model configuration
|
||||||
GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }}
|
||||||
GH_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }}
|
|
||||||
NODE_VERSION: ${{ env.NODE_VERSION }}
|
NODE_VERSION: ${{ env.NODE_VERSION }}
|
||||||
DETAILED_PERMISSION_MESSAGES: "1"
|
DETAILED_PERMISSION_MESSAGES: "1"
|
||||||
|
|
||||||
@@ -274,14 +250,12 @@ runs:
|
|||||||
ANTHROPIC_CUSTOM_HEADERS: ${{ env.ANTHROPIC_CUSTOM_HEADERS }}
|
ANTHROPIC_CUSTOM_HEADERS: ${{ env.ANTHROPIC_CUSTOM_HEADERS }}
|
||||||
CLAUDE_CODE_USE_BEDROCK: ${{ inputs.use_bedrock == 'true' && '1' || '' }}
|
CLAUDE_CODE_USE_BEDROCK: ${{ inputs.use_bedrock == 'true' && '1' || '' }}
|
||||||
CLAUDE_CODE_USE_VERTEX: ${{ inputs.use_vertex == 'true' && '1' || '' }}
|
CLAUDE_CODE_USE_VERTEX: ${{ inputs.use_vertex == 'true' && '1' || '' }}
|
||||||
CLAUDE_CODE_USE_FOUNDRY: ${{ inputs.use_foundry == 'true' && '1' || '' }}
|
|
||||||
|
|
||||||
# AWS configuration
|
# AWS configuration
|
||||||
AWS_REGION: ${{ env.AWS_REGION }}
|
AWS_REGION: ${{ env.AWS_REGION }}
|
||||||
AWS_ACCESS_KEY_ID: ${{ env.AWS_ACCESS_KEY_ID }}
|
AWS_ACCESS_KEY_ID: ${{ env.AWS_ACCESS_KEY_ID }}
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ env.AWS_SECRET_ACCESS_KEY }}
|
AWS_SECRET_ACCESS_KEY: ${{ env.AWS_SECRET_ACCESS_KEY }}
|
||||||
AWS_SESSION_TOKEN: ${{ env.AWS_SESSION_TOKEN }}
|
AWS_SESSION_TOKEN: ${{ env.AWS_SESSION_TOKEN }}
|
||||||
AWS_BEARER_TOKEN_BEDROCK: ${{ env.AWS_BEARER_TOKEN_BEDROCK }}
|
|
||||||
ANTHROPIC_BEDROCK_BASE_URL: ${{ env.ANTHROPIC_BEDROCK_BASE_URL || (env.AWS_REGION && format('https://bedrock-runtime.{0}.amazonaws.com', env.AWS_REGION)) }}
|
ANTHROPIC_BEDROCK_BASE_URL: ${{ env.ANTHROPIC_BEDROCK_BASE_URL || (env.AWS_REGION && format('https://bedrock-runtime.{0}.amazonaws.com', env.AWS_REGION)) }}
|
||||||
|
|
||||||
# GCP configuration
|
# GCP configuration
|
||||||
@@ -295,13 +269,6 @@ runs:
|
|||||||
VERTEX_REGION_CLAUDE_3_5_SONNET: ${{ env.VERTEX_REGION_CLAUDE_3_5_SONNET }}
|
VERTEX_REGION_CLAUDE_3_5_SONNET: ${{ env.VERTEX_REGION_CLAUDE_3_5_SONNET }}
|
||||||
VERTEX_REGION_CLAUDE_3_7_SONNET: ${{ env.VERTEX_REGION_CLAUDE_3_7_SONNET }}
|
VERTEX_REGION_CLAUDE_3_7_SONNET: ${{ env.VERTEX_REGION_CLAUDE_3_7_SONNET }}
|
||||||
|
|
||||||
# Microsoft Foundry configuration
|
|
||||||
ANTHROPIC_FOUNDRY_RESOURCE: ${{ env.ANTHROPIC_FOUNDRY_RESOURCE }}
|
|
||||||
ANTHROPIC_FOUNDRY_BASE_URL: ${{ env.ANTHROPIC_FOUNDRY_BASE_URL }}
|
|
||||||
ANTHROPIC_DEFAULT_SONNET_MODEL: ${{ env.ANTHROPIC_DEFAULT_SONNET_MODEL }}
|
|
||||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: ${{ env.ANTHROPIC_DEFAULT_HAIKU_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.prepare.outputs.contains_trigger == 'true' && steps.prepare.outputs.claude_comment_id && always()
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -313,7 +280,6 @@ runs:
|
|||||||
CLAUDE_COMMENT_ID: ${{ steps.prepare.outputs.claude_comment_id }}
|
CLAUDE_COMMENT_ID: ${{ steps.prepare.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.prepare.outputs.GITHUB_TOKEN }}
|
||||||
GH_TOKEN: ${{ steps.prepare.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.prepare.outputs.CLAUDE_BRANCH }}
|
||||||
@@ -345,12 +311,6 @@ runs:
|
|||||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Cleanup SSH signing key
|
|
||||||
if: always() && inputs.ssh_signing_key != ''
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
bun run ${GITHUB_ACTION_PATH}/src/entrypoints/cleanup-ssh-signing.ts
|
|
||||||
|
|
||||||
- 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.prepare.outputs.skipped_due_to_workflow_validation_mismatch != 'true'
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|||||||
@@ -42,10 +42,6 @@ inputs:
|
|||||||
description: "Use Google Vertex AI with OIDC authentication instead of direct Anthropic API"
|
description: "Use Google Vertex AI with OIDC authentication instead of direct Anthropic API"
|
||||||
required: false
|
required: false
|
||||||
default: "false"
|
default: "false"
|
||||||
use_foundry:
|
|
||||||
description: "Use Microsoft Foundry with OIDC authentication instead of direct Anthropic API"
|
|
||||||
required: false
|
|
||||||
default: "false"
|
|
||||||
|
|
||||||
use_node_cache:
|
use_node_cache:
|
||||||
description: "Whether to use Node.js dependency caching (set to true only for Node.js projects with lock files)"
|
description: "Whether to use Node.js dependency caching (set to true only for Node.js projects with lock files)"
|
||||||
@@ -71,6 +67,14 @@ inputs:
|
|||||||
description: "Newline-separated list of Claude Code plugin marketplace Git URLs to install from (e.g., 'https://github.com/user/marketplace1.git\nhttps://github.com/user/marketplace2.git')"
|
description: "Newline-separated list of Claude Code plugin marketplace Git URLs to install from (e.g., 'https://github.com/user/marketplace1.git\nhttps://github.com/user/marketplace2.git')"
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
|
json_schema:
|
||||||
|
description: |
|
||||||
|
JSON schema for structured output validation. Claude must return JSON matching this schema
|
||||||
|
or the action will fail. All fields are returned in a single structured_output JSON string.
|
||||||
|
|
||||||
|
Access outputs via: fromJSON(steps.<step-id>.outputs.structured_output).<field_name>
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
|
||||||
outputs:
|
outputs:
|
||||||
conclusion:
|
conclusion:
|
||||||
@@ -80,11 +84,8 @@ outputs:
|
|||||||
description: "Path to the JSON file containing Claude Code execution log"
|
description: "Path to the JSON file containing Claude Code execution log"
|
||||||
value: ${{ steps.run_claude.outputs.execution_file }}
|
value: ${{ steps.run_claude.outputs.execution_file }}
|
||||||
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 (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"
|
||||||
@@ -104,12 +105,10 @@ 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: $PATH_TO_BUN_EXECUTABLE"
|
echo "Using custom Bun executable: ${{ inputs.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 "$PATH_TO_BUN_EXECUTABLE")
|
BUN_DIR=$(dirname "${{ inputs.path_to_bun_executable }}")
|
||||||
echo "$BUN_DIR" >> "$GITHUB_PATH"
|
echo "$BUN_DIR" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
@@ -120,32 +119,14 @@ 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 "$PATH_TO_CLAUDE_CODE_EXECUTABLE" ]; then
|
if [ -z "${{ inputs.path_to_claude_code_executable }}" ]; then
|
||||||
CLAUDE_CODE_VERSION="2.1.1"
|
echo "Installing Claude Code..."
|
||||||
echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..."
|
curl -fsSL https://claude.ai/install.sh | bash -s 2.0.45
|
||||||
for attempt in 1 2 3; do
|
|
||||||
echo "Installation attempt $attempt..."
|
|
||||||
if command -v timeout &> /dev/null; then
|
|
||||||
# Use --foreground to kill entire process group on timeout, --kill-after to send SIGKILL if SIGTERM fails
|
|
||||||
timeout --foreground --kill-after=10 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"
|
|
||||||
else
|
else
|
||||||
echo "Using custom Claude Code executable: $PATH_TO_CLAUDE_CODE_EXECUTABLE"
|
echo "Using custom Claude Code executable: ${{ inputs.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 "$PATH_TO_CLAUDE_CODE_EXECUTABLE")
|
CLAUDE_DIR=$(dirname "${{ inputs.path_to_claude_code_executable }}")
|
||||||
echo "$CLAUDE_DIR" >> "$GITHUB_PATH"
|
echo "$CLAUDE_DIR" >> "$GITHUB_PATH"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -171,6 +152,7 @@ runs:
|
|||||||
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 }}
|
||||||
|
JSON_SCHEMA: ${{ inputs.json_schema }}
|
||||||
|
|
||||||
# Provider configuration
|
# Provider configuration
|
||||||
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
|
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
|
||||||
@@ -180,14 +162,12 @@ runs:
|
|||||||
# Only set provider flags if explicitly true, since any value (including "false") is truthy
|
# Only set provider flags if explicitly true, since any value (including "false") is truthy
|
||||||
CLAUDE_CODE_USE_BEDROCK: ${{ inputs.use_bedrock == 'true' && '1' || '' }}
|
CLAUDE_CODE_USE_BEDROCK: ${{ inputs.use_bedrock == 'true' && '1' || '' }}
|
||||||
CLAUDE_CODE_USE_VERTEX: ${{ inputs.use_vertex == 'true' && '1' || '' }}
|
CLAUDE_CODE_USE_VERTEX: ${{ inputs.use_vertex == 'true' && '1' || '' }}
|
||||||
CLAUDE_CODE_USE_FOUNDRY: ${{ inputs.use_foundry == 'true' && '1' || '' }}
|
|
||||||
|
|
||||||
# AWS configuration
|
# AWS configuration
|
||||||
AWS_REGION: ${{ env.AWS_REGION }}
|
AWS_REGION: ${{ env.AWS_REGION }}
|
||||||
AWS_ACCESS_KEY_ID: ${{ env.AWS_ACCESS_KEY_ID }}
|
AWS_ACCESS_KEY_ID: ${{ env.AWS_ACCESS_KEY_ID }}
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ env.AWS_SECRET_ACCESS_KEY }}
|
AWS_SECRET_ACCESS_KEY: ${{ env.AWS_SECRET_ACCESS_KEY }}
|
||||||
AWS_SESSION_TOKEN: ${{ env.AWS_SESSION_TOKEN }}
|
AWS_SESSION_TOKEN: ${{ env.AWS_SESSION_TOKEN }}
|
||||||
AWS_BEARER_TOKEN_BEDROCK: ${{ env.AWS_BEARER_TOKEN_BEDROCK }}
|
|
||||||
ANTHROPIC_BEDROCK_BASE_URL: ${{ env.ANTHROPIC_BEDROCK_BASE_URL || (env.AWS_REGION && format('https://bedrock-runtime.{0}.amazonaws.com', env.AWS_REGION)) }}
|
ANTHROPIC_BEDROCK_BASE_URL: ${{ env.ANTHROPIC_BEDROCK_BASE_URL || (env.AWS_REGION && format('https://bedrock-runtime.{0}.amazonaws.com', env.AWS_REGION)) }}
|
||||||
|
|
||||||
# GCP configuration
|
# GCP configuration
|
||||||
@@ -195,10 +175,3 @@ runs:
|
|||||||
CLOUD_ML_REGION: ${{ env.CLOUD_ML_REGION }}
|
CLOUD_ML_REGION: ${{ env.CLOUD_ML_REGION }}
|
||||||
GOOGLE_APPLICATION_CREDENTIALS: ${{ env.GOOGLE_APPLICATION_CREDENTIALS }}
|
GOOGLE_APPLICATION_CREDENTIALS: ${{ env.GOOGLE_APPLICATION_CREDENTIALS }}
|
||||||
ANTHROPIC_VERTEX_BASE_URL: ${{ env.ANTHROPIC_VERTEX_BASE_URL }}
|
ANTHROPIC_VERTEX_BASE_URL: ${{ env.ANTHROPIC_VERTEX_BASE_URL }}
|
||||||
|
|
||||||
# Microsoft Foundry configuration
|
|
||||||
ANTHROPIC_FOUNDRY_RESOURCE: ${{ env.ANTHROPIC_FOUNDRY_RESOURCE }}
|
|
||||||
ANTHROPIC_FOUNDRY_BASE_URL: ${{ env.ANTHROPIC_FOUNDRY_BASE_URL }}
|
|
||||||
ANTHROPIC_DEFAULT_SONNET_MODEL: ${{ env.ANTHROPIC_DEFAULT_SONNET_MODEL }}
|
|
||||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: ${{ env.ANTHROPIC_DEFAULT_HAIKU_MODEL }}
|
|
||||||
ANTHROPIC_DEFAULT_OPUS_MODEL: ${{ env.ANTHROPIC_DEFAULT_OPUS_MODEL }}
|
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
{
|
{
|
||||||
"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.2.1",
|
|
||||||
"shell-quote": "^1.8.3",
|
"shell-quote": "^1.8.3",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -27,40 +25,8 @@
|
|||||||
|
|
||||||
"@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.2.1", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-linuxmusl-arm64": "^0.33.5", "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-ZJO/TWcrFHGQTGHJDJl03mWozirWMBqdNpbuAgxZpLaHj2N5vyMxoeYiJC+7M0+gOSs7bjwKJLKTZcHGtGa34g=="],
|
|
||||||
|
|
||||||
"@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=="],
|
||||||
@@ -84,7 +50,5 @@
|
|||||||
"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,7 +11,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "^1.10.1",
|
"@actions/core": "^1.10.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.1",
|
|
||||||
"shell-quote": "^1.8.3"
|
"shell-quote": "^1.8.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -28,8 +28,22 @@ async function run() {
|
|||||||
promptFile: process.env.INPUT_PROMPT_FILE || "",
|
promptFile: process.env.INPUT_PROMPT_FILE || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Build claudeArgs with JSON schema if provided
|
||||||
|
let claudeArgs = process.env.INPUT_CLAUDE_ARGS || "";
|
||||||
|
|
||||||
|
// Add allowed tools if specified
|
||||||
|
if (process.env.INPUT_ALLOWED_TOOLS) {
|
||||||
|
claudeArgs += ` --allowedTools "${process.env.INPUT_ALLOWED_TOOLS}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add JSON schema if specified (no escaping - parseShellArgs handles it)
|
||||||
|
if (process.env.JSON_SCHEMA) {
|
||||||
|
// Wrap in single quotes for parseShellArgs
|
||||||
|
claudeArgs += ` --json-schema '${process.env.JSON_SCHEMA}'`;
|
||||||
|
}
|
||||||
|
|
||||||
await runClaude(promptConfig.path, {
|
await runClaude(promptConfig.path, {
|
||||||
claudeArgs: process.env.INPUT_CLAUDE_ARGS,
|
claudeArgs: claudeArgs.trim(),
|
||||||
allowedTools: process.env.INPUT_ALLOWED_TOOLS,
|
allowedTools: process.env.INPUT_ALLOWED_TOOLS,
|
||||||
disallowedTools: process.env.INPUT_DISALLOWED_TOOLS,
|
disallowedTools: process.env.INPUT_DISALLOWED_TOOLS,
|
||||||
maxTurns: process.env.INPUT_MAX_TURNS,
|
maxTurns: process.env.INPUT_MAX_TURNS,
|
||||||
|
|||||||
@@ -8,47 +8,26 @@ const MARKETPLACE_URL_REGEX =
|
|||||||
/^https:\/\/[a-zA-Z0-9\-._~:/?#[\]@!$&'()*+,;=%]+\.git$/;
|
/^https:\/\/[a-zA-Z0-9\-._~:/?#[\]@!$&'()*+,;=%]+\.git$/;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if a marketplace input is a local path (not a URL)
|
* Validates a marketplace URL for security issues
|
||||||
* @param input - The marketplace input to check
|
* @param url - The marketplace URL to validate
|
||||||
* @returns true if the input is a local path, false if it's a URL
|
* @throws {Error} If the URL is invalid
|
||||||
*/
|
*/
|
||||||
function isLocalPath(input: string): boolean {
|
function validateMarketplaceUrl(url: string): void {
|
||||||
// Local paths start with ./, ../, /, or a drive letter (Windows)
|
const normalized = url.trim();
|
||||||
return (
|
|
||||||
input.startsWith("./") ||
|
|
||||||
input.startsWith("../") ||
|
|
||||||
input.startsWith("/") ||
|
|
||||||
/^[a-zA-Z]:[\\\/]/.test(input)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates a marketplace URL or local path
|
|
||||||
* @param input - The marketplace URL or local path to validate
|
|
||||||
* @throws {Error} If the input is invalid
|
|
||||||
*/
|
|
||||||
function validateMarketplaceInput(input: string): void {
|
|
||||||
const normalized = input.trim();
|
|
||||||
|
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
throw new Error("Marketplace URL or path cannot be empty");
|
throw new Error("Marketplace URL cannot be empty");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Local paths are passed directly to Claude Code which handles them
|
|
||||||
if (isLocalPath(normalized)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate as URL
|
|
||||||
if (!MARKETPLACE_URL_REGEX.test(normalized)) {
|
if (!MARKETPLACE_URL_REGEX.test(normalized)) {
|
||||||
throw new Error(`Invalid marketplace URL format: ${input}`);
|
throw new Error(`Invalid marketplace URL format: ${url}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Additional check for valid URL structure
|
// Additional check for valid URL structure
|
||||||
try {
|
try {
|
||||||
new URL(normalized);
|
new URL(normalized);
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error(`Invalid marketplace URL: ${input}`);
|
throw new Error(`Invalid marketplace URL: ${url}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,9 +55,9 @@ function validatePluginName(pluginName: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a newline-separated list of marketplace URLs or local paths and return an array of validated entries
|
* Parse a newline-separated list of marketplace URLs and return an array of validated URLs
|
||||||
* @param marketplaces - Newline-separated list of marketplace Git URLs or local paths
|
* @param marketplaces - Newline-separated list of marketplace Git URLs
|
||||||
* @returns Array of validated marketplace URLs or paths (empty array if none provided)
|
* @returns Array of validated marketplace URLs (empty array if none provided)
|
||||||
*/
|
*/
|
||||||
function parseMarketplaces(marketplaces?: string): string[] {
|
function parseMarketplaces(marketplaces?: string): string[] {
|
||||||
const trimmed = marketplaces?.trim();
|
const trimmed = marketplaces?.trim();
|
||||||
@@ -87,14 +66,14 @@ function parseMarketplaces(marketplaces?: string): string[] {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split by newline and process each entry
|
// Split by newline and process each URL
|
||||||
return trimmed
|
return trimmed
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.map((entry) => entry.trim())
|
.map((url) => url.trim())
|
||||||
.filter((entry) => {
|
.filter((url) => {
|
||||||
if (entry.length === 0) return false;
|
if (url.length === 0) return false;
|
||||||
|
|
||||||
validateMarketplaceInput(entry);
|
validateMarketplaceUrl(url);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -184,26 +163,26 @@ async function installPlugin(
|
|||||||
/**
|
/**
|
||||||
* Adds a Claude Code plugin marketplace
|
* Adds a Claude Code plugin marketplace
|
||||||
* @param claudeExecutable - Path to the Claude executable
|
* @param claudeExecutable - Path to the Claude executable
|
||||||
* @param marketplace - The marketplace Git URL or local path to add
|
* @param marketplaceUrl - The marketplace Git URL to add
|
||||||
* @returns Promise that resolves when the marketplace add command completes
|
* @returns Promise that resolves when the marketplace add command completes
|
||||||
* @throws {Error} If the command fails to execute
|
* @throws {Error} If the command fails to execute
|
||||||
*/
|
*/
|
||||||
async function addMarketplace(
|
async function addMarketplace(
|
||||||
claudeExecutable: string,
|
claudeExecutable: string,
|
||||||
marketplace: string,
|
marketplaceUrl: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
console.log(`Adding marketplace: ${marketplace}`);
|
console.log(`Adding marketplace: ${marketplaceUrl}`);
|
||||||
|
|
||||||
return executeClaudeCommand(
|
return executeClaudeCommand(
|
||||||
claudeExecutable,
|
claudeExecutable,
|
||||||
["plugin", "marketplace", "add", marketplace],
|
["plugin", "marketplace", "add", marketplaceUrl],
|
||||||
`Failed to add marketplace '${marketplace}'`,
|
`Failed to add marketplace '${marketplaceUrl}'`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Installs Claude Code plugins from a newline-separated list
|
* Installs Claude Code plugins from a newline-separated list
|
||||||
* @param marketplacesInput - Newline-separated list of marketplace Git URLs or local paths
|
* @param marketplacesInput - Newline-separated list of marketplace Git URLs
|
||||||
* @param pluginsInput - Newline-separated list of plugin names
|
* @param pluginsInput - Newline-separated list of plugin names
|
||||||
* @param claudeExecutable - Path to the Claude executable (defaults to "claude")
|
* @param claudeExecutable - Path to the Claude executable (defaults to "claude")
|
||||||
* @returns Promise that resolves when all plugins are installed
|
* @returns Promise that resolves when all plugins are installed
|
||||||
|
|||||||
@@ -1,271 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
// Ensure SDK path uses the same entrypoint as the CLI path
|
|
||||||
env.CLAUDE_CODE_ENTRYPOINT = "claude-code-github-action";
|
|
||||||
|
|
||||||
// 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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
import * as core from "@actions/core";
|
|
||||||
import { readFile, writeFile, access } from "fs/promises";
|
|
||||||
import { dirname, join } from "path";
|
|
||||||
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
||||||
import type {
|
|
||||||
SDKMessage,
|
|
||||||
SDKResultMessage,
|
|
||||||
SDKUserMessage,
|
|
||||||
} from "@anthropic-ai/claude-agent-sdk";
|
|
||||||
import type { ParsedSdkOptions } from "./parse-sdk-options";
|
|
||||||
|
|
||||||
const EXECUTION_FILE = `${process.env.RUNNER_TEMP}/claude-execution-output.json`;
|
|
||||||
|
|
||||||
/** Filename for the user request file, written by prompt generation */
|
|
||||||
const USER_REQUEST_FILENAME = "claude-user-request.txt";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a file exists
|
|
||||||
*/
|
|
||||||
async function fileExists(path: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
await access(path);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a prompt configuration for the SDK.
|
|
||||||
* If a user request file exists alongside the prompt file, returns a multi-block
|
|
||||||
* SDKUserMessage that enables slash command processing in the CLI.
|
|
||||||
* Otherwise, returns the prompt as a simple string.
|
|
||||||
*/
|
|
||||||
async function createPromptConfig(
|
|
||||||
promptPath: string,
|
|
||||||
showFullOutput: boolean,
|
|
||||||
): Promise<string | AsyncIterable<SDKUserMessage>> {
|
|
||||||
const promptContent = await readFile(promptPath, "utf-8");
|
|
||||||
|
|
||||||
// Check for user request file in the same directory
|
|
||||||
const userRequestPath = join(dirname(promptPath), USER_REQUEST_FILENAME);
|
|
||||||
const hasUserRequest = await fileExists(userRequestPath);
|
|
||||||
|
|
||||||
if (!hasUserRequest) {
|
|
||||||
// No user request file - use simple string prompt
|
|
||||||
return promptContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
// User request file exists - create multi-block message
|
|
||||||
const userRequest = await readFile(userRequestPath, "utf-8");
|
|
||||||
if (showFullOutput) {
|
|
||||||
console.log("Using multi-block message with user request:", userRequest);
|
|
||||||
} else {
|
|
||||||
console.log("Using multi-block message with user request (content hidden)");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create an async generator that yields a single multi-block message
|
|
||||||
// The context/instructions go first, then the user's actual request last
|
|
||||||
// This allows the CLI to detect and process slash commands in the user request
|
|
||||||
async function* createMultiBlockMessage(): AsyncGenerator<SDKUserMessage> {
|
|
||||||
yield {
|
|
||||||
type: "user",
|
|
||||||
session_id: "",
|
|
||||||
message: {
|
|
||||||
role: "user",
|
|
||||||
content: [
|
|
||||||
{ type: "text", text: promptContent }, // Instructions + GitHub context
|
|
||||||
{ type: "text", text: userRequest }, // User's request (may be a slash command)
|
|
||||||
],
|
|
||||||
},
|
|
||||||
parent_tool_use_id: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return createMultiBlockMessage();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run Claude using the Agent SDK
|
|
||||||
*/
|
|
||||||
export async function runClaudeWithSdk(
|
|
||||||
promptPath: string,
|
|
||||||
{ sdkOptions, showFullOutput, hasJsonSchema }: ParsedSdkOptions,
|
|
||||||
): Promise<void> {
|
|
||||||
// Create prompt configuration - may be a string or multi-block message
|
|
||||||
const prompt = await createPromptConfig(promptPath, showFullOutput);
|
|
||||||
|
|
||||||
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.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Running Claude with prompt from file: ${promptPath}`);
|
|
||||||
// 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);
|
|
||||||
core.setOutput("conclusion", "failure");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write execution file
|
|
||||||
try {
|
|
||||||
await writeFile(EXECUTION_FILE, JSON.stringify(messages, null, 2));
|
|
||||||
console.log(`Log saved to ${EXECUTION_FILE}`);
|
|
||||||
core.setOutput("execution_file", EXECUTION_FILE);
|
|
||||||
} catch (error) {
|
|
||||||
core.warning(`Failed to write execution file: ${error}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!resultMessage) {
|
|
||||||
core.setOutput("conclusion", "failure");
|
|
||||||
core.error("No result message received from Claude");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isSuccess = resultMessage.subtype === "success";
|
|
||||||
core.setOutput("conclusion", isSuccess ? "success" : "failure");
|
|
||||||
|
|
||||||
// Handle structured output
|
|
||||||
if (hasJsonSchema) {
|
|
||||||
if (
|
|
||||||
isSuccess &&
|
|
||||||
"structured_output" in resultMessage &&
|
|
||||||
resultMessage.structured_output
|
|
||||||
) {
|
|
||||||
const structuredOutputJson = JSON.stringify(
|
|
||||||
resultMessage.structured_output,
|
|
||||||
);
|
|
||||||
core.setOutput("structured_output", structuredOutputJson);
|
|
||||||
core.info(
|
|
||||||
`Set structured_output with ${Object.keys(resultMessage.structured_output as object).length} field(s)`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
core.setFailed(
|
|
||||||
`--json-schema was provided but Claude did not return structured_output. Result subtype: ${resultMessage.subtype}`,
|
|
||||||
);
|
|
||||||
core.setOutput("conclusion", "failure");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isSuccess) {
|
|
||||||
if ("errors" in resultMessage && resultMessage.errors) {
|
|
||||||
core.error(`Execution failed: ${resultMessage.errors.join(", ")}`);
|
|
||||||
}
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,8 +5,6 @@ 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 { runClaudeWithSdk } from "./run-claude-sdk";
|
|
||||||
import { parseSdkOptions } from "./parse-sdk-options";
|
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
@@ -124,39 +122,9 @@ 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 by the user
|
||||||
* Exported for testing
|
* Exported for testing
|
||||||
*/
|
*/
|
||||||
export async function parseAndSetStructuredOutputs(
|
export async function parseAndSetStructuredOutputs(
|
||||||
@@ -176,7 +144,7 @@ export async function parseAndSetStructuredOutputs(
|
|||||||
|
|
||||||
if (!result?.structured_output) {
|
if (!result?.structured_output) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`--json-schema was provided but Claude did not return structured_output.\n` +
|
`json_schema was provided but Claude did not return structured_output.\n` +
|
||||||
`Found ${messages.length} messages. Result exists: ${!!result}\n`,
|
`Found ${messages.length} messages. Result exists: ${!!result}\n`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -197,22 +165,8 @@ 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 runClaudeWithSdk(promptPath, parsedOptions);
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = prepareRunConfig(promptPath, options);
|
const config = prepareRunConfig(promptPath, options);
|
||||||
|
|
||||||
// Detect if --json-schema is present in claude args
|
|
||||||
const hasJsonSchema = options.claudeArgs?.includes("--json-schema") ?? false;
|
|
||||||
|
|
||||||
// Create a named pipe
|
// Create a named pipe
|
||||||
try {
|
try {
|
||||||
await unlink(PIPE_PATH);
|
await unlink(PIPE_PATH);
|
||||||
@@ -398,11 +352,8 @@ 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
|
// Parse and set structured outputs only if user provided json_schema
|
||||||
await parseAndSetSessionId(EXECUTION_FILE);
|
if (process.env.JSON_SCHEMA) {
|
||||||
|
|
||||||
// Parse and set structured outputs only if user provided --json-schema in claude_args
|
|
||||||
if (hasJsonSchema) {
|
|
||||||
try {
|
try {
|
||||||
await parseAndSetStructuredOutputs(EXECUTION_FILE);
|
await parseAndSetStructuredOutputs(EXECUTION_FILE);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,50 +1,39 @@
|
|||||||
/**
|
/**
|
||||||
* Validates the environment variables required for running Claude Code
|
* Validates the environment variables required for running Claude Code
|
||||||
* based on the selected provider (Anthropic API, AWS Bedrock, Google Vertex AI, or Microsoft Foundry)
|
* based on the selected provider (Anthropic API, AWS Bedrock, or Google Vertex AI)
|
||||||
*/
|
*/
|
||||||
export function validateEnvironmentVariables() {
|
export function validateEnvironmentVariables() {
|
||||||
const useBedrock = process.env.CLAUDE_CODE_USE_BEDROCK === "1";
|
const useBedrock = process.env.CLAUDE_CODE_USE_BEDROCK === "1";
|
||||||
const useVertex = process.env.CLAUDE_CODE_USE_VERTEX === "1";
|
const useVertex = process.env.CLAUDE_CODE_USE_VERTEX === "1";
|
||||||
const useFoundry = process.env.CLAUDE_CODE_USE_FOUNDRY === "1";
|
|
||||||
const anthropicApiKey = process.env.ANTHROPIC_API_KEY;
|
const anthropicApiKey = process.env.ANTHROPIC_API_KEY;
|
||||||
const claudeCodeOAuthToken = process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
const claudeCodeOAuthToken = process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||||
|
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
// Check for mutual exclusivity between providers
|
if (useBedrock && useVertex) {
|
||||||
const activeProviders = [useBedrock, useVertex, useFoundry].filter(Boolean);
|
|
||||||
if (activeProviders.length > 1) {
|
|
||||||
errors.push(
|
errors.push(
|
||||||
"Cannot use multiple providers simultaneously. Please set only one of: CLAUDE_CODE_USE_BEDROCK, CLAUDE_CODE_USE_VERTEX, or CLAUDE_CODE_USE_FOUNDRY.",
|
"Cannot use both Bedrock and Vertex AI simultaneously. Please set only one provider.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!useBedrock && !useVertex && !useFoundry) {
|
if (!useBedrock && !useVertex) {
|
||||||
if (!anthropicApiKey && !claudeCodeOAuthToken) {
|
if (!anthropicApiKey && !claudeCodeOAuthToken) {
|
||||||
errors.push(
|
errors.push(
|
||||||
"Either ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN is required when using direct Anthropic API.",
|
"Either ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN is required when using direct Anthropic API.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (useBedrock) {
|
} else if (useBedrock) {
|
||||||
const awsRegion = process.env.AWS_REGION;
|
const requiredBedrockVars = {
|
||||||
const awsAccessKeyId = process.env.AWS_ACCESS_KEY_ID;
|
AWS_REGION: process.env.AWS_REGION,
|
||||||
const awsSecretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;
|
AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID,
|
||||||
const awsBearerToken = process.env.AWS_BEARER_TOKEN_BEDROCK;
|
AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY,
|
||||||
|
};
|
||||||
|
|
||||||
// AWS_REGION is always required for Bedrock
|
Object.entries(requiredBedrockVars).forEach(([key, value]) => {
|
||||||
if (!awsRegion) {
|
if (!value) {
|
||||||
errors.push("AWS_REGION is required when using AWS Bedrock.");
|
errors.push(`${key} is required when using AWS Bedrock.`);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
// Either bearer token OR access key credentials must be provided
|
|
||||||
const hasAccessKeyCredentials = awsAccessKeyId && awsSecretAccessKey;
|
|
||||||
const hasBearerToken = awsBearerToken;
|
|
||||||
|
|
||||||
if (!hasAccessKeyCredentials && !hasBearerToken) {
|
|
||||||
errors.push(
|
|
||||||
"Either AWS_BEARER_TOKEN_BEDROCK or both AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are required when using AWS Bedrock.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if (useVertex) {
|
} else if (useVertex) {
|
||||||
const requiredVertexVars = {
|
const requiredVertexVars = {
|
||||||
ANTHROPIC_VERTEX_PROJECT_ID: process.env.ANTHROPIC_VERTEX_PROJECT_ID,
|
ANTHROPIC_VERTEX_PROJECT_ID: process.env.ANTHROPIC_VERTEX_PROJECT_ID,
|
||||||
@@ -56,16 +45,6 @@ export function validateEnvironmentVariables() {
|
|||||||
errors.push(`${key} is required when using Google Vertex AI.`);
|
errors.push(`${key} is required when using Google Vertex AI.`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else if (useFoundry) {
|
|
||||||
const foundryResource = process.env.ANTHROPIC_FOUNDRY_RESOURCE;
|
|
||||||
const foundryBaseUrl = process.env.ANTHROPIC_FOUNDRY_BASE_URL;
|
|
||||||
|
|
||||||
// Either resource name or base URL is required
|
|
||||||
if (!foundryResource && !foundryBaseUrl) {
|
|
||||||
errors.push(
|
|
||||||
"Either ANTHROPIC_FOUNDRY_RESOURCE or ANTHROPIC_FOUNDRY_BASE_URL is required when using Microsoft Foundry.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
|
|||||||
@@ -596,111 +596,4 @@ describe("installPlugins", () => {
|
|||||||
{ stdio: "inherit" },
|
{ stdio: "inherit" },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Local marketplace path tests
|
|
||||||
test("should accept local marketplace path with ./", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
await installPlugins("./my-local-marketplace", "test-plugin");
|
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledTimes(2);
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
"claude",
|
|
||||||
["plugin", "marketplace", "add", "./my-local-marketplace"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
2,
|
|
||||||
"claude",
|
|
||||||
["plugin", "install", "test-plugin"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should accept local marketplace path with absolute Unix path", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
await installPlugins("/home/user/my-marketplace", "test-plugin");
|
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledTimes(2);
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
"claude",
|
|
||||||
["plugin", "marketplace", "add", "/home/user/my-marketplace"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should accept local marketplace path with Windows absolute path", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
await installPlugins("C:\\Users\\user\\marketplace", "test-plugin");
|
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledTimes(2);
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
"claude",
|
|
||||||
["plugin", "marketplace", "add", "C:\\Users\\user\\marketplace"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should accept mixed local and remote marketplaces", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
await installPlugins(
|
|
||||||
"./local-marketplace\nhttps://github.com/user/remote.git",
|
|
||||||
"test-plugin",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledTimes(3);
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
"claude",
|
|
||||||
["plugin", "marketplace", "add", "./local-marketplace"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
2,
|
|
||||||
"claude",
|
|
||||||
["plugin", "marketplace", "add", "https://github.com/user/remote.git"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should accept local path with ../ (parent directory)", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
await installPlugins("../shared-plugins/marketplace", "test-plugin");
|
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledTimes(2);
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
"claude",
|
|
||||||
["plugin", "marketplace", "add", "../shared-plugins/marketplace"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should accept local path with nested directories", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
await installPlugins("./plugins/my-org/my-marketplace", "test-plugin");
|
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledTimes(2);
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
"claude",
|
|
||||||
["plugin", "marketplace", "add", "./plugins/my-org/my-marketplace"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should accept local path with dots in directory name", async () => {
|
|
||||||
const spy = createMockSpawn();
|
|
||||||
await installPlugins("./my.plugin.marketplace", "test-plugin");
|
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledTimes(2);
|
|
||||||
expect(spy).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
"claude",
|
|
||||||
["plugin", "marketplace", "add", "./my.plugin.marketplace"],
|
|
||||||
{ stdio: "inherit" },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,6 @@
|
|||||||
"name": "mcp-test",
|
"name": "mcp-test",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.24.0"
|
"@modelcontextprotocol/sdk": "^1.11.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,315 +0,0 @@
|
|||||||
#!/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,10 +4,7 @@ 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 {
|
import { parseAndSetStructuredOutputs } from "../src/run-claude";
|
||||||
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
|
||||||
@@ -38,19 +35,16 @@ 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 {
|
||||||
@@ -119,7 +113,7 @@ describe("parseAndSetStructuredOutputs", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
parseAndSetStructuredOutputs(TEST_EXECUTION_FILE),
|
parseAndSetStructuredOutputs(TEST_EXECUTION_FILE),
|
||||||
).rejects.toThrow(
|
).rejects.toThrow(
|
||||||
"--json-schema was provided but Claude did not return structured_output",
|
"json_schema was provided but Claude did not return structured_output",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -133,7 +127,7 @@ describe("parseAndSetStructuredOutputs", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
parseAndSetStructuredOutputs(TEST_EXECUTION_FILE),
|
parseAndSetStructuredOutputs(TEST_EXECUTION_FILE),
|
||||||
).rejects.toThrow(
|
).rejects.toThrow(
|
||||||
"--json-schema was provided but Claude did not return structured_output",
|
"json_schema was provided but Claude did not return structured_output",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -162,66 +156,3 @@ 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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -13,19 +13,15 @@ describe("validateEnvironmentVariables", () => {
|
|||||||
delete process.env.ANTHROPIC_API_KEY;
|
delete process.env.ANTHROPIC_API_KEY;
|
||||||
delete process.env.CLAUDE_CODE_USE_BEDROCK;
|
delete process.env.CLAUDE_CODE_USE_BEDROCK;
|
||||||
delete process.env.CLAUDE_CODE_USE_VERTEX;
|
delete process.env.CLAUDE_CODE_USE_VERTEX;
|
||||||
delete process.env.CLAUDE_CODE_USE_FOUNDRY;
|
|
||||||
delete process.env.AWS_REGION;
|
delete process.env.AWS_REGION;
|
||||||
delete process.env.AWS_ACCESS_KEY_ID;
|
delete process.env.AWS_ACCESS_KEY_ID;
|
||||||
delete process.env.AWS_SECRET_ACCESS_KEY;
|
delete process.env.AWS_SECRET_ACCESS_KEY;
|
||||||
delete process.env.AWS_SESSION_TOKEN;
|
delete process.env.AWS_SESSION_TOKEN;
|
||||||
delete process.env.AWS_BEARER_TOKEN_BEDROCK;
|
|
||||||
delete process.env.ANTHROPIC_BEDROCK_BASE_URL;
|
delete process.env.ANTHROPIC_BEDROCK_BASE_URL;
|
||||||
delete process.env.ANTHROPIC_VERTEX_PROJECT_ID;
|
delete process.env.ANTHROPIC_VERTEX_PROJECT_ID;
|
||||||
delete process.env.CLOUD_ML_REGION;
|
delete process.env.CLOUD_ML_REGION;
|
||||||
delete process.env.GOOGLE_APPLICATION_CREDENTIALS;
|
delete process.env.GOOGLE_APPLICATION_CREDENTIALS;
|
||||||
delete process.env.ANTHROPIC_VERTEX_BASE_URL;
|
delete process.env.ANTHROPIC_VERTEX_BASE_URL;
|
||||||
delete process.env.ANTHROPIC_FOUNDRY_RESOURCE;
|
|
||||||
delete process.env.ANTHROPIC_FOUNDRY_BASE_URL;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -96,58 +92,31 @@ describe("validateEnvironmentVariables", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should fail when only AWS_SECRET_ACCESS_KEY is provided without bearer token", () => {
|
test("should fail when AWS_ACCESS_KEY_ID is missing", () => {
|
||||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
||||||
process.env.AWS_REGION = "us-east-1";
|
process.env.AWS_REGION = "us-east-1";
|
||||||
process.env.AWS_SECRET_ACCESS_KEY = "test-secret-key";
|
process.env.AWS_SECRET_ACCESS_KEY = "test-secret-key";
|
||||||
|
|
||||||
expect(() => validateEnvironmentVariables()).toThrow(
|
expect(() => validateEnvironmentVariables()).toThrow(
|
||||||
"Either AWS_BEARER_TOKEN_BEDROCK or both AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are required when using AWS Bedrock.",
|
"AWS_ACCESS_KEY_ID is required when using AWS Bedrock.",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should fail when only AWS_ACCESS_KEY_ID is provided without bearer token", () => {
|
test("should fail when AWS_SECRET_ACCESS_KEY is missing", () => {
|
||||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
||||||
process.env.AWS_REGION = "us-east-1";
|
process.env.AWS_REGION = "us-east-1";
|
||||||
process.env.AWS_ACCESS_KEY_ID = "test-access-key";
|
process.env.AWS_ACCESS_KEY_ID = "test-access-key";
|
||||||
|
|
||||||
expect(() => validateEnvironmentVariables()).toThrow(
|
expect(() => validateEnvironmentVariables()).toThrow(
|
||||||
"Either AWS_BEARER_TOKEN_BEDROCK or both AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are required when using AWS Bedrock.",
|
"AWS_SECRET_ACCESS_KEY is required when using AWS Bedrock.",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should pass when AWS_BEARER_TOKEN_BEDROCK is provided instead of access keys", () => {
|
test("should report all missing Bedrock variables", () => {
|
||||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
|
||||||
process.env.AWS_REGION = "us-east-1";
|
|
||||||
process.env.AWS_BEARER_TOKEN_BEDROCK = "test-bearer-token";
|
|
||||||
|
|
||||||
expect(() => validateEnvironmentVariables()).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should pass when both bearer token and access keys are provided", () => {
|
|
||||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
|
||||||
process.env.AWS_REGION = "us-east-1";
|
|
||||||
process.env.AWS_BEARER_TOKEN_BEDROCK = "test-bearer-token";
|
|
||||||
process.env.AWS_ACCESS_KEY_ID = "test-access-key";
|
|
||||||
process.env.AWS_SECRET_ACCESS_KEY = "test-secret-key";
|
|
||||||
|
|
||||||
expect(() => validateEnvironmentVariables()).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should fail when no authentication method is provided", () => {
|
|
||||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
|
||||||
process.env.AWS_REGION = "us-east-1";
|
|
||||||
|
|
||||||
expect(() => validateEnvironmentVariables()).toThrow(
|
|
||||||
"Either AWS_BEARER_TOKEN_BEDROCK or both AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are required when using AWS Bedrock.",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should report missing region and authentication", () => {
|
|
||||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
||||||
|
|
||||||
expect(() => validateEnvironmentVariables()).toThrow(
|
expect(() => validateEnvironmentVariables()).toThrow(
|
||||||
/AWS_REGION is required when using AWS Bedrock.*Either AWS_BEARER_TOKEN_BEDROCK or both AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are required when using AWS Bedrock/s,
|
/AWS_REGION is required when using AWS Bedrock.*AWS_ACCESS_KEY_ID is required when using AWS Bedrock.*AWS_SECRET_ACCESS_KEY is required when using AWS Bedrock/s,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -198,56 +167,6 @@ describe("validateEnvironmentVariables", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Microsoft Foundry", () => {
|
|
||||||
test("should pass when ANTHROPIC_FOUNDRY_RESOURCE is provided", () => {
|
|
||||||
process.env.CLAUDE_CODE_USE_FOUNDRY = "1";
|
|
||||||
process.env.ANTHROPIC_FOUNDRY_RESOURCE = "test-resource";
|
|
||||||
|
|
||||||
expect(() => validateEnvironmentVariables()).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should pass when ANTHROPIC_FOUNDRY_BASE_URL is provided", () => {
|
|
||||||
process.env.CLAUDE_CODE_USE_FOUNDRY = "1";
|
|
||||||
process.env.ANTHROPIC_FOUNDRY_BASE_URL =
|
|
||||||
"https://test-resource.services.ai.azure.com";
|
|
||||||
|
|
||||||
expect(() => validateEnvironmentVariables()).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should pass when both resource and base URL are provided", () => {
|
|
||||||
process.env.CLAUDE_CODE_USE_FOUNDRY = "1";
|
|
||||||
process.env.ANTHROPIC_FOUNDRY_RESOURCE = "test-resource";
|
|
||||||
process.env.ANTHROPIC_FOUNDRY_BASE_URL =
|
|
||||||
"https://custom.services.ai.azure.com";
|
|
||||||
|
|
||||||
expect(() => validateEnvironmentVariables()).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should construct Foundry base URL from resource name when ANTHROPIC_FOUNDRY_BASE_URL is not provided", () => {
|
|
||||||
// This test verifies our action.yml change, which constructs:
|
|
||||||
// ANTHROPIC_FOUNDRY_BASE_URL: ${{ env.ANTHROPIC_FOUNDRY_BASE_URL || (env.ANTHROPIC_FOUNDRY_RESOURCE && format('https://{0}.services.ai.azure.com', env.ANTHROPIC_FOUNDRY_RESOURCE)) }}
|
|
||||||
|
|
||||||
process.env.CLAUDE_CODE_USE_FOUNDRY = "1";
|
|
||||||
process.env.ANTHROPIC_FOUNDRY_RESOURCE = "my-foundry-resource";
|
|
||||||
// ANTHROPIC_FOUNDRY_BASE_URL is intentionally not set
|
|
||||||
|
|
||||||
// The actual URL construction happens in the composite action in action.yml
|
|
||||||
// This test is a placeholder to document the behavior
|
|
||||||
expect(() => validateEnvironmentVariables()).not.toThrow();
|
|
||||||
|
|
||||||
// In the actual action, ANTHROPIC_FOUNDRY_BASE_URL would be:
|
|
||||||
// https://my-foundry-resource.services.ai.azure.com
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should fail when neither ANTHROPIC_FOUNDRY_RESOURCE nor ANTHROPIC_FOUNDRY_BASE_URL is provided", () => {
|
|
||||||
process.env.CLAUDE_CODE_USE_FOUNDRY = "1";
|
|
||||||
|
|
||||||
expect(() => validateEnvironmentVariables()).toThrow(
|
|
||||||
"Either ANTHROPIC_FOUNDRY_RESOURCE or ANTHROPIC_FOUNDRY_BASE_URL is required when using Microsoft Foundry.",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Multiple providers", () => {
|
describe("Multiple providers", () => {
|
||||||
test("should fail when both Bedrock and Vertex are enabled", () => {
|
test("should fail when both Bedrock and Vertex are enabled", () => {
|
||||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
||||||
@@ -260,51 +179,7 @@ describe("validateEnvironmentVariables", () => {
|
|||||||
process.env.CLOUD_ML_REGION = "us-central1";
|
process.env.CLOUD_ML_REGION = "us-central1";
|
||||||
|
|
||||||
expect(() => validateEnvironmentVariables()).toThrow(
|
expect(() => validateEnvironmentVariables()).toThrow(
|
||||||
"Cannot use multiple providers simultaneously. Please set only one of: CLAUDE_CODE_USE_BEDROCK, CLAUDE_CODE_USE_VERTEX, or CLAUDE_CODE_USE_FOUNDRY.",
|
"Cannot use both Bedrock and Vertex AI simultaneously. Please set only one provider.",
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should fail when both Bedrock and Foundry are enabled", () => {
|
|
||||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
|
||||||
process.env.CLAUDE_CODE_USE_FOUNDRY = "1";
|
|
||||||
// Provide all required vars to isolate the mutual exclusion error
|
|
||||||
process.env.AWS_REGION = "us-east-1";
|
|
||||||
process.env.AWS_ACCESS_KEY_ID = "test-access-key";
|
|
||||||
process.env.AWS_SECRET_ACCESS_KEY = "test-secret-key";
|
|
||||||
process.env.ANTHROPIC_FOUNDRY_RESOURCE = "test-resource";
|
|
||||||
|
|
||||||
expect(() => validateEnvironmentVariables()).toThrow(
|
|
||||||
"Cannot use multiple providers simultaneously. Please set only one of: CLAUDE_CODE_USE_BEDROCK, CLAUDE_CODE_USE_VERTEX, or CLAUDE_CODE_USE_FOUNDRY.",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should fail when both Vertex and Foundry are enabled", () => {
|
|
||||||
process.env.CLAUDE_CODE_USE_VERTEX = "1";
|
|
||||||
process.env.CLAUDE_CODE_USE_FOUNDRY = "1";
|
|
||||||
// Provide all required vars to isolate the mutual exclusion error
|
|
||||||
process.env.ANTHROPIC_VERTEX_PROJECT_ID = "test-project";
|
|
||||||
process.env.CLOUD_ML_REGION = "us-central1";
|
|
||||||
process.env.ANTHROPIC_FOUNDRY_RESOURCE = "test-resource";
|
|
||||||
|
|
||||||
expect(() => validateEnvironmentVariables()).toThrow(
|
|
||||||
"Cannot use multiple providers simultaneously. Please set only one of: CLAUDE_CODE_USE_BEDROCK, CLAUDE_CODE_USE_VERTEX, or CLAUDE_CODE_USE_FOUNDRY.",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should fail when all three providers are enabled", () => {
|
|
||||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
|
||||||
process.env.CLAUDE_CODE_USE_VERTEX = "1";
|
|
||||||
process.env.CLAUDE_CODE_USE_FOUNDRY = "1";
|
|
||||||
// Provide all required vars to isolate the mutual exclusion error
|
|
||||||
process.env.AWS_REGION = "us-east-1";
|
|
||||||
process.env.AWS_ACCESS_KEY_ID = "test-access-key";
|
|
||||||
process.env.AWS_SECRET_ACCESS_KEY = "test-secret-key";
|
|
||||||
process.env.ANTHROPIC_VERTEX_PROJECT_ID = "test-project";
|
|
||||||
process.env.CLOUD_ML_REGION = "us-central1";
|
|
||||||
process.env.ANTHROPIC_FOUNDRY_RESOURCE = "test-resource";
|
|
||||||
|
|
||||||
expect(() => validateEnvironmentVariables()).toThrow(
|
|
||||||
"Cannot use multiple providers simultaneously. Please set only one of: CLAUDE_CODE_USE_BEDROCK, CLAUDE_CODE_USE_VERTEX, or CLAUDE_CODE_USE_FOUNDRY.",
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -329,7 +204,10 @@ describe("validateEnvironmentVariables", () => {
|
|||||||
" - AWS_REGION is required when using AWS Bedrock.",
|
" - AWS_REGION is required when using AWS Bedrock.",
|
||||||
);
|
);
|
||||||
expect(error!.message).toContain(
|
expect(error!.message).toContain(
|
||||||
" - Either AWS_BEARER_TOKEN_BEDROCK or both AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are required when using AWS Bedrock.",
|
" - AWS_ACCESS_KEY_ID is required when using AWS Bedrock.",
|
||||||
|
);
|
||||||
|
expect(error!.message).toContain(
|
||||||
|
" - AWS_SECRET_ACCESS_KEY is required when using AWS Bedrock.",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
34
bun.lock
34
bun.lock
@@ -1,13 +1,11 @@
|
|||||||
{
|
{
|
||||||
"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.2.1",
|
|
||||||
"@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",
|
||||||
@@ -37,40 +35,8 @@
|
|||||||
|
|
||||||
"@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.2.1", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-linuxmusl-arm64": "^0.33.5", "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-ZJO/TWcrFHGQTGHJDJl03mWozirWMBqdNpbuAgxZpLaHj2N5vyMxoeYiJC+7M0+gOSs7bjwKJLKTZcHGtGa34g=="],
|
|
||||||
|
|
||||||
"@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=="],
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
# Cloud Providers
|
# Cloud Providers
|
||||||
|
|
||||||
You can authenticate with Claude using any of these four methods:
|
You can authenticate with Claude using any of these three methods:
|
||||||
|
|
||||||
1. Direct Anthropic API (default)
|
1. Direct Anthropic API (default)
|
||||||
2. Amazon Bedrock with OIDC authentication
|
2. Amazon Bedrock with OIDC authentication
|
||||||
3. Google Vertex AI with OIDC authentication
|
3. Google Vertex AI 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://code.claude.com/docs/en/github-actions#for-aws-bedrock:).
|
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).
|
||||||
|
|
||||||
**Note**:
|
**Note**:
|
||||||
|
|
||||||
- Bedrock, Vertex, and Microsoft Foundry use OIDC authentication exclusively
|
- Bedrock and Vertex use OIDC authentication exclusively
|
||||||
- AWS Bedrock automatically uses cross-region inference profiles for certain models
|
- AWS Bedrock automatically uses cross-region inference profiles for certain models
|
||||||
- For cross-region inference profile models, you need to request and be granted access to the Claude models in all regions that the inference profile uses
|
- For cross-region inference profile models, you need to request and be granted access to the Claude models in all regions that the inference profile uses
|
||||||
|
|
||||||
@@ -41,19 +40,11 @@ Use provider-specific model names based on your chosen provider:
|
|||||||
claude_args: |
|
claude_args: |
|
||||||
--model claude-4-0-sonnet@20250805
|
--model claude-4-0-sonnet@20250805
|
||||||
# ... other inputs
|
# ... other inputs
|
||||||
|
|
||||||
# For Microsoft Foundry with OIDC
|
|
||||||
- uses: anthropics/claude-code-action@v1
|
|
||||||
with:
|
|
||||||
use_foundry: "true"
|
|
||||||
claude_args: |
|
|
||||||
--model claude-sonnet-4-5
|
|
||||||
# ... other inputs
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## OIDC Authentication for Cloud Providers
|
## OIDC Authentication for Bedrock and Vertex
|
||||||
|
|
||||||
AWS Bedrock, GCP Vertex AI, and Microsoft Foundry all support OIDC authentication.
|
Both AWS Bedrock and GCP Vertex AI require OIDC authentication.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# For AWS Bedrock with OIDC
|
# For AWS Bedrock with OIDC
|
||||||
@@ -106,36 +97,3 @@ AWS Bedrock, GCP Vertex AI, and Microsoft Foundry all support OIDC authenticatio
|
|||||||
permissions:
|
permissions:
|
||||||
id-token: write # Required for OIDC
|
id-token: write # Required for OIDC
|
||||||
```
|
```
|
||||||
|
|
||||||
```yaml
|
|
||||||
# For Microsoft Foundry with OIDC
|
|
||||||
- name: Authenticate to Azure
|
|
||||||
uses: azure/login@v2
|
|
||||||
with:
|
|
||||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
|
||||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
|
||||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
|
||||||
|
|
||||||
- name: Generate GitHub App token
|
|
||||||
id: app-token
|
|
||||||
uses: actions/create-github-app-token@v2
|
|
||||||
with:
|
|
||||||
app-id: ${{ secrets.APP_ID }}
|
|
||||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
|
||||||
|
|
||||||
- uses: anthropics/claude-code-action@v1
|
|
||||||
with:
|
|
||||||
use_foundry: "true"
|
|
||||||
claude_args: |
|
|
||||||
--model claude-sonnet-4-5
|
|
||||||
# ... other inputs
|
|
||||||
env:
|
|
||||||
ANTHROPIC_FOUNDRY_BASE_URL: https://my-resource.services.ai.azure.com
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
id-token: write # Required for OIDC
|
|
||||||
```
|
|
||||||
|
|
||||||
## Microsoft Foundry Setup
|
|
||||||
|
|
||||||
For detailed setup instructions for Microsoft Foundry, see the [official documentation](https://docs.anthropic.com/en/docs/claude-code/microsoft-foundry).
|
|
||||||
|
|||||||
@@ -61,3 +61,68 @@ For specialized use cases, you can fine-tune behavior using `claude_args`:
|
|||||||
--system-prompt "You are a code review specialist"
|
--system-prompt "You are a code review specialist"
|
||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Network Restrictions
|
||||||
|
|
||||||
|
For enhanced security, you can restrict Claude's network access to specific domains only. This feature is particularly useful for:
|
||||||
|
|
||||||
|
- Enterprise environments with strict security policies
|
||||||
|
- Preventing access to external services
|
||||||
|
- Limiting Claude to only your internal APIs and services
|
||||||
|
|
||||||
|
When `experimental_allowed_domains` is set, Claude can only access the domains you explicitly list. You'll need to include the appropriate provider domains based on your authentication method.
|
||||||
|
|
||||||
|
### Provider-Specific Examples
|
||||||
|
|
||||||
|
#### If using Anthropic API or subscription
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- uses: anthropics/claude-code-action@v1
|
||||||
|
with:
|
||||||
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
|
# Or: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
|
experimental_allowed_domains: |
|
||||||
|
.anthropic.com
|
||||||
|
```
|
||||||
|
|
||||||
|
#### If using AWS Bedrock
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- uses: anthropics/claude-code-action@v1
|
||||||
|
with:
|
||||||
|
use_bedrock: "true"
|
||||||
|
experimental_allowed_domains: |
|
||||||
|
bedrock.*.amazonaws.com
|
||||||
|
bedrock-runtime.*.amazonaws.com
|
||||||
|
```
|
||||||
|
|
||||||
|
#### If using Google Vertex AI
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- uses: anthropics/claude-code-action@v1
|
||||||
|
with:
|
||||||
|
use_vertex: "true"
|
||||||
|
experimental_allowed_domains: |
|
||||||
|
*.googleapis.com
|
||||||
|
vertexai.googleapis.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common GitHub Domains
|
||||||
|
|
||||||
|
In addition to your provider domains, you may need to include GitHub-related domains. For GitHub.com users, common domains include:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- uses: anthropics/claude-code-action@v1
|
||||||
|
with:
|
||||||
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
|
experimental_allowed_domains: |
|
||||||
|
.anthropic.com # For Anthropic API
|
||||||
|
.github.com
|
||||||
|
.githubusercontent.com
|
||||||
|
ghcr.io
|
||||||
|
.blob.core.windows.net
|
||||||
|
```
|
||||||
|
|
||||||
|
For GitHub Enterprise users, replace the GitHub.com domains above with your enterprise domains (e.g., `.github.company.com`, `packages.company.com`, etc.).
|
||||||
|
|
||||||
|
To determine which domains your workflow needs, you can temporarily run without restrictions and monitor the network requests, or check your GitHub Enterprise configuration for the specific services you use.
|
||||||
|
|||||||
@@ -38,64 +38,7 @@ The following permissions are requested but not yet actively used. These will en
|
|||||||
|
|
||||||
## Commit Signing
|
## Commit Signing
|
||||||
|
|
||||||
By default, commits made by Claude are unsigned. You can enable commit signing using one of two methods:
|
All commits made by Claude through this action are automatically signed with commit signatures. This ensures the authenticity and integrity of commits, providing a verifiable trail of changes made by the action.
|
||||||
|
|
||||||
### Option 1: GitHub API Commit Signing (use_commit_signing)
|
|
||||||
|
|
||||||
This uses GitHub's API to create commits, which automatically signs them as verified from the GitHub App:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- uses: anthropics/claude-code-action@main
|
|
||||||
with:
|
|
||||||
use_commit_signing: true
|
|
||||||
```
|
|
||||||
|
|
||||||
This is the simplest option and requires no additional setup. However, because it uses the GitHub API instead of git CLI, it cannot perform complex git operations like rebasing, cherry-picking, or interactive history manipulation.
|
|
||||||
|
|
||||||
### Option 2: SSH Signing Key (ssh_signing_key)
|
|
||||||
|
|
||||||
This uses an SSH key to sign commits via git CLI. Use this option when you need both signed commits AND standard git operations (rebasing, cherry-picking, etc.):
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- uses: anthropics/claude-code-action@main
|
|
||||||
with:
|
|
||||||
ssh_signing_key: ${{ secrets.SSH_SIGNING_KEY }}
|
|
||||||
bot_id: "YOUR_GITHUB_USER_ID"
|
|
||||||
bot_name: "YOUR_GITHUB_USERNAME"
|
|
||||||
```
|
|
||||||
|
|
||||||
Commits will show as verified and attributed to the GitHub account that owns the signing key.
|
|
||||||
|
|
||||||
**Setup steps:**
|
|
||||||
|
|
||||||
1. Generate an SSH key pair for signing:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ssh-keygen -t ed25519 -f ~/.ssh/signing_key -N "" -C "commit signing key"
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Add the **public key** to your GitHub account:
|
|
||||||
|
|
||||||
- Go to GitHub → Settings → SSH and GPG keys
|
|
||||||
- Click "New SSH key"
|
|
||||||
- Select **Key type: Signing Key** (important)
|
|
||||||
- Paste the contents of `~/.ssh/signing_key.pub`
|
|
||||||
|
|
||||||
3. Add the **private key** to your repository secrets:
|
|
||||||
|
|
||||||
- Go to your repo → Settings → Secrets and variables → Actions
|
|
||||||
- Create a new secret named `SSH_SIGNING_KEY`
|
|
||||||
- Paste the contents of `~/.ssh/signing_key`
|
|
||||||
|
|
||||||
4. Get your GitHub user ID:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gh api users/YOUR_USERNAME --jq '.id'
|
|
||||||
```
|
|
||||||
|
|
||||||
5. Update your workflow with `bot_id` and `bot_name` matching the account where you added the signing key.
|
|
||||||
|
|
||||||
**Note:** If both `ssh_signing_key` and `use_commit_signing` are provided, `ssh_signing_key` takes precedence.
|
|
||||||
|
|
||||||
## ⚠️ Authentication Protection
|
## ⚠️ Authentication Protection
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,6 @@ jobs:
|
|||||||
| `claude_code_oauth_token` | Claude Code OAuth token (alternative to anthropic_api_key) | No\* | - |
|
| `claude_code_oauth_token` | Claude Code OAuth token (alternative to anthropic_api_key) | No\* | - |
|
||||||
| `prompt` | Instructions for Claude. Can be a direct prompt or custom template for automation workflows | No | - |
|
| `prompt` | Instructions for Claude. Can be a direct prompt or custom template for automation workflows | No | - |
|
||||||
| `track_progress` | Force tag mode with tracking comments. Only works with specific PR/issue events. Preserves GitHub context | No | `false` |
|
| `track_progress` | Force tag mode with tracking comments. Only works with specific PR/issue events. Preserves GitHub context | No | `false` |
|
||||||
| `include_fix_links` | Include 'Fix this' links in PR code review feedback that open Claude Code with context to fix the identified issue | No | `true` |
|
|
||||||
| `claude_args` | Additional [arguments to pass directly to Claude CLI](https://docs.claude.com/en/docs/claude-code/cli-reference#cli-flags) (e.g., `--max-turns 10 --model claude-4-0-sonnet-20250805`) | No | "" |
|
| `claude_args` | Additional [arguments to pass directly to Claude CLI](https://docs.claude.com/en/docs/claude-code/cli-reference#cli-flags) (e.g., `--max-turns 10 --model claude-4-0-sonnet-20250805`) | No | "" |
|
||||||
| `base_branch` | The base branch to use for creating new branches (e.g., 'main', 'develop') | No | - |
|
| `base_branch` | The base branch to use for creating new branches (e.g., 'main', 'develop') | No | - |
|
||||||
| `use_sticky_comment` | Use just one comment to deliver PR comments (only applies for pull_request event workflows) | No | `false` |
|
| `use_sticky_comment` | Use just one comment to deliver PR comments (only applies for pull_request event workflows) | No | `false` |
|
||||||
@@ -71,16 +70,17 @@ jobs:
|
|||||||
| `branch_prefix` | The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format) | No | `claude/` |
|
| `branch_prefix` | The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format) | No | `claude/` |
|
||||||
| `settings` | Claude Code settings as JSON string or path to settings JSON file | No | "" |
|
| `settings` | Claude Code settings as JSON string or path to settings JSON file | No | "" |
|
||||||
| `additional_permissions` | Additional permissions to enable. Currently supports 'actions: read' for viewing workflow results | No | "" |
|
| `additional_permissions` | Additional permissions to enable. Currently supports 'actions: read' for viewing workflow results | No | "" |
|
||||||
| `use_commit_signing` | Enable commit signing using GitHub's API. Simple but cannot perform complex git operations like rebasing. See [Security](./security.md#commit-signing) | No | `false` |
|
| `experimental_allowed_domains` | Restrict network access to these domains only (newline-separated). | No | "" |
|
||||||
| `ssh_signing_key` | SSH private key for signing commits. Enables signed commits with full git CLI support (rebasing, etc.). See [Security](./security.md#commit-signing) | No | "" |
|
| `use_commit_signing` | Enable commit signing using GitHub's commit signature verification. When false, Claude uses standard git commands | No | `false` |
|
||||||
| `bot_id` | GitHub user ID to use for git operations (defaults to Claude's bot ID). Required with `ssh_signing_key` for verified commits | No | `41898282` |
|
| `bot_id` | GitHub user ID to use for git operations (defaults to Claude's bot ID) | No | `41898282` |
|
||||||
| `bot_name` | GitHub username to use for git operations (defaults to Claude's bot name). Required with `ssh_signing_key` for verified commits | No | `claude[bot]` |
|
| `bot_name` | GitHub username to use for git operations (defaults to Claude's bot name) | No | `claude[bot]` |
|
||||||
| `allowed_bots` | Comma-separated list of allowed bot usernames, or '\*' to allow all bots. Empty string (default) allows no bots | No | "" |
|
| `allowed_bots` | Comma-separated list of allowed bot usernames, or '\*' to allow all bots. Empty string (default) allows no bots | No | "" |
|
||||||
| `allowed_non_write_users` | **⚠️ RISKY**: Comma-separated list of usernames to allow without write permissions, or '\*' for all users. Only works with `github_token` input. See [Security](./security.md) | No | "" |
|
| `allowed_non_write_users` | **⚠️ RISKY**: Comma-separated list of usernames to allow without write permissions, or '\*' for all users. Only works with `github_token` input. See [Security](./security.md) | No | "" |
|
||||||
| `path_to_claude_code_executable` | Optional path to a custom Claude Code executable. Skips automatic installation. Useful for Nix, custom containers, or specialized environments | No | "" |
|
| `path_to_claude_code_executable` | Optional path to a custom Claude Code executable. Skips automatic installation. Useful for Nix, custom containers, or specialized environments | No | "" |
|
||||||
| `path_to_bun_executable` | Optional path to a custom Bun executable. Skips automatic Bun installation. Useful for Nix, custom containers, or specialized environments | No | "" |
|
| `path_to_bun_executable` | Optional path to a custom Bun executable. Skips automatic Bun installation. Useful for Nix, custom containers, or specialized environments | No | "" |
|
||||||
| `plugin_marketplaces` | Newline-separated list of Claude Code plugin marketplace Git URLs to install from (e.g., see example in workflow above). Marketplaces are added before plugin installation | No | "" |
|
| `plugin_marketplaces` | Newline-separated list of Claude Code plugin marketplace Git URLs to install from (e.g., see example in workflow above). Marketplaces are added before plugin installation | No | "" |
|
||||||
| `plugins` | Newline-separated list of Claude Code plugin names to install (e.g., see example in workflow above). Plugins are installed before Claude Code execution | No | "" |
|
| `plugins` | Newline-separated list of Claude Code plugin names to install (e.g., see example in workflow above). Plugins are installed before Claude Code execution | No | "" |
|
||||||
|
| `json_schema` | JSON schema for structured output validation. See [Structured Outputs](#structured-outputs) section below | No | "" |
|
||||||
|
|
||||||
### Deprecated Inputs
|
### Deprecated Inputs
|
||||||
|
|
||||||
@@ -201,8 +201,16 @@ Get validated JSON results from Claude that automatically become GitHub Action o
|
|||||||
prompt: |
|
prompt: |
|
||||||
Check the CI logs and determine if this is a flaky test.
|
Check the CI logs and determine if this is a flaky test.
|
||||||
Return: is_flaky (boolean), confidence (0-1), summary (string)
|
Return: is_flaky (boolean), confidence (0-1), summary (string)
|
||||||
claude_args: |
|
json_schema: |
|
||||||
--json-schema '{"type":"object","properties":{"is_flaky":{"type":"boolean"},"confidence":{"type":"number"},"summary":{"type":"string"}},"required":["is_flaky"]}'
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"is_flaky": {"type": "boolean"},
|
||||||
|
"confidence": {"type": "number"},
|
||||||
|
"summary": {"type": "string"}
|
||||||
|
},
|
||||||
|
"required": ["is_flaky"]
|
||||||
|
}
|
||||||
|
|
||||||
- name: Retry if flaky
|
- name: Retry if flaky
|
||||||
if: fromJSON(steps.analyze.outputs.structured_output).is_flaky == true
|
if: fromJSON(steps.analyze.outputs.structured_output).is_flaky == true
|
||||||
@@ -211,7 +219,7 @@ Get validated JSON results from Claude that automatically become GitHub Action o
|
|||||||
|
|
||||||
### How It Works
|
### How It Works
|
||||||
|
|
||||||
1. **Define Schema**: Provide a JSON schema via `--json-schema` flag in `claude_args`
|
1. **Define Schema**: Provide a JSON schema in the `json_schema` input
|
||||||
2. **Claude Executes**: Claude uses tools to complete your task
|
2. **Claude Executes**: Claude uses tools to complete your task
|
||||||
3. **Validated Output**: Result is validated against your schema
|
3. **Validated Output**: Result is validated against your schema
|
||||||
4. **JSON Output**: All fields are returned in a single `structured_output` JSON string
|
4. **JSON Output**: All fields are returned in a single `structured_output` JSON string
|
||||||
|
|||||||
@@ -43,8 +43,27 @@ jobs:
|
|||||||
- is_flaky: true if likely flaky, false if real bug
|
- is_flaky: true if likely flaky, false if real bug
|
||||||
- confidence: number 0-1 indicating confidence level
|
- confidence: number 0-1 indicating confidence level
|
||||||
- summary: brief one-sentence explanation
|
- summary: brief one-sentence explanation
|
||||||
claude_args: |
|
json_schema: |
|
||||||
--json-schema '{"type":"object","properties":{"is_flaky":{"type":"boolean","description":"Whether this appears to be a flaky test failure"},"confidence":{"type":"number","minimum":0,"maximum":1,"description":"Confidence level in the determination"},"summary":{"type":"string","description":"One-sentence explanation of the failure"}},"required":["is_flaky","confidence","summary"]}'
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"is_flaky": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether this appears to be a flaky test failure"
|
||||||
|
},
|
||||||
|
"confidence": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 1,
|
||||||
|
"description": "Confidence level in the determination"
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "One-sentence explanation of the failure"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["is_flaky", "confidence", "summary"]
|
||||||
|
}
|
||||||
|
|
||||||
# Auto-retry only if flaky AND high confidence (>= 0.7)
|
# Auto-retry only if flaky AND high confidence (>= 0.7)
|
||||||
- name: Retry flaky tests
|
- name: Retry flaky tests
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
"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.2.1",
|
|
||||||
"@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",
|
||||||
|
|||||||
123
scripts/setup-network-restrictions.sh
Executable file
123
scripts/setup-network-restrictions.sh
Executable file
@@ -0,0 +1,123 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Setup Network Restrictions with Squid Proxy
|
||||||
|
# This script sets up a Squid proxy to restrict network access to whitelisted domains only.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Check if experimental_allowed_domains is provided
|
||||||
|
if [ -z "$EXPERIMENTAL_ALLOWED_DOMAINS" ]; then
|
||||||
|
echo "ERROR: EXPERIMENTAL_ALLOWED_DOMAINS environment variable is required"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check required environment variables
|
||||||
|
if [ -z "$RUNNER_TEMP" ]; then
|
||||||
|
echo "ERROR: RUNNER_TEMP environment variable is required"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$GITHUB_ENV" ]; then
|
||||||
|
echo "ERROR: GITHUB_ENV environment variable is required"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Setting up network restrictions with Squid proxy..."
|
||||||
|
|
||||||
|
SQUID_START_TIME=$(date +%s.%N)
|
||||||
|
|
||||||
|
# Create whitelist file
|
||||||
|
echo "$EXPERIMENTAL_ALLOWED_DOMAINS" > $RUNNER_TEMP/whitelist.txt
|
||||||
|
|
||||||
|
# Ensure each domain has proper format
|
||||||
|
# If domain doesn't start with a dot and isn't an IP, add the dot for subdomain matching
|
||||||
|
mv $RUNNER_TEMP/whitelist.txt $RUNNER_TEMP/whitelist.txt.orig
|
||||||
|
while IFS= read -r domain; do
|
||||||
|
if [ -n "$domain" ]; then
|
||||||
|
# Trim whitespace
|
||||||
|
domain=$(echo "$domain" | xargs)
|
||||||
|
# If it's not empty and doesn't start with a dot, add one
|
||||||
|
if [[ "$domain" != .* ]] && [[ ! "$domain" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
echo ".$domain" >> $RUNNER_TEMP/whitelist.txt
|
||||||
|
else
|
||||||
|
echo "$domain" >> $RUNNER_TEMP/whitelist.txt
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done < $RUNNER_TEMP/whitelist.txt.orig
|
||||||
|
|
||||||
|
# Create Squid config with whitelist
|
||||||
|
echo "http_port 3128" > $RUNNER_TEMP/squid.conf
|
||||||
|
echo "" >> $RUNNER_TEMP/squid.conf
|
||||||
|
echo "# Define ACLs" >> $RUNNER_TEMP/squid.conf
|
||||||
|
echo "acl whitelist dstdomain \"/etc/squid/whitelist.txt\"" >> $RUNNER_TEMP/squid.conf
|
||||||
|
echo "acl localnet src 127.0.0.1/32" >> $RUNNER_TEMP/squid.conf
|
||||||
|
echo "acl localnet src 172.17.0.0/16" >> $RUNNER_TEMP/squid.conf
|
||||||
|
echo "acl SSL_ports port 443" >> $RUNNER_TEMP/squid.conf
|
||||||
|
echo "acl Safe_ports port 80" >> $RUNNER_TEMP/squid.conf
|
||||||
|
echo "acl Safe_ports port 443" >> $RUNNER_TEMP/squid.conf
|
||||||
|
echo "acl CONNECT method CONNECT" >> $RUNNER_TEMP/squid.conf
|
||||||
|
echo "" >> $RUNNER_TEMP/squid.conf
|
||||||
|
echo "# Deny requests to certain unsafe ports" >> $RUNNER_TEMP/squid.conf
|
||||||
|
echo "http_access deny !Safe_ports" >> $RUNNER_TEMP/squid.conf
|
||||||
|
echo "" >> $RUNNER_TEMP/squid.conf
|
||||||
|
echo "# Only allow CONNECT to SSL ports" >> $RUNNER_TEMP/squid.conf
|
||||||
|
echo "http_access deny CONNECT !SSL_ports" >> $RUNNER_TEMP/squid.conf
|
||||||
|
echo "" >> $RUNNER_TEMP/squid.conf
|
||||||
|
echo "# Allow localhost" >> $RUNNER_TEMP/squid.conf
|
||||||
|
echo "http_access allow localhost" >> $RUNNER_TEMP/squid.conf
|
||||||
|
echo "" >> $RUNNER_TEMP/squid.conf
|
||||||
|
echo "# Allow localnet access to whitelisted domains" >> $RUNNER_TEMP/squid.conf
|
||||||
|
echo "http_access allow localnet whitelist" >> $RUNNER_TEMP/squid.conf
|
||||||
|
echo "" >> $RUNNER_TEMP/squid.conf
|
||||||
|
echo "# Deny everything else" >> $RUNNER_TEMP/squid.conf
|
||||||
|
echo "http_access deny all" >> $RUNNER_TEMP/squid.conf
|
||||||
|
|
||||||
|
echo "Starting Squid proxy..."
|
||||||
|
# First, remove any existing container
|
||||||
|
sudo docker rm -f squid-proxy 2>/dev/null || true
|
||||||
|
|
||||||
|
# Ensure whitelist file is not empty (Squid fails with empty files)
|
||||||
|
if [ ! -s "$RUNNER_TEMP/whitelist.txt" ]; then
|
||||||
|
echo "WARNING: Whitelist file is empty, adding a dummy entry"
|
||||||
|
echo ".example.com" >> $RUNNER_TEMP/whitelist.txt
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use sudo to prevent Claude from stopping the container
|
||||||
|
CONTAINER_ID=$(sudo docker run -d \
|
||||||
|
--name squid-proxy \
|
||||||
|
-p 127.0.0.1:3128:3128 \
|
||||||
|
-v $RUNNER_TEMP/squid.conf:/etc/squid/squid.conf:ro \
|
||||||
|
-v $RUNNER_TEMP/whitelist.txt:/etc/squid/whitelist.txt:ro \
|
||||||
|
ubuntu/squid:latest 2>&1) || {
|
||||||
|
echo "ERROR: Failed to start Squid container"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Wait for proxy to be ready (usually < 1 second)
|
||||||
|
READY=false
|
||||||
|
for i in {1..30}; do
|
||||||
|
if nc -z 127.0.0.1 3128 2>/dev/null; then
|
||||||
|
TOTAL_TIME=$(echo "scale=3; $(date +%s.%N) - $SQUID_START_TIME" | bc)
|
||||||
|
echo "Squid proxy ready in ${TOTAL_TIME}s"
|
||||||
|
READY=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$READY" != "true" ]; then
|
||||||
|
echo "ERROR: Squid proxy failed to start within 3 seconds"
|
||||||
|
echo "Container logs:"
|
||||||
|
sudo docker logs squid-proxy 2>&1 || true
|
||||||
|
echo "Container status:"
|
||||||
|
sudo docker ps -a | grep squid-proxy || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Set proxy environment variables
|
||||||
|
echo "http_proxy=http://127.0.0.1:3128" >> $GITHUB_ENV
|
||||||
|
echo "https_proxy=http://127.0.0.1:3128" >> $GITHUB_ENV
|
||||||
|
echo "HTTP_PROXY=http://127.0.0.1:3128" >> $GITHUB_ENV
|
||||||
|
echo "HTTPS_PROXY=http://127.0.0.1:3128" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
echo "Network restrictions setup completed successfully"
|
||||||
@@ -21,12 +21,8 @@ import type { ParsedGitHubContext } from "../github/context";
|
|||||||
import type { CommonFields, PreparedContext, EventData } from "./types";
|
import type { CommonFields, PreparedContext, EventData } from "./types";
|
||||||
import { GITHUB_SERVER_URL } from "../github/api/config";
|
import { GITHUB_SERVER_URL } from "../github/api/config";
|
||||||
import type { Mode, ModeContext } from "../modes/types";
|
import type { Mode, ModeContext } from "../modes/types";
|
||||||
import { extractUserRequest } from "../utils/extract-user-request";
|
|
||||||
export type { CommonFields, PreparedContext } from "./types";
|
export type { CommonFields, PreparedContext } from "./types";
|
||||||
|
|
||||||
/** Filename for the user request file, read by the SDK runner */
|
|
||||||
const USER_REQUEST_FILENAME = "claude-user-request.txt";
|
|
||||||
|
|
||||||
// Tag mode defaults - these tools are needed for tag mode to function
|
// Tag mode defaults - these tools are needed for tag mode to function
|
||||||
const BASE_ALLOWED_TOOLS = [
|
const BASE_ALLOWED_TOOLS = [
|
||||||
"Edit",
|
"Edit",
|
||||||
@@ -196,6 +192,11 @@ 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,
|
||||||
@@ -463,123 +464,6 @@ 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
|
||||||
@@ -589,10 +473,6 @@ 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,
|
||||||
@@ -738,13 +618,7 @@ ${eventData.eventName === "issue_comment" || eventData.eventName === "pull_reque
|
|||||||
- Reference specific code sections with file paths and line numbers${eventData.isPR ? `\n - AFTER reading files and analyzing code, you MUST call mcp__github_comment__update_claude_comment to post your review` : ""}
|
- Reference specific code sections with file paths and line numbers${eventData.isPR ? `\n - AFTER reading files and analyzing code, you MUST call mcp__github_comment__update_claude_comment to post your review` : ""}
|
||||||
- Formulate a concise, technical, and helpful response based on the context.
|
- Formulate a concise, technical, and helpful response based on the context.
|
||||||
- Reference specific code with inline formatting or code blocks.
|
- Reference specific code with inline formatting or code blocks.
|
||||||
- Include relevant file paths and line numbers when applicable.${
|
- Include relevant file paths and line numbers when applicable.
|
||||||
eventData.isPR && context.githubContext?.inputs.includeFixLinks
|
|
||||||
? `
|
|
||||||
- When identifying issues that could be fixed, include an inline link: [Fix this →](https://claude.ai/code?q=<URI_ENCODED_INSTRUCTIONS>&repo=${context.repository})
|
|
||||||
The query should be URI-encoded and include enough context for Claude Code to understand and fix the issue (file path, line numbers, branch name, what needs to change).`
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
- ${eventData.isPR ? `IMPORTANT: Submit your review feedback by updating the Claude comment using mcp__github_comment__update_claude_comment. This will be displayed as your PR review.` : `Remember that this feedback must be posted to the GitHub comment using mcp__github_comment__update_claude_comment.`}
|
- ${eventData.isPR ? `IMPORTANT: Submit your review feedback by updating the Claude comment using mcp__github_comment__update_claude_comment. This will be displayed as your PR review.` : `Remember that this feedback must be posted to the GitHub comment using mcp__github_comment__update_claude_comment.`}
|
||||||
|
|
||||||
B. For Straightforward Changes:
|
B. For Straightforward Changes:
|
||||||
@@ -851,55 +725,6 @@ f. If you are unable to complete certain steps, such as running a linter or test
|
|||||||
return promptContent;
|
return promptContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Extracts the user's request from the prepared context and GitHub data.
|
|
||||||
*
|
|
||||||
* This is used to send the user's actual command/request as a separate
|
|
||||||
* content block, enabling slash command processing in the CLI.
|
|
||||||
*
|
|
||||||
* @param context - The prepared context containing event data and trigger phrase
|
|
||||||
* @param githubData - The fetched GitHub data containing issue/PR body content
|
|
||||||
* @returns The extracted user request text (e.g., "/review-pr" or "fix this bug"),
|
|
||||||
* or null for assigned/labeled events without an explicit trigger in the body
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* // Comment event: "@claude /review-pr" -> returns "/review-pr"
|
|
||||||
* // Issue body with "@claude fix this" -> returns "fix this"
|
|
||||||
* // Issue assigned without @claude in body -> returns null
|
|
||||||
*/
|
|
||||||
function extractUserRequestFromContext(
|
|
||||||
context: PreparedContext,
|
|
||||||
githubData: FetchDataResult,
|
|
||||||
): string | null {
|
|
||||||
const { eventData, triggerPhrase } = context;
|
|
||||||
|
|
||||||
// For comment events, extract from comment body
|
|
||||||
if (
|
|
||||||
"commentBody" in eventData &&
|
|
||||||
eventData.commentBody &&
|
|
||||||
(eventData.eventName === "issue_comment" ||
|
|
||||||
eventData.eventName === "pull_request_review_comment" ||
|
|
||||||
eventData.eventName === "pull_request_review")
|
|
||||||
) {
|
|
||||||
return extractUserRequest(eventData.commentBody, triggerPhrase);
|
|
||||||
}
|
|
||||||
|
|
||||||
// For issue/PR events triggered by body content, extract from the body
|
|
||||||
if (githubData.contextData?.body) {
|
|
||||||
const request = extractUserRequest(
|
|
||||||
githubData.contextData.body,
|
|
||||||
triggerPhrase,
|
|
||||||
);
|
|
||||||
if (request) {
|
|
||||||
return request;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// For assigned/labeled events without explicit trigger in body,
|
|
||||||
// return null to indicate the full context should be used
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createPrompt(
|
export async function createPrompt(
|
||||||
mode: Mode,
|
mode: Mode,
|
||||||
modeContext: ModeContext,
|
modeContext: ModeContext,
|
||||||
@@ -948,22 +773,6 @@ export async function createPrompt(
|
|||||||
promptContent,
|
promptContent,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Extract and write the user request separately for SDK multi-block messaging
|
|
||||||
// This allows the CLI to process slash commands (e.g., "@claude /review-pr")
|
|
||||||
const userRequest = extractUserRequestFromContext(
|
|
||||||
preparedContext,
|
|
||||||
githubData,
|
|
||||||
);
|
|
||||||
if (userRequest) {
|
|
||||||
await writeFile(
|
|
||||||
`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts/${USER_REQUEST_FILENAME}`,
|
|
||||||
userRequest,
|
|
||||||
);
|
|
||||||
console.log("===== USER REQUEST =====");
|
|
||||||
console.log(userRequest);
|
|
||||||
console.log("========================");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set allowed tools
|
// Set allowed tools
|
||||||
const hasActionsReadPermission = false;
|
const hasActionsReadPermission = false;
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ type PullRequestReviewEvent = {
|
|||||||
eventName: "pull_request_review";
|
eventName: "pull_request_review";
|
||||||
isPR: true;
|
isPR: true;
|
||||||
prNumber: string;
|
prNumber: string;
|
||||||
commentBody?: string; // May be absent for approvals without comments
|
commentBody: string;
|
||||||
claudeBranch?: string;
|
claudeBranch?: string;
|
||||||
baseBranch?: string;
|
baseBranch?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
#!/usr/bin/env bun
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cleanup SSH signing key after action completes
|
|
||||||
* This is run as a post step for security purposes
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { cleanupSshSigning } from "../github/operations/git-config";
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
try {
|
|
||||||
await cleanupSshSigning();
|
|
||||||
} catch (error) {
|
|
||||||
// Don't fail the action if cleanup fails, just log it
|
|
||||||
console.error("Failed to cleanup SSH signing key:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (import.meta.main) {
|
|
||||||
run();
|
|
||||||
}
|
|
||||||
@@ -26,7 +26,7 @@ export function collectActionInputsPresence(): void {
|
|||||||
max_turns: "",
|
max_turns: "",
|
||||||
use_sticky_comment: "false",
|
use_sticky_comment: "false",
|
||||||
use_commit_signing: "false",
|
use_commit_signing: "false",
|
||||||
ssh_signing_key: "",
|
experimental_allowed_domains: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
const allInputsJson = process.env.ALL_INPUTS;
|
const allInputsJson = process.env.ALL_INPUTS;
|
||||||
|
|||||||
@@ -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: {
|
||||||
total_cost_usd?: number;
|
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" &&
|
||||||
"total_cost_usd" in lastElement &&
|
"cost_usd" in lastElement &&
|
||||||
"duration_ms" in lastElement
|
"duration_ms" in lastElement
|
||||||
) {
|
) {
|
||||||
executionDetails = {
|
executionDetails = {
|
||||||
total_cost_usd: lastElement.total_cost_usd,
|
cost_usd: lastElement.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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,16 +13,9 @@ export const PR_QUERY = `
|
|||||||
headRefName
|
headRefName
|
||||||
headRefOid
|
headRefOid
|
||||||
createdAt
|
createdAt
|
||||||
updatedAt
|
|
||||||
lastEditedAt
|
|
||||||
additions
|
additions
|
||||||
deletions
|
deletions
|
||||||
state
|
state
|
||||||
labels(first: 1) {
|
|
||||||
nodes {
|
|
||||||
name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
commits(first: 100) {
|
commits(first: 100) {
|
||||||
totalCount
|
totalCount
|
||||||
nodes {
|
nodes {
|
||||||
@@ -103,14 +96,7 @@ export const ISSUE_QUERY = `
|
|||||||
login
|
login
|
||||||
}
|
}
|
||||||
createdAt
|
createdAt
|
||||||
updatedAt
|
|
||||||
lastEditedAt
|
|
||||||
state
|
state
|
||||||
labels(first: 1) {
|
|
||||||
nodes {
|
|
||||||
name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
comments(first: 100) {
|
comments(first: 100) {
|
||||||
nodes {
|
nodes {
|
||||||
id
|
id
|
||||||
|
|||||||
@@ -88,16 +88,13 @@ type BaseContext = {
|
|||||||
labelTrigger: string;
|
labelTrigger: string;
|
||||||
baseBranch?: string;
|
baseBranch?: string;
|
||||||
branchPrefix: string;
|
branchPrefix: string;
|
||||||
branchNameTemplate?: string;
|
|
||||||
useStickyComment: boolean;
|
useStickyComment: boolean;
|
||||||
useCommitSigning: boolean;
|
useCommitSigning: boolean;
|
||||||
sshSigningKey: string;
|
|
||||||
botId: string;
|
botId: string;
|
||||||
botName: string;
|
botName: string;
|
||||||
allowedBots: string;
|
allowedBots: string;
|
||||||
allowedNonWriteUsers: string;
|
allowedNonWriteUsers: string;
|
||||||
trackProgress: boolean;
|
trackProgress: boolean;
|
||||||
includeFixLinks: boolean;
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -146,16 +143,13 @@ export function parseGitHubContext(): GitHubContext {
|
|||||||
labelTrigger: process.env.LABEL_TRIGGER ?? "",
|
labelTrigger: process.env.LABEL_TRIGGER ?? "",
|
||||||
baseBranch: process.env.BASE_BRANCH,
|
baseBranch: process.env.BASE_BRANCH,
|
||||||
branchPrefix: process.env.BRANCH_PREFIX ?? "claude/",
|
branchPrefix: process.env.BRANCH_PREFIX ?? "claude/",
|
||||||
branchNameTemplate: process.env.BRANCH_NAME_TEMPLATE,
|
|
||||||
useStickyComment: process.env.USE_STICKY_COMMENT === "true",
|
useStickyComment: process.env.USE_STICKY_COMMENT === "true",
|
||||||
useCommitSigning: process.env.USE_COMMIT_SIGNING === "true",
|
useCommitSigning: process.env.USE_COMMIT_SIGNING === "true",
|
||||||
sshSigningKey: process.env.SSH_SIGNING_KEY || "",
|
|
||||||
botId: process.env.BOT_ID ?? String(CLAUDE_APP_BOT_ID),
|
botId: process.env.BOT_ID ?? String(CLAUDE_APP_BOT_ID),
|
||||||
botName: process.env.BOT_NAME ?? CLAUDE_BOT_LOGIN,
|
botName: process.env.BOT_NAME ?? CLAUDE_BOT_LOGIN,
|
||||||
allowedBots: process.env.ALLOWED_BOTS ?? "",
|
allowedBots: process.env.ALLOWED_BOTS ?? "",
|
||||||
allowedNonWriteUsers: process.env.ALLOWED_NON_WRITE_USERS ?? "",
|
allowedNonWriteUsers: process.env.ALLOWED_NON_WRITE_USERS ?? "",
|
||||||
trackProgress: process.env.TRACK_PROGRESS === "true",
|
trackProgress: process.env.TRACK_PROGRESS === "true",
|
||||||
includeFixLinks: process.env.INCLUDE_FIX_LINKS === "true",
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import type { Octokits } from "../api/client";
|
|||||||
import { ISSUE_QUERY, PR_QUERY, USER_QUERY } from "../api/queries/github";
|
import { ISSUE_QUERY, PR_QUERY, USER_QUERY } from "../api/queries/github";
|
||||||
import {
|
import {
|
||||||
isIssueCommentEvent,
|
isIssueCommentEvent,
|
||||||
isIssuesEvent,
|
|
||||||
isPullRequestEvent,
|
|
||||||
isPullRequestReviewEvent,
|
isPullRequestReviewEvent,
|
||||||
isPullRequestReviewCommentEvent,
|
isPullRequestReviewCommentEvent,
|
||||||
type ParsedGitHubContext,
|
type ParsedGitHubContext,
|
||||||
@@ -42,31 +40,6 @@ export function extractTriggerTimestamp(
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Extracts the original title from the GitHub webhook payload.
|
|
||||||
* This is the title as it existed when the trigger event occurred.
|
|
||||||
*
|
|
||||||
* @param context - Parsed GitHub context from webhook
|
|
||||||
* @returns The original title string or undefined if not available
|
|
||||||
*/
|
|
||||||
export function extractOriginalTitle(
|
|
||||||
context: ParsedGitHubContext,
|
|
||||||
): string | undefined {
|
|
||||||
if (isIssueCommentEvent(context)) {
|
|
||||||
return context.payload.issue?.title;
|
|
||||||
} else if (isPullRequestEvent(context)) {
|
|
||||||
return context.payload.pull_request?.title;
|
|
||||||
} else if (isPullRequestReviewEvent(context)) {
|
|
||||||
return context.payload.pull_request?.title;
|
|
||||||
} else if (isPullRequestReviewCommentEvent(context)) {
|
|
||||||
return context.payload.pull_request?.title;
|
|
||||||
} else if (isIssuesEvent(context)) {
|
|
||||||
return context.payload.issue?.title;
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filters comments to only include those that existed in their final state before the trigger time.
|
* Filters comments to only include those that existed in their final state before the trigger time.
|
||||||
* This prevents malicious actors from editing comments after the trigger to inject harmful content.
|
* This prevents malicious actors from editing comments after the trigger to inject harmful content.
|
||||||
@@ -134,38 +107,6 @@ export function filterReviewsToTriggerTime<
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if the issue/PR body was edited after the trigger time.
|
|
||||||
* This prevents a race condition where an attacker could edit the issue/PR body
|
|
||||||
* between when an authorized user triggered Claude and when Claude processes the request.
|
|
||||||
*
|
|
||||||
* @param contextData - The PR or issue data containing body and edit timestamps
|
|
||||||
* @param triggerTime - ISO timestamp of when the trigger event occurred
|
|
||||||
* @returns true if the body is safe to use, false if it was edited after trigger
|
|
||||||
*/
|
|
||||||
export function isBodySafeToUse(
|
|
||||||
contextData: { createdAt: string; updatedAt?: string; lastEditedAt?: string },
|
|
||||||
triggerTime: string | undefined,
|
|
||||||
): boolean {
|
|
||||||
// If no trigger time is available, we can't validate - allow the body
|
|
||||||
// This maintains backwards compatibility for triggers that don't have timestamps
|
|
||||||
if (!triggerTime) return true;
|
|
||||||
|
|
||||||
const triggerTimestamp = new Date(triggerTime).getTime();
|
|
||||||
|
|
||||||
// Check if the body was edited after the trigger
|
|
||||||
// Use lastEditedAt if available (more accurate for body edits), otherwise fall back to updatedAt
|
|
||||||
const lastEditTime = contextData.lastEditedAt || contextData.updatedAt;
|
|
||||||
if (lastEditTime) {
|
|
||||||
const lastEditTimestamp = new Date(lastEditTime).getTime();
|
|
||||||
if (lastEditTimestamp >= triggerTimestamp) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
type FetchDataParams = {
|
type FetchDataParams = {
|
||||||
octokits: Octokits;
|
octokits: Octokits;
|
||||||
repository: string;
|
repository: string;
|
||||||
@@ -173,7 +114,6 @@ type FetchDataParams = {
|
|||||||
isPR: boolean;
|
isPR: boolean;
|
||||||
triggerUsername?: string;
|
triggerUsername?: string;
|
||||||
triggerTime?: string;
|
triggerTime?: string;
|
||||||
originalTitle?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GitHubFileWithSHA = GitHubFile & {
|
export type GitHubFileWithSHA = GitHubFile & {
|
||||||
@@ -197,7 +137,6 @@ export async function fetchGitHubData({
|
|||||||
isPR,
|
isPR,
|
||||||
triggerUsername,
|
triggerUsername,
|
||||||
triggerTime,
|
triggerTime,
|
||||||
originalTitle,
|
|
||||||
}: FetchDataParams): Promise<FetchDataResult> {
|
}: FetchDataParams): Promise<FetchDataResult> {
|
||||||
const [owner, repo] = repository.split("/");
|
const [owner, repo] = repository.split("/");
|
||||||
if (!owner || !repo) {
|
if (!owner || !repo) {
|
||||||
@@ -334,13 +273,9 @@ export async function fetchGitHubData({
|
|||||||
body: c.body,
|
body: c.body,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Add the main issue/PR body if it has content and wasn't edited after trigger
|
// Add the main issue/PR body if it has content
|
||||||
// This prevents a TOCTOU race condition where an attacker could edit the body
|
const mainBody: CommentWithImages[] = contextData.body
|
||||||
// between when an authorized user triggered Claude and when Claude processes the request
|
? [
|
||||||
let mainBody: CommentWithImages[] = [];
|
|
||||||
if (contextData.body) {
|
|
||||||
if (isBodySafeToUse(contextData, triggerTime)) {
|
|
||||||
mainBody = [
|
|
||||||
{
|
{
|
||||||
...(isPR
|
...(isPR
|
||||||
? {
|
? {
|
||||||
@@ -354,14 +289,8 @@ export async function fetchGitHubData({
|
|||||||
body: contextData.body,
|
body: contextData.body,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
];
|
]
|
||||||
} else {
|
: [];
|
||||||
console.warn(
|
|
||||||
`Security: ${isPR ? "PR" : "Issue"} #${prNumber} body was edited after the trigger event. ` +
|
|
||||||
`Excluding body content to prevent potential injection attacks.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const allComments = [
|
const allComments = [
|
||||||
...mainBody,
|
...mainBody,
|
||||||
@@ -383,11 +312,6 @@ export async function fetchGitHubData({
|
|||||||
triggerDisplayName = await fetchUserDisplayName(octokits, triggerUsername);
|
triggerDisplayName = await fetchUserDisplayName(octokits, triggerUsername);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the original title from the webhook payload if provided
|
|
||||||
if (originalTitle !== undefined) {
|
|
||||||
contextData.title = originalTitle;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
contextData,
|
contextData,
|
||||||
comments,
|
comments,
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ export function formatContext(
|
|||||||
): string {
|
): string {
|
||||||
if (isPR) {
|
if (isPR) {
|
||||||
const prData = contextData as GitHubPullRequest;
|
const prData = contextData as GitHubPullRequest;
|
||||||
const sanitizedTitle = sanitizeContent(prData.title);
|
return `PR Title: ${prData.title}
|
||||||
return `PR Title: ${sanitizedTitle}
|
|
||||||
PR Author: ${prData.author.login}
|
PR Author: ${prData.author.login}
|
||||||
PR Branch: ${prData.headRefName} -> ${prData.baseRefName}
|
PR Branch: ${prData.headRefName} -> ${prData.baseRefName}
|
||||||
PR State: ${prData.state}
|
PR State: ${prData.state}
|
||||||
@@ -25,8 +24,7 @@ Total Commits: ${prData.commits.totalCount}
|
|||||||
Changed Files: ${prData.files.nodes.length} files`;
|
Changed Files: ${prData.files.nodes.length} files`;
|
||||||
} else {
|
} else {
|
||||||
const issueData = contextData as GitHubIssue;
|
const issueData = contextData as GitHubIssue;
|
||||||
const sanitizedTitle = sanitizeContent(issueData.title);
|
return `Issue Title: ${issueData.title}
|
||||||
return `Issue Title: ${sanitizedTitle}
|
|
||||||
Issue Author: ${issueData.author.login}
|
Issue Author: ${issueData.author.login}
|
||||||
Issue State: ${issueData.state}`;
|
Issue State: ${issueData.state}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,120 +7,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { $ } from "bun";
|
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";
|
||||||
import { generateBranchName } from "../../utils/branch-template";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extracts the first label from GitHub data, or returns undefined if no labels exist
|
|
||||||
*/
|
|
||||||
function extractFirstLabel(githubData: FetchDataResult): string | undefined {
|
|
||||||
const labels = githubData.contextData.labels?.nodes;
|
|
||||||
return labels && labels.length > 0 ? labels[0]?.name : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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;
|
||||||
@@ -135,7 +26,7 @@ export async function setupBranch(
|
|||||||
): Promise<BranchInfo> {
|
): Promise<BranchInfo> {
|
||||||
const { owner, repo } = context.repository;
|
const { owner, repo } = context.repository;
|
||||||
const entityNumber = context.entityNumber;
|
const entityNumber = context.entityNumber;
|
||||||
const { baseBranch, branchPrefix, branchNameTemplate } = context.inputs;
|
const { baseBranch, branchPrefix } = context.inputs;
|
||||||
const isPR = context.isPR;
|
const isPR = context.isPR;
|
||||||
|
|
||||||
if (isPR) {
|
if (isPR) {
|
||||||
@@ -162,19 +53,14 @@ 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)
|
||||||
// Using execFileSync instead of shell template literals for security
|
await $`git fetch origin --depth=${fetchDepth} ${branchName}`;
|
||||||
execGit(["fetch", "origin", `--depth=${fetchDepth}`, branchName]);
|
await $`git checkout ${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,
|
||||||
@@ -201,8 +87,17 @@ export async function setupBranch(
|
|||||||
// Generate branch name for either an issue or closed/merged PR
|
// Generate branch name for either an issue or closed/merged PR
|
||||||
const entityType = isPR ? "pr" : "issue";
|
const entityType = isPR ? "pr" : "issue";
|
||||||
|
|
||||||
// Get the SHA of the source branch to use in template
|
// Create Kubernetes-compatible timestamp: lowercase, hyphens only, shorter format
|
||||||
let sourceSHA: string | undefined;
|
const now = new Date();
|
||||||
|
const timestamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}-${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}`;
|
||||||
|
|
||||||
|
// Ensure branch name is Kubernetes-compatible:
|
||||||
|
// - Lowercase only
|
||||||
|
// - Alphanumeric with hyphens
|
||||||
|
// - No underscores
|
||||||
|
// - Max 50 chars (to allow for prefixes)
|
||||||
|
const branchName = `${branchPrefix}${entityType}-${entityNumber}-${timestamp}`;
|
||||||
|
const newBranch = branchName.toLowerCase().substring(0, 50);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get the SHA of the source branch to verify it exists
|
// Get the SHA of the source branch to verify it exists
|
||||||
@@ -212,46 +107,8 @@ export async function setupBranch(
|
|||||||
ref: `heads/${sourceBranch}`,
|
ref: `heads/${sourceBranch}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
sourceSHA = sourceBranchRef.data.object.sha;
|
const currentSHA = sourceBranchRef.data.object.sha;
|
||||||
console.log(`Source branch SHA: ${sourceSHA}`);
|
console.log(`Source branch SHA: ${currentSHA}`);
|
||||||
|
|
||||||
// Extract first label from GitHub data
|
|
||||||
const firstLabel = extractFirstLabel(githubData);
|
|
||||||
|
|
||||||
// Extract title from GitHub data
|
|
||||||
const title = githubData.contextData.title;
|
|
||||||
|
|
||||||
// Generate branch name using template or default format
|
|
||||||
let newBranch = generateBranchName(
|
|
||||||
branchNameTemplate,
|
|
||||||
branchPrefix,
|
|
||||||
entityType,
|
|
||||||
entityNumber,
|
|
||||||
sourceSHA,
|
|
||||||
firstLabel,
|
|
||||||
title,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check if generated branch already exists on remote
|
|
||||||
try {
|
|
||||||
await $`git ls-remote --exit-code origin refs/heads/${newBranch}`.quiet();
|
|
||||||
|
|
||||||
// If we get here, branch exists (exit code 0)
|
|
||||||
console.log(
|
|
||||||
`Branch '${newBranch}' already exists, falling back to default format`,
|
|
||||||
);
|
|
||||||
newBranch = generateBranchName(
|
|
||||||
undefined, // Force default template
|
|
||||||
branchPrefix,
|
|
||||||
entityType,
|
|
||||||
entityNumber,
|
|
||||||
sourceSHA,
|
|
||||||
firstLabel,
|
|
||||||
title,
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
// Branch doesn't exist (non-zero exit code), continue with generated name
|
|
||||||
}
|
|
||||||
|
|
||||||
// For commit signing, defer branch creation to the file ops server
|
// For commit signing, defer branch creation to the file ops server
|
||||||
if (context.inputs.useCommitSigning) {
|
if (context.inputs.useCommitSigning) {
|
||||||
@@ -261,9 +118,8 @@ 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}`);
|
||||||
validateBranchName(sourceBranch);
|
await $`git fetch origin ${sourceBranch} --depth=1`;
|
||||||
execGit(["fetch", "origin", sourceBranch, "--depth=1"]);
|
await $`git checkout ${sourceBranch}`;
|
||||||
execGit(["checkout", sourceBranch, "--"]);
|
|
||||||
|
|
||||||
// Set outputs for GitHub Actions
|
// Set outputs for GitHub Actions
|
||||||
core.setOutput("CLAUDE_BRANCH", newBranch);
|
core.setOutput("CLAUDE_BRANCH", newBranch);
|
||||||
@@ -282,13 +138,11 @@ 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}`);
|
||||||
validateBranchName(sourceBranch);
|
await $`git fetch origin ${sourceBranch} --depth=1`;
|
||||||
validateBranchName(newBranch);
|
await $`git checkout ${sourceBranch}`;
|
||||||
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
|
||||||
execGit(["checkout", "-b", newBranch]);
|
await $`git 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 = {
|
||||||
total_cost_usd?: number;
|
cost_usd?: number;
|
||||||
duration_ms?: number;
|
duration_ms?: number;
|
||||||
duration_api_ms?: number;
|
duration_api_ms?: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,14 +6,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { $ } from "bun";
|
import { $ } from "bun";
|
||||||
import { mkdir, writeFile, rm } from "fs/promises";
|
|
||||||
import { join } from "path";
|
|
||||||
import { homedir } from "os";
|
|
||||||
import type { GitHubContext } from "../context";
|
import type { GitHubContext } from "../context";
|
||||||
import { GITHUB_SERVER_URL } from "../api/config";
|
import { GITHUB_SERVER_URL } from "../api/config";
|
||||||
|
|
||||||
const SSH_SIGNING_KEY_PATH = join(homedir(), ".ssh", "claude_signing_key");
|
|
||||||
|
|
||||||
type GitUser = {
|
type GitUser = {
|
||||||
login: string;
|
login: string;
|
||||||
id: number;
|
id: number;
|
||||||
@@ -59,50 +54,3 @@ export async function configureGitAuth(
|
|||||||
|
|
||||||
console.log("Git authentication configured successfully");
|
console.log("Git authentication configured successfully");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Configure git to use SSH signing for commits
|
|
||||||
* This is an alternative to GitHub API-based commit signing (use_commit_signing)
|
|
||||||
*/
|
|
||||||
export async function setupSshSigning(sshSigningKey: string): Promise<void> {
|
|
||||||
console.log("Configuring SSH signing for commits...");
|
|
||||||
|
|
||||||
// Validate SSH key format
|
|
||||||
if (!sshSigningKey.trim()) {
|
|
||||||
throw new Error("SSH signing key cannot be empty");
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
!sshSigningKey.includes("BEGIN") ||
|
|
||||||
!sshSigningKey.includes("PRIVATE KEY")
|
|
||||||
) {
|
|
||||||
throw new Error("Invalid SSH private key format");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create .ssh directory with secure permissions (700)
|
|
||||||
const sshDir = join(homedir(), ".ssh");
|
|
||||||
await mkdir(sshDir, { recursive: true, mode: 0o700 });
|
|
||||||
|
|
||||||
// Write the signing key atomically with secure permissions (600)
|
|
||||||
await writeFile(SSH_SIGNING_KEY_PATH, sshSigningKey, { mode: 0o600 });
|
|
||||||
console.log(`✓ SSH signing key written to ${SSH_SIGNING_KEY_PATH}`);
|
|
||||||
|
|
||||||
// Configure git to use SSH signing
|
|
||||||
await $`git config gpg.format ssh`;
|
|
||||||
await $`git config user.signingkey ${SSH_SIGNING_KEY_PATH}`;
|
|
||||||
await $`git config commit.gpgsign true`;
|
|
||||||
|
|
||||||
console.log("✓ Git configured to use SSH signing for commits");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clean up the SSH signing key file
|
|
||||||
* Should be called in the post step for security
|
|
||||||
*/
|
|
||||||
export async function cleanupSshSigning(): Promise<void> {
|
|
||||||
try {
|
|
||||||
await rm(SSH_SIGNING_KEY_PATH, { force: true });
|
|
||||||
console.log("✓ SSH signing key cleaned up");
|
|
||||||
} catch (error) {
|
|
||||||
console.log("No SSH signing key to clean up");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -58,16 +58,9 @@ export type GitHubPullRequest = {
|
|||||||
headRefName: string;
|
headRefName: string;
|
||||||
headRefOid: string;
|
headRefOid: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt?: string;
|
|
||||||
lastEditedAt?: string;
|
|
||||||
additions: number;
|
additions: number;
|
||||||
deletions: number;
|
deletions: number;
|
||||||
state: string;
|
state: string;
|
||||||
labels: {
|
|
||||||
nodes: Array<{
|
|
||||||
name: string;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
commits: {
|
commits: {
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
nodes: Array<{
|
nodes: Array<{
|
||||||
@@ -90,14 +83,7 @@ export type GitHubIssue = {
|
|||||||
body: string;
|
body: string;
|
||||||
author: GitHubAuthor;
|
author: GitHubAuthor;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt?: string;
|
|
||||||
lastEditedAt?: string;
|
|
||||||
state: string;
|
state: string;
|
||||||
labels: {
|
|
||||||
nodes: Array<{
|
|
||||||
name: string;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
comments: {
|
comments: {
|
||||||
nodes: GitHubComment[];
|
nodes: GitHubComment[];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,12 +4,11 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|||||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { readFile, stat } from "fs/promises";
|
import { readFile, stat } from "fs/promises";
|
||||||
import { resolve } from "path";
|
import { join } from "path";
|
||||||
import { constants } from "fs";
|
import { constants } from "fs";
|
||||||
import fetch from "node-fetch";
|
import fetch from "node-fetch";
|
||||||
import { GITHUB_API_URL } from "../github/api/config";
|
import { GITHUB_API_URL } from "../github/api/config";
|
||||||
import { retryWithBackoff } from "../utils/retry";
|
import { retryWithBackoff } from "../utils/retry";
|
||||||
import { validatePathWithinRepo } from "./path-validation";
|
|
||||||
|
|
||||||
type GitHubRef = {
|
type GitHubRef = {
|
||||||
object: {
|
object: {
|
||||||
@@ -214,18 +213,12 @@ server.tool(
|
|||||||
throw new Error("GITHUB_TOKEN environment variable is required");
|
throw new Error("GITHUB_TOKEN environment variable is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate all paths are within repository root and get full/relative paths
|
const processedFiles = files.map((filePath) => {
|
||||||
const resolvedRepoDir = resolve(REPO_DIR);
|
if (filePath.startsWith("/")) {
|
||||||
const validatedFiles = await Promise.all(
|
return filePath.slice(1);
|
||||||
files.map(async (filePath) => {
|
}
|
||||||
const fullPath = await validatePathWithinRepo(filePath, REPO_DIR);
|
return filePath;
|
||||||
// Calculate the relative path for the git tree entry
|
});
|
||||||
// Use the original filePath (normalized) for the git path, not the symlink-resolved path
|
|
||||||
const normalizedPath = resolve(resolvedRepoDir, filePath);
|
|
||||||
const relativePath = normalizedPath.slice(resolvedRepoDir.length + 1);
|
|
||||||
return { fullPath, relativePath };
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 1. Get the branch reference (create if doesn't exist)
|
// 1. Get the branch reference (create if doesn't exist)
|
||||||
const baseSha = await getOrCreateBranchRef(
|
const baseSha = await getOrCreateBranchRef(
|
||||||
@@ -254,14 +247,18 @@ server.tool(
|
|||||||
|
|
||||||
// 3. Create tree entries for all files
|
// 3. Create tree entries for all files
|
||||||
const treeEntries = await Promise.all(
|
const treeEntries = await Promise.all(
|
||||||
validatedFiles.map(async ({ fullPath, relativePath }) => {
|
processedFiles.map(async (filePath) => {
|
||||||
|
const fullPath = filePath.startsWith("/")
|
||||||
|
? filePath
|
||||||
|
: join(REPO_DIR, filePath);
|
||||||
|
|
||||||
// Get the proper file mode based on file permissions
|
// Get the proper file mode based on file permissions
|
||||||
const fileMode = await getFileMode(fullPath);
|
const fileMode = await getFileMode(fullPath);
|
||||||
|
|
||||||
// Check if file is binary (images, etc.)
|
// Check if file is binary (images, etc.)
|
||||||
const isBinaryFile =
|
const isBinaryFile =
|
||||||
/\.(png|jpg|jpeg|gif|webp|ico|pdf|zip|tar|gz|exe|bin|woff|woff2|ttf|eot)$/i.test(
|
/\.(png|jpg|jpeg|gif|webp|ico|pdf|zip|tar|gz|exe|bin|woff|woff2|ttf|eot)$/i.test(
|
||||||
relativePath,
|
filePath,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isBinaryFile) {
|
if (isBinaryFile) {
|
||||||
@@ -287,7 +284,7 @@ server.tool(
|
|||||||
if (!blobResponse.ok) {
|
if (!blobResponse.ok) {
|
||||||
const errorText = await blobResponse.text();
|
const errorText = await blobResponse.text();
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to create blob for ${relativePath}: ${blobResponse.status} - ${errorText}`,
|
`Failed to create blob for ${filePath}: ${blobResponse.status} - ${errorText}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,7 +292,7 @@ server.tool(
|
|||||||
|
|
||||||
// Return tree entry with blob SHA
|
// Return tree entry with blob SHA
|
||||||
return {
|
return {
|
||||||
path: relativePath,
|
path: filePath,
|
||||||
mode: fileMode,
|
mode: fileMode,
|
||||||
type: "blob",
|
type: "blob",
|
||||||
sha: blobData.sha,
|
sha: blobData.sha,
|
||||||
@@ -304,7 +301,7 @@ server.tool(
|
|||||||
// For text files, include content directly in tree
|
// For text files, include content directly in tree
|
||||||
const content = await readFile(fullPath, "utf-8");
|
const content = await readFile(fullPath, "utf-8");
|
||||||
return {
|
return {
|
||||||
path: relativePath,
|
path: filePath,
|
||||||
mode: fileMode,
|
mode: fileMode,
|
||||||
type: "blob",
|
type: "blob",
|
||||||
content: content,
|
content: content,
|
||||||
@@ -424,9 +421,7 @@ server.tool(
|
|||||||
author: newCommitData.author.name,
|
author: newCommitData.author.name,
|
||||||
date: newCommitData.author.date,
|
date: newCommitData.author.date,
|
||||||
},
|
},
|
||||||
files: validatedFiles.map(({ relativePath }) => ({
|
files: processedFiles.map((path) => ({ path })),
|
||||||
path: relativePath,
|
|
||||||
})),
|
|
||||||
tree: {
|
tree: {
|
||||||
sha: treeData.sha,
|
sha: treeData.sha,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
import { realpath } from "fs/promises";
|
|
||||||
import { resolve, sep } from "path";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates that a file path resolves within the repository root.
|
|
||||||
* Prevents path traversal attacks via "../" sequences and symlinks.
|
|
||||||
* @param filePath - The file path to validate (can be relative or absolute)
|
|
||||||
* @param repoRoot - The repository root directory
|
|
||||||
* @returns The resolved absolute path (with symlinks resolved) if valid
|
|
||||||
* @throws Error if the path resolves outside the repository root
|
|
||||||
*/
|
|
||||||
export async function validatePathWithinRepo(
|
|
||||||
filePath: string,
|
|
||||||
repoRoot: string,
|
|
||||||
): Promise<string> {
|
|
||||||
// First resolve the path string (handles .. and . segments)
|
|
||||||
const initialPath = resolve(repoRoot, filePath);
|
|
||||||
|
|
||||||
// Resolve symlinks to get the real path
|
|
||||||
// This prevents symlink attacks where a link inside the repo points outside
|
|
||||||
let resolvedRoot: string;
|
|
||||||
let resolvedPath: string;
|
|
||||||
|
|
||||||
try {
|
|
||||||
resolvedRoot = await realpath(repoRoot);
|
|
||||||
} catch {
|
|
||||||
throw new Error(`Repository root '${repoRoot}' does not exist`);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
resolvedPath = await realpath(initialPath);
|
|
||||||
} catch {
|
|
||||||
// File doesn't exist yet - fall back to checking the parent directory
|
|
||||||
// This handles the case where we're creating a new file
|
|
||||||
const parentDir = resolve(initialPath, "..");
|
|
||||||
try {
|
|
||||||
const resolvedParent = await realpath(parentDir);
|
|
||||||
if (
|
|
||||||
resolvedParent !== resolvedRoot &&
|
|
||||||
!resolvedParent.startsWith(resolvedRoot + sep)
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
`Path '${filePath}' resolves outside the repository root`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Parent is valid, return the initial path since file doesn't exist yet
|
|
||||||
return initialPath;
|
|
||||||
} catch {
|
|
||||||
throw new Error(
|
|
||||||
`Path '${filePath}' resolves outside the repository root`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Path must be within repo root (or be the root itself)
|
|
||||||
if (
|
|
||||||
resolvedPath !== resolvedRoot &&
|
|
||||||
!resolvedPath.startsWith(resolvedRoot + sep)
|
|
||||||
) {
|
|
||||||
throw new Error(`Path '${filePath}' resolves outside the repository root`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return resolvedPath;
|
|
||||||
}
|
|
||||||
@@ -4,12 +4,10 @@ import type { Mode, ModeOptions, ModeResult } from "../types";
|
|||||||
import type { PreparedContext } from "../../create-prompt/types";
|
import type { PreparedContext } from "../../create-prompt/types";
|
||||||
import { prepareMcpConfig } from "../../mcp/install-mcp-server";
|
import { prepareMcpConfig } from "../../mcp/install-mcp-server";
|
||||||
import { parseAllowedTools } from "./parse-tools";
|
import { parseAllowedTools } from "./parse-tools";
|
||||||
import {
|
import { configureGitAuth } from "../../github/operations/git-config";
|
||||||
configureGitAuth,
|
|
||||||
setupSshSigning,
|
|
||||||
} from "../../github/operations/git-config";
|
|
||||||
import type { GitHubContext } from "../../github/context";
|
import type { GitHubContext } from "../../github/context";
|
||||||
import { isEntityContext } from "../../github/context";
|
import { isEntityContext } from "../../github/context";
|
||||||
|
import { appendJsonSchemaArg } from "../../utils/json-schema";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract GitHub context as environment variables for agent mode
|
* Extract GitHub context as environment variables for agent mode
|
||||||
@@ -82,27 +80,7 @@ export const agentMode: Mode = {
|
|||||||
|
|
||||||
async prepare({ context, githubToken }: ModeOptions): Promise<ModeResult> {
|
async prepare({ context, githubToken }: ModeOptions): Promise<ModeResult> {
|
||||||
// Configure git authentication for agent mode (same as tag mode)
|
// Configure git authentication for agent mode (same as tag mode)
|
||||||
// SSH signing takes precedence if provided
|
if (!context.inputs.useCommitSigning) {
|
||||||
const useSshSigning = !!context.inputs.sshSigningKey;
|
|
||||||
const useApiCommitSigning =
|
|
||||||
context.inputs.useCommitSigning && !useSshSigning;
|
|
||||||
|
|
||||||
if (useSshSigning) {
|
|
||||||
// Setup SSH signing for commits
|
|
||||||
await setupSshSigning(context.inputs.sshSigningKey);
|
|
||||||
|
|
||||||
// Still configure git auth for push operations (user/email and remote URL)
|
|
||||||
const user = {
|
|
||||||
login: context.inputs.botName,
|
|
||||||
id: parseInt(context.inputs.botId),
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
await configureGitAuth(githubToken, context, user);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to configure git authentication:", error);
|
|
||||||
// Continue anyway - git operations may still work with default config
|
|
||||||
}
|
|
||||||
} else if (!useApiCommitSigning) {
|
|
||||||
// Use bot_id and bot_name from inputs directly
|
// Use bot_id and bot_name from inputs directly
|
||||||
const user = {
|
const user = {
|
||||||
login: context.inputs.botName,
|
login: context.inputs.botName,
|
||||||
@@ -172,6 +150,9 @@ export const agentMode: Mode = {
|
|||||||
claudeArgs = `--mcp-config '${escapedOurConfig}'`;
|
claudeArgs = `--mcp-config '${escapedOurConfig}'`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add JSON schema if provided
|
||||||
|
claudeArgs = appendJsonSchemaArg(claudeArgs);
|
||||||
|
|
||||||
// Append user's claude_args (which may have more --mcp-config flags)
|
// Append user's claude_args (which may have more --mcp-config flags)
|
||||||
claudeArgs = `${claudeArgs} ${userClaudeArgs}`.trim();
|
claudeArgs = `${claudeArgs} ${userClaudeArgs}`.trim();
|
||||||
|
|
||||||
|
|||||||
@@ -4,21 +4,18 @@ import { checkContainsTrigger } from "../../github/validation/trigger";
|
|||||||
import { checkHumanActor } from "../../github/validation/actor";
|
import { checkHumanActor } from "../../github/validation/actor";
|
||||||
import { createInitialComment } from "../../github/operations/comments/create-initial";
|
import { createInitialComment } from "../../github/operations/comments/create-initial";
|
||||||
import { setupBranch } from "../../github/operations/branch";
|
import { setupBranch } from "../../github/operations/branch";
|
||||||
import {
|
import { configureGitAuth } from "../../github/operations/git-config";
|
||||||
configureGitAuth,
|
|
||||||
setupSshSigning,
|
|
||||||
} from "../../github/operations/git-config";
|
|
||||||
import { prepareMcpConfig } from "../../mcp/install-mcp-server";
|
import { prepareMcpConfig } from "../../mcp/install-mcp-server";
|
||||||
import {
|
import {
|
||||||
fetchGitHubData,
|
fetchGitHubData,
|
||||||
extractTriggerTimestamp,
|
extractTriggerTimestamp,
|
||||||
extractOriginalTitle,
|
|
||||||
} from "../../github/data/fetcher";
|
} from "../../github/data/fetcher";
|
||||||
import { createPrompt, generateDefaultPrompt } from "../../create-prompt";
|
import { createPrompt, generateDefaultPrompt } 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";
|
||||||
import { parseAllowedTools } from "../agent/parse-tools";
|
import { parseAllowedTools } from "../agent/parse-tools";
|
||||||
|
import { appendJsonSchemaArg } from "../../utils/json-schema";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tag mode implementation.
|
* Tag mode implementation.
|
||||||
@@ -79,7 +76,6 @@ export const tagMode: Mode = {
|
|||||||
const commentId = commentData.id;
|
const commentId = commentData.id;
|
||||||
|
|
||||||
const triggerTime = extractTriggerTimestamp(context);
|
const triggerTime = extractTriggerTimestamp(context);
|
||||||
const originalTitle = extractOriginalTitle(context);
|
|
||||||
|
|
||||||
const githubData = await fetchGitHubData({
|
const githubData = await fetchGitHubData({
|
||||||
octokits: octokit,
|
octokits: octokit,
|
||||||
@@ -88,34 +84,13 @@ export const tagMode: Mode = {
|
|||||||
isPR: context.isPR,
|
isPR: context.isPR,
|
||||||
triggerUsername: context.actor,
|
triggerUsername: context.actor,
|
||||||
triggerTime,
|
triggerTime,
|
||||||
originalTitle,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Setup branch
|
// Setup branch
|
||||||
const branchInfo = await setupBranch(octokit, githubData, context);
|
const branchInfo = await setupBranch(octokit, githubData, context);
|
||||||
|
|
||||||
// Configure git authentication
|
// Configure git authentication if not using commit signing
|
||||||
// SSH signing takes precedence if provided
|
if (!context.inputs.useCommitSigning) {
|
||||||
const useSshSigning = !!context.inputs.sshSigningKey;
|
|
||||||
const useApiCommitSigning =
|
|
||||||
context.inputs.useCommitSigning && !useSshSigning;
|
|
||||||
|
|
||||||
if (useSshSigning) {
|
|
||||||
// Setup SSH signing for commits
|
|
||||||
await setupSshSigning(context.inputs.sshSigningKey);
|
|
||||||
|
|
||||||
// Still configure git auth for push operations (user/email and remote URL)
|
|
||||||
const user = {
|
|
||||||
login: context.inputs.botName,
|
|
||||||
id: parseInt(context.inputs.botId),
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
await configureGitAuth(githubToken, context, user);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to configure git authentication:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
} else if (!useApiCommitSigning) {
|
|
||||||
// Use bot_id and bot_name from inputs directly
|
// Use bot_id and bot_name from inputs directly
|
||||||
const user = {
|
const user = {
|
||||||
login: context.inputs.botName,
|
login: context.inputs.botName,
|
||||||
@@ -161,9 +136,8 @@ export const tagMode: Mode = {
|
|||||||
...userAllowedMCPTools,
|
...userAllowedMCPTools,
|
||||||
];
|
];
|
||||||
|
|
||||||
// Add git commands when using git CLI (no API commit signing, or SSH signing)
|
// Add git commands when not using commit signing
|
||||||
// SSH signing still uses git CLI, just with signing enabled
|
if (!context.inputs.useCommitSigning) {
|
||||||
if (!useApiCommitSigning) {
|
|
||||||
tagModeTools.push(
|
tagModeTools.push(
|
||||||
"Bash(git add:*)",
|
"Bash(git add:*)",
|
||||||
"Bash(git commit:*)",
|
"Bash(git commit:*)",
|
||||||
@@ -174,7 +148,7 @@ export const tagMode: Mode = {
|
|||||||
"Bash(git rm:*)",
|
"Bash(git rm:*)",
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// When using API commit signing, use MCP file ops tools
|
// When using commit signing, use MCP file ops tools
|
||||||
tagModeTools.push(
|
tagModeTools.push(
|
||||||
"mcp__github_file_ops__commit_files",
|
"mcp__github_file_ops__commit_files",
|
||||||
"mcp__github_file_ops__delete_files",
|
"mcp__github_file_ops__delete_files",
|
||||||
@@ -204,6 +178,9 @@ export const tagMode: Mode = {
|
|||||||
// Add required tools for tag mode
|
// Add required tools for tag mode
|
||||||
claudeArgs += ` --allowedTools "${tagModeTools.join(",")}"`;
|
claudeArgs += ` --allowedTools "${tagModeTools.join(",")}"`;
|
||||||
|
|
||||||
|
// Add JSON schema if provided
|
||||||
|
claudeArgs = appendJsonSchemaArg(claudeArgs);
|
||||||
|
|
||||||
// Append user's claude_args (which may have more --mcp-config flags)
|
// Append user's claude_args (which may have more --mcp-config flags)
|
||||||
if (userClaudeArgs) {
|
if (userClaudeArgs) {
|
||||||
claudeArgs += ` ${userClaudeArgs}`;
|
claudeArgs += ` ${userClaudeArgs}`;
|
||||||
|
|||||||
@@ -1,99 +0,0 @@
|
|||||||
#!/usr/bin/env bun
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Branch name template parsing and variable substitution utilities
|
|
||||||
*/
|
|
||||||
|
|
||||||
const NUM_DESCRIPTION_WORDS = 5;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extracts the first 5 words from a title and converts them to kebab-case
|
|
||||||
*/
|
|
||||||
function extractDescription(
|
|
||||||
title: string,
|
|
||||||
numWords: number = NUM_DESCRIPTION_WORDS,
|
|
||||||
): string {
|
|
||||||
if (!title || title.trim() === "") {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
return title
|
|
||||||
.trim()
|
|
||||||
.split(/\s+/)
|
|
||||||
.slice(0, numWords) // Only first `numWords` words
|
|
||||||
.join("-")
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9-]/g, "") // Remove non-alphanumeric except hyphens
|
|
||||||
.replace(/-+/g, "-") // Replace multiple hyphens with single
|
|
||||||
.replace(/^-|-$/g, ""); // Remove leading/trailing hyphens
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BranchTemplateVariables {
|
|
||||||
prefix: string;
|
|
||||||
entityType: string;
|
|
||||||
entityNumber: number;
|
|
||||||
timestamp: string;
|
|
||||||
sha?: string;
|
|
||||||
label?: string;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replaces template variables in a branch name template
|
|
||||||
* Template format: {{variableName}}
|
|
||||||
*/
|
|
||||||
export function applyBranchTemplate(
|
|
||||||
template: string,
|
|
||||||
variables: BranchTemplateVariables,
|
|
||||||
): string {
|
|
||||||
let result = template;
|
|
||||||
|
|
||||||
// Replace each variable
|
|
||||||
Object.entries(variables).forEach(([key, value]) => {
|
|
||||||
const placeholder = `{{${key}}}`;
|
|
||||||
const replacement = value ? String(value) : "";
|
|
||||||
result = result.replaceAll(placeholder, replacement);
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates a branch name from the provided `template` and set of `variables`. Uses a default format if the template is empty or produces an empty result.
|
|
||||||
*/
|
|
||||||
export function generateBranchName(
|
|
||||||
template: string | undefined,
|
|
||||||
branchPrefix: string,
|
|
||||||
entityType: string,
|
|
||||||
entityNumber: number,
|
|
||||||
sha?: string,
|
|
||||||
label?: string,
|
|
||||||
title?: string,
|
|
||||||
): string {
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
const variables: BranchTemplateVariables = {
|
|
||||||
prefix: branchPrefix,
|
|
||||||
entityType,
|
|
||||||
entityNumber,
|
|
||||||
timestamp: `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}-${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}`,
|
|
||||||
sha: sha?.substring(0, 8), // First 8 characters of SHA
|
|
||||||
label: label || entityType, // Fall back to entityType if no label
|
|
||||||
description: title ? extractDescription(title) : undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (template?.trim()) {
|
|
||||||
const branchName = applyBranchTemplate(template, variables);
|
|
||||||
|
|
||||||
// Some templates could produce empty results- validate
|
|
||||||
if (branchName.trim().length > 0) return branchName;
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`Branch template '${template}' generated empty result, falling back to default format`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const branchName = `${branchPrefix}${entityType}-${entityNumber}-${variables.timestamp}`;
|
|
||||||
// Kubernetes compatible: lowercase, max 50 chars, alphanumeric and hyphens only
|
|
||||||
return branchName.toLowerCase().substring(0, 50);
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
/**
|
|
||||||
* Extracts the user's request from a trigger comment.
|
|
||||||
*
|
|
||||||
* Given a comment like "@claude /review-pr please check the auth module",
|
|
||||||
* this extracts "/review-pr please check the auth module".
|
|
||||||
*
|
|
||||||
* @param commentBody - The full comment body containing the trigger phrase
|
|
||||||
* @param triggerPhrase - The trigger phrase (e.g., "@claude")
|
|
||||||
* @returns The user's request (text after the trigger phrase), or null if not found
|
|
||||||
*/
|
|
||||||
export function extractUserRequest(
|
|
||||||
commentBody: string | undefined,
|
|
||||||
triggerPhrase: string,
|
|
||||||
): string | null {
|
|
||||||
if (!commentBody) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use string operations instead of regex for better performance and security
|
|
||||||
// (avoids potential ReDoS with large comment bodies)
|
|
||||||
const triggerIndex = commentBody
|
|
||||||
.toLowerCase()
|
|
||||||
.indexOf(triggerPhrase.toLowerCase());
|
|
||||||
if (triggerIndex === -1) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const afterTrigger = commentBody
|
|
||||||
.substring(triggerIndex + triggerPhrase.length)
|
|
||||||
.trim();
|
|
||||||
return afterTrigger || null;
|
|
||||||
}
|
|
||||||
17
src/utils/json-schema.ts
Normal file
17
src/utils/json-schema.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Appends JSON schema CLI argument if json_schema is provided
|
||||||
|
* Escapes schema for safe shell passing
|
||||||
|
*/
|
||||||
|
export function appendJsonSchemaArg(
|
||||||
|
claudeArgs: string,
|
||||||
|
jsonSchemaStr?: string,
|
||||||
|
): string {
|
||||||
|
const schema = jsonSchemaStr || process.env.JSON_SCHEMA || "";
|
||||||
|
if (!schema) {
|
||||||
|
return claudeArgs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLI validates schema - just escape for safe shell passing
|
||||||
|
const escapedSchema = schema.replace(/'/g, "'\\''");
|
||||||
|
return `${claudeArgs} --json-schema '${escapedSchema}'`;
|
||||||
|
}
|
||||||
@@ -1,247 +0,0 @@
|
|||||||
#!/usr/bin/env bun
|
|
||||||
|
|
||||||
import { describe, it, expect } from "bun:test";
|
|
||||||
import {
|
|
||||||
applyBranchTemplate,
|
|
||||||
generateBranchName,
|
|
||||||
} from "../src/utils/branch-template";
|
|
||||||
|
|
||||||
describe("branch template utilities", () => {
|
|
||||||
describe("applyBranchTemplate", () => {
|
|
||||||
it("should replace all template variables", () => {
|
|
||||||
const template =
|
|
||||||
"{{prefix}}{{entityType}}-{{entityNumber}}-{{timestamp}}";
|
|
||||||
const variables = {
|
|
||||||
prefix: "feat/",
|
|
||||||
entityType: "issue",
|
|
||||||
entityNumber: 123,
|
|
||||||
timestamp: "20240301-1430",
|
|
||||||
sha: "abcd1234",
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = applyBranchTemplate(template, variables);
|
|
||||||
expect(result).toBe("feat/issue-123-20240301-1430");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle custom templates with multiple variables", () => {
|
|
||||||
const template =
|
|
||||||
"{{prefix}}fix/{{entityType}}_{{entityNumber}}_{{timestamp}}_{{sha}}";
|
|
||||||
const variables = {
|
|
||||||
prefix: "claude-",
|
|
||||||
entityType: "pr",
|
|
||||||
entityNumber: 456,
|
|
||||||
timestamp: "20240301-1430",
|
|
||||||
sha: "abcd1234",
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = applyBranchTemplate(template, variables);
|
|
||||||
expect(result).toBe("claude-fix/pr_456_20240301-1430_abcd1234");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle templates with missing variables gracefully", () => {
|
|
||||||
const template = "{{prefix}}{{entityType}}-{{missing}}-{{entityNumber}}";
|
|
||||||
const variables = {
|
|
||||||
prefix: "feat/",
|
|
||||||
entityType: "issue",
|
|
||||||
entityNumber: 123,
|
|
||||||
timestamp: "20240301-1430",
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = applyBranchTemplate(template, variables);
|
|
||||||
expect(result).toBe("feat/issue-{{missing}}-123");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("generateBranchName", () => {
|
|
||||||
it("should use custom template when provided", () => {
|
|
||||||
const template = "{{prefix}}custom-{{entityType}}_{{entityNumber}}";
|
|
||||||
const result = generateBranchName(template, "feature/", "issue", 123);
|
|
||||||
|
|
||||||
expect(result).toBe("feature/custom-issue_123");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should use default format when template is empty", () => {
|
|
||||||
const result = generateBranchName("", "claude/", "issue", 123);
|
|
||||||
|
|
||||||
expect(result).toMatch(/^claude\/issue-123-\d{8}-\d{4}$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should use default format when template is undefined", () => {
|
|
||||||
const result = generateBranchName(undefined, "claude/", "pr", 456);
|
|
||||||
|
|
||||||
expect(result).toMatch(/^claude\/pr-456-\d{8}-\d{4}$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should preserve custom template formatting (no automatic lowercase/truncation)", () => {
|
|
||||||
const template = "{{prefix}}UPPERCASE_Branch-Name_{{entityNumber}}";
|
|
||||||
const result = generateBranchName(template, "Feature/", "issue", 123);
|
|
||||||
|
|
||||||
expect(result).toBe("Feature/UPPERCASE_Branch-Name_123");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not truncate custom template results", () => {
|
|
||||||
const template =
|
|
||||||
"{{prefix}}very-long-branch-name-that-exceeds-the-maximum-allowed-length-{{entityNumber}}";
|
|
||||||
const result = generateBranchName(template, "feature/", "issue", 123);
|
|
||||||
|
|
||||||
expect(result).toBe(
|
|
||||||
"feature/very-long-branch-name-that-exceeds-the-maximum-allowed-length-123",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should apply Kubernetes-compatible transformations to default template only", () => {
|
|
||||||
const result = generateBranchName(undefined, "Feature/", "issue", 123);
|
|
||||||
|
|
||||||
expect(result).toMatch(/^feature\/issue-123-\d{8}-\d{4}$/);
|
|
||||||
expect(result.length).toBeLessThanOrEqual(50);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle SHA in template", () => {
|
|
||||||
const template = "{{prefix}}{{entityType}}-{{entityNumber}}-{{sha}}";
|
|
||||||
const result = generateBranchName(
|
|
||||||
template,
|
|
||||||
"fix/",
|
|
||||||
"pr",
|
|
||||||
789,
|
|
||||||
"abcdef123456",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toBe("fix/pr-789-abcdef12");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should use label in template when provided", () => {
|
|
||||||
const template = "{{prefix}}{{label}}/{{entityNumber}}";
|
|
||||||
const result = generateBranchName(
|
|
||||||
template,
|
|
||||||
"feature/",
|
|
||||||
"issue",
|
|
||||||
123,
|
|
||||||
undefined,
|
|
||||||
"bug",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toBe("feature/bug/123");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should fallback to entityType when label template is used but no label provided", () => {
|
|
||||||
const template = "{{prefix}}{{label}}-{{entityNumber}}";
|
|
||||||
const result = generateBranchName(template, "fix/", "pr", 456);
|
|
||||||
|
|
||||||
expect(result).toBe("fix/pr-456");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle template with both label and entityType", () => {
|
|
||||||
const template = "{{prefix}}{{label}}-{{entityType}}_{{entityNumber}}";
|
|
||||||
const result = generateBranchName(
|
|
||||||
template,
|
|
||||||
"dev/",
|
|
||||||
"issue",
|
|
||||||
789,
|
|
||||||
undefined,
|
|
||||||
"enhancement",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toBe("dev/enhancement-issue_789");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should use description in template when provided", () => {
|
|
||||||
const template = "{{prefix}}{{description}}/{{entityNumber}}";
|
|
||||||
const result = generateBranchName(
|
|
||||||
template,
|
|
||||||
"feature/",
|
|
||||||
"issue",
|
|
||||||
123,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
"Fix login bug with OAuth",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toBe("feature/fix-login-bug-with-oauth/123");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle template with multiple variables including description", () => {
|
|
||||||
const template =
|
|
||||||
"{{prefix}}{{label}}/{{description}}-{{entityType}}_{{entityNumber}}";
|
|
||||||
const result = generateBranchName(
|
|
||||||
template,
|
|
||||||
"dev/",
|
|
||||||
"issue",
|
|
||||||
456,
|
|
||||||
undefined,
|
|
||||||
"bug",
|
|
||||||
"User authentication fails completely",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toBe(
|
|
||||||
"dev/bug/user-authentication-fails-completely-issue_456",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle description with special characters in template", () => {
|
|
||||||
const template = "{{prefix}}{{description}}-{{entityNumber}}";
|
|
||||||
const result = generateBranchName(
|
|
||||||
template,
|
|
||||||
"fix/",
|
|
||||||
"pr",
|
|
||||||
789,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
"Add: User Registration & Email Validation",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toBe("fix/add-user-registration-email-789");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should truncate descriptions to exactly 5 words", () => {
|
|
||||||
const result = generateBranchName(
|
|
||||||
"{{prefix}}{{description}}/{{entityNumber}}",
|
|
||||||
"feature/",
|
|
||||||
"issue",
|
|
||||||
999,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
"This is a very long title with many more than five words in it",
|
|
||||||
);
|
|
||||||
expect(result).toBe("feature/this-is-a-very-long/999");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle empty description in template", () => {
|
|
||||||
const template = "{{prefix}}{{description}}-{{entityNumber}}";
|
|
||||||
const result = generateBranchName(
|
|
||||||
template,
|
|
||||||
"test/",
|
|
||||||
"issue",
|
|
||||||
101,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toBe("test/-101");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should fallback to default format when template produces empty result", () => {
|
|
||||||
const template = "{{description}}"; // Will be empty if no title provided
|
|
||||||
const result = generateBranchName(template, "claude/", "issue", 123);
|
|
||||||
|
|
||||||
expect(result).toMatch(/^claude\/issue-123-\d{8}-\d{4}$/);
|
|
||||||
expect(result.length).toBeLessThanOrEqual(50);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should fallback to default format when template produces only whitespace", () => {
|
|
||||||
const template = " {{description}} "; // Will be " " if description is empty
|
|
||||||
const result = generateBranchName(
|
|
||||||
template,
|
|
||||||
"fix/",
|
|
||||||
"pr",
|
|
||||||
456,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toMatch(/^fix\/pr-456-\d{8}-\d{4}$/);
|
|
||||||
expect(result.length).toBeLessThanOrEqual(50);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -258,7 +258,7 @@ describe("updateCommentBody", () => {
|
|||||||
const input = {
|
const input = {
|
||||||
...baseInput,
|
...baseInput,
|
||||||
executionDetails: {
|
executionDetails: {
|
||||||
total_cost_usd: 0.13382595,
|
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: {
|
||||||
total_cost_usd: 0.25,
|
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: {
|
||||||
total_cost_usd: 0.01,
|
cost_usd: 0.01,
|
||||||
duration_ms: 65000, // 1 minute 5 seconds
|
duration_ms: 65000, // 1 minute 5 seconds
|
||||||
},
|
},
|
||||||
triggerUsername: "trigger-user",
|
triggerUsername: "trigger-user",
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ describe("generatePrompt", () => {
|
|||||||
body: "This is a test PR",
|
body: "This is a test PR",
|
||||||
author: { login: "testuser" },
|
author: { login: "testuser" },
|
||||||
state: "OPEN",
|
state: "OPEN",
|
||||||
labels: { nodes: [] },
|
|
||||||
createdAt: "2023-01-01T00:00:00Z",
|
createdAt: "2023-01-01T00:00:00Z",
|
||||||
additions: 15,
|
additions: 15,
|
||||||
deletions: 5,
|
deletions: 5,
|
||||||
@@ -476,7 +475,6 @@ describe("generatePrompt", () => {
|
|||||||
body: "The login form is not working",
|
body: "The login form is not working",
|
||||||
author: { login: "testuser" },
|
author: { login: "testuser" },
|
||||||
state: "OPEN",
|
state: "OPEN",
|
||||||
labels: { nodes: [] },
|
|
||||||
createdAt: "2023-01-01T00:00:00Z",
|
createdAt: "2023-01-01T00:00:00Z",
|
||||||
comments: {
|
comments: {
|
||||||
nodes: [],
|
nodes: [],
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
import { describe, expect, it, jest } from "bun:test";
|
import { describe, expect, it, jest } from "bun:test";
|
||||||
import {
|
import {
|
||||||
extractTriggerTimestamp,
|
extractTriggerTimestamp,
|
||||||
extractOriginalTitle,
|
|
||||||
fetchGitHubData,
|
fetchGitHubData,
|
||||||
filterCommentsToTriggerTime,
|
filterCommentsToTriggerTime,
|
||||||
filterReviewsToTriggerTime,
|
filterReviewsToTriggerTime,
|
||||||
isBodySafeToUse,
|
|
||||||
} from "../src/github/data/fetcher";
|
} from "../src/github/data/fetcher";
|
||||||
import {
|
import {
|
||||||
createMockContext,
|
createMockContext,
|
||||||
mockIssueCommentContext,
|
mockIssueCommentContext,
|
||||||
mockPullRequestCommentContext,
|
|
||||||
mockPullRequestReviewContext,
|
mockPullRequestReviewContext,
|
||||||
mockPullRequestReviewCommentContext,
|
mockPullRequestReviewCommentContext,
|
||||||
mockPullRequestOpenedContext,
|
mockPullRequestOpenedContext,
|
||||||
@@ -65,47 +62,6 @@ describe("extractTriggerTimestamp", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("extractOriginalTitle", () => {
|
|
||||||
it("should extract title from IssueCommentEvent on PR", () => {
|
|
||||||
const title = extractOriginalTitle(mockPullRequestCommentContext);
|
|
||||||
expect(title).toBe("Fix: Memory leak in user service");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should extract title from PullRequestReviewEvent", () => {
|
|
||||||
const title = extractOriginalTitle(mockPullRequestReviewContext);
|
|
||||||
expect(title).toBe("Refactor: Improve error handling in API layer");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should extract title from PullRequestReviewCommentEvent", () => {
|
|
||||||
const title = extractOriginalTitle(mockPullRequestReviewCommentContext);
|
|
||||||
expect(title).toBe("Performance: Optimize search algorithm");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should extract title from pull_request event", () => {
|
|
||||||
const title = extractOriginalTitle(mockPullRequestOpenedContext);
|
|
||||||
expect(title).toBe("Feature: Add user authentication");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should extract title from issues event", () => {
|
|
||||||
const title = extractOriginalTitle(mockIssueOpenedContext);
|
|
||||||
expect(title).toBe("Bug: Application crashes on startup");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return undefined for event without title", () => {
|
|
||||||
const context = createMockContext({
|
|
||||||
eventName: "issue_comment",
|
|
||||||
payload: {
|
|
||||||
comment: {
|
|
||||||
id: 123,
|
|
||||||
body: "test",
|
|
||||||
},
|
|
||||||
} as any,
|
|
||||||
});
|
|
||||||
const title = extractOriginalTitle(context);
|
|
||||||
expect(title).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("filterCommentsToTriggerTime", () => {
|
describe("filterCommentsToTriggerTime", () => {
|
||||||
const createMockComment = (
|
const createMockComment = (
|
||||||
createdAt: string,
|
createdAt: string,
|
||||||
@@ -415,139 +371,6 @@ describe("filterReviewsToTriggerTime", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("isBodySafeToUse", () => {
|
|
||||||
const triggerTime = "2024-01-15T12:00:00Z";
|
|
||||||
|
|
||||||
const createMockContextData = (
|
|
||||||
createdAt: string,
|
|
||||||
updatedAt?: string,
|
|
||||||
lastEditedAt?: string,
|
|
||||||
) => ({
|
|
||||||
createdAt,
|
|
||||||
updatedAt,
|
|
||||||
lastEditedAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("body edit time validation", () => {
|
|
||||||
it("should return true when body was never edited", () => {
|
|
||||||
const contextData = createMockContextData("2024-01-15T10:00:00Z");
|
|
||||||
expect(isBodySafeToUse(contextData, triggerTime)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return true when body was edited before trigger time", () => {
|
|
||||||
const contextData = createMockContextData(
|
|
||||||
"2024-01-15T10:00:00Z",
|
|
||||||
"2024-01-15T11:00:00Z",
|
|
||||||
"2024-01-15T11:30:00Z",
|
|
||||||
);
|
|
||||||
expect(isBodySafeToUse(contextData, triggerTime)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return false when body was edited after trigger time (using updatedAt)", () => {
|
|
||||||
const contextData = createMockContextData(
|
|
||||||
"2024-01-15T10:00:00Z",
|
|
||||||
"2024-01-15T13:00:00Z",
|
|
||||||
);
|
|
||||||
expect(isBodySafeToUse(contextData, triggerTime)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return false when body was edited after trigger time (using lastEditedAt)", () => {
|
|
||||||
const contextData = createMockContextData(
|
|
||||||
"2024-01-15T10:00:00Z",
|
|
||||||
undefined,
|
|
||||||
"2024-01-15T13:00:00Z",
|
|
||||||
);
|
|
||||||
expect(isBodySafeToUse(contextData, triggerTime)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return false when body was edited exactly at trigger time", () => {
|
|
||||||
const contextData = createMockContextData(
|
|
||||||
"2024-01-15T10:00:00Z",
|
|
||||||
"2024-01-15T12:00:00Z",
|
|
||||||
);
|
|
||||||
expect(isBodySafeToUse(contextData, triggerTime)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should prioritize lastEditedAt over updatedAt", () => {
|
|
||||||
// updatedAt is after trigger, but lastEditedAt is before - should be safe
|
|
||||||
const contextData = createMockContextData(
|
|
||||||
"2024-01-15T10:00:00Z",
|
|
||||||
"2024-01-15T13:00:00Z", // updatedAt after trigger
|
|
||||||
"2024-01-15T11:00:00Z", // lastEditedAt before trigger
|
|
||||||
);
|
|
||||||
expect(isBodySafeToUse(contextData, triggerTime)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("edge cases", () => {
|
|
||||||
it("should return true when no trigger time is provided (backward compatibility)", () => {
|
|
||||||
const contextData = createMockContextData(
|
|
||||||
"2024-01-15T10:00:00Z",
|
|
||||||
"2024-01-15T13:00:00Z", // Would normally fail
|
|
||||||
"2024-01-15T14:00:00Z", // Would normally fail
|
|
||||||
);
|
|
||||||
expect(isBodySafeToUse(contextData, undefined)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle millisecond precision correctly", () => {
|
|
||||||
// Edit 1ms after trigger - should be unsafe
|
|
||||||
const contextData = createMockContextData(
|
|
||||||
"2024-01-15T10:00:00Z",
|
|
||||||
"2024-01-15T12:00:00.001Z",
|
|
||||||
);
|
|
||||||
expect(isBodySafeToUse(contextData, triggerTime)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle edit 1ms before trigger - should be safe", () => {
|
|
||||||
const contextData = createMockContextData(
|
|
||||||
"2024-01-15T10:00:00Z",
|
|
||||||
"2024-01-15T11:59:59.999Z",
|
|
||||||
);
|
|
||||||
expect(isBodySafeToUse(contextData, triggerTime)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle various ISO timestamp formats", () => {
|
|
||||||
const contextData1 = createMockContextData(
|
|
||||||
"2024-01-15T10:00:00Z",
|
|
||||||
"2024-01-15T11:00:00Z",
|
|
||||||
);
|
|
||||||
const contextData2 = createMockContextData(
|
|
||||||
"2024-01-15T10:00:00+00:00",
|
|
||||||
"2024-01-15T11:00:00+00:00",
|
|
||||||
);
|
|
||||||
const contextData3 = createMockContextData(
|
|
||||||
"2024-01-15T10:00:00.000Z",
|
|
||||||
"2024-01-15T11:00:00.000Z",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(isBodySafeToUse(contextData1, triggerTime)).toBe(true);
|
|
||||||
expect(isBodySafeToUse(contextData2, triggerTime)).toBe(true);
|
|
||||||
expect(isBodySafeToUse(contextData3, triggerTime)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("security scenarios", () => {
|
|
||||||
it("should detect race condition attack - body edited between trigger and processing", () => {
|
|
||||||
// Simulates: Owner triggers @claude at 12:00, attacker edits body at 12:00:30
|
|
||||||
const contextData = createMockContextData(
|
|
||||||
"2024-01-15T10:00:00Z", // Issue created
|
|
||||||
"2024-01-15T12:00:30Z", // Body edited after trigger
|
|
||||||
);
|
|
||||||
expect(isBodySafeToUse(contextData, "2024-01-15T12:00:00Z")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should allow body that was stable at trigger time", () => {
|
|
||||||
// Body was last edited well before the trigger
|
|
||||||
const contextData = createMockContextData(
|
|
||||||
"2024-01-15T10:00:00Z",
|
|
||||||
"2024-01-15T10:30:00Z",
|
|
||||||
"2024-01-15T10:30:00Z",
|
|
||||||
);
|
|
||||||
expect(isBodySafeToUse(contextData, "2024-01-15T12:00:00Z")).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("fetchGitHubData integration with time filtering", () => {
|
describe("fetchGitHubData integration with time filtering", () => {
|
||||||
it("should filter comments based on trigger time when provided", async () => {
|
it("should filter comments based on trigger time when provided", async () => {
|
||||||
const mockOctokits = {
|
const mockOctokits = {
|
||||||
@@ -873,230 +696,4 @@ describe("fetchGitHubData integration with time filtering", () => {
|
|||||||
// All three comments should be included as they're all before trigger time
|
// All three comments should be included as they're all before trigger time
|
||||||
expect(result.comments.length).toBe(3);
|
expect(result.comments.length).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should exclude issue body when edited after trigger time (TOCTOU protection)", async () => {
|
|
||||||
const mockOctokits = {
|
|
||||||
graphql: jest.fn().mockResolvedValue({
|
|
||||||
repository: {
|
|
||||||
issue: {
|
|
||||||
number: 555,
|
|
||||||
title: "Test Issue",
|
|
||||||
body: "Malicious body edited after trigger",
|
|
||||||
author: { login: "attacker" },
|
|
||||||
createdAt: "2024-01-15T10:00:00Z",
|
|
||||||
updatedAt: "2024-01-15T12:30:00Z", // Edited after trigger
|
|
||||||
lastEditedAt: "2024-01-15T12:30:00Z", // Edited after trigger
|
|
||||||
comments: { nodes: [] },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
user: { login: "trigger-user" },
|
|
||||||
}),
|
|
||||||
rest: jest.fn() as any,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await fetchGitHubData({
|
|
||||||
octokits: mockOctokits as any,
|
|
||||||
repository: "test-owner/test-repo",
|
|
||||||
prNumber: "555",
|
|
||||||
isPR: false,
|
|
||||||
triggerUsername: "trigger-user",
|
|
||||||
triggerTime: "2024-01-15T12:00:00Z",
|
|
||||||
});
|
|
||||||
|
|
||||||
// The body should be excluded from image processing due to TOCTOU protection
|
|
||||||
// We can verify this by checking that issue_body is NOT in the imageUrlMap keys
|
|
||||||
const hasIssueBodyInMap = Array.from(result.imageUrlMap.keys()).some(
|
|
||||||
(key) => key.includes("issue_body"),
|
|
||||||
);
|
|
||||||
expect(hasIssueBodyInMap).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should include issue body when not edited after trigger time", async () => {
|
|
||||||
const mockOctokits = {
|
|
||||||
graphql: jest.fn().mockResolvedValue({
|
|
||||||
repository: {
|
|
||||||
issue: {
|
|
||||||
number: 666,
|
|
||||||
title: "Test Issue",
|
|
||||||
body: "Safe body not edited after trigger",
|
|
||||||
author: { login: "author" },
|
|
||||||
createdAt: "2024-01-15T10:00:00Z",
|
|
||||||
updatedAt: "2024-01-15T11:00:00Z", // Edited before trigger
|
|
||||||
lastEditedAt: "2024-01-15T11:00:00Z", // Edited before trigger
|
|
||||||
comments: { nodes: [] },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
user: { login: "trigger-user" },
|
|
||||||
}),
|
|
||||||
rest: jest.fn() as any,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await fetchGitHubData({
|
|
||||||
octokits: mockOctokits as any,
|
|
||||||
repository: "test-owner/test-repo",
|
|
||||||
prNumber: "666",
|
|
||||||
isPR: false,
|
|
||||||
triggerUsername: "trigger-user",
|
|
||||||
triggerTime: "2024-01-15T12:00:00Z",
|
|
||||||
});
|
|
||||||
|
|
||||||
// The contextData should still contain the body
|
|
||||||
expect(result.contextData.body).toBe("Safe body not edited after trigger");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should exclude PR body when edited after trigger time (TOCTOU protection)", async () => {
|
|
||||||
const mockOctokits = {
|
|
||||||
graphql: jest.fn().mockResolvedValue({
|
|
||||||
repository: {
|
|
||||||
pullRequest: {
|
|
||||||
number: 777,
|
|
||||||
title: "Test PR",
|
|
||||||
body: "Malicious PR body edited after trigger",
|
|
||||||
author: { login: "attacker" },
|
|
||||||
baseRefName: "main",
|
|
||||||
headRefName: "feature",
|
|
||||||
headRefOid: "abc123",
|
|
||||||
createdAt: "2024-01-15T10:00:00Z",
|
|
||||||
updatedAt: "2024-01-15T12:30:00Z", // Edited after trigger
|
|
||||||
lastEditedAt: "2024-01-15T12:30:00Z", // Edited after trigger
|
|
||||||
additions: 10,
|
|
||||||
deletions: 5,
|
|
||||||
state: "OPEN",
|
|
||||||
commits: { totalCount: 1, nodes: [] },
|
|
||||||
files: { nodes: [] },
|
|
||||||
comments: { nodes: [] },
|
|
||||||
reviews: { nodes: [] },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
user: { login: "trigger-user" },
|
|
||||||
}),
|
|
||||||
rest: jest.fn() as any,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await fetchGitHubData({
|
|
||||||
octokits: mockOctokits as any,
|
|
||||||
repository: "test-owner/test-repo",
|
|
||||||
prNumber: "777",
|
|
||||||
isPR: true,
|
|
||||||
triggerUsername: "trigger-user",
|
|
||||||
triggerTime: "2024-01-15T12:00:00Z",
|
|
||||||
});
|
|
||||||
|
|
||||||
// The body should be excluded from image processing due to TOCTOU protection
|
|
||||||
const hasPrBodyInMap = Array.from(result.imageUrlMap.keys()).some((key) =>
|
|
||||||
key.includes("pr_body"),
|
|
||||||
);
|
|
||||||
expect(hasPrBodyInMap).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should use originalTitle when provided instead of fetched title", async () => {
|
|
||||||
const mockOctokits = {
|
|
||||||
graphql: jest.fn().mockResolvedValue({
|
|
||||||
repository: {
|
|
||||||
pullRequest: {
|
|
||||||
number: 123,
|
|
||||||
title: "Fetched Title From GraphQL",
|
|
||||||
body: "PR body",
|
|
||||||
author: { login: "author" },
|
|
||||||
createdAt: "2024-01-15T10:00:00Z",
|
|
||||||
additions: 10,
|
|
||||||
deletions: 5,
|
|
||||||
state: "OPEN",
|
|
||||||
commits: { totalCount: 1, nodes: [] },
|
|
||||||
files: { nodes: [] },
|
|
||||||
comments: { nodes: [] },
|
|
||||||
reviews: { nodes: [] },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
user: { login: "trigger-user" },
|
|
||||||
}),
|
|
||||||
rest: jest.fn() as any,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await fetchGitHubData({
|
|
||||||
octokits: mockOctokits as any,
|
|
||||||
repository: "test-owner/test-repo",
|
|
||||||
prNumber: "123",
|
|
||||||
isPR: true,
|
|
||||||
triggerUsername: "trigger-user",
|
|
||||||
originalTitle: "Original Title From Webhook",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.contextData.title).toBe("Original Title From Webhook");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should use fetched title when originalTitle is not provided", async () => {
|
|
||||||
const mockOctokits = {
|
|
||||||
graphql: jest.fn().mockResolvedValue({
|
|
||||||
repository: {
|
|
||||||
pullRequest: {
|
|
||||||
number: 123,
|
|
||||||
title: "Fetched Title From GraphQL",
|
|
||||||
body: "PR body",
|
|
||||||
author: { login: "author" },
|
|
||||||
createdAt: "2024-01-15T10:00:00Z",
|
|
||||||
additions: 10,
|
|
||||||
deletions: 5,
|
|
||||||
state: "OPEN",
|
|
||||||
commits: { totalCount: 1, nodes: [] },
|
|
||||||
files: { nodes: [] },
|
|
||||||
comments: { nodes: [] },
|
|
||||||
reviews: { nodes: [] },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
user: { login: "trigger-user" },
|
|
||||||
}),
|
|
||||||
rest: jest.fn() as any,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await fetchGitHubData({
|
|
||||||
octokits: mockOctokits as any,
|
|
||||||
repository: "test-owner/test-repo",
|
|
||||||
prNumber: "123",
|
|
||||||
isPR: true,
|
|
||||||
triggerUsername: "trigger-user",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.contextData.title).toBe("Fetched Title From GraphQL");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should use original title from webhook even if title was edited after trigger", async () => {
|
|
||||||
const mockOctokits = {
|
|
||||||
graphql: jest.fn().mockResolvedValue({
|
|
||||||
repository: {
|
|
||||||
pullRequest: {
|
|
||||||
number: 123,
|
|
||||||
title: "Edited Title (from GraphQL)",
|
|
||||||
body: "PR body",
|
|
||||||
author: { login: "author" },
|
|
||||||
createdAt: "2024-01-15T10:00:00Z",
|
|
||||||
lastEditedAt: "2024-01-15T12:30:00Z", // Edited after trigger
|
|
||||||
additions: 10,
|
|
||||||
deletions: 5,
|
|
||||||
state: "OPEN",
|
|
||||||
commits: { totalCount: 1, nodes: [] },
|
|
||||||
files: { nodes: [] },
|
|
||||||
comments: { nodes: [] },
|
|
||||||
reviews: { nodes: [] },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
user: { login: "trigger-user" },
|
|
||||||
}),
|
|
||||||
rest: jest.fn() as any,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await fetchGitHubData({
|
|
||||||
octokits: mockOctokits as any,
|
|
||||||
repository: "test-owner/test-repo",
|
|
||||||
prNumber: "123",
|
|
||||||
isPR: true,
|
|
||||||
triggerUsername: "trigger-user",
|
|
||||||
triggerTime: "2024-01-15T12:00:00Z",
|
|
||||||
originalTitle: "Original Title (from webhook at trigger time)",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.contextData.title).toBe(
|
|
||||||
"Original Title (from webhook at trigger time)",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,9 +28,6 @@ describe("formatContext", () => {
|
|||||||
additions: 50,
|
additions: 50,
|
||||||
deletions: 30,
|
deletions: 30,
|
||||||
state: "OPEN",
|
state: "OPEN",
|
||||||
labels: {
|
|
||||||
nodes: [],
|
|
||||||
},
|
|
||||||
commits: {
|
commits: {
|
||||||
totalCount: 3,
|
totalCount: 3,
|
||||||
nodes: [],
|
nodes: [],
|
||||||
@@ -66,9 +63,6 @@ Changed Files: 2 files`,
|
|||||||
author: { login: "test-user" },
|
author: { login: "test-user" },
|
||||||
createdAt: "2023-01-01T00:00:00Z",
|
createdAt: "2023-01-01T00:00:00Z",
|
||||||
state: "OPEN",
|
state: "OPEN",
|
||||||
labels: {
|
|
||||||
nodes: [],
|
|
||||||
},
|
|
||||||
comments: {
|
comments: {
|
||||||
nodes: [],
|
nodes: [],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
import { describe, test, expect } from "bun:test";
|
|
||||||
import { extractUserRequest } from "../src/utils/extract-user-request";
|
|
||||||
|
|
||||||
describe("extractUserRequest", () => {
|
|
||||||
test("extracts text after @claude trigger", () => {
|
|
||||||
expect(extractUserRequest("@claude /review-pr", "@claude")).toBe(
|
|
||||||
"/review-pr",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("extracts slash command with arguments", () => {
|
|
||||||
expect(
|
|
||||||
extractUserRequest(
|
|
||||||
"@claude /review-pr please check the auth module",
|
|
||||||
"@claude",
|
|
||||||
),
|
|
||||||
).toBe("/review-pr please check the auth module");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles trigger phrase with extra whitespace", () => {
|
|
||||||
expect(extractUserRequest("@claude /review-pr", "@claude")).toBe(
|
|
||||||
"/review-pr",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles trigger phrase at start of multiline comment", () => {
|
|
||||||
const comment = `@claude /review-pr
|
|
||||||
Please review this PR carefully.
|
|
||||||
Focus on security issues.`;
|
|
||||||
expect(extractUserRequest(comment, "@claude")).toBe(
|
|
||||||
`/review-pr
|
|
||||||
Please review this PR carefully.
|
|
||||||
Focus on security issues.`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles trigger phrase in middle of text", () => {
|
|
||||||
expect(
|
|
||||||
extractUserRequest("Hey team, @claude can you review this?", "@claude"),
|
|
||||||
).toBe("can you review this?");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns null for empty comment body", () => {
|
|
||||||
expect(extractUserRequest("", "@claude")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns null for undefined comment body", () => {
|
|
||||||
expect(extractUserRequest(undefined, "@claude")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns null when trigger phrase not found", () => {
|
|
||||||
expect(extractUserRequest("Please review this PR", "@claude")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns null when only trigger phrase with no request", () => {
|
|
||||||
expect(extractUserRequest("@claude", "@claude")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles custom trigger phrase", () => {
|
|
||||||
expect(extractUserRequest("/claude help me", "/claude")).toBe("help me");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles trigger phrase with special regex characters", () => {
|
|
||||||
expect(
|
|
||||||
extractUserRequest("@claude[bot] do something", "@claude[bot]"),
|
|
||||||
).toBe("do something");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("is case insensitive", () => {
|
|
||||||
expect(extractUserRequest("@CLAUDE /review-pr", "@claude")).toBe(
|
|
||||||
"/review-pr",
|
|
||||||
);
|
|
||||||
expect(extractUserRequest("@Claude /review-pr", "@claude")).toBe(
|
|
||||||
"/review-pr",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
2
test/fixtures/sample-turns.json
vendored
2
test/fixtures/sample-turns.json
vendored
@@ -189,7 +189,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "result",
|
"type": "result",
|
||||||
"total_cost_usd": 0.0347,
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,214 +0,0 @@
|
|||||||
import { describe, expect, it, beforeAll, afterAll } from "bun:test";
|
|
||||||
import { validatePathWithinRepo } from "../src/mcp/path-validation";
|
|
||||||
import { resolve } from "path";
|
|
||||||
import { mkdir, writeFile, symlink, rm, realpath } from "fs/promises";
|
|
||||||
import { tmpdir } from "os";
|
|
||||||
|
|
||||||
describe("validatePathWithinRepo", () => {
|
|
||||||
// Use a real temp directory for tests that need filesystem access
|
|
||||||
let testDir: string;
|
|
||||||
let repoRoot: string;
|
|
||||||
let outsideDir: string;
|
|
||||||
// Real paths after symlink resolution (e.g., /tmp -> /private/tmp on macOS)
|
|
||||||
let realRepoRoot: string;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
// Create test directory structure
|
|
||||||
testDir = resolve(tmpdir(), `path-validation-test-${Date.now()}`);
|
|
||||||
repoRoot = resolve(testDir, "repo");
|
|
||||||
outsideDir = resolve(testDir, "outside");
|
|
||||||
|
|
||||||
await mkdir(repoRoot, { recursive: true });
|
|
||||||
await mkdir(resolve(repoRoot, "src"), { recursive: true });
|
|
||||||
await mkdir(outsideDir, { recursive: true });
|
|
||||||
|
|
||||||
// Create test files
|
|
||||||
await writeFile(resolve(repoRoot, "file.txt"), "inside repo");
|
|
||||||
await writeFile(resolve(repoRoot, "src", "main.js"), "console.log('hi')");
|
|
||||||
await writeFile(resolve(outsideDir, "secret.txt"), "sensitive data");
|
|
||||||
|
|
||||||
// Get real paths after symlink resolution
|
|
||||||
realRepoRoot = await realpath(repoRoot);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
// Cleanup
|
|
||||||
await rm(testDir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("valid paths", () => {
|
|
||||||
it("should accept simple relative paths", async () => {
|
|
||||||
const result = await validatePathWithinRepo("file.txt", repoRoot);
|
|
||||||
expect(result).toBe(resolve(realRepoRoot, "file.txt"));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should accept nested relative paths", async () => {
|
|
||||||
const result = await validatePathWithinRepo("src/main.js", repoRoot);
|
|
||||||
expect(result).toBe(resolve(realRepoRoot, "src/main.js"));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should accept paths with single dot segments", async () => {
|
|
||||||
const result = await validatePathWithinRepo("./src/main.js", repoRoot);
|
|
||||||
expect(result).toBe(resolve(realRepoRoot, "src/main.js"));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should accept paths that use .. but resolve inside repo", async () => {
|
|
||||||
// src/../file.txt resolves to file.txt which is still inside repo
|
|
||||||
const result = await validatePathWithinRepo("src/../file.txt", repoRoot);
|
|
||||||
expect(result).toBe(resolve(realRepoRoot, "file.txt"));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should accept absolute paths within the repo root", async () => {
|
|
||||||
const absolutePath = resolve(repoRoot, "file.txt");
|
|
||||||
const result = await validatePathWithinRepo(absolutePath, repoRoot);
|
|
||||||
expect(result).toBe(resolve(realRepoRoot, "file.txt"));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should accept the repo root itself", async () => {
|
|
||||||
const result = await validatePathWithinRepo(".", repoRoot);
|
|
||||||
expect(result).toBe(realRepoRoot);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle new files (non-existent) in valid directories", async () => {
|
|
||||||
const result = await validatePathWithinRepo("src/newfile.js", repoRoot);
|
|
||||||
// For non-existent files, we validate the parent but return the initial path
|
|
||||||
// (can't realpath a file that doesn't exist yet)
|
|
||||||
expect(result).toBe(resolve(repoRoot, "src/newfile.js"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("path traversal attacks", () => {
|
|
||||||
it("should reject simple parent directory traversal", async () => {
|
|
||||||
await expect(
|
|
||||||
validatePathWithinRepo("../outside/secret.txt", repoRoot),
|
|
||||||
).rejects.toThrow(/resolves outside the repository root/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject deeply nested parent directory traversal", async () => {
|
|
||||||
await expect(
|
|
||||||
validatePathWithinRepo("../../../etc/passwd", repoRoot),
|
|
||||||
).rejects.toThrow(/resolves outside the repository root/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject traversal hidden within path", async () => {
|
|
||||||
await expect(
|
|
||||||
validatePathWithinRepo("src/../../outside/secret.txt", repoRoot),
|
|
||||||
).rejects.toThrow(/resolves outside the repository root/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject traversal at the end of path", async () => {
|
|
||||||
await expect(
|
|
||||||
validatePathWithinRepo("src/../..", repoRoot),
|
|
||||||
).rejects.toThrow(/resolves outside the repository root/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject absolute paths outside the repo root", async () => {
|
|
||||||
await expect(
|
|
||||||
validatePathWithinRepo("/etc/passwd", repoRoot),
|
|
||||||
).rejects.toThrow(/resolves outside the repository root/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject absolute paths to sibling directories", async () => {
|
|
||||||
await expect(
|
|
||||||
validatePathWithinRepo(resolve(outsideDir, "secret.txt"), repoRoot),
|
|
||||||
).rejects.toThrow(/resolves outside the repository root/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("symlink attacks", () => {
|
|
||||||
it("should reject symlinks pointing outside the repo", async () => {
|
|
||||||
// Create a symlink inside the repo that points to a file outside
|
|
||||||
const symlinkPath = resolve(repoRoot, "evil-link");
|
|
||||||
await symlink(resolve(outsideDir, "secret.txt"), symlinkPath);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// The symlink path looks like it's inside the repo, but points outside
|
|
||||||
await expect(
|
|
||||||
validatePathWithinRepo("evil-link", repoRoot),
|
|
||||||
).rejects.toThrow(/resolves outside the repository root/);
|
|
||||||
} finally {
|
|
||||||
await rm(symlinkPath, { force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject symlinks to parent directories", async () => {
|
|
||||||
// Create a symlink to the parent directory
|
|
||||||
const symlinkPath = resolve(repoRoot, "parent-link");
|
|
||||||
await symlink(testDir, symlinkPath);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await expect(
|
|
||||||
validatePathWithinRepo("parent-link/outside/secret.txt", repoRoot),
|
|
||||||
).rejects.toThrow(/resolves outside the repository root/);
|
|
||||||
} finally {
|
|
||||||
await rm(symlinkPath, { force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should accept symlinks that resolve within the repo", async () => {
|
|
||||||
// Create a symlink inside the repo that points to another file inside
|
|
||||||
const symlinkPath = resolve(repoRoot, "good-link");
|
|
||||||
await symlink(resolve(repoRoot, "file.txt"), symlinkPath);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await validatePathWithinRepo("good-link", repoRoot);
|
|
||||||
// Should resolve to the actual file location
|
|
||||||
expect(result).toBe(resolve(realRepoRoot, "file.txt"));
|
|
||||||
} finally {
|
|
||||||
await rm(symlinkPath, { force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject directory symlinks that escape the repo", async () => {
|
|
||||||
// Create a symlink to outside directory
|
|
||||||
const symlinkPath = resolve(repoRoot, "escape-dir");
|
|
||||||
await symlink(outsideDir, symlinkPath);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await expect(
|
|
||||||
validatePathWithinRepo("escape-dir/secret.txt", repoRoot),
|
|
||||||
).rejects.toThrow(/resolves outside the repository root/);
|
|
||||||
} finally {
|
|
||||||
await rm(symlinkPath, { force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("edge cases", () => {
|
|
||||||
it("should handle empty path (current directory)", async () => {
|
|
||||||
const result = await validatePathWithinRepo("", repoRoot);
|
|
||||||
expect(result).toBe(realRepoRoot);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle paths with multiple consecutive slashes", async () => {
|
|
||||||
const result = await validatePathWithinRepo("src//main.js", repoRoot);
|
|
||||||
expect(result).toBe(resolve(realRepoRoot, "src/main.js"));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle paths with trailing slashes", async () => {
|
|
||||||
const result = await validatePathWithinRepo("src/", repoRoot);
|
|
||||||
expect(result).toBe(resolve(realRepoRoot, "src"));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject prefix attack (repo root as prefix but not parent)", async () => {
|
|
||||||
// Create a sibling directory with repo name as prefix
|
|
||||||
const evilDir = repoRoot + "-evil";
|
|
||||||
await mkdir(evilDir, { recursive: true });
|
|
||||||
await writeFile(resolve(evilDir, "file.txt"), "evil");
|
|
||||||
|
|
||||||
try {
|
|
||||||
await expect(
|
|
||||||
validatePathWithinRepo(resolve(evilDir, "file.txt"), repoRoot),
|
|
||||||
).rejects.toThrow(/resolves outside the repository root/);
|
|
||||||
} finally {
|
|
||||||
await rm(evilDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw error for non-existent repo root", async () => {
|
|
||||||
await expect(
|
|
||||||
validatePathWithinRepo("file.txt", "/nonexistent/repo"),
|
|
||||||
).rejects.toThrow(/does not exist/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -32,13 +32,11 @@ describe("prepareMcpConfig", () => {
|
|||||||
branchPrefix: "",
|
branchPrefix: "",
|
||||||
useStickyComment: false,
|
useStickyComment: false,
|
||||||
useCommitSigning: false,
|
useCommitSigning: false,
|
||||||
sshSigningKey: "",
|
|
||||||
botId: String(CLAUDE_APP_BOT_ID),
|
botId: String(CLAUDE_APP_BOT_ID),
|
||||||
botName: CLAUDE_BOT_LOGIN,
|
botName: CLAUDE_BOT_LOGIN,
|
||||||
allowedBots: "",
|
allowedBots: "",
|
||||||
allowedNonWriteUsers: "",
|
allowedNonWriteUsers: "",
|
||||||
trackProgress: false,
|
trackProgress: false,
|
||||||
includeFixLinks: true,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -20,13 +20,11 @@ const defaultInputs = {
|
|||||||
branchPrefix: "claude/",
|
branchPrefix: "claude/",
|
||||||
useStickyComment: false,
|
useStickyComment: false,
|
||||||
useCommitSigning: false,
|
useCommitSigning: false,
|
||||||
sshSigningKey: "",
|
|
||||||
botId: String(CLAUDE_APP_BOT_ID),
|
botId: String(CLAUDE_APP_BOT_ID),
|
||||||
botName: CLAUDE_BOT_LOGIN,
|
botName: CLAUDE_BOT_LOGIN,
|
||||||
allowedBots: "",
|
allowedBots: "",
|
||||||
allowedNonWriteUsers: "",
|
allowedNonWriteUsers: "",
|
||||||
trackProgress: false,
|
trackProgress: false,
|
||||||
includeFixLinks: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultRepository = {
|
const defaultRepository = {
|
||||||
@@ -403,53 +401,6 @@ 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",
|
||||||
|
|||||||
@@ -20,13 +20,11 @@ describe("detectMode with enhanced routing", () => {
|
|||||||
branchPrefix: "claude/",
|
branchPrefix: "claude/",
|
||||||
useStickyComment: false,
|
useStickyComment: false,
|
||||||
useCommitSigning: false,
|
useCommitSigning: false,
|
||||||
sshSigningKey: "",
|
|
||||||
botId: "123456",
|
botId: "123456",
|
||||||
botName: "claude-bot",
|
botName: "claude-bot",
|
||||||
allowedBots: "",
|
allowedBots: "",
|
||||||
allowedNonWriteUsers: "",
|
allowedNonWriteUsers: "",
|
||||||
trackProgress: false,
|
trackProgress: false,
|
||||||
includeFixLinks: true,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -68,13 +68,11 @@ describe("checkWritePermissions", () => {
|
|||||||
branchPrefix: "claude/",
|
branchPrefix: "claude/",
|
||||||
useStickyComment: false,
|
useStickyComment: false,
|
||||||
useCommitSigning: false,
|
useCommitSigning: false,
|
||||||
sshSigningKey: "",
|
|
||||||
botId: String(CLAUDE_APP_BOT_ID),
|
botId: String(CLAUDE_APP_BOT_ID),
|
||||||
botName: CLAUDE_BOT_LOGIN,
|
botName: CLAUDE_BOT_LOGIN,
|
||||||
allowedBots: "",
|
allowedBots: "",
|
||||||
allowedNonWriteUsers: "",
|
allowedNonWriteUsers: "",
|
||||||
trackProgress: false,
|
trackProgress: false,
|
||||||
includeFixLinks: true,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
mockPullRequestCommentContext,
|
mockPullRequestCommentContext,
|
||||||
mockPullRequestReviewContext,
|
mockPullRequestReviewContext,
|
||||||
mockPullRequestReviewCommentContext,
|
mockPullRequestReviewCommentContext,
|
||||||
mockPullRequestReviewWithoutCommentContext,
|
|
||||||
} from "./mockContext";
|
} from "./mockContext";
|
||||||
|
|
||||||
const BASE_ENV = {
|
const BASE_ENV = {
|
||||||
@@ -127,24 +126,6 @@ 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;
|
||||||
|
|||||||
@@ -87,7 +87,6 @@ describe("pull_request_target event support", () => {
|
|||||||
},
|
},
|
||||||
comments: { nodes: [] },
|
comments: { nodes: [] },
|
||||||
reviews: { nodes: [] },
|
reviews: { nodes: [] },
|
||||||
labels: { nodes: [] },
|
|
||||||
},
|
},
|
||||||
comments: [],
|
comments: [],
|
||||||
changedFiles: [],
|
changedFiles: [],
|
||||||
|
|||||||
@@ -1,250 +0,0 @@
|
|||||||
#!/usr/bin/env bun
|
|
||||||
|
|
||||||
import {
|
|
||||||
describe,
|
|
||||||
test,
|
|
||||||
expect,
|
|
||||||
afterEach,
|
|
||||||
beforeAll,
|
|
||||||
afterAll,
|
|
||||||
} from "bun:test";
|
|
||||||
import { mkdir, writeFile, rm, readFile, stat } from "fs/promises";
|
|
||||||
import { join } from "path";
|
|
||||||
import { tmpdir } from "os";
|
|
||||||
|
|
||||||
describe("SSH Signing", () => {
|
|
||||||
// Use a temp directory for tests
|
|
||||||
const testTmpDir = join(tmpdir(), "claude-ssh-signing-test");
|
|
||||||
const testSshDir = join(testTmpDir, ".ssh");
|
|
||||||
const testKeyPath = join(testSshDir, "claude_signing_key");
|
|
||||||
const testKey =
|
|
||||||
"-----BEGIN OPENSSH PRIVATE KEY-----\ntest-key-content\n-----END OPENSSH PRIVATE KEY-----";
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
await mkdir(testTmpDir, { recursive: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await rm(testTmpDir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
// Clean up test key if it exists
|
|
||||||
try {
|
|
||||||
await rm(testKeyPath, { force: true });
|
|
||||||
} catch {
|
|
||||||
// Ignore cleanup errors
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("setupSshSigning file operations", () => {
|
|
||||||
test("should write key file atomically with correct permissions", async () => {
|
|
||||||
// Create the directory with secure permissions (same as setupSshSigning does)
|
|
||||||
await mkdir(testSshDir, { recursive: true, mode: 0o700 });
|
|
||||||
|
|
||||||
// Write key atomically with proper permissions (same as setupSshSigning does)
|
|
||||||
await writeFile(testKeyPath, testKey, { mode: 0o600 });
|
|
||||||
|
|
||||||
// Verify key was written
|
|
||||||
const keyContent = await readFile(testKeyPath, "utf-8");
|
|
||||||
expect(keyContent).toBe(testKey);
|
|
||||||
|
|
||||||
// Verify permissions (0o600 = 384 in decimal for permission bits only)
|
|
||||||
const stats = await stat(testKeyPath);
|
|
||||||
const permissions = stats.mode & 0o777; // Get only permission bits
|
|
||||||
expect(permissions).toBe(0o600);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should create .ssh directory with secure permissions", async () => {
|
|
||||||
// Clean up first
|
|
||||||
await rm(testSshDir, { recursive: true, force: true });
|
|
||||||
|
|
||||||
// Create directory with secure permissions (same as setupSshSigning does)
|
|
||||||
await mkdir(testSshDir, { recursive: true, mode: 0o700 });
|
|
||||||
|
|
||||||
// Verify directory exists
|
|
||||||
const dirStats = await stat(testSshDir);
|
|
||||||
expect(dirStats.isDirectory()).toBe(true);
|
|
||||||
|
|
||||||
// Verify directory permissions
|
|
||||||
const dirPermissions = dirStats.mode & 0o777;
|
|
||||||
expect(dirPermissions).toBe(0o700);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("setupSshSigning validation", () => {
|
|
||||||
test("should reject empty SSH key", () => {
|
|
||||||
const emptyKey = "";
|
|
||||||
expect(() => {
|
|
||||||
if (!emptyKey.trim()) {
|
|
||||||
throw new Error("SSH signing key cannot be empty");
|
|
||||||
}
|
|
||||||
}).toThrow("SSH signing key cannot be empty");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should reject whitespace-only SSH key", () => {
|
|
||||||
const whitespaceKey = " \n\t ";
|
|
||||||
expect(() => {
|
|
||||||
if (!whitespaceKey.trim()) {
|
|
||||||
throw new Error("SSH signing key cannot be empty");
|
|
||||||
}
|
|
||||||
}).toThrow("SSH signing key cannot be empty");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should reject invalid SSH key format", () => {
|
|
||||||
const invalidKey = "not a valid key";
|
|
||||||
expect(() => {
|
|
||||||
if (
|
|
||||||
!invalidKey.includes("BEGIN") ||
|
|
||||||
!invalidKey.includes("PRIVATE KEY")
|
|
||||||
) {
|
|
||||||
throw new Error("Invalid SSH private key format");
|
|
||||||
}
|
|
||||||
}).toThrow("Invalid SSH private key format");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should accept valid SSH key format", () => {
|
|
||||||
const validKey =
|
|
||||||
"-----BEGIN OPENSSH PRIVATE KEY-----\nkey-content\n-----END OPENSSH PRIVATE KEY-----";
|
|
||||||
expect(() => {
|
|
||||||
if (!validKey.trim()) {
|
|
||||||
throw new Error("SSH signing key cannot be empty");
|
|
||||||
}
|
|
||||||
if (!validKey.includes("BEGIN") || !validKey.includes("PRIVATE KEY")) {
|
|
||||||
throw new Error("Invalid SSH private key format");
|
|
||||||
}
|
|
||||||
}).not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("cleanupSshSigning file operations", () => {
|
|
||||||
test("should remove the signing key file", async () => {
|
|
||||||
// Create the key file first
|
|
||||||
await mkdir(testSshDir, { recursive: true });
|
|
||||||
await writeFile(testKeyPath, testKey, { mode: 0o600 });
|
|
||||||
|
|
||||||
// Verify it exists
|
|
||||||
const existsBefore = await stat(testKeyPath)
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false);
|
|
||||||
expect(existsBefore).toBe(true);
|
|
||||||
|
|
||||||
// Clean up (same operation as cleanupSshSigning)
|
|
||||||
await rm(testKeyPath, { force: true });
|
|
||||||
|
|
||||||
// Verify it's gone
|
|
||||||
const existsAfter = await stat(testKeyPath)
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false);
|
|
||||||
expect(existsAfter).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should not throw if key file does not exist", async () => {
|
|
||||||
// Make sure file doesn't exist
|
|
||||||
await rm(testKeyPath, { force: true });
|
|
||||||
|
|
||||||
// Should not throw (rm with force: true doesn't throw on missing files)
|
|
||||||
await expect(rm(testKeyPath, { force: true })).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("SSH Signing Mode Detection", () => {
|
|
||||||
test("sshSigningKey should take precedence over useCommitSigning", () => {
|
|
||||||
// When both are set, SSH signing takes precedence
|
|
||||||
const sshSigningKey = "test-key";
|
|
||||||
const useCommitSigning = true;
|
|
||||||
|
|
||||||
const useSshSigning = !!sshSigningKey;
|
|
||||||
const useApiCommitSigning = useCommitSigning && !useSshSigning;
|
|
||||||
|
|
||||||
expect(useSshSigning).toBe(true);
|
|
||||||
expect(useApiCommitSigning).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("useCommitSigning should work when sshSigningKey is not set", () => {
|
|
||||||
const sshSigningKey = "";
|
|
||||||
const useCommitSigning = true;
|
|
||||||
|
|
||||||
const useSshSigning = !!sshSigningKey;
|
|
||||||
const useApiCommitSigning = useCommitSigning && !useSshSigning;
|
|
||||||
|
|
||||||
expect(useSshSigning).toBe(false);
|
|
||||||
expect(useApiCommitSigning).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("neither signing method when both are false/empty", () => {
|
|
||||||
const sshSigningKey = "";
|
|
||||||
const useCommitSigning = false;
|
|
||||||
|
|
||||||
const useSshSigning = !!sshSigningKey;
|
|
||||||
const useApiCommitSigning = useCommitSigning && !useSshSigning;
|
|
||||||
|
|
||||||
expect(useSshSigning).toBe(false);
|
|
||||||
expect(useApiCommitSigning).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("git CLI tools should be used when sshSigningKey is set", () => {
|
|
||||||
// This tests the logic in tag mode for tool selection
|
|
||||||
const sshSigningKey = "test-key";
|
|
||||||
const useCommitSigning = true; // Even if this is true
|
|
||||||
|
|
||||||
const useSshSigning = !!sshSigningKey;
|
|
||||||
const useApiCommitSigning = useCommitSigning && !useSshSigning;
|
|
||||||
|
|
||||||
// When SSH signing is used, we should use git CLI (not API)
|
|
||||||
const shouldUseGitCli = !useApiCommitSigning;
|
|
||||||
expect(shouldUseGitCli).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("MCP file ops should only be used with API commit signing", () => {
|
|
||||||
// Case 1: API commit signing
|
|
||||||
{
|
|
||||||
const sshSigningKey = "";
|
|
||||||
const useCommitSigning = true;
|
|
||||||
|
|
||||||
const useSshSigning = !!sshSigningKey;
|
|
||||||
const useApiCommitSigning = useCommitSigning && !useSshSigning;
|
|
||||||
|
|
||||||
expect(useApiCommitSigning).toBe(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Case 2: SSH signing (should NOT use API)
|
|
||||||
{
|
|
||||||
const sshSigningKey = "test-key";
|
|
||||||
const useCommitSigning = true;
|
|
||||||
|
|
||||||
const useSshSigning = !!sshSigningKey;
|
|
||||||
const useApiCommitSigning = useCommitSigning && !useSshSigning;
|
|
||||||
|
|
||||||
expect(useApiCommitSigning).toBe(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Case 3: No signing (should NOT use API)
|
|
||||||
{
|
|
||||||
const sshSigningKey = "";
|
|
||||||
const useCommitSigning = false;
|
|
||||||
|
|
||||||
const useSshSigning = !!sshSigningKey;
|
|
||||||
const useApiCommitSigning = useCommitSigning && !useSshSigning;
|
|
||||||
|
|
||||||
expect(useApiCommitSigning).toBe(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Context parsing", () => {
|
|
||||||
test("sshSigningKey should be parsed from environment", () => {
|
|
||||||
// Test that context.ts parses SSH_SIGNING_KEY correctly
|
|
||||||
const testCases = [
|
|
||||||
{ env: "test-key", expected: "test-key" },
|
|
||||||
{ env: "", expected: "" },
|
|
||||||
{ env: undefined, expected: "" },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const { env, expected } of testCases) {
|
|
||||||
const result = env || "";
|
|
||||||
expect(result).toBe(expected);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
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