mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-23 23:14:13 +08:00
Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f551b358e | ||
|
|
0d8a8fe1ac | ||
|
|
93df09fd88 | ||
|
|
d290268f83 | ||
|
|
d69f61e377 | ||
|
|
de86beb3ae | ||
|
|
5c420d2402 | ||
|
|
f6e7adf89e | ||
|
|
d1e03ad18e | ||
|
|
dfa92d6952 | ||
|
|
8335bda243 | ||
|
|
00b4a23551 | ||
|
|
d4d7974604 | ||
|
|
8fcb8e16b8 | ||
|
|
06b3126baf | ||
|
|
bf2400d475 | ||
|
|
4e2cfbac36 | ||
|
|
018533dc9a | ||
|
|
a9d9ad3612 | ||
|
|
4824494f4d | ||
|
|
c09fc691c5 | ||
|
|
b3c6de94ea | ||
|
|
b92e56a96b | ||
|
|
b6868bfc27 | ||
|
|
0f9a2c4dc3 | ||
|
|
cefe963a6b | ||
|
|
eda5af4e69 | ||
|
|
87facd7051 | ||
|
|
a804c9e83f | ||
|
|
d6bc8ddf8a | ||
|
|
86665d0984 | ||
|
|
6364776f60 | ||
|
|
e43c1b7fac | ||
|
|
23fae74fdb | ||
|
|
3c739a8cf3 | ||
|
|
aa28d465c5 | ||
|
|
55b7205cd2 | ||
|
|
8fe405c45f | ||
|
|
73012199e4 | ||
|
|
459b56e54d | ||
|
|
00f9595fb4 | ||
|
|
79f2086fce | ||
|
|
bcb072b63f | ||
|
|
e3b3e531a7 | ||
|
|
a7665d3698 | ||
|
|
91c510a769 | ||
|
|
1e006bf2d0 | ||
|
|
ece712ea81 | ||
|
|
032008d3b6 | ||
|
|
b0d9b8c4cd | ||
|
|
c831be8f54 | ||
|
|
38254908ae | ||
|
|
882586e496 | ||
|
|
28aaa5404d |
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 }}
|
||||
1
.github/workflows/claude-review.yml
vendored
1
.github/workflows/claude-review.yml
vendored
@@ -26,6 +26,7 @@ jobs:
|
||||
- Potential bugs or issues
|
||||
- Suggestions for improvements
|
||||
- Overall architecture and design decisions
|
||||
- Documentation consistency: Verify that README.md and other documentation files are updated to reflect any code changes (especially new inputs, features, or configuration options)
|
||||
|
||||
Be constructive and specific in your feedback. Give inline comments where applicable.
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
2
.github/workflows/issue-triage.yml
vendored
2
.github/workflows/issue-triage.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
||||
"--rm",
|
||||
"-e",
|
||||
"GITHUB_PERSONAL_ACCESS_TOKEN",
|
||||
"ghcr.io/github/github-mcp-server:sha-6d69797"
|
||||
"ghcr.io/github/github-mcp-server:sha-721fd3e"
|
||||
],
|
||||
"env": {
|
||||
"GITHUB_PERSONAL_ACCESS_TOKEN": "${{ secrets.GITHUB_TOKEN }}"
|
||||
|
||||
49
.github/workflows/release.yml
vendored
49
.github/workflows/release.yml
vendored
@@ -12,6 +12,7 @@ on:
|
||||
jobs:
|
||||
create-release:
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
permissions:
|
||||
contents: write
|
||||
outputs:
|
||||
@@ -85,6 +86,7 @@ jobs:
|
||||
needs: create-release
|
||||
if: ${{ !inputs.dry_run }}
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
@@ -115,6 +117,7 @@ jobs:
|
||||
needs: create-release
|
||||
if: ${{ !inputs.dry_run }}
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
@@ -136,3 +139,49 @@ jobs:
|
||||
git push origin "$major_version" --force
|
||||
|
||||
echo "Updated $major_version tag to point to $next_version"
|
||||
|
||||
release-base-action:
|
||||
needs: create-release
|
||||
if: ${{ !inputs.dry_run }}
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
steps:
|
||||
- name: Checkout base-action repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: anthropics/claude-code-base-action
|
||||
token: ${{ secrets.CLAUDE_CODE_BASE_ACTION_PAT }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Create and push tag
|
||||
run: |
|
||||
next_version="${{ needs.create-release.outputs.next_version }}"
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
# Create the version tag
|
||||
git tag -a "$next_version" -m "Release $next_version - synced from claude-code-action"
|
||||
git push origin "$next_version"
|
||||
|
||||
# Update the beta tag
|
||||
git tag -fa beta -m "Update beta tag to ${next_version}"
|
||||
git push origin beta --force
|
||||
|
||||
- name: Create GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.CLAUDE_CODE_BASE_ACTION_PAT }}
|
||||
run: |
|
||||
next_version="${{ needs.create-release.outputs.next_version }}"
|
||||
|
||||
# Create the release
|
||||
gh release create "$next_version" \
|
||||
--repo anthropics/claude-code-base-action \
|
||||
--title "$next_version" \
|
||||
--notes "Release $next_version - synced from anthropics/claude-code-action" \
|
||||
--latest=false
|
||||
|
||||
# Update beta release to be latest
|
||||
gh release edit beta \
|
||||
--repo anthropics/claude-code-base-action \
|
||||
--latest
|
||||
|
||||
98
.github/workflows/sync-base-action.yml
vendored
Normal file
98
.github/workflows/sync-base-action.yml
vendored
Normal file
@@ -0,0 +1,98 @@
|
||||
name: Sync Base Action to claude-code-base-action
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "base-action/**"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
sync-base-action:
|
||||
name: Sync base-action to claude-code-base-action repository
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout source repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup SSH and clone target repository
|
||||
run: |
|
||||
# Configure SSH with deploy key
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.CLAUDE_CODE_BASE_ACTION_REPO_DEPLOY_KEY }}" > ~/.ssh/deploy_key_base
|
||||
chmod 600 ~/.ssh/deploy_key_base
|
||||
|
||||
# Configure SSH host
|
||||
cat > ~/.ssh/config <<EOL
|
||||
Host base-action.github.com
|
||||
HostName github.com
|
||||
User git
|
||||
IdentityFile ~/.ssh/deploy_key_base
|
||||
StrictHostKeyChecking no
|
||||
EOL
|
||||
|
||||
# Clone the target repository
|
||||
git clone git@base-action.github.com:anthropics/claude-code-base-action.git target-repo
|
||||
|
||||
- name: Sync base-action contents
|
||||
run: |
|
||||
cd target-repo
|
||||
|
||||
# Configure git
|
||||
git config user.name "GitHub Actions"
|
||||
git config user.email "actions@github.com"
|
||||
|
||||
# Remove all existing files except .git directory
|
||||
find . -mindepth 1 -maxdepth 1 -name '.git' -prune -o -exec rm -rf {} +
|
||||
|
||||
# Copy all contents from base-action
|
||||
cp -r ../base-action/. .
|
||||
|
||||
# Prepend mirror disclaimer to README if both files exist
|
||||
if [ -f "README.md" ] && [ -f "MIRROR_DISCLAIMER.md" ]; then
|
||||
cat MIRROR_DISCLAIMER.md README.md > README.tmp
|
||||
mv README.tmp README.md
|
||||
fi
|
||||
|
||||
# Check if there are any changes
|
||||
if git diff --quiet && git diff --staged --quiet; then
|
||||
echo "No changes to sync"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Stage all changes
|
||||
git add -A
|
||||
|
||||
# Get source commit info for the commit message
|
||||
SOURCE_COMMIT="${GITHUB_SHA:0:7}"
|
||||
SOURCE_COMMIT_MESSAGE=$(git -C .. log -1 --pretty=format:"%s" || echo "Update from base-action")
|
||||
|
||||
# Commit with descriptive message
|
||||
git commit -m "Sync from claude-code-action base-action@${SOURCE_COMMIT}" \
|
||||
-m "" \
|
||||
-m "Source: anthropics/claude-code-action@${GITHUB_SHA}" \
|
||||
-m "Original message: ${SOURCE_COMMIT_MESSAGE}"
|
||||
|
||||
# Push to main branch
|
||||
git push origin main
|
||||
|
||||
echo "Successfully synced base-action to claude-code-base-action"
|
||||
|
||||
- name: Create sync summary
|
||||
if: success()
|
||||
run: |
|
||||
echo "## Sync Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $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 "- **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 "- **Actor**: @${{ github.actor }}" >> $GITHUB_STEP_SUMMARY
|
||||
122
.github/workflows/test-base-action.yml
vendored
Normal file
122
.github/workflows/test-base-action.yml
vendored
Normal file
@@ -0,0 +1,122 @@
|
||||
name: Test Claude Code Action
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
test_prompt:
|
||||
description: "Test prompt for Claude"
|
||||
required: false
|
||||
default: "List the files in the current directory starting with 'package'"
|
||||
|
||||
jobs:
|
||||
test-inline-prompt:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Test with inline prompt
|
||||
id: inline-test
|
||||
uses: ./base-action
|
||||
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"
|
||||
timeout_minutes: "3"
|
||||
|
||||
- name: Verify inline prompt output
|
||||
run: |
|
||||
OUTPUT_FILE="${{ steps.inline-test.outputs.execution_file }}"
|
||||
CONCLUSION="${{ steps.inline-test.outputs.conclusion }}"
|
||||
|
||||
echo "Conclusion: $CONCLUSION"
|
||||
echo "Output file: $OUTPUT_FILE"
|
||||
|
||||
if [ "$CONCLUSION" = "success" ]; then
|
||||
echo "✅ Action completed successfully"
|
||||
else
|
||||
echo "❌ Action failed"
|
||||
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"
|
||||
echo "Content preview:"
|
||||
head -c 200 "$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
|
||||
|
||||
test-prompt-file:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Create test prompt file
|
||||
run: |
|
||||
cat > test-prompt.txt << EOF
|
||||
${PROMPT}
|
||||
EOF
|
||||
env:
|
||||
PROMPT: ${{ github.event.inputs.test_prompt || 'List the files in the current directory starting with "package"' }}
|
||||
|
||||
- name: Test with prompt file and allowed tools
|
||||
id: prompt-file-test
|
||||
uses: ./base-action
|
||||
with:
|
||||
prompt_file: "test-prompt.txt"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
allowed_tools: "LS,Read"
|
||||
timeout_minutes: "3"
|
||||
|
||||
- name: Verify prompt file output
|
||||
run: |
|
||||
OUTPUT_FILE="${{ steps.prompt-file-test.outputs.execution_file }}"
|
||||
CONCLUSION="${{ steps.prompt-file-test.outputs.conclusion }}"
|
||||
|
||||
echo "Conclusion: $CONCLUSION"
|
||||
echo "Output file: $OUTPUT_FILE"
|
||||
|
||||
if [ "$CONCLUSION" = "success" ]; then
|
||||
echo "✅ Action completed successfully"
|
||||
else
|
||||
echo "❌ Action failed"
|
||||
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"
|
||||
echo "Content preview:"
|
||||
head -c 200 "$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
|
||||
47
.github/workflows/test-claude-env.yml
vendored
Normal file
47
.github/workflows/test-claude-env.yml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
name: Test Claude Env Feature
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test-claude-env-with-comments:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Test with comments in env
|
||||
id: comment-test
|
||||
uses: ./base-action
|
||||
with:
|
||||
prompt: |
|
||||
Use the Bash tool to run: echo "VAR1: $VAR1" && echo "VAR2: $VAR2"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
claude_env: |
|
||||
# This is a comment
|
||||
VAR1: value1
|
||||
# Another comment
|
||||
VAR2: value2
|
||||
|
||||
# Empty lines above should be ignored
|
||||
allowed_tools: "Bash(echo:*)"
|
||||
timeout_minutes: "2"
|
||||
|
||||
- name: Verify comment handling
|
||||
run: |
|
||||
OUTPUT_FILE="${{ steps.comment-test.outputs.execution_file }}"
|
||||
if [ "${{ steps.comment-test.outputs.conclusion }}" = "success" ]; then
|
||||
echo "✅ Comments in claude_env handled correctly"
|
||||
if grep -q "value1" "$OUTPUT_FILE" && grep -q "value2" "$OUTPUT_FILE"; then
|
||||
echo "✅ Environment variables set correctly despite comments"
|
||||
else
|
||||
echo "❌ Environment variables not found"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "❌ Failed with comments in claude_env"
|
||||
exit 1
|
||||
fi
|
||||
160
.github/workflows/test-mcp-servers.yml
vendored
Normal file
160
.github/workflows/test-mcp-servers.yml
vendored
Normal file
@@ -0,0 +1,160 @@
|
||||
name: Test MCP Servers
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test-mcp-integration:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 #v2
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
bun install
|
||||
cd base-action/test/mcp-test
|
||||
bun install
|
||||
|
||||
- name: Run Claude Code with MCP test
|
||||
uses: ./base-action
|
||||
id: claude-test
|
||||
with:
|
||||
prompt: "List all available tools"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
env:
|
||||
# Change to test directory so it finds .mcp.json
|
||||
CLAUDE_WORKING_DIR: ${{ github.workspace }}/base-action/test/mcp-test
|
||||
|
||||
- name: Check MCP server output
|
||||
run: |
|
||||
echo "Checking Claude output for MCP servers..."
|
||||
|
||||
# Parse the JSON output
|
||||
OUTPUT_FILE="${RUNNER_TEMP}/claude-execution-output.json"
|
||||
|
||||
if [ ! -f "$OUTPUT_FILE" ]; then
|
||||
echo "Error: Output file not found!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Output file contents:"
|
||||
cat $OUTPUT_FILE
|
||||
|
||||
# Check if mcp_servers field exists in the init event
|
||||
if jq -e '.[] | select(.type == "system" and .subtype == "init") | .mcp_servers' "$OUTPUT_FILE" > /dev/null; then
|
||||
echo "✓ Found mcp_servers in output"
|
||||
|
||||
# Check if test-server is connected
|
||||
if jq -e '.[] | select(.type == "system" and .subtype == "init") | .mcp_servers[] | select(.name == "test-server" and .status == "connected")' "$OUTPUT_FILE" > /dev/null; then
|
||||
echo "✓ test-server is connected"
|
||||
else
|
||||
echo "✗ test-server not found or not connected"
|
||||
jq '.[] | select(.type == "system" and .subtype == "init") | .mcp_servers' "$OUTPUT_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if mcp tools are available
|
||||
if jq -e '.[] | select(.type == "system" and .subtype == "init") | .tools[] | select(. == "mcp__test-server__test_tool")' "$OUTPUT_FILE" > /dev/null; then
|
||||
echo "✓ MCP test tool found"
|
||||
else
|
||||
echo "✗ MCP test tool not found"
|
||||
jq '.[] | select(.type == "system" and .subtype == "init") | .tools' "$OUTPUT_FILE"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "✗ No mcp_servers field found in init event"
|
||||
jq '.[] | select(.type == "system" and .subtype == "init")' "$OUTPUT_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✓ All MCP server checks passed!"
|
||||
|
||||
test-mcp-config-flag:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 #v2
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
bun install
|
||||
cd base-action/test/mcp-test
|
||||
bun install
|
||||
|
||||
- name: Debug environment paths (--mcp-config test)
|
||||
run: |
|
||||
echo "=== Environment Variables (--mcp-config test) ==="
|
||||
echo "HOME: $HOME"
|
||||
echo ""
|
||||
echo "=== Expected Config Paths ==="
|
||||
echo "GitHub action writes to: $HOME/.claude/settings.json"
|
||||
echo "Claude should read from: $HOME/.claude/settings.json"
|
||||
echo ""
|
||||
echo "=== Actual File System ==="
|
||||
ls -la $HOME/.claude/ || echo "No $HOME/.claude directory"
|
||||
|
||||
- name: Run Claude Code with --mcp-config flag
|
||||
uses: ./base-action
|
||||
id: claude-config-test
|
||||
with:
|
||||
prompt: "List all available tools"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
mcp_config: '{"mcpServers":{"test-server":{"type":"stdio","command":"bun","args":["simple-mcp-server.ts"],"env":{}}}}'
|
||||
env:
|
||||
# Change to test directory so bun can find the MCP server script
|
||||
CLAUDE_WORKING_DIR: ${{ github.workspace }}/base-action/test/mcp-test
|
||||
|
||||
- name: Check MCP server output with --mcp-config
|
||||
run: |
|
||||
echo "Checking Claude output for MCP servers with --mcp-config flag..."
|
||||
|
||||
# Parse the JSON output
|
||||
OUTPUT_FILE="${RUNNER_TEMP}/claude-execution-output.json"
|
||||
|
||||
if [ ! -f "$OUTPUT_FILE" ]; then
|
||||
echo "Error: Output file not found!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Output file contents:"
|
||||
cat $OUTPUT_FILE
|
||||
|
||||
# Check if mcp_servers field exists in the init event
|
||||
if jq -e '.[] | select(.type == "system" and .subtype == "init") | .mcp_servers' "$OUTPUT_FILE" > /dev/null; then
|
||||
echo "✓ Found mcp_servers in output"
|
||||
|
||||
# Check if test-server is connected
|
||||
if jq -e '.[] | select(.type == "system" and .subtype == "init") | .mcp_servers[] | select(.name == "test-server" and .status == "connected")' "$OUTPUT_FILE" > /dev/null; then
|
||||
echo "✓ test-server is connected"
|
||||
else
|
||||
echo "✗ test-server not found or not connected"
|
||||
jq '.[] | select(.type == "system" and .subtype == "init") | .mcp_servers' "$OUTPUT_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if mcp tools are available
|
||||
if jq -e '.[] | select(.type == "system" and .subtype == "init") | .tools[] | select(. == "mcp__test-server__test_tool")' "$OUTPUT_FILE" > /dev/null; then
|
||||
echo "✓ MCP test tool found"
|
||||
else
|
||||
echo "✗ MCP test tool not found"
|
||||
jq '.[] | select(.type == "system" and .subtype == "init") | .tools' "$OUTPUT_FILE"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "✗ No mcp_servers field found in init event"
|
||||
jq '.[] | select(.type == "system" and .subtype == "init")' "$OUTPUT_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✓ All MCP server checks passed with --mcp-config flag!"
|
||||
185
.github/workflows/test-settings.yml
vendored
Normal file
185
.github/workflows/test-settings.yml
vendored
Normal file
@@ -0,0 +1,185 @@
|
||||
name: Test Settings Feature
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test-settings-inline-allow:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Test with inline settings JSON (echo allowed)
|
||||
id: inline-settings-test
|
||||
uses: ./base-action
|
||||
with:
|
||||
prompt: |
|
||||
Use Bash to echo "Hello from settings test"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
settings: |
|
||||
{
|
||||
"permissions": {
|
||||
"allow": ["Bash(echo:*)"]
|
||||
}
|
||||
}
|
||||
timeout_minutes: "2"
|
||||
|
||||
- name: Verify echo worked
|
||||
run: |
|
||||
OUTPUT_FILE="${{ steps.inline-settings-test.outputs.execution_file }}"
|
||||
CONCLUSION="${{ steps.inline-settings-test.outputs.conclusion }}"
|
||||
|
||||
echo "Conclusion: $CONCLUSION"
|
||||
|
||||
if [ "$CONCLUSION" = "success" ]; then
|
||||
echo "✅ Action completed successfully"
|
||||
else
|
||||
echo "❌ Action failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check that permission was NOT denied
|
||||
if grep -q "Permission to use Bash with command echo.*has been denied" "$OUTPUT_FILE"; then
|
||||
echo "❌ Echo command was denied when it should have been allowed"
|
||||
cat "$OUTPUT_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if the echo command worked
|
||||
if grep -q "Hello from settings test" "$OUTPUT_FILE"; then
|
||||
echo "✅ Bash echo command worked (allowed by permissions)"
|
||||
else
|
||||
echo "❌ Bash echo command didn't work"
|
||||
cat "$OUTPUT_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
test-settings-inline-deny:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Test with inline settings JSON (echo denied)
|
||||
id: inline-settings-test
|
||||
uses: ./base-action
|
||||
with:
|
||||
prompt: |
|
||||
Use Bash to echo "This should not work"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
settings: |
|
||||
{
|
||||
"permissions": {
|
||||
"deny": ["Bash(echo:*)"]
|
||||
}
|
||||
}
|
||||
timeout_minutes: "2"
|
||||
|
||||
- name: Verify echo was denied
|
||||
run: |
|
||||
OUTPUT_FILE="${{ steps.inline-settings-test.outputs.execution_file }}"
|
||||
|
||||
# Check that permission was denied in the tool_result
|
||||
if grep -q "Permission to use Bash with command echo.*has been denied" "$OUTPUT_FILE"; then
|
||||
echo "✅ Echo command was correctly denied by permissions"
|
||||
else
|
||||
echo "❌ Expected permission denied message not found"
|
||||
cat "$OUTPUT_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
test-settings-file-allow:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Create settings file (echo allowed)
|
||||
run: |
|
||||
cat > test-settings.json << EOF
|
||||
{
|
||||
"permissions": {
|
||||
"allow": ["Bash(echo:*)"]
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
- name: Test with settings file
|
||||
id: file-settings-test
|
||||
uses: ./base-action
|
||||
with:
|
||||
prompt: |
|
||||
Use Bash to echo "Hello from settings file test"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
settings: "test-settings.json"
|
||||
timeout_minutes: "2"
|
||||
|
||||
- name: Verify echo worked
|
||||
run: |
|
||||
OUTPUT_FILE="${{ steps.file-settings-test.outputs.execution_file }}"
|
||||
CONCLUSION="${{ steps.file-settings-test.outputs.conclusion }}"
|
||||
|
||||
echo "Conclusion: $CONCLUSION"
|
||||
|
||||
if [ "$CONCLUSION" = "success" ]; then
|
||||
echo "✅ Action completed successfully"
|
||||
else
|
||||
echo "❌ Action failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check that permission was NOT denied
|
||||
if grep -q "Permission to use Bash with command echo.*has been denied" "$OUTPUT_FILE"; then
|
||||
echo "❌ Echo command was denied when it should have been allowed"
|
||||
cat "$OUTPUT_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if the echo command worked
|
||||
if grep -q "Hello from settings file test" "$OUTPUT_FILE"; then
|
||||
echo "✅ Bash echo command worked (allowed by permissions)"
|
||||
else
|
||||
echo "❌ Bash echo command didn't work"
|
||||
cat "$OUTPUT_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
test-settings-file-deny:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Create settings file (echo denied)
|
||||
run: |
|
||||
cat > test-settings.json << EOF
|
||||
{
|
||||
"permissions": {
|
||||
"deny": ["Bash(echo:*)"]
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
- name: Test with settings file
|
||||
id: file-settings-test
|
||||
uses: ./base-action
|
||||
with:
|
||||
prompt: |
|
||||
Use Bash to echo "This should not work from file"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
settings: "test-settings.json"
|
||||
timeout_minutes: "2"
|
||||
|
||||
- name: Verify echo was denied
|
||||
run: |
|
||||
OUTPUT_FILE="${{ steps.file-settings-test.outputs.execution_file }}"
|
||||
|
||||
# Check that permission was denied in the tool_result
|
||||
if grep -q "Permission to use Bash with command echo.*has been denied" "$OUTPUT_FILE"; then
|
||||
echo "✅ Echo command was correctly denied by permissions"
|
||||
else
|
||||
echo "❌ Expected permission denied message not found"
|
||||
cat "$OUTPUT_FILE"
|
||||
exit 1
|
||||
fi
|
||||
2
.prettierignore
Normal file
2
.prettierignore
Normal file
@@ -0,0 +1,2 @@
|
||||
# Test fixtures should not be formatted to preserve exact output matching
|
||||
test/fixtures/
|
||||
29
FAQ.md
29
FAQ.md
@@ -12,6 +12,10 @@ The `github-actions` user cannot trigger subsequent GitHub Actions workflows. Th
|
||||
|
||||
Only users with **write permissions** to the repository can trigger Claude. This is a security feature to prevent unauthorized use. Make sure the user commenting has at least write access to the repository.
|
||||
|
||||
### Why can't I assign @claude to an issue on my repository?
|
||||
|
||||
If you're in a public repository, you should be able to assign to Claude without issue. If it's a private organization repository, you can only assign to users in your own organization, which Claude isn't. In this case, you'll need to make a custom user in that case.
|
||||
|
||||
### Why am I getting OIDC authentication errors?
|
||||
|
||||
If you're using the default GitHub App authentication, you must add the `id-token: write` permission to your workflow:
|
||||
@@ -47,14 +51,29 @@ allowed_tools: "Bash(git rebase:*)" # Use with caution
|
||||
|
||||
Claude doesn't create PRs by default. Instead, it pushes commits to a branch and provides a link to a pre-filled PR submission page. This approach ensures your repository's branch protection rules are still adhered to and gives you final control over PR creation.
|
||||
|
||||
### Why can't Claude run my tests or see CI results?
|
||||
### Can Claude see my GitHub Actions CI results?
|
||||
|
||||
Claude cannot access GitHub Actions logs, test results, or other CI/CD outputs by default. It only has access to the repository files. If you need Claude to see test results, you can either:
|
||||
Yes! Claude can access GitHub Actions workflow runs, job logs, and test results on the PR where it's tagged. To enable this:
|
||||
|
||||
1. Instruct Claude to run tests before making commits
|
||||
2. Copy and paste CI results into a comment for Claude to analyze
|
||||
1. Add `actions: read` permission to your workflow:
|
||||
|
||||
This limitation exists for security reasons but may be reconsidered in the future based on user feedback.
|
||||
```yaml
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
actions: read
|
||||
```
|
||||
|
||||
2. Configure the action with additional permissions:
|
||||
```yaml
|
||||
- uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
```
|
||||
|
||||
Claude will then be able to analyze CI failures and help debug workflow issues. For running tests locally before commits, you can still instruct Claude to do so in your request.
|
||||
|
||||
### Why does Claude only update one comment instead of creating new ones?
|
||||
|
||||
|
||||
391
README.md
391
README.md
@@ -30,9 +30,91 @@ This command will guide you through setting up the GitHub app and required secre
|
||||
**Requirements**: You must be a repository admin to complete these steps.
|
||||
|
||||
1. Install the Claude GitHub app to your repository: https://github.com/apps/claude
|
||||
2. Add `ANTHROPIC_API_KEY` to your repository secrets ([Learn how to use secrets in GitHub Actions](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions))
|
||||
2. Add authentication to your repository secrets ([Learn how to use secrets in GitHub Actions](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions)):
|
||||
- Either `ANTHROPIC_API_KEY` for API key authentication
|
||||
- Or `CLAUDE_CODE_OAUTH_TOKEN` for OAuth token authentication (Pro and Max users can generate this by running `claude setup-token` locally)
|
||||
3. Copy the workflow file from [`examples/claude.yml`](./examples/claude.yml) into your repository's `.github/workflows/`
|
||||
|
||||
### Using a Custom GitHub App
|
||||
|
||||
If you prefer not to install the official Claude app, you can create your own GitHub App to use with this action. This gives you complete control over permissions and access.
|
||||
|
||||
**When you may want to use a custom GitHub App:**
|
||||
|
||||
- You need more restrictive permissions than the official app
|
||||
- Organization policies prevent installing third-party apps
|
||||
- You're using AWS Bedrock or Google Vertex AI
|
||||
|
||||
**Steps to create and use a custom GitHub App:**
|
||||
|
||||
1. **Create a new GitHub App:**
|
||||
|
||||
- Go to https://github.com/settings/apps (for personal apps) or your organization's settings
|
||||
- Click "New GitHub App"
|
||||
- Configure the app with these minimum permissions:
|
||||
- **Repository permissions:**
|
||||
- Contents: Read & Write
|
||||
- Issues: Read & Write
|
||||
- Pull requests: Read & Write
|
||||
- **Account permissions:** None required
|
||||
- Set "Where can this GitHub App be installed?" to your preference
|
||||
- Create the app
|
||||
|
||||
2. **Generate and download a private key:**
|
||||
|
||||
- After creating the app, scroll down to "Private keys"
|
||||
- Click "Generate a private key"
|
||||
- Download the `.pem` file (keep this secure!)
|
||||
|
||||
3. **Install the app on your repository:**
|
||||
|
||||
- Go to the app's settings page
|
||||
- Click "Install App"
|
||||
- Select the repositories where you want to use Claude
|
||||
|
||||
4. **Add the app credentials to your repository secrets:**
|
||||
|
||||
- Go to your repository's Settings → Secrets and variables → Actions
|
||||
- Add these secrets:
|
||||
- `APP_ID`: Your GitHub App's ID (found in the app settings)
|
||||
- `APP_PRIVATE_KEY`: The contents of the downloaded `.pem` file
|
||||
|
||||
5. **Update your workflow to use the custom app:**
|
||||
|
||||
```yaml
|
||||
name: Claude with Custom App
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
# ... other triggers
|
||||
|
||||
jobs:
|
||||
claude-response:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Generate a token from your custom app
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
# Use Claude with your custom app's token
|
||||
- uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
github_token: ${{ steps.app-token.outputs.token }}
|
||||
# ... other configuration
|
||||
```
|
||||
|
||||
**Important notes:**
|
||||
|
||||
- The custom app must have read/write permissions for Issues, Pull Requests, and Contents
|
||||
- Your app's token will have the exact permissions you configured, nothing more
|
||||
|
||||
For more information on creating GitHub Apps, see the [GitHub documentation](https://docs.github.com/en/apps/creating-github-apps).
|
||||
|
||||
## 📚 FAQ
|
||||
|
||||
Having issues or questions? Check out our [Frequently Asked Questions](./FAQ.md) for solutions to common problems and detailed explanations of Claude's capabilities and limitations.
|
||||
@@ -49,7 +131,7 @@ on:
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
types: [opened, assigned, labeled]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
@@ -60,11 +142,15 @@ jobs:
|
||||
- uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
# Or use OAuth token instead:
|
||||
# claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Optional: add custom trigger phrase (default: @claude)
|
||||
# trigger_phrase: "/claude"
|
||||
# Optional: add assignee trigger for issues
|
||||
# assignee_trigger: "claude"
|
||||
# Optional: add label trigger for issues
|
||||
# label_trigger: "claude"
|
||||
# Optional: add custom environment variables (YAML format)
|
||||
# claude_env: |
|
||||
# NODE_ENV: test
|
||||
@@ -72,28 +158,42 @@ jobs:
|
||||
# API_URL: https://api.example.com
|
||||
# Optional: limit the number of conversation turns
|
||||
# max_turns: "5"
|
||||
# Optional: grant additional permissions (requires corresponding GitHub token permissions)
|
||||
# additional_permissions: |
|
||||
# actions: read
|
||||
```
|
||||
|
||||
## Inputs
|
||||
|
||||
| Input | Description | Required | Default |
|
||||
| --------------------- | -------------------------------------------------------------------------------------------------------------------- | -------- | --------- |
|
||||
| `anthropic_api_key` | Anthropic API key (required for direct API, not needed for Bedrock/Vertex) | No\* | - |
|
||||
| `direct_prompt` | Direct prompt for Claude to execute automatically without needing a trigger (for automated workflows) | No | - |
|
||||
| `max_turns` | Maximum number of conversation turns Claude can take (limits back-and-forth exchanges) | No | - |
|
||||
| `timeout_minutes` | Timeout in minutes for execution | No | `30` |
|
||||
| `github_token` | GitHub token for Claude to operate with. **Only include this if you're connecting a custom GitHub app of your own!** | No | - |
|
||||
| `model` | Model to use (provider-specific format required for Bedrock/Vertex) | No | - |
|
||||
| `anthropic_model` | **DEPRECATED**: Use `model` instead. Kept for backward compatibility. | No | - |
|
||||
| `use_bedrock` | Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API | No | `false` |
|
||||
| `use_vertex` | Use Google Vertex AI with OIDC authentication instead of direct Anthropic API | No | `false` |
|
||||
| `allowed_tools` | Additional tools for Claude to use (the base GitHub tools will always be included) | No | "" |
|
||||
| `disallowed_tools` | Tools that Claude should never use | No | "" |
|
||||
| `custom_instructions` | Additional custom instructions to include in the prompt for Claude | No | "" |
|
||||
| `mcp_config` | Additional MCP configuration (JSON string) that merges with the built-in GitHub MCP servers | No | "" |
|
||||
| `assignee_trigger` | The assignee username that triggers the action (e.g. @claude). Only used for issue assignment | No | - |
|
||||
| `trigger_phrase` | The trigger phrase to look for in comments, issue/PR bodies, and issue titles | No | `@claude` |
|
||||
| `claude_env` | Custom environment variables to pass to Claude Code execution (YAML format) | No | "" |
|
||||
| Input | Description | Required | Default |
|
||||
| ------------------------------ | -------------------------------------------------------------------------------------------------------------------- | -------- | --------- |
|
||||
| `anthropic_api_key` | Anthropic API key (required for direct API, not needed for Bedrock/Vertex) | No\* | - |
|
||||
| `claude_code_oauth_token` | Claude Code OAuth token (alternative to anthropic_api_key) | No\* | - |
|
||||
| `direct_prompt` | Direct prompt for Claude to execute automatically without needing a trigger (for automated workflows) | No | - |
|
||||
| `override_prompt` | Complete replacement of Claude's prompt with custom template (supports variable substitution) | No | - |
|
||||
| `base_branch` | The base branch to use for creating new branches (e.g., 'main', 'develop') | No | - |
|
||||
| `max_turns` | Maximum number of conversation turns Claude can take (limits back-and-forth exchanges) | No | - |
|
||||
| `timeout_minutes` | Timeout in minutes for execution | No | `30` |
|
||||
| `use_sticky_comment` | Use just one comment to deliver PR comments (only applies for pull_request event workflows) | No | `false` |
|
||||
| `github_token` | GitHub token for Claude to operate with. **Only include this if you're connecting a custom GitHub app of your own!** | No | - |
|
||||
| `model` | Model to use (provider-specific format required for Bedrock/Vertex) | No | - |
|
||||
| `fallback_model` | Enable automatic fallback to specified model when primary model is unavailable | No | - |
|
||||
| `anthropic_model` | **DEPRECATED**: Use `model` instead. Kept for backward compatibility. | No | - |
|
||||
| `use_bedrock` | Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API | No | `false` |
|
||||
| `use_vertex` | Use Google Vertex AI with OIDC authentication instead of direct Anthropic API | No | `false` |
|
||||
| `allowed_tools` | Additional tools for Claude to use (the base GitHub tools will always be included) | No | "" |
|
||||
| `disallowed_tools` | Tools that Claude should never use | No | "" |
|
||||
| `custom_instructions` | Additional custom instructions to include in the prompt for Claude | No | "" |
|
||||
| `mcp_config` | Additional MCP configuration (JSON string) that merges with the built-in GitHub MCP servers | No | "" |
|
||||
| `assignee_trigger` | The assignee username that triggers the action (e.g. @claude). Only used for issue assignment | No | - |
|
||||
| `label_trigger` | The label name that triggers the action when applied to an issue (e.g. "claude") | No | - |
|
||||
| `trigger_phrase` | The trigger phrase to look for in comments, issue/PR bodies, and issue titles | No | `@claude` |
|
||||
| `branch_prefix` | The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format) | No | `claude/` |
|
||||
| `claude_env` | Custom environment variables to pass to Claude Code execution (YAML format) | 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 | "" |
|
||||
| `experimental_allowed_domains` | Restrict network access to these domains only (newline-separated). | No | "" |
|
||||
| `use_commit_signing` | Enable commit signing using GitHub's commit signature verification. When false, Claude uses standard git commands | No | `false` |
|
||||
|
||||
\*Required when using direct Anthropic API (default and when not using Bedrock or Vertex)
|
||||
|
||||
@@ -296,6 +396,36 @@ jobs:
|
||||
|
||||
Perfect for automatically reviewing PRs from new team members, external contributors, or specific developers who need extra guidance.
|
||||
|
||||
#### Custom Prompt Templates
|
||||
|
||||
Use `override_prompt` for complete control over Claude's behavior with variable substitution:
|
||||
|
||||
```yaml
|
||||
- uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
override_prompt: |
|
||||
Analyze PR #$PR_NUMBER in $REPOSITORY for security vulnerabilities.
|
||||
|
||||
Changed files:
|
||||
$CHANGED_FILES
|
||||
|
||||
Focus on:
|
||||
- SQL injection risks
|
||||
- XSS vulnerabilities
|
||||
- Authentication bypasses
|
||||
- Exposed secrets or credentials
|
||||
|
||||
Provide severity ratings (Critical/High/Medium/Low) for any issues found.
|
||||
```
|
||||
|
||||
The `override_prompt` feature supports these variables:
|
||||
|
||||
- `$REPOSITORY`, `$PR_NUMBER`, `$ISSUE_NUMBER`
|
||||
- `$PR_TITLE`, `$ISSUE_TITLE`, `$PR_BODY`, `$ISSUE_BODY`
|
||||
- `$PR_COMMENTS`, `$ISSUE_COMMENTS`, `$REVIEW_COMMENTS`
|
||||
- `$CHANGED_FILES`, `$TRIGGER_COMMENT`, `$TRIGGER_USERNAME`
|
||||
- `$BRANCH_NAME`, `$BASE_BRANCH`, `$EVENT_TYPE`, `$IS_PR`
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Trigger Detection**: Listens for comments containing the trigger phrase (default: `@claude`) or issue assignment to a specific user
|
||||
@@ -319,6 +449,7 @@ This action is built on top of [`anthropics/claude-code-base-action`](https://gi
|
||||
- When triggered on an **issue**: Always creates a new branch for the work
|
||||
- When triggered on an **open PR**: Always pushes directly to the existing PR branch
|
||||
- When triggered on a **closed PR**: Creates a new branch since the original is no longer active
|
||||
- **View GitHub Actions Results**: Can access workflow runs, job logs, and test results on the PR where it's tagged when `actions: read` permission is configured (see [Additional Permissions for CI/CD Integration](#additional-permissions-for-cicd-integration))
|
||||
|
||||
### What Claude Cannot Do
|
||||
|
||||
@@ -327,11 +458,79 @@ This action is built on top of [`anthropics/claude-code-base-action`](https://gi
|
||||
- **Post Multiple Comments**: Claude only acts by updating its initial comment
|
||||
- **Execute Commands Outside Its Context**: Claude only has access to the repository and PR/issue context it's triggered in
|
||||
- **Run Arbitrary Bash Commands**: By default, Claude cannot execute Bash commands unless explicitly allowed using the `allowed_tools` configuration
|
||||
- **View CI/CD Results**: Cannot access CI systems, test results, or build logs unless an additional tool or MCP server is configured
|
||||
- **Perform Branch Operations**: Cannot merge branches, rebase, or perform other git operations beyond pushing commits
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
### Additional Permissions for CI/CD Integration
|
||||
|
||||
The `additional_permissions` input allows Claude to access GitHub Actions workflow information when you grant the necessary permissions. This is particularly useful for analyzing CI/CD failures and debugging workflow issues.
|
||||
|
||||
#### Enabling GitHub Actions Access
|
||||
|
||||
To allow Claude to view workflow run results, job logs, and CI status:
|
||||
|
||||
1. **Grant the necessary permission to your GitHub token**:
|
||||
|
||||
- When using the default `GITHUB_TOKEN`, add the `actions: read` permission to your workflow:
|
||||
|
||||
```yaml
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
actions: read # Add this line
|
||||
```
|
||||
|
||||
2. **Configure the action with additional permissions**:
|
||||
|
||||
```yaml
|
||||
- uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
# ... other inputs
|
||||
```
|
||||
|
||||
3. **Claude will automatically get access to CI/CD tools**:
|
||||
When you enable `actions: read`, Claude can use the following MCP tools:
|
||||
- `mcp__github_ci__get_ci_status` - View workflow run statuses
|
||||
- `mcp__github_ci__get_workflow_run_details` - Get detailed workflow information
|
||||
- `mcp__github_ci__download_job_log` - Download and analyze job logs
|
||||
|
||||
#### Example: Debugging Failed CI Runs
|
||||
|
||||
```yaml
|
||||
name: Claude CI Helper
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
actions: read # Required for CI access
|
||||
|
||||
jobs:
|
||||
claude-ci-helper:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
# Now Claude can respond to "@claude why did the CI fail?"
|
||||
```
|
||||
|
||||
**Important Notes**:
|
||||
|
||||
- The GitHub token must have the `actions: read` permission in your workflow
|
||||
- If the permission is missing, Claude will warn you and suggest adding it
|
||||
- Currently, only `actions: read` is supported, but the format allows for future extensions
|
||||
|
||||
### Custom Environment Variables
|
||||
|
||||
You can pass custom environment variables to Claude Code execution using the `claude_env` input. This is useful for CI/test setups that require specific environment variables:
|
||||
@@ -406,6 +605,130 @@ Use a specific Claude model:
|
||||
# ... other inputs
|
||||
```
|
||||
|
||||
### 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@beta
|
||||
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@beta
|
||||
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@beta
|
||||
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@beta
|
||||
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.
|
||||
|
||||
### Claude Code Settings
|
||||
|
||||
You can provide Claude Code settings to customize behavior such as model selection, environment variables, permissions, and hooks. Settings can be provided either as a JSON string or a path to a settings file.
|
||||
|
||||
#### Option 1: Settings File
|
||||
|
||||
```yaml
|
||||
- uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
settings: "path/to/settings.json"
|
||||
# ... other inputs
|
||||
```
|
||||
|
||||
#### Option 2: Inline Settings
|
||||
|
||||
```yaml
|
||||
- uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
settings: |
|
||||
{
|
||||
"model": "claude-opus-4-20250514",
|
||||
"env": {
|
||||
"DEBUG": "true",
|
||||
"API_URL": "https://api.example.com"
|
||||
},
|
||||
"permissions": {
|
||||
"allow": ["Bash", "Read"],
|
||||
"deny": ["WebFetch"]
|
||||
},
|
||||
"hooks": {
|
||||
"PreToolUse": [{
|
||||
"matcher": "Bash",
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": "echo Running bash command..."
|
||||
}]
|
||||
}]
|
||||
}
|
||||
}
|
||||
# ... other inputs
|
||||
```
|
||||
|
||||
The settings support all Claude Code settings options including:
|
||||
|
||||
- `model`: Override the default model
|
||||
- `env`: Environment variables for the session
|
||||
- `permissions`: Tool usage permissions
|
||||
- `hooks`: Pre/post tool execution hooks
|
||||
- And more...
|
||||
|
||||
For a complete list of available settings and their descriptions, see the [Claude Code settings documentation](https://docs.anthropic.com/en/docs/claude-code/settings).
|
||||
|
||||
**Notes**:
|
||||
|
||||
- The `enableAllProjectMcpServers` setting is always set to `true` by this action to ensure MCP servers work correctly.
|
||||
- If both the `model` input parameter and a `model` in settings are provided, the `model` input parameter takes precedence.
|
||||
- The `allowed_tools` and `disallowed_tools` input parameters take precedence over `permissions` in settings.
|
||||
- In a future version, we may deprecate individual input parameters in favor of using the settings file for all configuration.
|
||||
|
||||
## Cloud Providers
|
||||
|
||||
You can authenticate with Claude using any of these three methods:
|
||||
@@ -524,18 +847,21 @@ The [Claude Code GitHub app](https://github.com/apps/claude) requires these perm
|
||||
|
||||
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.
|
||||
|
||||
### ⚠️ ANTHROPIC_API_KEY Protection
|
||||
### ⚠️ Authentication Protection
|
||||
|
||||
**CRITICAL: Never hardcode your Anthropic API key in workflow files!**
|
||||
**CRITICAL: Never hardcode your Anthropic API key or OAuth token in workflow files!**
|
||||
|
||||
Your ANTHROPIC_API_KEY must always be stored in GitHub secrets to prevent unauthorized access:
|
||||
Your authentication credentials must always be stored in GitHub secrets to prevent unauthorized access:
|
||||
|
||||
```yaml
|
||||
# CORRECT ✅
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
# OR
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# NEVER DO THIS ❌
|
||||
anthropic_api_key: "sk-ant-api03-..." # Exposed and vulnerable!
|
||||
claude_code_oauth_token: "oauth_token_..." # Exposed and vulnerable!
|
||||
```
|
||||
|
||||
### Setting Up GitHub Secrets
|
||||
@@ -543,17 +869,18 @@ anthropic_api_key: "sk-ant-api03-..." # Exposed and vulnerable!
|
||||
1. Go to your repository's Settings
|
||||
2. Click on "Secrets and variables" → "Actions"
|
||||
3. Click "New repository secret"
|
||||
4. Name: `ANTHROPIC_API_KEY`
|
||||
5. Value: Your Anthropic API key (starting with `sk-ant-`)
|
||||
6. Click "Add secret"
|
||||
4. For authentication, choose one:
|
||||
- API Key: Name: `ANTHROPIC_API_KEY`, Value: Your Anthropic API key (starting with `sk-ant-`)
|
||||
- OAuth Token: Name: `CLAUDE_CODE_OAUTH_TOKEN`, Value: Your Claude Code OAuth token (Pro and Max users can generate this by running `claude setup-token` locally)
|
||||
5. Click "Add secret"
|
||||
|
||||
### Best Practices for ANTHROPIC_API_KEY
|
||||
### Best Practices for Authentication
|
||||
|
||||
1. ✅ Always use `${{ secrets.ANTHROPIC_API_KEY }}` in workflows
|
||||
2. ✅ Never commit API keys to version control
|
||||
3. ✅ Regularly rotate your API keys
|
||||
1. ✅ Always use `${{ secrets.ANTHROPIC_API_KEY }}` or `${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}` in workflows
|
||||
2. ✅ Never commit API keys or tokens to version control
|
||||
3. ✅ Regularly rotate your API keys and tokens
|
||||
4. ✅ Use environment secrets for organization-wide access
|
||||
5. ❌ Never share API keys in pull requests or issues
|
||||
5. ❌ Never share API keys or tokens in pull requests or issues
|
||||
6. ❌ Avoid logging workflow variables that might contain keys
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
@@ -4,7 +4,7 @@ Thank you for trying out the beta of our GitHub Action! This document outlines o
|
||||
|
||||
## Path to 1.0
|
||||
|
||||
- **Ability to see GitHub Action CI results** - This will enable Claude to look at CI failures and make updates to PRs to fix test failures, lint errors, and the like.
|
||||
- ~**Ability to see GitHub Action CI results** - This will enable Claude to look at CI failures and make updates to PRs to fix test failures, lint errors, and the like.~
|
||||
- **Cross-repo support** - Enable Claude to work across multiple repositories in a single session
|
||||
- **Ability to modify workflow files** - Let Claude update GitHub Actions workflows and other CI configuration files
|
||||
- **Support for workflow_dispatch and repository_dispatch events** - Dispatch Claude on events triggered via API from other workflows or from other services
|
||||
|
||||
142
action.yml
142
action.yml
@@ -12,9 +12,17 @@ inputs:
|
||||
assignee_trigger:
|
||||
description: "The assignee username that triggers the action (e.g. @claude)"
|
||||
required: false
|
||||
label_trigger:
|
||||
description: "The label that triggers the action (e.g. claude)"
|
||||
required: false
|
||||
default: "claude"
|
||||
base_branch:
|
||||
description: "The branch to use as the base/source when creating new branches (defaults to repository default branch)"
|
||||
required: false
|
||||
branch_prefix:
|
||||
description: "The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format)"
|
||||
required: false
|
||||
default: "claude/"
|
||||
|
||||
# Claude Code configuration
|
||||
model:
|
||||
@@ -23,6 +31,9 @@ inputs:
|
||||
anthropic_model:
|
||||
description: "DEPRECATED: Use 'model' instead. Model to use (provider-specific format required for Bedrock/Vertex)"
|
||||
required: false
|
||||
fallback_model:
|
||||
description: "Enable automatic fallback to specified model when primary model is unavailable"
|
||||
required: false
|
||||
allowed_tools:
|
||||
description: "Additional tools for Claude to use (the base GitHub tools will always be included)"
|
||||
required: false
|
||||
@@ -39,17 +50,32 @@ inputs:
|
||||
description: "Direct instruction for Claude (bypasses normal trigger detection)"
|
||||
required: false
|
||||
default: ""
|
||||
override_prompt:
|
||||
description: "Complete replacement of Claude's prompt with custom template (supports variable substitution)"
|
||||
required: false
|
||||
default: ""
|
||||
mcp_config:
|
||||
description: "Additional MCP configuration (JSON string) that merges with the built-in GitHub MCP servers"
|
||||
additional_permissions:
|
||||
description: "Additional permissions to enable. Currently supports 'actions: read' for viewing workflow results"
|
||||
required: false
|
||||
default: ""
|
||||
claude_env:
|
||||
description: "Custom environment variables to pass to Claude Code execution (YAML format)"
|
||||
required: false
|
||||
default: ""
|
||||
settings:
|
||||
description: "Claude Code settings as JSON string or path to settings JSON file"
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
# Auth configuration
|
||||
anthropic_api_key:
|
||||
description: "Anthropic API key (required for direct API, not needed for Bedrock/Vertex)"
|
||||
required: false
|
||||
claude_code_oauth_token:
|
||||
description: "Claude Code OAuth token (alternative to anthropic_api_key)"
|
||||
required: false
|
||||
github_token:
|
||||
description: "GitHub token with repo and pull request permissions (optional if using GitHub App)"
|
||||
required: false
|
||||
@@ -70,11 +96,26 @@ inputs:
|
||||
description: "Timeout in minutes for execution"
|
||||
required: false
|
||||
default: "30"
|
||||
use_sticky_comment:
|
||||
description: "Use just one comment to deliver issue/PR comments"
|
||||
required: false
|
||||
default: "false"
|
||||
use_commit_signing:
|
||||
description: "Enable commit signing using GitHub's commit signature verification. When false, Claude uses standard git commands"
|
||||
required: false
|
||||
default: "false"
|
||||
experimental_allowed_domains:
|
||||
description: "Restrict network access to these domains only (newline-separated). If not set, no restrictions are applied. Provider domains are auto-detected."
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
outputs:
|
||||
execution_file:
|
||||
description: "Path to the Claude Code execution output file"
|
||||
value: ${{ steps.claude-code.outputs.execution_file }}
|
||||
branch_name:
|
||||
description: "The branch created by Claude Code for this execution"
|
||||
value: ${{ steps.prepare.outputs.CLAUDE_BRANCH }}
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
@@ -98,45 +139,100 @@ runs:
|
||||
env:
|
||||
TRIGGER_PHRASE: ${{ inputs.trigger_phrase }}
|
||||
ASSIGNEE_TRIGGER: ${{ inputs.assignee_trigger }}
|
||||
LABEL_TRIGGER: ${{ inputs.label_trigger }}
|
||||
BASE_BRANCH: ${{ inputs.base_branch }}
|
||||
BRANCH_PREFIX: ${{ inputs.branch_prefix }}
|
||||
ALLOWED_TOOLS: ${{ inputs.allowed_tools }}
|
||||
DISALLOWED_TOOLS: ${{ inputs.disallowed_tools }}
|
||||
CUSTOM_INSTRUCTIONS: ${{ inputs.custom_instructions }}
|
||||
DIRECT_PROMPT: ${{ inputs.direct_prompt }}
|
||||
OVERRIDE_PROMPT: ${{ inputs.override_prompt }}
|
||||
MCP_CONFIG: ${{ inputs.mcp_config }}
|
||||
OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }}
|
||||
ACTIONS_TOKEN: ${{ github.token }}
|
||||
ADDITIONAL_PERMISSIONS: ${{ inputs.additional_permissions }}
|
||||
USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }}
|
||||
|
||||
- name: Setup Network Restrictions
|
||||
if: steps.prepare.outputs.contains_trigger == 'true' && inputs.experimental_allowed_domains != ''
|
||||
shell: bash
|
||||
run: |
|
||||
# Install and configure Squid proxy
|
||||
sudo apt-get update && sudo apt-get install -y squid
|
||||
|
||||
echo "${{ inputs.experimental_allowed_domains }}" > $RUNNER_TEMP/whitelist.txt
|
||||
|
||||
# Configure Squid
|
||||
sudo tee /etc/squid/squid.conf << EOF
|
||||
http_port 127.0.0.1:3128
|
||||
acl whitelist dstdomain "$RUNNER_TEMP/whitelist.txt"
|
||||
acl localhost src 127.0.0.1/32
|
||||
http_access allow localhost whitelist
|
||||
http_access deny all
|
||||
cache deny all
|
||||
EOF
|
||||
|
||||
# Stop any existing squid instance and start with our config
|
||||
sudo squid -k shutdown || true
|
||||
sleep 2
|
||||
sudo rm -f /run/squid.pid
|
||||
sudo squid -N -d 1 &
|
||||
sleep 5
|
||||
|
||||
# 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
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude-code
|
||||
if: steps.prepare.outputs.contains_trigger == 'true'
|
||||
uses: anthropics/claude-code-base-action@f382bd1ea00f26043eb461ebabebe0d850572a71 # v0.0.24
|
||||
with:
|
||||
prompt_file: ${{ runner.temp }}/claude-prompts/claude-prompt.txt
|
||||
allowed_tools: ${{ env.ALLOWED_TOOLS }}
|
||||
disallowed_tools: ${{ env.DISALLOWED_TOOLS }}
|
||||
timeout_minutes: ${{ inputs.timeout_minutes }}
|
||||
max_turns: ${{ inputs.max_turns }}
|
||||
model: ${{ inputs.model || inputs.anthropic_model }}
|
||||
mcp_config: ${{ steps.prepare.outputs.mcp_config }}
|
||||
use_bedrock: ${{ inputs.use_bedrock }}
|
||||
use_vertex: ${{ inputs.use_vertex }}
|
||||
anthropic_api_key: ${{ inputs.anthropic_api_key }}
|
||||
claude_env: ${{ inputs.claude_env }}
|
||||
shell: bash
|
||||
run: |
|
||||
# Install Claude Code globally
|
||||
npm install -g @anthropic-ai/claude-code@1.0.57
|
||||
|
||||
# Run the base-action
|
||||
cd ${GITHUB_ACTION_PATH}/base-action
|
||||
bun install
|
||||
cd -
|
||||
bun run ${GITHUB_ACTION_PATH}/base-action/src/index.ts
|
||||
env:
|
||||
# Base-action inputs
|
||||
CLAUDE_CODE_ACTION: "1"
|
||||
INPUT_PROMPT_FILE: ${{ runner.temp }}/claude-prompts/claude-prompt.txt
|
||||
INPUT_ALLOWED_TOOLS: ${{ env.ALLOWED_TOOLS }}
|
||||
INPUT_DISALLOWED_TOOLS: ${{ env.DISALLOWED_TOOLS }}
|
||||
INPUT_MAX_TURNS: ${{ inputs.max_turns }}
|
||||
INPUT_MCP_CONFIG: ${{ steps.prepare.outputs.mcp_config }}
|
||||
INPUT_SETTINGS: ${{ inputs.settings }}
|
||||
INPUT_SYSTEM_PROMPT: ""
|
||||
INPUT_APPEND_SYSTEM_PROMPT: ""
|
||||
INPUT_TIMEOUT_MINUTES: ${{ inputs.timeout_minutes }}
|
||||
INPUT_CLAUDE_ENV: ${{ inputs.claude_env }}
|
||||
INPUT_FALLBACK_MODEL: ${{ inputs.fallback_model }}
|
||||
|
||||
# Model configuration
|
||||
ANTHROPIC_MODEL: ${{ inputs.model || inputs.anthropic_model }}
|
||||
GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }}
|
||||
NODE_VERSION: ${{ env.NODE_VERSION }}
|
||||
|
||||
# Provider configuration
|
||||
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ inputs.claude_code_oauth_token }}
|
||||
ANTHROPIC_BASE_URL: ${{ env.ANTHROPIC_BASE_URL }}
|
||||
CLAUDE_CODE_USE_BEDROCK: ${{ inputs.use_bedrock == 'true' && '1' || '' }}
|
||||
CLAUDE_CODE_USE_VERTEX: ${{ inputs.use_vertex == 'true' && '1' || '' }}
|
||||
|
||||
# AWS configuration
|
||||
AWS_REGION: ${{ env.AWS_REGION }}
|
||||
AWS_ACCESS_KEY_ID: ${{ env.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ env.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_SESSION_TOKEN: ${{ env.AWS_SESSION_TOKEN }}
|
||||
ANTHROPIC_BEDROCK_BASE_URL: ${{ env.ANTHROPIC_BEDROCK_BASE_URL }}
|
||||
ANTHROPIC_BEDROCK_BASE_URL: ${{ env.ANTHROPIC_BEDROCK_BASE_URL || (env.AWS_REGION && format('https://bedrock-runtime.{0}.amazonaws.com', env.AWS_REGION)) }}
|
||||
|
||||
# GCP configuration
|
||||
ANTHROPIC_VERTEX_PROJECT_ID: ${{ env.ANTHROPIC_VERTEX_PROJECT_ID }}
|
||||
@@ -170,15 +266,25 @@ runs:
|
||||
TRIGGER_USERNAME: ${{ github.event.comment.user.login || github.event.issue.user.login || github.event.pull_request.user.login || github.event.sender.login || github.triggering_actor || github.actor || '' }}
|
||||
PREPARE_SUCCESS: ${{ steps.prepare.outcome == 'success' }}
|
||||
PREPARE_ERROR: ${{ steps.prepare.outputs.prepare_error || '' }}
|
||||
USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }}
|
||||
USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }}
|
||||
|
||||
- name: Display Claude Code Report
|
||||
if: steps.prepare.outputs.contains_trigger == 'true' && steps.claude-code.outputs.execution_file != ''
|
||||
shell: bash
|
||||
run: |
|
||||
echo "## Claude Code Report" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```json' >> $GITHUB_STEP_SUMMARY
|
||||
cat "${{ steps.claude-code.outputs.execution_file }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
# Try to format the turns, but if it fails, dump the raw JSON
|
||||
if bun run ${{ github.action_path }}/src/entrypoints/format-turns.ts "${{ steps.claude-code.outputs.execution_file }}" >> $GITHUB_STEP_SUMMARY 2>/dev/null; then
|
||||
echo "Successfully formatted Claude Code report"
|
||||
else
|
||||
echo "## Claude Code Report (Raw Output)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Failed to format output (please report). Here's the raw JSON:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```json' >> $GITHUB_STEP_SUMMARY
|
||||
cat "${{ steps.claude-code.outputs.execution_file }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: Revoke app token
|
||||
if: always() && inputs.github_token == ''
|
||||
|
||||
4
base-action/.gitignore
vendored
Normal file
4
base-action/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
|
||||
**/.claude/settings.local.json
|
||||
2
base-action/.npmrc
Normal file
2
base-action/.npmrc
Normal file
@@ -0,0 +1,2 @@
|
||||
engine-strict=true
|
||||
registry=https://registry.npmjs.org/
|
||||
1
base-action/.prettierrc
Normal file
1
base-action/.prettierrc
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
60
base-action/CLAUDE.md
Normal file
60
base-action/CLAUDE.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# CLAUDE.md
|
||||
|
||||
## Common Commands
|
||||
|
||||
### Development Commands
|
||||
|
||||
- Build/Type check: `bun run typecheck`
|
||||
- Format code: `bun run format`
|
||||
- Check formatting: `bun run format:check`
|
||||
- Run tests: `bun test`
|
||||
- Install dependencies: `bun install`
|
||||
|
||||
### Action Testing
|
||||
|
||||
- Test action locally: `./test-local.sh`
|
||||
- Test specific file: `bun test test/prepare-prompt.test.ts`
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
This is a GitHub Action that allows running Claude Code within GitHub workflows. The action consists of:
|
||||
|
||||
### Core Components
|
||||
|
||||
1. **Action Definition** (`action.yml`): Defines inputs, outputs, and the composite action steps
|
||||
2. **Prompt Preparation** (`src/index.ts`): Runs Claude Code with specified arguments
|
||||
|
||||
### Key Design Patterns
|
||||
|
||||
- Uses Bun runtime for development and execution
|
||||
- Named pipes for IPC between prompt input and Claude process
|
||||
- JSON streaming output format for execution logs
|
||||
- Composite action pattern to orchestrate multiple steps
|
||||
- Provider-agnostic design supporting Anthropic API, AWS Bedrock, and Google Vertex AI
|
||||
|
||||
## Provider Authentication
|
||||
|
||||
1. **Anthropic API** (default): Requires API key via `anthropic_api_key` input
|
||||
2. **AWS Bedrock**: Uses OIDC authentication when `use_bedrock: true`
|
||||
3. **Google Vertex AI**: Uses OIDC authentication when `use_vertex: true`
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Local Testing
|
||||
|
||||
- Use `act` tool to run GitHub Actions workflows locally
|
||||
- `test-local.sh` script automates local testing setup
|
||||
- Requires `ANTHROPIC_API_KEY` environment variable
|
||||
|
||||
### Test Structure
|
||||
|
||||
- Unit tests for configuration logic
|
||||
- Integration tests for prompt preparation
|
||||
- Full workflow tests in `.github/workflows/test-action.yml`
|
||||
|
||||
## Important Technical Details
|
||||
|
||||
- Uses `mkfifo` to create named pipes for prompt input
|
||||
- Outputs execution logs as JSON to `/tmp/claude-execution-output.json`
|
||||
- Timeout enforcement via `timeout` command wrapper
|
||||
- Strict TypeScript configuration with Bun-specific settings
|
||||
128
base-action/CODE_OF_CONDUCT.md
Normal file
128
base-action/CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
- Demonstrating empathy and kindness toward other people
|
||||
- Being respectful of differing opinions, viewpoints, and experiences
|
||||
- Giving and gracefully accepting constructive feedback
|
||||
- Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
- Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
- The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
claude-code-action-coc@anthropic.com.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
136
base-action/CONTRIBUTING.md
Normal file
136
base-action/CONTRIBUTING.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# Contributing to Claude Code Base Action
|
||||
|
||||
Thank you for your interest in contributing to Claude Code Base Action! This document provides guidelines and instructions for contributing to the project.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Bun](https://bun.sh/) runtime
|
||||
- [Docker](https://www.docker.com/) (for running GitHub Actions locally)
|
||||
- [act](https://github.com/nektos/act) (installed automatically by our test script)
|
||||
- An Anthropic API key (for testing)
|
||||
|
||||
### Setup
|
||||
|
||||
1. Fork the repository on GitHub and clone your fork:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/your-username/claude-code-base-action.git
|
||||
cd claude-code-base-action
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
3. Set up your Anthropic API key:
|
||||
```bash
|
||||
export ANTHROPIC_API_KEY="your-api-key-here"
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Available Scripts
|
||||
|
||||
- `bun test` - Run all tests
|
||||
- `bun run typecheck` - Type check the code
|
||||
- `bun run format` - Format code with Prettier
|
||||
- `bun run format:check` - Check code formatting
|
||||
|
||||
## Testing
|
||||
|
||||
### Running Tests Locally
|
||||
|
||||
1. **Unit Tests**:
|
||||
|
||||
```bash
|
||||
bun test
|
||||
```
|
||||
|
||||
2. **Integration Tests** (using GitHub Actions locally):
|
||||
|
||||
```bash
|
||||
./test-local.sh
|
||||
```
|
||||
|
||||
This script:
|
||||
|
||||
- Installs `act` if not present (requires Homebrew on macOS)
|
||||
- Runs the GitHub Action workflow locally using Docker
|
||||
- Requires your `ANTHROPIC_API_KEY` to be set
|
||||
|
||||
On Apple Silicon Macs, the script automatically adds the `--container-architecture linux/amd64` flag to avoid compatibility issues.
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
1. Create a new branch from `main`:
|
||||
|
||||
```bash
|
||||
git checkout -b feature/your-feature-name
|
||||
```
|
||||
|
||||
2. Make your changes and commit them:
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "feat: add new feature"
|
||||
```
|
||||
|
||||
3. Run tests and formatting:
|
||||
|
||||
```bash
|
||||
bun test
|
||||
bun run typecheck
|
||||
bun run format:check
|
||||
```
|
||||
|
||||
4. Push your branch and create a Pull Request:
|
||||
|
||||
```bash
|
||||
git push origin feature/your-feature-name
|
||||
```
|
||||
|
||||
5. Ensure all CI checks pass
|
||||
|
||||
6. Request review from maintainers
|
||||
|
||||
## Action Development
|
||||
|
||||
### Testing Your Changes
|
||||
|
||||
When modifying the action:
|
||||
|
||||
1. Test locally with the test script:
|
||||
|
||||
```bash
|
||||
./test-local.sh
|
||||
```
|
||||
|
||||
2. Test in a real GitHub Actions workflow by:
|
||||
- Creating a test repository
|
||||
- Using your branch as the action source:
|
||||
```yaml
|
||||
uses: your-username/claude-code-base-action@your-branch
|
||||
```
|
||||
|
||||
### Debugging
|
||||
|
||||
- Use `console.log` for debugging in development
|
||||
- Check GitHub Actions logs for runtime issues
|
||||
- Use `act` with `-v` flag for verbose output:
|
||||
```bash
|
||||
act push -v --secret ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY"
|
||||
```
|
||||
|
||||
## Common Issues
|
||||
|
||||
### Docker Issues
|
||||
|
||||
Make sure Docker is running before using `act`. You can check with:
|
||||
|
||||
```bash
|
||||
docker ps
|
||||
```
|
||||
21
base-action/LICENSE
Normal file
21
base-action/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Anthropic, PBC
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
11
base-action/MIRROR_DISCLAIMER.md
Normal file
11
base-action/MIRROR_DISCLAIMER.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# ⚠️ This is a Mirror Repository
|
||||
|
||||
This repository is an automated mirror of the `base-action` directory from [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action).
|
||||
|
||||
**Do not submit PRs or issues to this repository.** Instead, please contribute to the main repository:
|
||||
|
||||
- 🐛 [Report issues](https://github.com/anthropics/claude-code-action/issues)
|
||||
- 🔧 [Submit pull requests](https://github.com/anthropics/claude-code-action/pulls)
|
||||
- 📖 [View documentation](https://github.com/anthropics/claude-code-action#readme)
|
||||
|
||||
---
|
||||
523
base-action/README.md
Normal file
523
base-action/README.md
Normal file
@@ -0,0 +1,523 @@
|
||||
# Claude Code Base Action
|
||||
|
||||
This GitHub Action allows you to run [Claude Code](https://www.anthropic.com/claude-code) within your GitHub Actions workflows. You can use this to build any custom workflow on top of Claude Code.
|
||||
|
||||
For simply tagging @claude in issues and PRs out of the box, [check out the Claude Code action and GitHub app](https://github.com/anthropics/claude-code-action).
|
||||
|
||||
## Usage
|
||||
|
||||
Add the following to your workflow file:
|
||||
|
||||
```yaml
|
||||
# Using a direct prompt
|
||||
- name: Run Claude Code with direct prompt
|
||||
uses: anthropics/claude-code-base-action@beta
|
||||
with:
|
||||
prompt: "Your prompt here"
|
||||
allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
# Or using a prompt from a file
|
||||
- name: Run Claude Code with prompt file
|
||||
uses: anthropics/claude-code-base-action@beta
|
||||
with:
|
||||
prompt_file: "/path/to/prompt.txt"
|
||||
allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
# Or limiting the conversation turns
|
||||
- name: Run Claude Code with limited turns
|
||||
uses: anthropics/claude-code-base-action@beta
|
||||
with:
|
||||
prompt: "Your prompt here"
|
||||
allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool"
|
||||
max_turns: "5" # Limit conversation to 5 turns
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
# Using custom system prompts
|
||||
- name: Run Claude Code with custom system prompt
|
||||
uses: anthropics/claude-code-base-action@beta
|
||||
with:
|
||||
prompt: "Build a REST API"
|
||||
system_prompt: "You are a senior backend engineer. Focus on security, performance, and maintainability."
|
||||
allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
# Or appending to the default system prompt
|
||||
- name: Run Claude Code with appended system prompt
|
||||
uses: anthropics/claude-code-base-action@beta
|
||||
with:
|
||||
prompt: "Create a database schema"
|
||||
append_system_prompt: "After writing code, be sure to code review yourself."
|
||||
allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
# Using custom environment variables
|
||||
- name: Run Claude Code with custom environment variables
|
||||
uses: anthropics/claude-code-base-action@beta
|
||||
with:
|
||||
prompt: "Deploy to staging environment"
|
||||
claude_env: |
|
||||
ENVIRONMENT: staging
|
||||
API_URL: https://api-staging.example.com
|
||||
DEBUG: true
|
||||
allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
# Using fallback model for handling API errors
|
||||
- name: Run Claude Code with fallback model
|
||||
uses: anthropics/claude-code-base-action@beta
|
||||
with:
|
||||
prompt: "Review and fix TypeScript errors"
|
||||
model: "claude-opus-4-20250514"
|
||||
fallback_model: "claude-sonnet-4-20250514"
|
||||
allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
# Using OAuth token instead of API key
|
||||
- name: Run Claude Code with OAuth token
|
||||
uses: anthropics/claude-code-base-action@beta
|
||||
with:
|
||||
prompt: "Update dependencies"
|
||||
allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool"
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
```
|
||||
|
||||
## Inputs
|
||||
|
||||
| Input | Description | Required | Default |
|
||||
| ------------------------- | ------------------------------------------------------------------------------------------------- | -------- | ---------------------------- |
|
||||
| `prompt` | The prompt to send to Claude Code | No\* | '' |
|
||||
| `prompt_file` | Path to a file containing the prompt to send to Claude Code | No\* | '' |
|
||||
| `allowed_tools` | Comma-separated list of allowed tools for Claude Code to use | No | '' |
|
||||
| `disallowed_tools` | Comma-separated list of disallowed tools that Claude Code cannot use | No | '' |
|
||||
| `max_turns` | Maximum number of conversation turns (default: no limit) | No | '' |
|
||||
| `mcp_config` | Path to the MCP configuration JSON file, or MCP configuration JSON string | No | '' |
|
||||
| `settings` | Path to Claude Code settings JSON file, or settings JSON string | No | '' |
|
||||
| `system_prompt` | Override system prompt | No | '' |
|
||||
| `append_system_prompt` | Append to system prompt | No | '' |
|
||||
| `claude_env` | Custom environment variables to pass to Claude Code execution (YAML multiline format) | No | '' |
|
||||
| `model` | Model to use (provider-specific format required for Bedrock/Vertex) | No | 'claude-4-0-sonnet-20250219' |
|
||||
| `anthropic_model` | DEPRECATED: Use 'model' instead | No | 'claude-4-0-sonnet-20250219' |
|
||||
| `fallback_model` | Enable automatic fallback to specified model when default model is overloaded | No | '' |
|
||||
| `timeout_minutes` | Timeout in minutes for Claude Code execution | No | '10' |
|
||||
| `anthropic_api_key` | Anthropic API key (required for direct Anthropic API) | No | '' |
|
||||
| `claude_code_oauth_token` | Claude Code OAuth token (alternative to anthropic_api_key) | No | '' |
|
||||
| `use_bedrock` | Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API | No | 'false' |
|
||||
| `use_vertex` | Use Google Vertex AI with OIDC authentication instead of direct Anthropic API | No | 'false' |
|
||||
| `use_node_cache` | Whether to use Node.js dependency caching (set to true only for Node.js projects with lock files) | No | 'false' |
|
||||
|
||||
\*Either `prompt` or `prompt_file` must be provided, but not both.
|
||||
|
||||
## Outputs
|
||||
|
||||
| Output | Description |
|
||||
| ---------------- | ---------------------------------------------------------- |
|
||||
| `conclusion` | Execution status of Claude Code ('success' or 'failure') |
|
||||
| `execution_file` | Path to the JSON file containing Claude Code execution log |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
The following environment variables can be used to configure the action:
|
||||
|
||||
| Variable | Description | Default |
|
||||
| -------------- | ----------------------------------------------------- | ------- |
|
||||
| `NODE_VERSION` | Node.js version to use (e.g., '18.x', '20.x', '22.x') | '18.x' |
|
||||
|
||||
Example usage:
|
||||
|
||||
```yaml
|
||||
- name: Run Claude Code with Node.js 20
|
||||
uses: anthropics/claude-code-base-action@beta
|
||||
env:
|
||||
NODE_VERSION: "20.x"
|
||||
with:
|
||||
prompt: "Your prompt here"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
```
|
||||
|
||||
## Custom Environment Variables
|
||||
|
||||
You can pass custom environment variables to Claude Code execution using the `claude_env` input. This allows Claude to access environment-specific configuration during its execution.
|
||||
|
||||
The `claude_env` input accepts YAML multiline format with key-value pairs:
|
||||
|
||||
```yaml
|
||||
- name: Deploy with custom environment
|
||||
uses: anthropics/claude-code-base-action@beta
|
||||
with:
|
||||
prompt: "Deploy the application to the staging environment"
|
||||
claude_env: |
|
||||
ENVIRONMENT: staging
|
||||
API_BASE_URL: https://api-staging.example.com
|
||||
DATABASE_URL: ${{ secrets.STAGING_DB_URL }}
|
||||
DEBUG: true
|
||||
LOG_LEVEL: debug
|
||||
allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
```
|
||||
|
||||
### Features:
|
||||
|
||||
- **YAML Format**: Use standard YAML key-value syntax (`KEY: value`)
|
||||
- **Multiline Support**: Define multiple environment variables in a single input
|
||||
- **Comments**: Lines starting with `#` are ignored
|
||||
- **GitHub Secrets**: Can reference GitHub secrets using `${{ secrets.SECRET_NAME }}`
|
||||
- **Runtime Access**: Environment variables are available to Claude during execution
|
||||
|
||||
### Example Use Cases:
|
||||
|
||||
```yaml
|
||||
# Development configuration
|
||||
claude_env: |
|
||||
NODE_ENV: development
|
||||
API_URL: http://localhost:3000
|
||||
DEBUG: true
|
||||
|
||||
# Production deployment
|
||||
claude_env: |
|
||||
NODE_ENV: production
|
||||
API_URL: https://api.example.com
|
||||
DATABASE_URL: ${{ secrets.PROD_DB_URL }}
|
||||
REDIS_URL: ${{ secrets.REDIS_URL }}
|
||||
|
||||
# Feature flags and configuration
|
||||
claude_env: |
|
||||
FEATURE_NEW_UI: enabled
|
||||
MAX_RETRIES: 3
|
||||
TIMEOUT_MS: 5000
|
||||
```
|
||||
|
||||
## Using Settings Configuration
|
||||
|
||||
You can provide Claude Code settings configuration in two ways:
|
||||
|
||||
### Option 1: Settings Configuration File
|
||||
|
||||
Provide a path to a JSON file containing Claude Code settings:
|
||||
|
||||
```yaml
|
||||
- name: Run Claude Code with settings file
|
||||
uses: anthropics/claude-code-base-action@beta
|
||||
with:
|
||||
prompt: "Your prompt here"
|
||||
settings: "path/to/settings.json"
|
||||
allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
```
|
||||
|
||||
### Option 2: Inline Settings Configuration
|
||||
|
||||
Provide the settings configuration directly as a JSON string:
|
||||
|
||||
```yaml
|
||||
- name: Run Claude Code with inline settings
|
||||
uses: anthropics/claude-code-base-action@beta
|
||||
with:
|
||||
prompt: "Your prompt here"
|
||||
settings: |
|
||||
{
|
||||
"model": "claude-opus-4-20250514",
|
||||
"env": {
|
||||
"DEBUG": "true",
|
||||
"API_URL": "https://api.example.com"
|
||||
},
|
||||
"permissions": {
|
||||
"allow": ["Bash", "Read"],
|
||||
"deny": ["WebFetch"]
|
||||
},
|
||||
"hooks": {
|
||||
"PreToolUse": [{
|
||||
"matcher": "Bash",
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": "echo Running bash command..."
|
||||
}]
|
||||
}]
|
||||
}
|
||||
}
|
||||
allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
```
|
||||
|
||||
The settings file supports all Claude Code settings options including:
|
||||
|
||||
- `model`: Override the default model
|
||||
- `env`: Environment variables for the session
|
||||
- `permissions`: Tool usage permissions
|
||||
- `hooks`: Pre/post tool execution hooks
|
||||
- `includeCoAuthoredBy`: Include co-authored-by in git commits
|
||||
- And more...
|
||||
|
||||
**Note**: The `enableAllProjectMcpServers` setting is always set to `true` by this action to ensure MCP servers work correctly.
|
||||
|
||||
## Using MCP Config
|
||||
|
||||
You can provide MCP configuration in two ways:
|
||||
|
||||
### Option 1: MCP Configuration File
|
||||
|
||||
Provide a path to a JSON file containing MCP configuration:
|
||||
|
||||
```yaml
|
||||
- name: Run Claude Code with MCP config file
|
||||
uses: anthropics/claude-code-base-action@beta
|
||||
with:
|
||||
prompt: "Your prompt here"
|
||||
mcp_config: "path/to/mcp-config.json"
|
||||
allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
```
|
||||
|
||||
### Option 2: Inline MCP Configuration
|
||||
|
||||
Provide the MCP configuration directly as a JSON string:
|
||||
|
||||
```yaml
|
||||
- name: Run Claude Code with inline MCP config
|
||||
uses: anthropics/claude-code-base-action@beta
|
||||
with:
|
||||
prompt: "Your prompt here"
|
||||
mcp_config: |
|
||||
{
|
||||
"mcpServers": {
|
||||
"server-name": {
|
||||
"command": "node",
|
||||
"args": ["./server.js"],
|
||||
"env": {
|
||||
"API_KEY": "your-api-key"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
```
|
||||
|
||||
The MCP config file should follow this format:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"server-name": {
|
||||
"command": "node",
|
||||
"args": ["./server.js"],
|
||||
"env": {
|
||||
"API_KEY": "your-api-key"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You can combine MCP config with other inputs like allowed tools:
|
||||
|
||||
```yaml
|
||||
# Using multiple inputs together
|
||||
- name: Run Claude Code with MCP and custom tools
|
||||
uses: anthropics/claude-code-base-action@beta
|
||||
with:
|
||||
prompt: "Access the custom MCP server and use its tools"
|
||||
mcp_config: "mcp-config.json"
|
||||
allowed_tools: "Bash(git:*),View,mcp__server-name__custom_tool"
|
||||
timeout_minutes: "15"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
```
|
||||
|
||||
## Example: PR Code Review
|
||||
|
||||
```yaml
|
||||
name: Claude Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
|
||||
jobs:
|
||||
code-review:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Run Code Review with Claude
|
||||
id: code-review
|
||||
uses: anthropics/claude-code-base-action@beta
|
||||
with:
|
||||
prompt: "Review the PR changes. Focus on code quality, potential bugs, and performance issues. Suggest improvements where appropriate. Write your review as markdown text."
|
||||
allowed_tools: "Bash(git diff --name-only HEAD~1),Bash(git diff HEAD~1),View,GlobTool,GrepTool,Write"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
- name: Extract and Comment PR Review
|
||||
if: steps.code-review.outputs.conclusion == 'success'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const executionFile = '${{ steps.code-review.outputs.execution_file }}';
|
||||
const executionLog = JSON.parse(fs.readFileSync(executionFile, 'utf8'));
|
||||
|
||||
// Extract the review content from the execution log
|
||||
// The execution log contains the full conversation including Claude's responses
|
||||
let review = '';
|
||||
|
||||
// Find the last assistant message which should contain the review
|
||||
for (let i = executionLog.length - 1; i >= 0; i--) {
|
||||
if (executionLog[i].role === 'assistant') {
|
||||
review = executionLog[i].content;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (review) {
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: "## Claude Code Review\n\n" + review + "\n\n*Generated by Claude Code*"
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Check out additional examples in [`./examples`](./examples).
|
||||
|
||||
## Using Cloud Providers
|
||||
|
||||
You can authenticate with Claude using any of these methods:
|
||||
|
||||
1. Direct Anthropic API (default) - requires API key or OAuth token
|
||||
2. Amazon Bedrock - requires OIDC authentication and automatically uses cross-region inference profiles
|
||||
3. Google Vertex AI - requires OIDC authentication
|
||||
|
||||
**Note**:
|
||||
|
||||
- Bedrock and Vertex use OIDC authentication exclusively
|
||||
- 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
|
||||
- The Bedrock API endpoint URL is automatically constructed using the AWS_REGION environment variable (e.g., `https://bedrock-runtime.us-west-2.amazonaws.com`)
|
||||
- You can override the Bedrock API endpoint URL by setting the `ANTHROPIC_BEDROCK_BASE_URL` environment variable
|
||||
|
||||
### Model Configuration
|
||||
|
||||
Use provider-specific model names based on your chosen provider:
|
||||
|
||||
```yaml
|
||||
# For direct Anthropic API (default)
|
||||
- name: Run Claude Code with Anthropic API
|
||||
uses: anthropics/claude-code-base-action@beta
|
||||
with:
|
||||
prompt: "Your prompt here"
|
||||
model: "claude-3-7-sonnet-20250219"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
# For Amazon Bedrock (requires OIDC authentication)
|
||||
- name: Configure AWS Credentials (OIDC)
|
||||
uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }}
|
||||
aws-region: us-west-2
|
||||
|
||||
- name: Run Claude Code with Bedrock
|
||||
uses: anthropics/claude-code-base-action@beta
|
||||
with:
|
||||
prompt: "Your prompt here"
|
||||
model: "anthropic.claude-3-7-sonnet-20250219-v1:0"
|
||||
use_bedrock: "true"
|
||||
|
||||
# For Google Vertex AI (requires OIDC authentication)
|
||||
- name: Authenticate to Google Cloud
|
||||
uses: google-github-actions/auth@v2
|
||||
with:
|
||||
workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }}
|
||||
service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }}
|
||||
|
||||
- name: Run Claude Code with Vertex AI
|
||||
uses: anthropics/claude-code-base-action@beta
|
||||
with:
|
||||
prompt: "Your prompt here"
|
||||
model: "claude-3-7-sonnet@20250219"
|
||||
use_vertex: "true"
|
||||
```
|
||||
|
||||
## Example: Using OIDC Authentication for AWS Bedrock
|
||||
|
||||
This example shows how to use OIDC authentication with AWS Bedrock:
|
||||
|
||||
```yaml
|
||||
- name: Configure AWS Credentials (OIDC)
|
||||
uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }}
|
||||
aws-region: us-west-2
|
||||
|
||||
- name: Run Claude Code with AWS OIDC
|
||||
uses: anthropics/claude-code-base-action@beta
|
||||
with:
|
||||
prompt: "Your prompt here"
|
||||
use_bedrock: "true"
|
||||
model: "anthropic.claude-3-7-sonnet-20250219-v1:0"
|
||||
allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool"
|
||||
```
|
||||
|
||||
## Example: Using OIDC Authentication for GCP Vertex AI
|
||||
|
||||
This example shows how to use OIDC authentication with GCP Vertex AI:
|
||||
|
||||
```yaml
|
||||
- name: Authenticate to Google Cloud
|
||||
uses: google-github-actions/auth@v2
|
||||
with:
|
||||
workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }}
|
||||
service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }}
|
||||
|
||||
- name: Run Claude Code with GCP OIDC
|
||||
uses: anthropics/claude-code-base-action@beta
|
||||
with:
|
||||
prompt: "Your prompt here"
|
||||
use_vertex: "true"
|
||||
model: "claude-3-7-sonnet@20250219"
|
||||
allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool"
|
||||
```
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
**⚠️ IMPORTANT: Never commit API keys directly to your repository! Always use GitHub Actions secrets.**
|
||||
|
||||
To securely use your Anthropic API key:
|
||||
|
||||
1. Add your API key as a repository secret:
|
||||
|
||||
- Go to your repository's Settings
|
||||
- Navigate to "Secrets and variables" → "Actions"
|
||||
- Click "New repository secret"
|
||||
- Name it `ANTHROPIC_API_KEY`
|
||||
- Paste your API key as the value
|
||||
|
||||
2. Reference the secret in your workflow:
|
||||
```yaml
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
```
|
||||
|
||||
**Never do this:**
|
||||
|
||||
```yaml
|
||||
# ❌ WRONG - Exposes your API key
|
||||
anthropic_api_key: "sk-ant-..."
|
||||
```
|
||||
|
||||
**Always do this:**
|
||||
|
||||
```yaml
|
||||
# ✅ CORRECT - Uses GitHub secrets
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
```
|
||||
|
||||
This applies to all sensitive values including API keys, access tokens, and credentials.
|
||||
We also recommend that you always use short-lived tokens when possible
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License—see the LICENSE file for details.
|
||||
166
base-action/action.yml
Normal file
166
base-action/action.yml
Normal file
@@ -0,0 +1,166 @@
|
||||
name: "Claude Code Base Action"
|
||||
description: "Run Claude Code in GitHub Actions workflows"
|
||||
branding:
|
||||
icon: "code"
|
||||
color: "orange"
|
||||
|
||||
inputs:
|
||||
# Claude Code arguments
|
||||
prompt:
|
||||
description: "The prompt to send to Claude Code (mutually exclusive with prompt_file)"
|
||||
required: false
|
||||
default: ""
|
||||
prompt_file:
|
||||
description: "Path to a file containing the prompt to send to Claude Code (mutually exclusive with prompt)"
|
||||
required: false
|
||||
default: ""
|
||||
allowed_tools:
|
||||
description: "Comma-separated list of allowed tools for Claude Code to use"
|
||||
required: false
|
||||
default: ""
|
||||
disallowed_tools:
|
||||
description: "Comma-separated list of disallowed tools that Claude Code cannot use"
|
||||
required: false
|
||||
default: ""
|
||||
max_turns:
|
||||
description: "Maximum number of conversation turns (default: no limit)"
|
||||
required: false
|
||||
default: ""
|
||||
mcp_config:
|
||||
description: "MCP configuration as JSON string or path to MCP configuration JSON file"
|
||||
required: false
|
||||
default: ""
|
||||
settings:
|
||||
description: "Claude Code settings as JSON string or path to settings JSON file"
|
||||
required: false
|
||||
default: ""
|
||||
system_prompt:
|
||||
description: "Override system prompt"
|
||||
required: false
|
||||
default: ""
|
||||
append_system_prompt:
|
||||
description: "Append to system prompt"
|
||||
required: false
|
||||
default: ""
|
||||
model:
|
||||
description: "Model to use (provider-specific format required for Bedrock/Vertex)"
|
||||
required: false
|
||||
anthropic_model:
|
||||
description: "DEPRECATED: Use 'model' instead. Model to use (provider-specific format required for Bedrock/Vertex)"
|
||||
required: false
|
||||
fallback_model:
|
||||
description: "Enable automatic fallback to specified model when default model is unavailable"
|
||||
required: false
|
||||
claude_env:
|
||||
description: "Custom environment variables to pass to Claude Code execution (YAML multiline format)"
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
# Action settings
|
||||
timeout_minutes:
|
||||
description: "Timeout in minutes for Claude Code execution"
|
||||
required: false
|
||||
default: "10"
|
||||
|
||||
# Authentication settings
|
||||
anthropic_api_key:
|
||||
description: "Anthropic API key (required for direct Anthropic API)"
|
||||
required: false
|
||||
default: ""
|
||||
claude_code_oauth_token:
|
||||
description: "Claude Code OAuth token (alternative to anthropic_api_key)"
|
||||
required: false
|
||||
default: ""
|
||||
use_bedrock:
|
||||
description: "Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API"
|
||||
required: false
|
||||
default: "false"
|
||||
use_vertex:
|
||||
description: "Use Google Vertex AI with OIDC authentication instead of direct Anthropic API"
|
||||
required: false
|
||||
default: "false"
|
||||
|
||||
use_node_cache:
|
||||
description: "Whether to use Node.js dependency caching (set to true only for Node.js projects with lock files)"
|
||||
required: false
|
||||
default: "false"
|
||||
|
||||
outputs:
|
||||
conclusion:
|
||||
description: "Execution status of Claude Code ('success' or 'failure')"
|
||||
value: ${{ steps.run_claude.outputs.conclusion }}
|
||||
execution_file:
|
||||
description: "Path to the JSON file containing Claude Code execution log"
|
||||
value: ${{ steps.run_claude.outputs.execution_file }}
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # https://github.com/actions/setup-node/releases/tag/v4.4.0
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION || '18.x' }}
|
||||
cache: ${{ inputs.use_node_cache == 'true' && 'npm' || '' }}
|
||||
|
||||
- name: Install Bun
|
||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # https://github.com/oven-sh/setup-bun/releases/tag/v2.0.2
|
||||
with:
|
||||
bun-version: 1.2.11
|
||||
|
||||
- name: Install Dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
cd ${GITHUB_ACTION_PATH}
|
||||
bun install
|
||||
|
||||
- name: Install Claude Code
|
||||
shell: bash
|
||||
run: npm install -g @anthropic-ai/claude-code@1.0.57
|
||||
|
||||
- name: Run Claude Code Action
|
||||
shell: bash
|
||||
id: run_claude
|
||||
run: |
|
||||
# Change to CLAUDE_WORKING_DIR if set (for running in custom directories)
|
||||
if [ -n "$CLAUDE_WORKING_DIR" ]; then
|
||||
echo "Changing directory to CLAUDE_WORKING_DIR: $CLAUDE_WORKING_DIR"
|
||||
cd "$CLAUDE_WORKING_DIR"
|
||||
fi
|
||||
bun run ${GITHUB_ACTION_PATH}/src/index.ts
|
||||
env:
|
||||
# Model configuration
|
||||
CLAUDE_CODE_ACTION: "1"
|
||||
ANTHROPIC_MODEL: ${{ inputs.model || inputs.anthropic_model }}
|
||||
INPUT_PROMPT: ${{ inputs.prompt }}
|
||||
INPUT_PROMPT_FILE: ${{ inputs.prompt_file }}
|
||||
INPUT_ALLOWED_TOOLS: ${{ inputs.allowed_tools }}
|
||||
INPUT_DISALLOWED_TOOLS: ${{ inputs.disallowed_tools }}
|
||||
INPUT_MAX_TURNS: ${{ inputs.max_turns }}
|
||||
INPUT_MCP_CONFIG: ${{ inputs.mcp_config }}
|
||||
INPUT_SETTINGS: ${{ inputs.settings }}
|
||||
INPUT_SYSTEM_PROMPT: ${{ inputs.system_prompt }}
|
||||
INPUT_APPEND_SYSTEM_PROMPT: ${{ inputs.append_system_prompt }}
|
||||
INPUT_TIMEOUT_MINUTES: ${{ inputs.timeout_minutes }}
|
||||
INPUT_CLAUDE_ENV: ${{ inputs.claude_env }}
|
||||
INPUT_FALLBACK_MODEL: ${{ inputs.fallback_model }}
|
||||
|
||||
# Provider configuration
|
||||
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ inputs.claude_code_oauth_token }}
|
||||
ANTHROPIC_BASE_URL: ${{ env.ANTHROPIC_BASE_URL }}
|
||||
# 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_VERTEX: ${{ inputs.use_vertex == 'true' && '1' || '' }}
|
||||
|
||||
# AWS configuration
|
||||
AWS_REGION: ${{ env.AWS_REGION }}
|
||||
AWS_ACCESS_KEY_ID: ${{ env.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ env.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_SESSION_TOKEN: ${{ env.AWS_SESSION_TOKEN }}
|
||||
ANTHROPIC_BEDROCK_BASE_URL: ${{ env.ANTHROPIC_BEDROCK_BASE_URL || (env.AWS_REGION && format('https://bedrock-runtime.{0}.amazonaws.com', env.AWS_REGION)) }}
|
||||
|
||||
# GCP configuration
|
||||
ANTHROPIC_VERTEX_PROJECT_ID: ${{ env.ANTHROPIC_VERTEX_PROJECT_ID }}
|
||||
CLOUD_ML_REGION: ${{ env.CLOUD_ML_REGION }}
|
||||
GOOGLE_APPLICATION_CREDENTIALS: ${{ env.GOOGLE_APPLICATION_CREDENTIALS }}
|
||||
ANTHROPIC_VERTEX_BASE_URL: ${{ env.ANTHROPIC_VERTEX_BASE_URL }}
|
||||
44
base-action/bun.lock
Normal file
44
base-action/bun.lock
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "@anthropic-ai/claude-code-base-action",
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.10.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.2.12",
|
||||
"@types/node": "^20.0.0",
|
||||
"prettier": "3.5.3",
|
||||
"typescript": "^5.8.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@actions/core": ["@actions/core@1.11.1", "", { "dependencies": { "@actions/exec": "^1.1.1", "@actions/http-client": "^2.0.1" } }, "sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A=="],
|
||||
|
||||
"@actions/exec": ["@actions/exec@1.1.1", "", { "dependencies": { "@actions/io": "^1.0.1" } }, "sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w=="],
|
||||
|
||||
"@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="],
|
||||
|
||||
"@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="],
|
||||
|
||||
"@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.2.12", "", { "dependencies": { "bun-types": "1.2.12" } }, "sha512-lY/GQTXDGsolT/TiH72p1tuyUORuRrdV7VwOTOjDOt8uTBJQOJc5zz3ufwwDl0VBaoxotSk4LdP0hhjLJ6ypIQ=="],
|
||||
|
||||
"@types/node": ["@types/node@20.17.32", "", { "dependencies": { "undici-types": "~6.19.2" } }, "sha512-zeMXFn8zQ+UkjK4ws0RiOC9EWByyW1CcVmLe+2rQocXRsGEDxUCwPEIVgpsGcLHS/P8JkT0oa3839BRABS0oPw=="],
|
||||
|
||||
"bun-types": ["bun-types@1.2.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-tvWMx5vPqbRXgE8WUZI94iS1xAYs8bkqESR9cxBB1Wi+urvfTrF1uzuDgBHFAdO0+d2lmsbG3HmeKMvUyj6pWA=="],
|
||||
|
||||
"prettier": ["prettier@3.5.3", "", { "bin": "bin/prettier.cjs" }, "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw=="],
|
||||
|
||||
"tunnel": ["tunnel@0.0.6", "", {}, "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg=="],
|
||||
|
||||
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
|
||||
|
||||
"undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
|
||||
|
||||
"undici-types": ["undici-types@6.19.8", "", {}, "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="],
|
||||
}
|
||||
}
|
||||
108
base-action/examples/issue-triage.yml
Normal file
108
base-action/examples/issue-triage.yml
Normal file
@@ -0,0 +1,108 @@
|
||||
name: Claude Issue Triage Example
|
||||
description: Run Claude Code for issue triage in GitHub Actions
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
triage-issue:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup GitHub MCP Server
|
||||
run: |
|
||||
mkdir -p /tmp/mcp-config
|
||||
cat > /tmp/mcp-config/mcp-servers.json << 'EOF'
|
||||
{
|
||||
"mcpServers": {
|
||||
"github": {
|
||||
"command": "docker",
|
||||
"args": [
|
||||
"run",
|
||||
"-i",
|
||||
"--rm",
|
||||
"-e",
|
||||
"GITHUB_PERSONAL_ACCESS_TOKEN",
|
||||
"ghcr.io/github/github-mcp-server:sha-7aced2b"
|
||||
],
|
||||
"env": {
|
||||
"GITHUB_PERSONAL_ACCESS_TOKEN": "${{ secrets.GITHUB_TOKEN }}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
- name: Create triage prompt
|
||||
run: |
|
||||
mkdir -p /tmp/claude-prompts
|
||||
cat > /tmp/claude-prompts/triage-prompt.txt << 'EOF'
|
||||
You're an issue triage assistant for GitHub issues. Your task is to analyze the issue and select appropriate labels from the provided list.
|
||||
|
||||
IMPORTANT: Don't post any comments or messages to the issue. Your only action should be to apply labels.
|
||||
|
||||
Issue Information:
|
||||
- REPO: ${GITHUB_REPOSITORY}
|
||||
- ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
|
||||
TASK OVERVIEW:
|
||||
|
||||
1. First, fetch the list of labels available in this repository by running: `gh label list`. Run exactly this command with nothing else.
|
||||
|
||||
2. Next, use the GitHub tools to get context about the issue:
|
||||
- You have access to these tools:
|
||||
- mcp__github__get_issue: Use this to retrieve the current issue's details including title, description, and existing labels
|
||||
- mcp__github__get_issue_comments: Use this to read any discussion or additional context provided in the comments
|
||||
- mcp__github__update_issue: Use this to apply labels to the issue (do not use this for commenting)
|
||||
- mcp__github__search_issues: Use this to find similar issues that might provide context for proper categorization and to identify potential duplicate issues
|
||||
- mcp__github__list_issues: Use this to understand patterns in how other issues are labeled
|
||||
- Start by using mcp__github__get_issue to get the issue details
|
||||
|
||||
3. Analyze the issue content, considering:
|
||||
- The issue title and description
|
||||
- The type of issue (bug report, feature request, question, etc.)
|
||||
- Technical areas mentioned
|
||||
- Severity or priority indicators
|
||||
- User impact
|
||||
- Components affected
|
||||
|
||||
4. Select appropriate labels from the available labels list provided above:
|
||||
- Choose labels that accurately reflect the issue's nature
|
||||
- Be specific but comprehensive
|
||||
- Select priority labels if you can determine urgency (high-priority, med-priority, or low-priority)
|
||||
- Consider platform labels (android, ios) if applicable
|
||||
- If you find similar issues using mcp__github__search_issues, consider using a "duplicate" label if appropriate. Only do so if the issue is a duplicate of another OPEN issue.
|
||||
|
||||
5. Apply the selected labels:
|
||||
- Use mcp__github__update_issue to apply your selected labels
|
||||
- DO NOT post any comments explaining your decision
|
||||
- DO NOT communicate directly with users
|
||||
- If no labels are clearly applicable, do not apply any labels
|
||||
|
||||
IMPORTANT GUIDELINES:
|
||||
- Be thorough in your analysis
|
||||
- Only select labels from the provided list above
|
||||
- DO NOT post any comments to the issue
|
||||
- Your ONLY action should be to apply labels using mcp__github__update_issue
|
||||
- It's okay to not add any labels if none are clearly applicable
|
||||
EOF
|
||||
env:
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
|
||||
- name: Run Claude Code for Issue Triage
|
||||
uses: anthropics/claude-code-base-action@beta
|
||||
with:
|
||||
prompt_file: /tmp/claude-prompts/triage-prompt.txt
|
||||
allowed_tools: "Bash(gh label list),mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__update_issue,mcp__github__search_issues,mcp__github__list_issues"
|
||||
mcp_config: /tmp/mcp-config/mcp-servers.json
|
||||
timeout_minutes: "5"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
21
base-action/package.json
Normal file
21
base-action/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@anthropic-ai/claude-code-base-action",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"install-hooks": "bun run scripts/install-hooks.sh",
|
||||
"test": "bun test",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.10.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.2.12",
|
||||
"@types/node": "^20.0.0",
|
||||
"prettier": "3.5.3",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
13
base-action/scripts/install-hooks.sh
Executable file
13
base-action/scripts/install-hooks.sh
Executable file
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Install git hooks
|
||||
echo "Installing git hooks..."
|
||||
|
||||
# Make sure hooks directory exists
|
||||
mkdir -p .git/hooks
|
||||
|
||||
# Install pre-push hook
|
||||
cp scripts/pre-push .git/hooks/pre-push
|
||||
chmod +x .git/hooks/pre-push
|
||||
|
||||
echo "Git hooks installed successfully!"
|
||||
46
base-action/scripts/pre-push
Normal file
46
base-action/scripts/pre-push
Normal file
@@ -0,0 +1,46 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Check if files need formatting before push
|
||||
echo "Checking code formatting..."
|
||||
|
||||
# First check if any files need formatting
|
||||
if ! bun run format:check; then
|
||||
echo "Code formatting errors found. Running formatter..."
|
||||
bun run format
|
||||
|
||||
# Check if there are any staged changes after formatting
|
||||
if git diff --name-only --exit-code; then
|
||||
echo "All files are now properly formatted."
|
||||
else
|
||||
echo ""
|
||||
echo "ERROR: Code has been formatted but changes need to be committed!"
|
||||
echo "Please commit the formatted files and try again."
|
||||
echo ""
|
||||
echo "The following files were modified:"
|
||||
git diff --name-only
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "Code formatting is already correct."
|
||||
fi
|
||||
|
||||
# Run type checking
|
||||
echo "Running type checking..."
|
||||
if ! bun run typecheck; then
|
||||
echo "Type checking failed. Please fix the type errors and try again."
|
||||
exit 1
|
||||
else
|
||||
echo "Type checking passed."
|
||||
fi
|
||||
|
||||
# Run tests
|
||||
echo "Running tests..."
|
||||
if ! bun run test; then
|
||||
echo "Tests failed. Please fix the failing tests and try again."
|
||||
exit 1
|
||||
else
|
||||
echo "All tests passed."
|
||||
fi
|
||||
|
||||
exit 0
|
||||
40
base-action/src/index.ts
Normal file
40
base-action/src/index.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import * as core from "@actions/core";
|
||||
import { preparePrompt } from "./prepare-prompt";
|
||||
import { runClaude } from "./run-claude";
|
||||
import { setupClaudeCodeSettings } from "./setup-claude-code-settings";
|
||||
import { validateEnvironmentVariables } from "./validate-env";
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
validateEnvironmentVariables();
|
||||
|
||||
await setupClaudeCodeSettings(process.env.INPUT_SETTINGS);
|
||||
|
||||
const promptConfig = await preparePrompt({
|
||||
prompt: process.env.INPUT_PROMPT || "",
|
||||
promptFile: process.env.INPUT_PROMPT_FILE || "",
|
||||
});
|
||||
|
||||
await runClaude(promptConfig.path, {
|
||||
allowedTools: process.env.INPUT_ALLOWED_TOOLS,
|
||||
disallowedTools: process.env.INPUT_DISALLOWED_TOOLS,
|
||||
maxTurns: process.env.INPUT_MAX_TURNS,
|
||||
mcpConfig: process.env.INPUT_MCP_CONFIG,
|
||||
systemPrompt: process.env.INPUT_SYSTEM_PROMPT,
|
||||
appendSystemPrompt: process.env.INPUT_APPEND_SYSTEM_PROMPT,
|
||||
claudeEnv: process.env.INPUT_CLAUDE_ENV,
|
||||
fallbackModel: process.env.INPUT_FALLBACK_MODEL,
|
||||
model: process.env.ANTHROPIC_MODEL,
|
||||
});
|
||||
} catch (error) {
|
||||
core.setFailed(`Action failed with error: ${error}`);
|
||||
core.setOutput("conclusion", "failure");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
run();
|
||||
}
|
||||
82
base-action/src/prepare-prompt.ts
Normal file
82
base-action/src/prepare-prompt.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { existsSync, statSync } from "fs";
|
||||
import { mkdir, writeFile } from "fs/promises";
|
||||
|
||||
export type PreparePromptInput = {
|
||||
prompt: string;
|
||||
promptFile: string;
|
||||
};
|
||||
|
||||
export type PreparePromptConfig = {
|
||||
type: "file" | "inline";
|
||||
path: string;
|
||||
};
|
||||
|
||||
async function validateAndPreparePrompt(
|
||||
input: PreparePromptInput,
|
||||
): Promise<PreparePromptConfig> {
|
||||
// Validate inputs
|
||||
if (!input.prompt && !input.promptFile) {
|
||||
throw new Error(
|
||||
"Neither 'prompt' nor 'prompt_file' was provided. At least one is required.",
|
||||
);
|
||||
}
|
||||
|
||||
if (input.prompt && input.promptFile) {
|
||||
throw new Error(
|
||||
"Both 'prompt' and 'prompt_file' were provided. Please specify only one.",
|
||||
);
|
||||
}
|
||||
|
||||
// Handle prompt file
|
||||
if (input.promptFile) {
|
||||
if (!existsSync(input.promptFile)) {
|
||||
throw new Error(`Prompt file '${input.promptFile}' does not exist.`);
|
||||
}
|
||||
|
||||
// Validate that the file is not empty
|
||||
const stats = statSync(input.promptFile);
|
||||
if (stats.size === 0) {
|
||||
throw new Error(
|
||||
"Prompt file is empty. Please provide a non-empty prompt.",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
type: "file",
|
||||
path: input.promptFile,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle inline prompt
|
||||
if (!input.prompt || input.prompt.trim().length === 0) {
|
||||
throw new Error("Prompt is empty. Please provide a non-empty prompt.");
|
||||
}
|
||||
|
||||
const inlinePath = "/tmp/claude-action/prompt.txt";
|
||||
return {
|
||||
type: "inline",
|
||||
path: inlinePath,
|
||||
};
|
||||
}
|
||||
|
||||
async function createTemporaryPromptFile(
|
||||
prompt: string,
|
||||
promptPath: string,
|
||||
): Promise<void> {
|
||||
// Create the directory path
|
||||
const dirPath = promptPath.substring(0, promptPath.lastIndexOf("/"));
|
||||
await mkdir(dirPath, { recursive: true });
|
||||
await writeFile(promptPath, prompt);
|
||||
}
|
||||
|
||||
export async function preparePrompt(
|
||||
input: PreparePromptInput,
|
||||
): Promise<PreparePromptConfig> {
|
||||
const config = await validateAndPreparePrompt(input);
|
||||
|
||||
if (config.type === "inline") {
|
||||
await createTemporaryPromptFile(input.prompt, config.path);
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
331
base-action/src/run-claude.ts
Normal file
331
base-action/src/run-claude.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
import * as core from "@actions/core";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { unlink, writeFile, stat } from "fs/promises";
|
||||
import { createWriteStream } from "fs";
|
||||
import { spawn } from "child_process";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const PIPE_PATH = `${process.env.RUNNER_TEMP}/claude_prompt_pipe`;
|
||||
const EXECUTION_FILE = `${process.env.RUNNER_TEMP}/claude-execution-output.json`;
|
||||
const BASE_ARGS = ["-p", "--verbose", "--output-format", "stream-json"];
|
||||
|
||||
export type ClaudeOptions = {
|
||||
allowedTools?: string;
|
||||
disallowedTools?: string;
|
||||
maxTurns?: string;
|
||||
mcpConfig?: string;
|
||||
systemPrompt?: string;
|
||||
appendSystemPrompt?: string;
|
||||
claudeEnv?: string;
|
||||
fallbackModel?: string;
|
||||
timeoutMinutes?: string;
|
||||
model?: string;
|
||||
};
|
||||
|
||||
type PreparedConfig = {
|
||||
claudeArgs: string[];
|
||||
promptPath: string;
|
||||
env: Record<string, string>;
|
||||
};
|
||||
|
||||
function parseCustomEnvVars(claudeEnv?: string): Record<string, string> {
|
||||
if (!claudeEnv || claudeEnv.trim() === "") {
|
||||
return {};
|
||||
}
|
||||
|
||||
const customEnv: Record<string, string> = {};
|
||||
|
||||
// Split by lines and parse each line as KEY: VALUE
|
||||
const lines = claudeEnv.split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
if (trimmedLine === "" || trimmedLine.startsWith("#")) {
|
||||
continue; // Skip empty lines and comments
|
||||
}
|
||||
|
||||
const colonIndex = trimmedLine.indexOf(":");
|
||||
if (colonIndex === -1) {
|
||||
continue; // Skip lines without colons
|
||||
}
|
||||
|
||||
const key = trimmedLine.substring(0, colonIndex).trim();
|
||||
const value = trimmedLine.substring(colonIndex + 1).trim();
|
||||
|
||||
if (key) {
|
||||
customEnv[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return customEnv;
|
||||
}
|
||||
|
||||
export function prepareRunConfig(
|
||||
promptPath: string,
|
||||
options: ClaudeOptions,
|
||||
): PreparedConfig {
|
||||
const claudeArgs = [...BASE_ARGS];
|
||||
|
||||
if (options.allowedTools) {
|
||||
claudeArgs.push("--allowedTools", options.allowedTools);
|
||||
}
|
||||
if (options.disallowedTools) {
|
||||
claudeArgs.push("--disallowedTools", options.disallowedTools);
|
||||
}
|
||||
if (options.maxTurns) {
|
||||
const maxTurnsNum = parseInt(options.maxTurns, 10);
|
||||
if (isNaN(maxTurnsNum) || maxTurnsNum <= 0) {
|
||||
throw new Error(
|
||||
`maxTurns must be a positive number, got: ${options.maxTurns}`,
|
||||
);
|
||||
}
|
||||
claudeArgs.push("--max-turns", options.maxTurns);
|
||||
}
|
||||
if (options.mcpConfig) {
|
||||
claudeArgs.push("--mcp-config", options.mcpConfig);
|
||||
}
|
||||
if (options.systemPrompt) {
|
||||
claudeArgs.push("--system-prompt", options.systemPrompt);
|
||||
}
|
||||
if (options.appendSystemPrompt) {
|
||||
claudeArgs.push("--append-system-prompt", options.appendSystemPrompt);
|
||||
}
|
||||
if (options.fallbackModel) {
|
||||
claudeArgs.push("--fallback-model", options.fallbackModel);
|
||||
}
|
||||
if (options.model) {
|
||||
claudeArgs.push("--model", options.model);
|
||||
}
|
||||
if (options.timeoutMinutes) {
|
||||
const timeoutMinutesNum = parseInt(options.timeoutMinutes, 10);
|
||||
if (isNaN(timeoutMinutesNum) || timeoutMinutesNum <= 0) {
|
||||
throw new Error(
|
||||
`timeoutMinutes must be a positive number, got: ${options.timeoutMinutes}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse custom environment variables
|
||||
const customEnv = parseCustomEnvVars(options.claudeEnv);
|
||||
|
||||
return {
|
||||
claudeArgs,
|
||||
promptPath,
|
||||
env: customEnv,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runClaude(promptPath: string, options: ClaudeOptions) {
|
||||
const config = prepareRunConfig(promptPath, options);
|
||||
|
||||
// Create a named pipe
|
||||
try {
|
||||
await unlink(PIPE_PATH);
|
||||
} catch (e) {
|
||||
// Ignore if file doesn't exist
|
||||
}
|
||||
|
||||
// Create the named pipe
|
||||
await execAsync(`mkfifo "${PIPE_PATH}"`);
|
||||
|
||||
// Log prompt file size
|
||||
let promptSize = "unknown";
|
||||
try {
|
||||
const stats = await stat(config.promptPath);
|
||||
promptSize = stats.size.toString();
|
||||
} catch (e) {
|
||||
// Ignore error
|
||||
}
|
||||
|
||||
console.log(`Prompt file size: ${promptSize} bytes`);
|
||||
|
||||
// Log custom environment variables if any
|
||||
if (Object.keys(config.env).length > 0) {
|
||||
const envKeys = Object.keys(config.env).join(", ");
|
||||
console.log(`Custom environment variables: ${envKeys}`);
|
||||
}
|
||||
|
||||
// Output to console
|
||||
console.log(`Running Claude with prompt from file: ${config.promptPath}`);
|
||||
|
||||
// Start sending prompt to pipe in background
|
||||
const catProcess = spawn("cat", [config.promptPath], {
|
||||
stdio: ["ignore", "pipe", "inherit"],
|
||||
});
|
||||
const pipeStream = createWriteStream(PIPE_PATH);
|
||||
catProcess.stdout.pipe(pipeStream);
|
||||
|
||||
catProcess.on("error", (error) => {
|
||||
console.error("Error reading prompt file:", error);
|
||||
pipeStream.destroy();
|
||||
});
|
||||
|
||||
const claudeProcess = spawn("claude", config.claudeArgs, {
|
||||
stdio: ["pipe", "pipe", "inherit"],
|
||||
env: {
|
||||
...process.env,
|
||||
...config.env,
|
||||
},
|
||||
});
|
||||
|
||||
// Handle Claude process errors
|
||||
claudeProcess.on("error", (error) => {
|
||||
console.error("Error spawning Claude process:", error);
|
||||
pipeStream.destroy();
|
||||
});
|
||||
|
||||
// Capture output for parsing execution metrics
|
||||
let output = "";
|
||||
claudeProcess.stdout.on("data", (data) => {
|
||||
const text = data.toString();
|
||||
|
||||
// Try to parse as JSON and pretty print if it's on a single line
|
||||
const lines = text.split("\n");
|
||||
lines.forEach((line: string, index: number) => {
|
||||
if (line.trim() === "") return;
|
||||
|
||||
try {
|
||||
// Check if this line is a JSON object
|
||||
const parsed = JSON.parse(line);
|
||||
const prettyJson = JSON.stringify(parsed, null, 2);
|
||||
process.stdout.write(prettyJson);
|
||||
if (index < lines.length - 1 || text.endsWith("\n")) {
|
||||
process.stdout.write("\n");
|
||||
}
|
||||
} catch (e) {
|
||||
// Not a JSON object, print as is
|
||||
process.stdout.write(line);
|
||||
if (index < lines.length - 1 || text.endsWith("\n")) {
|
||||
process.stdout.write("\n");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
output += text;
|
||||
});
|
||||
|
||||
// Handle stdout errors
|
||||
claudeProcess.stdout.on("error", (error) => {
|
||||
console.error("Error reading Claude stdout:", error);
|
||||
});
|
||||
|
||||
// Pipe from named pipe to Claude
|
||||
const pipeProcess = spawn("cat", [PIPE_PATH]);
|
||||
pipeProcess.stdout.pipe(claudeProcess.stdin);
|
||||
|
||||
// Handle pipe process errors
|
||||
pipeProcess.on("error", (error) => {
|
||||
console.error("Error reading from named pipe:", error);
|
||||
claudeProcess.kill("SIGTERM");
|
||||
});
|
||||
|
||||
// Wait for Claude to finish with timeout
|
||||
let timeoutMs = 10 * 60 * 1000; // Default 10 minutes
|
||||
if (options.timeoutMinutes) {
|
||||
timeoutMs = parseInt(options.timeoutMinutes, 10) * 60 * 1000;
|
||||
} else if (process.env.INPUT_TIMEOUT_MINUTES) {
|
||||
const envTimeout = parseInt(process.env.INPUT_TIMEOUT_MINUTES, 10);
|
||||
if (isNaN(envTimeout) || envTimeout <= 0) {
|
||||
throw new Error(
|
||||
`INPUT_TIMEOUT_MINUTES must be a positive number, got: ${process.env.INPUT_TIMEOUT_MINUTES}`,
|
||||
);
|
||||
}
|
||||
timeoutMs = envTimeout * 60 * 1000;
|
||||
}
|
||||
const exitCode = await new Promise<number>((resolve) => {
|
||||
let resolved = false;
|
||||
|
||||
// Set a timeout for the process
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
console.error(
|
||||
`Claude process timed out after ${timeoutMs / 1000} seconds`,
|
||||
);
|
||||
claudeProcess.kill("SIGTERM");
|
||||
// Give it 5 seconds to terminate gracefully, then force kill
|
||||
setTimeout(() => {
|
||||
try {
|
||||
claudeProcess.kill("SIGKILL");
|
||||
} catch (e) {
|
||||
// Process may already be dead
|
||||
}
|
||||
}, 5000);
|
||||
resolved = true;
|
||||
resolve(124); // Standard timeout exit code
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
claudeProcess.on("close", (code) => {
|
||||
if (!resolved) {
|
||||
clearTimeout(timeoutId);
|
||||
resolved = true;
|
||||
resolve(code || 0);
|
||||
}
|
||||
});
|
||||
|
||||
claudeProcess.on("error", (error) => {
|
||||
if (!resolved) {
|
||||
console.error("Claude process error:", error);
|
||||
clearTimeout(timeoutId);
|
||||
resolved = true;
|
||||
resolve(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Clean up processes
|
||||
try {
|
||||
catProcess.kill("SIGTERM");
|
||||
} catch (e) {
|
||||
// Process may already be dead
|
||||
}
|
||||
try {
|
||||
pipeProcess.kill("SIGTERM");
|
||||
} catch (e) {
|
||||
// Process may already be dead
|
||||
}
|
||||
|
||||
// Clean up pipe file
|
||||
try {
|
||||
await unlink(PIPE_PATH);
|
||||
} catch (e) {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
|
||||
// Set conclusion based on exit code
|
||||
if (exitCode === 0) {
|
||||
// Try to process the output and save execution metrics
|
||||
try {
|
||||
await writeFile("output.txt", output);
|
||||
|
||||
// Process output.txt into JSON and save to execution file
|
||||
const { stdout: jsonOutput } = await execAsync("jq -s '.' output.txt");
|
||||
await writeFile(EXECUTION_FILE, jsonOutput);
|
||||
|
||||
console.log(`Log saved to ${EXECUTION_FILE}`);
|
||||
} catch (e) {
|
||||
core.warning(`Failed to process output for execution metrics: ${e}`);
|
||||
}
|
||||
|
||||
core.setOutput("conclusion", "success");
|
||||
core.setOutput("execution_file", EXECUTION_FILE);
|
||||
} else {
|
||||
core.setOutput("conclusion", "failure");
|
||||
|
||||
// Still try to save execution file if we have output
|
||||
if (output) {
|
||||
try {
|
||||
await writeFile("output.txt", output);
|
||||
const { stdout: jsonOutput } = await execAsync("jq -s '.' output.txt");
|
||||
await writeFile(EXECUTION_FILE, jsonOutput);
|
||||
core.setOutput("execution_file", EXECUTION_FILE);
|
||||
} catch (e) {
|
||||
// Ignore errors when processing output during failure
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(exitCode);
|
||||
}
|
||||
}
|
||||
68
base-action/src/setup-claude-code-settings.ts
Normal file
68
base-action/src/setup-claude-code-settings.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { $ } from "bun";
|
||||
import { homedir } from "os";
|
||||
import { readFile } from "fs/promises";
|
||||
|
||||
export async function setupClaudeCodeSettings(
|
||||
settingsInput?: string,
|
||||
homeDir?: string,
|
||||
) {
|
||||
const home = homeDir ?? homedir();
|
||||
const settingsPath = `${home}/.claude/settings.json`;
|
||||
console.log(`Setting up Claude settings at: ${settingsPath}`);
|
||||
|
||||
// Ensure .claude directory exists
|
||||
console.log(`Creating .claude directory...`);
|
||||
await $`mkdir -p ${home}/.claude`.quiet();
|
||||
|
||||
let settings: Record<string, unknown> = {};
|
||||
try {
|
||||
const existingSettings = await $`cat ${settingsPath}`.quiet().text();
|
||||
if (existingSettings.trim()) {
|
||||
settings = JSON.parse(existingSettings);
|
||||
console.log(
|
||||
`Found existing settings:`,
|
||||
JSON.stringify(settings, null, 2),
|
||||
);
|
||||
} else {
|
||||
console.log(`Settings file exists but is empty`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`No existing settings file found, creating new one`);
|
||||
}
|
||||
|
||||
// Handle settings input (either file path or JSON string)
|
||||
if (settingsInput && settingsInput.trim()) {
|
||||
console.log(`Processing settings input...`);
|
||||
let inputSettings: Record<string, unknown> = {};
|
||||
|
||||
try {
|
||||
// First try to parse as JSON
|
||||
inputSettings = JSON.parse(settingsInput);
|
||||
console.log(`Parsed settings input as JSON`);
|
||||
} catch (e) {
|
||||
// If not JSON, treat as file path
|
||||
console.log(
|
||||
`Settings input is not JSON, treating as file path: ${settingsInput}`,
|
||||
);
|
||||
try {
|
||||
const fileContent = await readFile(settingsInput, "utf-8");
|
||||
inputSettings = JSON.parse(fileContent);
|
||||
console.log(`Successfully read and parsed settings from file`);
|
||||
} catch (fileError) {
|
||||
console.error(`Failed to read or parse settings file: ${fileError}`);
|
||||
throw new Error(`Failed to process settings input: ${fileError}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Merge input settings with existing settings
|
||||
settings = { ...settings, ...inputSettings };
|
||||
console.log(`Merged settings with input settings`);
|
||||
}
|
||||
|
||||
// Always set enableAllProjectMcpServers to true
|
||||
settings.enableAllProjectMcpServers = true;
|
||||
console.log(`Updated settings with enableAllProjectMcpServers: true`);
|
||||
|
||||
await $`echo ${JSON.stringify(settings, null, 2)} > ${settingsPath}`.quiet();
|
||||
console.log(`Settings saved successfully`);
|
||||
}
|
||||
54
base-action/src/validate-env.ts
Normal file
54
base-action/src/validate-env.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Validates the environment variables required for running Claude Code
|
||||
* based on the selected provider (Anthropic API, AWS Bedrock, or Google Vertex AI)
|
||||
*/
|
||||
export function validateEnvironmentVariables() {
|
||||
const useBedrock = process.env.CLAUDE_CODE_USE_BEDROCK === "1";
|
||||
const useVertex = process.env.CLAUDE_CODE_USE_VERTEX === "1";
|
||||
const anthropicApiKey = process.env.ANTHROPIC_API_KEY;
|
||||
const claudeCodeOAuthToken = process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
|
||||
const errors: string[] = [];
|
||||
|
||||
if (useBedrock && useVertex) {
|
||||
errors.push(
|
||||
"Cannot use both Bedrock and Vertex AI simultaneously. Please set only one provider.",
|
||||
);
|
||||
}
|
||||
|
||||
if (!useBedrock && !useVertex) {
|
||||
if (!anthropicApiKey && !claudeCodeOAuthToken) {
|
||||
errors.push(
|
||||
"Either ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN is required when using direct Anthropic API.",
|
||||
);
|
||||
}
|
||||
} else if (useBedrock) {
|
||||
const requiredBedrockVars = {
|
||||
AWS_REGION: process.env.AWS_REGION,
|
||||
AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID,
|
||||
AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY,
|
||||
};
|
||||
|
||||
Object.entries(requiredBedrockVars).forEach(([key, value]) => {
|
||||
if (!value) {
|
||||
errors.push(`${key} is required when using AWS Bedrock.`);
|
||||
}
|
||||
});
|
||||
} else if (useVertex) {
|
||||
const requiredVertexVars = {
|
||||
ANTHROPIC_VERTEX_PROJECT_ID: process.env.ANTHROPIC_VERTEX_PROJECT_ID,
|
||||
CLOUD_ML_REGION: process.env.CLOUD_ML_REGION,
|
||||
};
|
||||
|
||||
Object.entries(requiredVertexVars).forEach(([key, value]) => {
|
||||
if (!value) {
|
||||
errors.push(`${key} is required when using Google Vertex AI.`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
const errorMessage = `Environment variable validation failed:\n${errors.map((e) => ` - ${e}`).join("\n")}`;
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
}
|
||||
12
base-action/test-local.sh
Executable file
12
base-action/test-local.sh
Executable file
@@ -0,0 +1,12 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Install act if not already installed
|
||||
if ! command -v act &> /dev/null; then
|
||||
echo "Installing act..."
|
||||
brew install act
|
||||
fi
|
||||
|
||||
# Run the test workflow locally
|
||||
# You'll need to provide your ANTHROPIC_API_KEY
|
||||
echo "Running action locally with act..."
|
||||
act push --secret ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" -W .github/workflows/test-action.yml --container-architecture linux/amd64
|
||||
18
base-action/test-mcp-local.sh
Executable file
18
base-action/test-mcp-local.sh
Executable file
@@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Install act if not already installed
|
||||
if ! command -v act &> /dev/null; then
|
||||
echo "Installing act..."
|
||||
brew install act
|
||||
fi
|
||||
|
||||
# Check if ANTHROPIC_API_KEY is set
|
||||
if [ -z "$ANTHROPIC_API_KEY" ]; then
|
||||
echo "Error: ANTHROPIC_API_KEY environment variable is not set"
|
||||
echo "Please export your API key: export ANTHROPIC_API_KEY='your-key-here'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run the MCP test workflow locally
|
||||
echo "Running MCP server test locally with act..."
|
||||
act push --secret ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" -W .github/workflows/test-mcp-servers.yml --container-architecture linux/amd64
|
||||
10
base-action/test/mcp-test/.mcp.json
Normal file
10
base-action/test/mcp-test/.mcp.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"test-server": {
|
||||
"type": "stdio",
|
||||
"command": "bun",
|
||||
"args": ["simple-mcp-server.ts"],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
base-action/test/mcp-test/.npmrc
Normal file
2
base-action/test/mcp-test/.npmrc
Normal file
@@ -0,0 +1,2 @@
|
||||
engine-strict=true
|
||||
registry=https://registry.npmjs.org/
|
||||
186
base-action/test/mcp-test/bun.lock
Normal file
186
base-action/test/mcp-test/bun.lock
Normal file
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "mcp-test",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.12.0", "", { "dependencies": { "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "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-m//7RlINx1F3sz3KqwY1WWzVgTcYX52HYk4bJ1hkBXV3zccAEth+jRvG8DBRrdaQuRsPAJOx2MH3zaHNCKL7Zg=="],
|
||||
|
||||
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||
|
||||
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
|
||||
|
||||
"body-parser": ["body-parser@2.2.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.0", "http-errors": "^2.0.0", "iconv-lite": "^0.6.3", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.0", "type-is": "^2.0.0" } }, "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg=="],
|
||||
|
||||
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
||||
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||
|
||||
"content-disposition": ["content-disposition@1.0.0", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg=="],
|
||||
|
||||
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
||||
|
||||
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
||||
|
||||
"cors": ["cors@2.8.5", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
|
||||
|
||||
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||
|
||||
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||
|
||||
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
|
||||
|
||||
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
|
||||
|
||||
"eventsource-parser": ["eventsource-parser@3.0.2", "", {}, "sha512-6RxOBZ/cYgd8usLwsEl+EC09Au/9BcmCKYF2/xbml6DNczf7nv0MQb+7BA2F+li6//I+28VNlQR37XfQtcAJuA=="],
|
||||
|
||||
"express": ["express@5.1.0", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA=="],
|
||||
|
||||
"express-rate-limit": ["express-rate-limit@7.5.0", "", { "peerDependencies": { "express": "^4.11 || 5 || ^5.0.0-beta.1" } }, "sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
||||
|
||||
"finalhandler": ["finalhandler@2.1.0", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q=="],
|
||||
|
||||
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
||||
|
||||
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||
|
||||
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
|
||||
|
||||
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
|
||||
|
||||
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
|
||||
"mime-types": ["mime-types@3.0.1", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
||||
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"path-to-regexp": ["path-to-regexp@8.2.0", "", {}, "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ=="],
|
||||
|
||||
"pkce-challenge": ["pkce-challenge@5.0.0", "", {}, "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ=="],
|
||||
|
||||
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="],
|
||||
|
||||
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
|
||||
|
||||
"raw-body": ["raw-body@3.0.0", "", { "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", "iconv-lite": "0.6.3", "unpipe": "1.0.0" } }, "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g=="],
|
||||
|
||||
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"send": ["send@1.2.0", "", { "dependencies": { "debug": "^4.3.5", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.0", "mime-types": "^3.0.1", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.1" } }, "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw=="],
|
||||
|
||||
"serve-static": ["serve-static@2.2.0", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ=="],
|
||||
|
||||
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
|
||||
|
||||
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
|
||||
|
||||
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
|
||||
|
||||
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||
|
||||
"statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="],
|
||||
|
||||
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||
|
||||
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
|
||||
|
||||
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
|
||||
|
||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
"zod": ["zod@3.25.32", "", {}, "sha512-OSm2xTIRfW8CV5/QKgngwmQW/8aPfGdaQFlrGoErlgg/Epm7cjb6K6VEyExfe65a3VybUOnu381edLb0dfJl0g=="],
|
||||
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.24.5", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g=="],
|
||||
}
|
||||
}
|
||||
7
base-action/test/mcp-test/package.json
Normal file
7
base-action/test/mcp-test/package.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "mcp-test",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.11.0"
|
||||
}
|
||||
}
|
||||
29
base-action/test/mcp-test/simple-mcp-server.ts
Normal file
29
base-action/test/mcp-test/simple-mcp-server.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bun
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
|
||||
const server = new McpServer({
|
||||
name: "test-server",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
server.tool("test_tool", "A simple test tool", {}, async () => {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Test tool response",
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
async function runServer() {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
process.on("exit", () => {
|
||||
server.close();
|
||||
});
|
||||
}
|
||||
|
||||
runServer().catch(console.error);
|
||||
114
base-action/test/prepare-prompt.test.ts
Normal file
114
base-action/test/prepare-prompt.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { preparePrompt, type PreparePromptInput } from "../src/prepare-prompt";
|
||||
import { unlink, writeFile, readFile, stat } from "fs/promises";
|
||||
|
||||
describe("preparePrompt integration tests", () => {
|
||||
beforeEach(async () => {
|
||||
try {
|
||||
await unlink("/tmp/claude-action/prompt.txt");
|
||||
} catch {
|
||||
// Ignore if file doesn't exist
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
await unlink("/tmp/claude-action/prompt.txt");
|
||||
} catch {
|
||||
// Ignore if file doesn't exist
|
||||
}
|
||||
});
|
||||
|
||||
test("should create temporary prompt file when only prompt is provided", async () => {
|
||||
const input: PreparePromptInput = {
|
||||
prompt: "This is a test prompt",
|
||||
promptFile: "",
|
||||
};
|
||||
|
||||
const config = await preparePrompt(input);
|
||||
|
||||
expect(config.path).toBe("/tmp/claude-action/prompt.txt");
|
||||
expect(config.type).toBe("inline");
|
||||
|
||||
const fileContent = await readFile(config.path, "utf-8");
|
||||
expect(fileContent).toBe("This is a test prompt");
|
||||
|
||||
const fileStat = await stat(config.path);
|
||||
expect(fileStat.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("should use existing file when promptFile is provided", async () => {
|
||||
const testFilePath = "/tmp/test-prompt.txt";
|
||||
await writeFile(testFilePath, "Prompt from file");
|
||||
|
||||
const input: PreparePromptInput = {
|
||||
prompt: "",
|
||||
promptFile: testFilePath,
|
||||
};
|
||||
|
||||
const config = await preparePrompt(input);
|
||||
|
||||
expect(config.path).toBe(testFilePath);
|
||||
expect(config.type).toBe("file");
|
||||
|
||||
await unlink(testFilePath);
|
||||
});
|
||||
|
||||
test("should fail when neither prompt nor promptFile is provided", async () => {
|
||||
const input: PreparePromptInput = {
|
||||
prompt: "",
|
||||
promptFile: "",
|
||||
};
|
||||
|
||||
await expect(preparePrompt(input)).rejects.toThrow(
|
||||
"Neither 'prompt' nor 'prompt_file' was provided",
|
||||
);
|
||||
});
|
||||
|
||||
test("should fail when promptFile points to non-existent file", async () => {
|
||||
const input: PreparePromptInput = {
|
||||
prompt: "",
|
||||
promptFile: "/tmp/non-existent-file.txt",
|
||||
};
|
||||
|
||||
await expect(preparePrompt(input)).rejects.toThrow(
|
||||
"Prompt file '/tmp/non-existent-file.txt' does not exist.",
|
||||
);
|
||||
});
|
||||
|
||||
test("should fail when prompt is empty", async () => {
|
||||
const emptyFilePath = "/tmp/empty-prompt.txt";
|
||||
await writeFile(emptyFilePath, "");
|
||||
|
||||
const input: PreparePromptInput = {
|
||||
prompt: "",
|
||||
promptFile: emptyFilePath,
|
||||
};
|
||||
|
||||
await expect(preparePrompt(input)).rejects.toThrow("Prompt file is empty");
|
||||
|
||||
try {
|
||||
await unlink(emptyFilePath);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
test("should fail when both prompt and promptFile are provided", async () => {
|
||||
const testFilePath = "/tmp/test-prompt.txt";
|
||||
await writeFile(testFilePath, "Prompt from file");
|
||||
|
||||
const input: PreparePromptInput = {
|
||||
prompt: "This should cause an error",
|
||||
promptFile: testFilePath,
|
||||
};
|
||||
|
||||
await expect(preparePrompt(input)).rejects.toThrow(
|
||||
"Both 'prompt' and 'prompt_file' were provided. Please specify only one.",
|
||||
);
|
||||
|
||||
await unlink(testFilePath);
|
||||
});
|
||||
});
|
||||
297
base-action/test/run-claude.test.ts
Normal file
297
base-action/test/run-claude.test.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { prepareRunConfig, type ClaudeOptions } from "../src/run-claude";
|
||||
|
||||
describe("prepareRunConfig", () => {
|
||||
test("should prepare config with basic arguments", () => {
|
||||
const options: ClaudeOptions = {};
|
||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
||||
|
||||
expect(prepared.claudeArgs.slice(0, 4)).toEqual([
|
||||
"-p",
|
||||
"--verbose",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
]);
|
||||
});
|
||||
|
||||
test("should include promptPath", () => {
|
||||
const options: ClaudeOptions = {};
|
||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
||||
|
||||
expect(prepared.promptPath).toBe("/tmp/test-prompt.txt");
|
||||
});
|
||||
|
||||
test("should include allowed tools in command arguments", () => {
|
||||
const options: ClaudeOptions = {
|
||||
allowedTools: "Bash,Read",
|
||||
};
|
||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
||||
|
||||
expect(prepared.claudeArgs).toContain("--allowedTools");
|
||||
expect(prepared.claudeArgs).toContain("Bash,Read");
|
||||
});
|
||||
|
||||
test("should include disallowed tools in command arguments", () => {
|
||||
const options: ClaudeOptions = {
|
||||
disallowedTools: "Bash,Read",
|
||||
};
|
||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
||||
|
||||
expect(prepared.claudeArgs).toContain("--disallowedTools");
|
||||
expect(prepared.claudeArgs).toContain("Bash,Read");
|
||||
});
|
||||
|
||||
test("should include max turns in command arguments", () => {
|
||||
const options: ClaudeOptions = {
|
||||
maxTurns: "5",
|
||||
};
|
||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
||||
|
||||
expect(prepared.claudeArgs).toContain("--max-turns");
|
||||
expect(prepared.claudeArgs).toContain("5");
|
||||
});
|
||||
|
||||
test("should include mcp config in command arguments", () => {
|
||||
const options: ClaudeOptions = {
|
||||
mcpConfig: "/path/to/mcp-config.json",
|
||||
};
|
||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
||||
|
||||
expect(prepared.claudeArgs).toContain("--mcp-config");
|
||||
expect(prepared.claudeArgs).toContain("/path/to/mcp-config.json");
|
||||
});
|
||||
|
||||
test("should include system prompt in command arguments", () => {
|
||||
const options: ClaudeOptions = {
|
||||
systemPrompt: "You are a senior backend engineer.",
|
||||
};
|
||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
||||
|
||||
expect(prepared.claudeArgs).toContain("--system-prompt");
|
||||
expect(prepared.claudeArgs).toContain("You are a senior backend engineer.");
|
||||
});
|
||||
|
||||
test("should include append system prompt in command arguments", () => {
|
||||
const options: ClaudeOptions = {
|
||||
appendSystemPrompt:
|
||||
"After writing code, be sure to code review yourself.",
|
||||
};
|
||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
||||
|
||||
expect(prepared.claudeArgs).toContain("--append-system-prompt");
|
||||
expect(prepared.claudeArgs).toContain(
|
||||
"After writing code, be sure to code review yourself.",
|
||||
);
|
||||
});
|
||||
|
||||
test("should include fallback model in command arguments", () => {
|
||||
const options: ClaudeOptions = {
|
||||
fallbackModel: "claude-sonnet-4-20250514",
|
||||
};
|
||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
||||
|
||||
expect(prepared.claudeArgs).toContain("--fallback-model");
|
||||
expect(prepared.claudeArgs).toContain("claude-sonnet-4-20250514");
|
||||
});
|
||||
|
||||
test("should use provided prompt path", () => {
|
||||
const options: ClaudeOptions = {};
|
||||
const prepared = prepareRunConfig("/custom/prompt/path.txt", options);
|
||||
|
||||
expect(prepared.promptPath).toBe("/custom/prompt/path.txt");
|
||||
});
|
||||
|
||||
test("should not include optional arguments when not set", () => {
|
||||
const options: ClaudeOptions = {};
|
||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
||||
|
||||
expect(prepared.claudeArgs).not.toContain("--allowedTools");
|
||||
expect(prepared.claudeArgs).not.toContain("--disallowedTools");
|
||||
expect(prepared.claudeArgs).not.toContain("--max-turns");
|
||||
expect(prepared.claudeArgs).not.toContain("--mcp-config");
|
||||
expect(prepared.claudeArgs).not.toContain("--system-prompt");
|
||||
expect(prepared.claudeArgs).not.toContain("--append-system-prompt");
|
||||
expect(prepared.claudeArgs).not.toContain("--fallback-model");
|
||||
});
|
||||
|
||||
test("should preserve order of claude arguments", () => {
|
||||
const options: ClaudeOptions = {
|
||||
allowedTools: "Bash,Read",
|
||||
maxTurns: "3",
|
||||
};
|
||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
||||
|
||||
expect(prepared.claudeArgs).toEqual([
|
||||
"-p",
|
||||
"--verbose",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--allowedTools",
|
||||
"Bash,Read",
|
||||
"--max-turns",
|
||||
"3",
|
||||
]);
|
||||
});
|
||||
|
||||
test("should preserve order with all options including fallback model", () => {
|
||||
const options: ClaudeOptions = {
|
||||
allowedTools: "Bash,Read",
|
||||
disallowedTools: "Write",
|
||||
maxTurns: "3",
|
||||
mcpConfig: "/path/to/config.json",
|
||||
systemPrompt: "You are a helpful assistant",
|
||||
appendSystemPrompt: "Be concise",
|
||||
fallbackModel: "claude-sonnet-4-20250514",
|
||||
};
|
||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
||||
|
||||
expect(prepared.claudeArgs).toEqual([
|
||||
"-p",
|
||||
"--verbose",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--allowedTools",
|
||||
"Bash,Read",
|
||||
"--disallowedTools",
|
||||
"Write",
|
||||
"--max-turns",
|
||||
"3",
|
||||
"--mcp-config",
|
||||
"/path/to/config.json",
|
||||
"--system-prompt",
|
||||
"You are a helpful assistant",
|
||||
"--append-system-prompt",
|
||||
"Be concise",
|
||||
"--fallback-model",
|
||||
"claude-sonnet-4-20250514",
|
||||
]);
|
||||
});
|
||||
|
||||
describe("maxTurns validation", () => {
|
||||
test("should accept valid maxTurns value", () => {
|
||||
const options: ClaudeOptions = { maxTurns: "5" };
|
||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
||||
expect(prepared.claudeArgs).toContain("--max-turns");
|
||||
expect(prepared.claudeArgs).toContain("5");
|
||||
});
|
||||
|
||||
test("should throw error for non-numeric maxTurns", () => {
|
||||
const options: ClaudeOptions = { maxTurns: "abc" };
|
||||
expect(() => prepareRunConfig("/tmp/test-prompt.txt", options)).toThrow(
|
||||
"maxTurns must be a positive number, got: abc",
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw error for negative maxTurns", () => {
|
||||
const options: ClaudeOptions = { maxTurns: "-1" };
|
||||
expect(() => prepareRunConfig("/tmp/test-prompt.txt", options)).toThrow(
|
||||
"maxTurns must be a positive number, got: -1",
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw error for zero maxTurns", () => {
|
||||
const options: ClaudeOptions = { maxTurns: "0" };
|
||||
expect(() => prepareRunConfig("/tmp/test-prompt.txt", options)).toThrow(
|
||||
"maxTurns must be a positive number, got: 0",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("timeoutMinutes validation", () => {
|
||||
test("should accept valid timeoutMinutes value", () => {
|
||||
const options: ClaudeOptions = { timeoutMinutes: "15" };
|
||||
expect(() =>
|
||||
prepareRunConfig("/tmp/test-prompt.txt", options),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("should throw error for non-numeric timeoutMinutes", () => {
|
||||
const options: ClaudeOptions = { timeoutMinutes: "abc" };
|
||||
expect(() => prepareRunConfig("/tmp/test-prompt.txt", options)).toThrow(
|
||||
"timeoutMinutes must be a positive number, got: abc",
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw error for negative timeoutMinutes", () => {
|
||||
const options: ClaudeOptions = { timeoutMinutes: "-5" };
|
||||
expect(() => prepareRunConfig("/tmp/test-prompt.txt", options)).toThrow(
|
||||
"timeoutMinutes must be a positive number, got: -5",
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw error for zero timeoutMinutes", () => {
|
||||
const options: ClaudeOptions = { timeoutMinutes: "0" };
|
||||
expect(() => prepareRunConfig("/tmp/test-prompt.txt", options)).toThrow(
|
||||
"timeoutMinutes must be a positive number, got: 0",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom environment variables", () => {
|
||||
test("should parse empty claudeEnv correctly", () => {
|
||||
const options: ClaudeOptions = { claudeEnv: "" };
|
||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
||||
expect(prepared.env).toEqual({});
|
||||
});
|
||||
|
||||
test("should parse single environment variable", () => {
|
||||
const options: ClaudeOptions = { claudeEnv: "API_KEY: secret123" };
|
||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
||||
expect(prepared.env).toEqual({ API_KEY: "secret123" });
|
||||
});
|
||||
|
||||
test("should parse multiple environment variables", () => {
|
||||
const options: ClaudeOptions = {
|
||||
claudeEnv: "API_KEY: secret123\nDEBUG: true\nUSER: testuser",
|
||||
};
|
||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
||||
expect(prepared.env).toEqual({
|
||||
API_KEY: "secret123",
|
||||
DEBUG: "true",
|
||||
USER: "testuser",
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle environment variables with spaces around values", () => {
|
||||
const options: ClaudeOptions = {
|
||||
claudeEnv: "API_KEY: secret123 \n DEBUG : true ",
|
||||
};
|
||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
||||
expect(prepared.env).toEqual({
|
||||
API_KEY: "secret123",
|
||||
DEBUG: "true",
|
||||
});
|
||||
});
|
||||
|
||||
test("should skip empty lines and comments", () => {
|
||||
const options: ClaudeOptions = {
|
||||
claudeEnv:
|
||||
"API_KEY: secret123\n\n# This is a comment\nDEBUG: true\n# Another comment",
|
||||
};
|
||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
||||
expect(prepared.env).toEqual({
|
||||
API_KEY: "secret123",
|
||||
DEBUG: "true",
|
||||
});
|
||||
});
|
||||
|
||||
test("should skip lines without colons", () => {
|
||||
const options: ClaudeOptions = {
|
||||
claudeEnv: "API_KEY: secret123\nINVALID_LINE\nDEBUG: true",
|
||||
};
|
||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
||||
expect(prepared.env).toEqual({
|
||||
API_KEY: "secret123",
|
||||
DEBUG: "true",
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle undefined claudeEnv", () => {
|
||||
const options: ClaudeOptions = {};
|
||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
||||
expect(prepared.env).toEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
150
base-action/test/setup-claude-code-settings.test.ts
Normal file
150
base-action/test/setup-claude-code-settings.test.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { setupClaudeCodeSettings } from "../src/setup-claude-code-settings";
|
||||
import { tmpdir } from "os";
|
||||
import { mkdir, writeFile, readFile, rm } from "fs/promises";
|
||||
import { join } from "path";
|
||||
|
||||
const testHomeDir = join(
|
||||
tmpdir(),
|
||||
"claude-code-test-home",
|
||||
Date.now().toString(),
|
||||
);
|
||||
const settingsPath = join(testHomeDir, ".claude", "settings.json");
|
||||
const testSettingsDir = join(testHomeDir, ".claude-test");
|
||||
const testSettingsPath = join(testSettingsDir, "test-settings.json");
|
||||
|
||||
describe("setupClaudeCodeSettings", () => {
|
||||
beforeEach(async () => {
|
||||
// Create test home directory and test settings directory
|
||||
await mkdir(testHomeDir, { recursive: true });
|
||||
await mkdir(testSettingsDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up test home directory
|
||||
await rm(testHomeDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("should always set enableAllProjectMcpServers to true when no input", async () => {
|
||||
await setupClaudeCodeSettings(undefined, testHomeDir);
|
||||
|
||||
const settingsContent = await readFile(settingsPath, "utf-8");
|
||||
const settings = JSON.parse(settingsContent);
|
||||
|
||||
expect(settings.enableAllProjectMcpServers).toBe(true);
|
||||
});
|
||||
|
||||
test("should merge settings from JSON string input", async () => {
|
||||
const inputSettings = JSON.stringify({
|
||||
model: "claude-sonnet-4-20250514",
|
||||
env: { API_KEY: "test-key" },
|
||||
});
|
||||
|
||||
await setupClaudeCodeSettings(inputSettings, testHomeDir);
|
||||
|
||||
const settingsContent = await readFile(settingsPath, "utf-8");
|
||||
const settings = JSON.parse(settingsContent);
|
||||
|
||||
expect(settings.enableAllProjectMcpServers).toBe(true);
|
||||
expect(settings.model).toBe("claude-sonnet-4-20250514");
|
||||
expect(settings.env).toEqual({ API_KEY: "test-key" });
|
||||
});
|
||||
|
||||
test("should merge settings from file path input", async () => {
|
||||
const testSettings = {
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: "Bash",
|
||||
hooks: [{ type: "command", command: "echo test" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
permissions: {
|
||||
allow: ["Bash", "Read"],
|
||||
},
|
||||
};
|
||||
|
||||
await writeFile(testSettingsPath, JSON.stringify(testSettings, null, 2));
|
||||
|
||||
await setupClaudeCodeSettings(testSettingsPath, testHomeDir);
|
||||
|
||||
const settingsContent = await readFile(settingsPath, "utf-8");
|
||||
const settings = JSON.parse(settingsContent);
|
||||
|
||||
expect(settings.enableAllProjectMcpServers).toBe(true);
|
||||
expect(settings.hooks).toEqual(testSettings.hooks);
|
||||
expect(settings.permissions).toEqual(testSettings.permissions);
|
||||
});
|
||||
|
||||
test("should override enableAllProjectMcpServers even if false in input", async () => {
|
||||
const inputSettings = JSON.stringify({
|
||||
enableAllProjectMcpServers: false,
|
||||
model: "test-model",
|
||||
});
|
||||
|
||||
await setupClaudeCodeSettings(inputSettings, testHomeDir);
|
||||
|
||||
const settingsContent = await readFile(settingsPath, "utf-8");
|
||||
const settings = JSON.parse(settingsContent);
|
||||
|
||||
expect(settings.enableAllProjectMcpServers).toBe(true);
|
||||
expect(settings.model).toBe("test-model");
|
||||
});
|
||||
|
||||
test("should throw error for invalid JSON string", async () => {
|
||||
expect(() =>
|
||||
setupClaudeCodeSettings("{ invalid json", testHomeDir),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("should throw error for non-existent file path", async () => {
|
||||
expect(() =>
|
||||
setupClaudeCodeSettings("/non/existent/file.json", testHomeDir),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("should handle empty string input", async () => {
|
||||
await setupClaudeCodeSettings("", testHomeDir);
|
||||
|
||||
const settingsContent = await readFile(settingsPath, "utf-8");
|
||||
const settings = JSON.parse(settingsContent);
|
||||
|
||||
expect(settings.enableAllProjectMcpServers).toBe(true);
|
||||
});
|
||||
|
||||
test("should handle whitespace-only input", async () => {
|
||||
await setupClaudeCodeSettings(" \n\t ", testHomeDir);
|
||||
|
||||
const settingsContent = await readFile(settingsPath, "utf-8");
|
||||
const settings = JSON.parse(settingsContent);
|
||||
|
||||
expect(settings.enableAllProjectMcpServers).toBe(true);
|
||||
});
|
||||
|
||||
test("should merge with existing settings", async () => {
|
||||
// First, create some existing settings
|
||||
await setupClaudeCodeSettings(
|
||||
JSON.stringify({ existingKey: "existingValue" }),
|
||||
testHomeDir,
|
||||
);
|
||||
|
||||
// Then, add new settings
|
||||
const newSettings = JSON.stringify({
|
||||
newKey: "newValue",
|
||||
model: "claude-opus-4-20250514",
|
||||
});
|
||||
|
||||
await setupClaudeCodeSettings(newSettings, testHomeDir);
|
||||
|
||||
const settingsContent = await readFile(settingsPath, "utf-8");
|
||||
const settings = JSON.parse(settingsContent);
|
||||
|
||||
expect(settings.enableAllProjectMcpServers).toBe(true);
|
||||
expect(settings.existingKey).toBe("existingValue");
|
||||
expect(settings.newKey).toBe("newValue");
|
||||
expect(settings.model).toBe("claude-opus-4-20250514");
|
||||
});
|
||||
});
|
||||
214
base-action/test/validate-env.test.ts
Normal file
214
base-action/test/validate-env.test.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { validateEnvironmentVariables } from "../src/validate-env";
|
||||
|
||||
describe("validateEnvironmentVariables", () => {
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
// Save the original environment
|
||||
originalEnv = { ...process.env };
|
||||
// Clear relevant environment variables
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
delete process.env.CLAUDE_CODE_USE_BEDROCK;
|
||||
delete process.env.CLAUDE_CODE_USE_VERTEX;
|
||||
delete process.env.AWS_REGION;
|
||||
delete process.env.AWS_ACCESS_KEY_ID;
|
||||
delete process.env.AWS_SECRET_ACCESS_KEY;
|
||||
delete process.env.AWS_SESSION_TOKEN;
|
||||
delete process.env.ANTHROPIC_BEDROCK_BASE_URL;
|
||||
delete process.env.ANTHROPIC_VERTEX_PROJECT_ID;
|
||||
delete process.env.CLOUD_ML_REGION;
|
||||
delete process.env.GOOGLE_APPLICATION_CREDENTIALS;
|
||||
delete process.env.ANTHROPIC_VERTEX_BASE_URL;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore the original environment
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe("Direct Anthropic API", () => {
|
||||
test("should pass when ANTHROPIC_API_KEY is provided", () => {
|
||||
process.env.ANTHROPIC_API_KEY = "test-api-key";
|
||||
|
||||
expect(() => validateEnvironmentVariables()).not.toThrow();
|
||||
});
|
||||
|
||||
test("should fail when ANTHROPIC_API_KEY is missing", () => {
|
||||
expect(() => validateEnvironmentVariables()).toThrow(
|
||||
"Either ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN is required when using direct Anthropic API.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AWS Bedrock", () => {
|
||||
test("should pass when all required Bedrock variables are provided", () => {
|
||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
||||
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";
|
||||
|
||||
expect(() => validateEnvironmentVariables()).not.toThrow();
|
||||
});
|
||||
|
||||
test("should pass with optional Bedrock variables", () => {
|
||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
||||
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.AWS_SESSION_TOKEN = "test-session-token";
|
||||
process.env.ANTHROPIC_BEDROCK_BASE_URL = "https://test.url";
|
||||
|
||||
expect(() => validateEnvironmentVariables()).not.toThrow();
|
||||
});
|
||||
|
||||
test("should construct Bedrock base URL from AWS_REGION when ANTHROPIC_BEDROCK_BASE_URL is not provided", () => {
|
||||
// This test verifies our action.yml change, which constructs:
|
||||
// ANTHROPIC_BEDROCK_BASE_URL: ${{ env.ANTHROPIC_BEDROCK_BASE_URL || (env.AWS_REGION && format('https://bedrock-runtime.{0}.amazonaws.com', env.AWS_REGION)) }}
|
||||
|
||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
||||
process.env.AWS_REGION = "us-west-2";
|
||||
process.env.AWS_ACCESS_KEY_ID = "test-access-key";
|
||||
process.env.AWS_SECRET_ACCESS_KEY = "test-secret-key";
|
||||
// ANTHROPIC_BEDROCK_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_BEDROCK_BASE_URL would be:
|
||||
// https://bedrock-runtime.us-west-2.amazonaws.com
|
||||
});
|
||||
|
||||
test("should fail when AWS_REGION is missing", () => {
|
||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
||||
process.env.AWS_ACCESS_KEY_ID = "test-access-key";
|
||||
process.env.AWS_SECRET_ACCESS_KEY = "test-secret-key";
|
||||
|
||||
expect(() => validateEnvironmentVariables()).toThrow(
|
||||
"AWS_REGION is required when using AWS Bedrock.",
|
||||
);
|
||||
});
|
||||
|
||||
test("should fail when AWS_ACCESS_KEY_ID is missing", () => {
|
||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
||||
process.env.AWS_REGION = "us-east-1";
|
||||
process.env.AWS_SECRET_ACCESS_KEY = "test-secret-key";
|
||||
|
||||
expect(() => validateEnvironmentVariables()).toThrow(
|
||||
"AWS_ACCESS_KEY_ID is required when using AWS Bedrock.",
|
||||
);
|
||||
});
|
||||
|
||||
test("should fail when AWS_SECRET_ACCESS_KEY is missing", () => {
|
||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
||||
process.env.AWS_REGION = "us-east-1";
|
||||
process.env.AWS_ACCESS_KEY_ID = "test-access-key";
|
||||
|
||||
expect(() => validateEnvironmentVariables()).toThrow(
|
||||
"AWS_SECRET_ACCESS_KEY is required when using AWS Bedrock.",
|
||||
);
|
||||
});
|
||||
|
||||
test("should report all missing Bedrock variables", () => {
|
||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
||||
|
||||
expect(() => validateEnvironmentVariables()).toThrow(
|
||||
/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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Google Vertex AI", () => {
|
||||
test("should pass when all required Vertex variables are provided", () => {
|
||||
process.env.CLAUDE_CODE_USE_VERTEX = "1";
|
||||
process.env.ANTHROPIC_VERTEX_PROJECT_ID = "test-project";
|
||||
process.env.CLOUD_ML_REGION = "us-central1";
|
||||
|
||||
expect(() => validateEnvironmentVariables()).not.toThrow();
|
||||
});
|
||||
|
||||
test("should pass with optional Vertex variables", () => {
|
||||
process.env.CLAUDE_CODE_USE_VERTEX = "1";
|
||||
process.env.ANTHROPIC_VERTEX_PROJECT_ID = "test-project";
|
||||
process.env.CLOUD_ML_REGION = "us-central1";
|
||||
process.env.GOOGLE_APPLICATION_CREDENTIALS = "/path/to/creds.json";
|
||||
process.env.ANTHROPIC_VERTEX_BASE_URL = "https://test.url";
|
||||
|
||||
expect(() => validateEnvironmentVariables()).not.toThrow();
|
||||
});
|
||||
|
||||
test("should fail when ANTHROPIC_VERTEX_PROJECT_ID is missing", () => {
|
||||
process.env.CLAUDE_CODE_USE_VERTEX = "1";
|
||||
process.env.CLOUD_ML_REGION = "us-central1";
|
||||
|
||||
expect(() => validateEnvironmentVariables()).toThrow(
|
||||
"ANTHROPIC_VERTEX_PROJECT_ID is required when using Google Vertex AI.",
|
||||
);
|
||||
});
|
||||
|
||||
test("should fail when CLOUD_ML_REGION is missing", () => {
|
||||
process.env.CLAUDE_CODE_USE_VERTEX = "1";
|
||||
process.env.ANTHROPIC_VERTEX_PROJECT_ID = "test-project";
|
||||
|
||||
expect(() => validateEnvironmentVariables()).toThrow(
|
||||
"CLOUD_ML_REGION is required when using Google Vertex AI.",
|
||||
);
|
||||
});
|
||||
|
||||
test("should report all missing Vertex variables", () => {
|
||||
process.env.CLAUDE_CODE_USE_VERTEX = "1";
|
||||
|
||||
expect(() => validateEnvironmentVariables()).toThrow(
|
||||
/ANTHROPIC_VERTEX_PROJECT_ID is required when using Google Vertex AI.*CLOUD_ML_REGION is required when using Google Vertex AI/s,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Multiple providers", () => {
|
||||
test("should fail when both Bedrock and Vertex are enabled", () => {
|
||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
||||
process.env.CLAUDE_CODE_USE_VERTEX = "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";
|
||||
|
||||
expect(() => validateEnvironmentVariables()).toThrow(
|
||||
"Cannot use both Bedrock and Vertex AI simultaneously. Please set only one provider.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error message formatting", () => {
|
||||
test("should format error message properly with multiple errors", () => {
|
||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
||||
// Missing all required Bedrock vars
|
||||
|
||||
let error: Error | undefined;
|
||||
try {
|
||||
validateEnvironmentVariables();
|
||||
} catch (e) {
|
||||
error = e as Error;
|
||||
}
|
||||
|
||||
expect(error).toBeDefined();
|
||||
expect(error!.message).toMatch(
|
||||
/^Environment variable validation failed:/,
|
||||
);
|
||||
expect(error!.message).toContain(
|
||||
" - AWS_REGION is required when using AWS Bedrock.",
|
||||
);
|
||||
expect(error!.message).toContain(
|
||||
" - AWS_ACCESS_KEY_ID is required when using AWS Bedrock.",
|
||||
);
|
||||
expect(error!.message).toContain(
|
||||
" - AWS_SECRET_ACCESS_KEY is required when using AWS Bedrock.",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
30
base-action/tsconfig.json
Normal file
30
base-action/tsconfig.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": ["ESNext"],
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
|
||||
// Bundler mode (Bun-specific)
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
|
||||
// Some stricter flags
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
},
|
||||
"include": ["src/**/*", "test/**/*"],
|
||||
"exclude": ["node_modules", "test/mcp-test"]
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
name: Claude Task Executor
|
||||
|
||||
on:
|
||||
repository_dispatch:
|
||||
types: [claude-task]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
id-token: write # Required for OIDC authentication
|
||||
|
||||
jobs:
|
||||
execute-claude-task:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Execute Claude Task
|
||||
uses: anthropics/claude-code-action@main
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Base branch for creating task branches
|
||||
base_branch: main
|
||||
# Optional: Custom instructions for Claude
|
||||
custom_instructions: |
|
||||
Follow the CLAUDE.md guidelines strictly.
|
||||
Commit changes with descriptive messages.
|
||||
# Optional: Tool restrictions
|
||||
allowed_tools: |
|
||||
file_editor
|
||||
bash_command
|
||||
github_comment
|
||||
mcp__github__create_or_update_file
|
||||
# Optional: Anthropic API configuration
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
# Or use AWS Bedrock
|
||||
# aws_access_key: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
# aws_secret_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
# aws_region: us-east-1
|
||||
# Or use Google Vertex AI
|
||||
# google_credentials: ${{ secrets.GOOGLE_CREDENTIALS }}
|
||||
# vertex_project: my-project
|
||||
# vertex_location: us-central1
|
||||
# Example: Triggering this workflow from another service
|
||||
#
|
||||
# curl -X POST \
|
||||
# https://api.github.com/repos/owner/repo/dispatches \
|
||||
# -H "Authorization: token $GITHUB_TOKEN" \
|
||||
# -H "Accept: application/vnd.github.v3+json" \
|
||||
# -d '{
|
||||
# "event_type": "claude-task",
|
||||
# "client_payload": {
|
||||
# "description": "Analyze the codebase and create a comprehensive test suite for the authentication module",
|
||||
# "progress_endpoint": "https://api.example.com/claude/progress",
|
||||
# "correlation_id": "task-auth-tests-2024-01-17"
|
||||
# }
|
||||
# }'
|
||||
#
|
||||
# The progress_endpoint will receive POST requests with:
|
||||
# {
|
||||
# "repository": "owner/repo",
|
||||
# "run_id": "123456789",
|
||||
# "correlation_id": "task-auth-tests-2024-01-17",
|
||||
# "status": "in_progress" | "completed" | "failed",
|
||||
# "message": "Current progress description",
|
||||
# "completed_tasks": ["task1", "task2"],
|
||||
# "current_task": "Working on task3",
|
||||
# "timestamp": "2024-01-17T12:00:00Z"
|
||||
# }
|
||||
#
|
||||
# Authentication: Progress updates include a GitHub OIDC token in the Authorization header
|
||||
@@ -33,4 +33,15 @@ jobs:
|
||||
uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
# Or use OAuth token instead:
|
||||
# claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
timeout_minutes: "60"
|
||||
# Optional: Restrict network access to specific domains only
|
||||
# experimental_allowed_domains: |
|
||||
# .anthropic.com
|
||||
# .github.com
|
||||
# api.github.com
|
||||
# .githubusercontent.com
|
||||
# bun.sh
|
||||
# registry.npmjs.org
|
||||
# .blob.core.windows.net
|
||||
|
||||
118
pr-summary.md
118
pr-summary.md
@@ -1,118 +0,0 @@
|
||||
## Summary
|
||||
|
||||
Adds support for `repository_dispatch` events, enabling backend services to programmatically trigger Claude to perform tasks and receive progress updates via API.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Backend as Backend Service
|
||||
participant GH as GitHub
|
||||
participant Action as Claude Action
|
||||
participant Claude as Claude
|
||||
participant MCP as Progress MCP Server
|
||||
participant API as Progress API
|
||||
|
||||
Backend->>GH: POST /repos/{owner}/{repo}/dispatches
|
||||
Note over Backend,GH: Payload includes:<br/>- description (task)<br/>- progress_endpoint<br/>- correlation_id
|
||||
|
||||
GH->>Action: Trigger workflow<br/>(repository_dispatch)
|
||||
|
||||
Action->>Action: Parse dispatch payload
|
||||
Note over Action: Extract task description,<br/>endpoint, correlation_id
|
||||
|
||||
Action->>MCP: Install Progress Server
|
||||
Note over MCP: Configure with:<br/>- PROGRESS_ENDPOINT<br/>- CORRELATION_ID<br/>- GITHUB_RUN_ID
|
||||
|
||||
Action->>Claude: Execute task with<br/>MCP tools available
|
||||
|
||||
loop Task Execution
|
||||
Claude->>MCP: update_claude_progress()
|
||||
MCP->>MCP: Get OIDC token
|
||||
MCP->>API: POST progress update
|
||||
Note over API: Payload includes:<br/>- correlation_id<br/>- status<br/>- message<br/>- completed_tasks
|
||||
API->>Backend: Forward update
|
||||
end
|
||||
|
||||
Claude->>Action: Task complete
|
||||
Action->>GH: Commit changes
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
### 1. Repository Dispatch Support
|
||||
|
||||
- New event handler for `repository_dispatch` events
|
||||
- Extracts task description, progress endpoint, and correlation ID from `client_payload`
|
||||
- Bypasses GitHub UI interaction for fully programmatic operation
|
||||
|
||||
### 2. Progress Reporting MCP Server
|
||||
|
||||
- New MCP server (`progress-server.ts`) for sending progress updates
|
||||
- OIDC authentication for secure API communication
|
||||
- Includes correlation ID in all updates for request tracking
|
||||
|
||||
### 3. Simplified Dispatch Prompts
|
||||
|
||||
- Focused instructions for dispatch events (no PR/issue context)
|
||||
- Clear directives: answer questions or implement changes
|
||||
- Automatic progress updates at start and completion
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Triggering a Dispatch
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
https://api.github.com/repos/{owner}/{repo}/dispatches \
|
||||
-H "Authorization: token $GITHUB_TOKEN" \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
-d '{
|
||||
"event_type": "claude-task",
|
||||
"client_payload": {
|
||||
"description": "Implement a new feature that...",
|
||||
"progress_endpoint": "https://api.example.com/progress",
|
||||
"correlation_id": "req-123-abc"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### Progress Update Payload
|
||||
|
||||
```json
|
||||
{
|
||||
"repository": "owner/repo",
|
||||
"run_id": "123456789",
|
||||
"correlation_id": "req-123-abc",
|
||||
"status": "in_progress",
|
||||
"message": "Implementing feature...",
|
||||
"completed_tasks": ["Setup environment", "Created base structure"],
|
||||
"current_task": "Writing tests",
|
||||
"timestamp": "2024-01-17T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
- **OIDC Authentication**: All progress updates use GitHub OIDC tokens
|
||||
- **Correlation IDs**: Included in request body (not URL) for security
|
||||
- **Endpoint Validation**: Progress endpoint must be explicitly provided
|
||||
- **No Credential Storage**: Tokens are generated per-request
|
||||
|
||||
## Testing
|
||||
|
||||
To test the repository_dispatch flow:
|
||||
|
||||
1. Configure workflow with `repository_dispatch` trigger
|
||||
2. Send dispatch event with required payload
|
||||
3. Monitor GitHub Actions logs for execution
|
||||
4. Verify progress updates at configured endpoint
|
||||
|
||||
## Changes
|
||||
|
||||
- Added `repository_dispatch` event handling in `context.ts`
|
||||
- Created new `progress-server.ts` MCP server
|
||||
- Updated `isDispatch` flag across all event types
|
||||
- Modified prompt generation for dispatch events
|
||||
- Made `githubData` optional for dispatch workflows
|
||||
- Added correlation ID support throughout the pipeline
|
||||
@@ -30,15 +30,49 @@ const BASE_ALLOWED_TOOLS = [
|
||||
"LS",
|
||||
"Read",
|
||||
"Write",
|
||||
"mcp__github_file_ops__commit_files",
|
||||
"mcp__github_file_ops__delete_files",
|
||||
"mcp__github_file_ops__update_claude_comment",
|
||||
];
|
||||
const DISALLOWED_TOOLS = ["WebSearch", "WebFetch"];
|
||||
|
||||
export function buildAllowedToolsString(customAllowedTools?: string[]): string {
|
||||
export function buildAllowedToolsString(
|
||||
customAllowedTools?: string[],
|
||||
includeActionsTools: boolean = false,
|
||||
useCommitSigning: boolean = false,
|
||||
): string {
|
||||
let baseTools = [...BASE_ALLOWED_TOOLS];
|
||||
|
||||
// Always include the comment update tool from the comment server
|
||||
baseTools.push("mcp__github_comment__update_claude_comment");
|
||||
|
||||
// Add commit signing tools if enabled
|
||||
if (useCommitSigning) {
|
||||
baseTools.push(
|
||||
"mcp__github_file_ops__commit_files",
|
||||
"mcp__github_file_ops__delete_files",
|
||||
);
|
||||
} else {
|
||||
// When not using commit signing, add specific Bash git commands only
|
||||
baseTools.push(
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(git push:*)",
|
||||
"Bash(git status:*)",
|
||||
"Bash(git diff:*)",
|
||||
"Bash(git log:*)",
|
||||
"Bash(git rm:*)",
|
||||
"Bash(git config user.name:*)",
|
||||
"Bash(git config user.email:*)",
|
||||
);
|
||||
}
|
||||
|
||||
// Add GitHub Actions MCP tools if enabled
|
||||
if (includeActionsTools) {
|
||||
baseTools.push(
|
||||
"mcp__github_ci__get_ci_status",
|
||||
"mcp__github_ci__get_workflow_run_details",
|
||||
"mcp__github_ci__download_job_log",
|
||||
);
|
||||
}
|
||||
|
||||
let allAllowedTools = baseTools.join(",");
|
||||
if (customAllowedTools && customAllowedTools.length > 0) {
|
||||
allAllowedTools = `${allAllowedTools},${customAllowedTools.join(",")}`;
|
||||
@@ -81,10 +115,12 @@ export function prepareContext(
|
||||
const eventAction = context.eventAction;
|
||||
const triggerPhrase = context.inputs.triggerPhrase || "@claude";
|
||||
const assigneeTrigger = context.inputs.assigneeTrigger;
|
||||
const labelTrigger = context.inputs.labelTrigger;
|
||||
const customInstructions = context.inputs.customInstructions;
|
||||
const allowedTools = context.inputs.allowedTools;
|
||||
const disallowedTools = context.inputs.disallowedTools;
|
||||
const directPrompt = context.inputs.directPrompt;
|
||||
const overridePrompt = context.inputs.overridePrompt;
|
||||
const isPR = context.isPR;
|
||||
|
||||
// Get PR/Issue number from entityNumber
|
||||
@@ -123,6 +159,7 @@ export function prepareContext(
|
||||
disallowedTools: disallowedTools.join(","),
|
||||
}),
|
||||
...(directPrompt && { directPrompt }),
|
||||
...(overridePrompt && { overridePrompt }),
|
||||
...(claudeBranch && { claudeBranch }),
|
||||
};
|
||||
|
||||
@@ -242,7 +279,7 @@ export function prepareContext(
|
||||
}
|
||||
|
||||
if (eventAction === "assigned") {
|
||||
if (!assigneeTrigger) {
|
||||
if (!assigneeTrigger && !directPrompt) {
|
||||
throw new Error(
|
||||
"ASSIGNEE_TRIGGER is required for issue assigned event",
|
||||
);
|
||||
@@ -254,7 +291,20 @@ export function prepareContext(
|
||||
issueNumber,
|
||||
baseBranch,
|
||||
claudeBranch,
|
||||
assigneeTrigger,
|
||||
...(assigneeTrigger && { assigneeTrigger }),
|
||||
};
|
||||
} else if (eventAction === "labeled") {
|
||||
if (!labelTrigger) {
|
||||
throw new Error("LABEL_TRIGGER is required for issue labeled event");
|
||||
}
|
||||
eventData = {
|
||||
eventName: "issues",
|
||||
eventAction: "labeled",
|
||||
isPR: false,
|
||||
issueNumber,
|
||||
baseBranch,
|
||||
claudeBranch,
|
||||
labelTrigger,
|
||||
};
|
||||
} else if (eventAction === "opened") {
|
||||
eventData = {
|
||||
@@ -328,10 +378,17 @@ export function getEventTypeAndContext(envVars: PreparedContext): {
|
||||
eventType: "ISSUE_CREATED",
|
||||
triggerContext: `new issue with '${envVars.triggerPhrase}' in body`,
|
||||
};
|
||||
} else if (eventData.eventAction === "labeled") {
|
||||
return {
|
||||
eventType: "ISSUE_LABELED",
|
||||
triggerContext: `issue labeled with '${eventData.labelTrigger}'`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
eventType: "ISSUE_ASSIGNED",
|
||||
triggerContext: `issue assigned to '${eventData.assigneeTrigger}'`,
|
||||
triggerContext: eventData.assigneeTrigger
|
||||
? `issue assigned to '${eventData.assigneeTrigger}'`
|
||||
: `issue assigned event`,
|
||||
};
|
||||
|
||||
case "pull_request":
|
||||
@@ -347,10 +404,134 @@ export function getEventTypeAndContext(envVars: PreparedContext): {
|
||||
}
|
||||
}
|
||||
|
||||
export function generatePrompt(
|
||||
function getCommitInstructions(
|
||||
eventData: EventData,
|
||||
githubData: FetchDataResult,
|
||||
context: PreparedContext,
|
||||
useCommitSigning: boolean,
|
||||
): string {
|
||||
const coAuthorLine =
|
||||
(githubData.triggerDisplayName ?? context.triggerUsername !== "Unknown")
|
||||
? `Co-authored-by: ${githubData.triggerDisplayName ?? context.triggerUsername} <${context.triggerUsername}@users.noreply.github.com>`
|
||||
: "";
|
||||
|
||||
if (useCommitSigning) {
|
||||
if (eventData.isPR && !eventData.claudeBranch) {
|
||||
return `
|
||||
- Push directly using mcp__github_file_ops__commit_files to the existing branch (works for both new and existing files).
|
||||
- Use mcp__github_file_ops__commit_files to commit files atomically in a single commit (supports single or multiple files).
|
||||
- When pushing changes with this tool and the trigger user is not "Unknown", include a Co-authored-by trailer in the commit message.
|
||||
- Use: "${coAuthorLine}"`;
|
||||
} else {
|
||||
return `
|
||||
- You are already on the correct branch (${eventData.claudeBranch || "the PR branch"}). Do not create a new branch.
|
||||
- Push changes directly to the current branch using mcp__github_file_ops__commit_files (works for both new and existing files)
|
||||
- Use mcp__github_file_ops__commit_files to commit files atomically in a single commit (supports single or multiple files).
|
||||
- When pushing changes and the trigger user is not "Unknown", include a Co-authored-by trailer in the commit message.
|
||||
- Use: "${coAuthorLine}"`;
|
||||
}
|
||||
} else {
|
||||
// Non-signing instructions
|
||||
if (eventData.isPR && !eventData.claudeBranch) {
|
||||
return `
|
||||
- Use git commands via the Bash tool to commit and push your changes:
|
||||
- Stage files: Bash(git add <files>)
|
||||
- Commit with a descriptive message: Bash(git commit -m "<message>")
|
||||
${
|
||||
coAuthorLine
|
||||
? `- When committing and the trigger user is not "Unknown", include a Co-authored-by trailer:
|
||||
Bash(git commit -m "<message>\\n\\n${coAuthorLine}")`
|
||||
: ""
|
||||
}
|
||||
- Push to the remote: Bash(git push origin HEAD)`;
|
||||
} else {
|
||||
const branchName = eventData.claudeBranch || eventData.baseBranch;
|
||||
return `
|
||||
- You are already on the correct branch (${eventData.claudeBranch || "the PR branch"}). Do not create a new branch.
|
||||
- Use git commands via the Bash tool to commit and push your changes:
|
||||
- Stage files: Bash(git add <files>)
|
||||
- Commit with a descriptive message: Bash(git commit -m "<message>")
|
||||
${
|
||||
coAuthorLine
|
||||
? `- When committing and the trigger user is not "Unknown", include a Co-authored-by trailer:
|
||||
Bash(git commit -m "<message>\\n\\n${coAuthorLine}")`
|
||||
: ""
|
||||
}
|
||||
- Push to the remote: Bash(git push origin ${branchName})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function substitutePromptVariables(
|
||||
template: string,
|
||||
context: PreparedContext,
|
||||
githubData: FetchDataResult,
|
||||
): string {
|
||||
const { contextData, comments, reviewData, changedFilesWithSHA } = githubData;
|
||||
const { eventData } = context;
|
||||
|
||||
const variables: Record<string, string> = {
|
||||
REPOSITORY: context.repository,
|
||||
PR_NUMBER:
|
||||
eventData.isPR && "prNumber" in eventData ? eventData.prNumber : "",
|
||||
ISSUE_NUMBER:
|
||||
!eventData.isPR && "issueNumber" in eventData
|
||||
? eventData.issueNumber
|
||||
: "",
|
||||
PR_TITLE: eventData.isPR && contextData?.title ? contextData.title : "",
|
||||
ISSUE_TITLE: !eventData.isPR && contextData?.title ? contextData.title : "",
|
||||
PR_BODY: eventData.isPR && contextData?.body ? contextData.body : "",
|
||||
ISSUE_BODY: !eventData.isPR && contextData?.body ? contextData.body : "",
|
||||
PR_COMMENTS: eventData.isPR
|
||||
? formatComments(comments, githubData.imageUrlMap)
|
||||
: "",
|
||||
ISSUE_COMMENTS: !eventData.isPR
|
||||
? formatComments(comments, githubData.imageUrlMap)
|
||||
: "",
|
||||
REVIEW_COMMENTS: eventData.isPR
|
||||
? formatReviewComments(reviewData, githubData.imageUrlMap)
|
||||
: "",
|
||||
CHANGED_FILES: eventData.isPR
|
||||
? formatChangedFilesWithSHA(changedFilesWithSHA)
|
||||
: "",
|
||||
TRIGGER_COMMENT: "commentBody" in eventData ? eventData.commentBody : "",
|
||||
TRIGGER_USERNAME: context.triggerUsername || "",
|
||||
BRANCH_NAME:
|
||||
"claudeBranch" in eventData && eventData.claudeBranch
|
||||
? eventData.claudeBranch
|
||||
: "baseBranch" in eventData && eventData.baseBranch
|
||||
? eventData.baseBranch
|
||||
: "",
|
||||
BASE_BRANCH:
|
||||
"baseBranch" in eventData && eventData.baseBranch
|
||||
? eventData.baseBranch
|
||||
: "",
|
||||
EVENT_TYPE: eventData.eventName,
|
||||
IS_PR: eventData.isPR ? "true" : "false",
|
||||
};
|
||||
|
||||
let result = template;
|
||||
for (const [key, value] of Object.entries(variables)) {
|
||||
const regex = new RegExp(`\\$${key}`, "g");
|
||||
result = result.replace(regex, value);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function generatePrompt(
|
||||
context: PreparedContext,
|
||||
githubData: FetchDataResult,
|
||||
useCommitSigning: boolean,
|
||||
): string {
|
||||
if (context.overridePrompt) {
|
||||
return substitutePromptVariables(
|
||||
context.overridePrompt,
|
||||
context,
|
||||
githubData,
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
contextData,
|
||||
comments,
|
||||
@@ -438,9 +619,9 @@ ${sanitizeContent(context.directPrompt)}
|
||||
: ""
|
||||
}
|
||||
${`<comment_tool_info>
|
||||
IMPORTANT: You have been provided with the mcp__github_file_ops__update_claude_comment tool to update your comment. This tool automatically handles both issue and PR comments.
|
||||
IMPORTANT: You have been provided with the mcp__github_comment__update_claude_comment tool to update your comment. This tool automatically handles both issue and PR comments.
|
||||
|
||||
Tool usage example for mcp__github_file_ops__update_claude_comment:
|
||||
Tool usage example for mcp__github_comment__update_claude_comment:
|
||||
{
|
||||
"body": "Your comment text here"
|
||||
}
|
||||
@@ -459,12 +640,13 @@ Follow these steps:
|
||||
1. Create a Todo List:
|
||||
- Use your GitHub comment to maintain a detailed task list based on the request.
|
||||
- Format todos as a checklist (- [ ] for incomplete, - [x] for complete).
|
||||
- Update the comment using mcp__github_file_ops__update_claude_comment with each task completion.
|
||||
- Update the comment using mcp__github_comment__update_claude_comment with each task completion.
|
||||
|
||||
2. Gather Context:
|
||||
- Analyze the pre-fetched data provided above.
|
||||
- For ISSUE_CREATED: Read the issue body to find the request after the trigger phrase.
|
||||
- For ISSUE_ASSIGNED: Read the entire issue body to understand the task.
|
||||
- For ISSUE_LABELED: Read the entire issue body to understand the task.
|
||||
${eventData.eventName === "issue_comment" || eventData.eventName === "pull_request_review_comment" || eventData.eventName === "pull_request_review" ? ` - For comment/review events: Your instructions are in the <trigger_comment> tag above.` : ""}
|
||||
${context.directPrompt ? ` - DIRECT INSTRUCTION: A direct instruction was provided and is shown in the <direct_prompt> tag above. This is not from any GitHub comment but a direct instruction to execute.` : ""}
|
||||
- IMPORTANT: Only the comment/issue containing '${context.triggerPhrase}' has your instructions.
|
||||
@@ -489,29 +671,16 @@ ${context.directPrompt ? ` - DIRECT INSTRUCTION: A direct instruction was prov
|
||||
- Look for bugs, security issues, performance problems, and other issues
|
||||
- Suggest improvements for readability and maintainability
|
||||
- Check for best practices and coding standards
|
||||
- Reference specific code sections with file paths and line numbers${eventData.isPR ? "\n - AFTER reading files and analyzing code, you MUST call mcp__github_file_ops__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.
|
||||
- Reference specific code with inline formatting or code blocks.
|
||||
- Include relevant file paths and line numbers when applicable.
|
||||
- ${eventData.isPR ? "IMPORTANT: Submit your review feedback by updating the Claude comment using mcp__github_file_ops__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_file_ops__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:
|
||||
- Use file system tools to make the change locally.
|
||||
- If you discover related tasks (e.g., updating tests), add them to the todo list.
|
||||
- Mark each subtask as completed as you progress.
|
||||
${
|
||||
eventData.isPR && !eventData.claudeBranch
|
||||
? `
|
||||
- Push directly using mcp__github_file_ops__commit_files to the existing branch (works for both new and existing files).
|
||||
- Use mcp__github_file_ops__commit_files to commit files atomically in a single commit (supports single or multiple files).
|
||||
- When pushing changes with this tool and the trigger user is not "Unknown", include a Co-authored-by trailer in the commit message.
|
||||
- Use: "Co-authored-by: ${githubData.triggerDisplayName ?? context.triggerUsername} <${context.triggerUsername}@users.noreply.github.com>"`
|
||||
: `
|
||||
- You are already on the correct branch (${eventData.claudeBranch || "the PR branch"}). Do not create a new branch.
|
||||
- Push changes directly to the current branch using mcp__github_file_ops__commit_files (works for both new and existing files)
|
||||
- Use mcp__github_file_ops__commit_files to commit files atomically in a single commit (supports single or multiple files).
|
||||
- When pushing changes and the trigger user is not "Unknown", include a Co-authored-by trailer in the commit message.
|
||||
- Use: "Co-authored-by: ${githubData.triggerDisplayName ?? context.triggerUsername} <${context.triggerUsername}@users.noreply.github.com>"
|
||||
- Mark each subtask as completed as you progress.${getCommitInstructions(eventData, githubData, context, useCommitSigning)}
|
||||
${
|
||||
eventData.claudeBranch
|
||||
? `- Provide a URL to create a PR manually in this format:
|
||||
@@ -529,7 +698,6 @@ ${context.directPrompt ? ` - DIRECT INSTRUCTION: A direct instruction was prov
|
||||
- The signature: "Generated with [Claude Code](https://claude.ai/code)"
|
||||
- Just include the markdown link with text "Create a PR" - do not add explanatory text before it like "You can create a PR using this link"`
|
||||
: ""
|
||||
}`
|
||||
}
|
||||
|
||||
C. For Complex Changes:
|
||||
@@ -545,20 +713,31 @@ ${context.directPrompt ? ` - DIRECT INSTRUCTION: A direct instruction was prov
|
||||
- Always update the GitHub comment to reflect the current todo state.
|
||||
- When all todos are completed, remove the spinner and add a brief summary of what was accomplished, and what was not done.
|
||||
- Note: If you see previous Claude comments with headers like "**Claude finished @user's task**" followed by "---", do not include this in your comment. The system adds this automatically.
|
||||
- If you changed any files locally, you must update them in the remote branch via mcp__github_file_ops__commit_files before saying that you're done.
|
||||
- If you changed any files locally, you must update them in the remote branch via ${useCommitSigning ? "mcp__github_file_ops__commit_files" : "git commands (add, commit, push)"} before saying that you're done.
|
||||
${eventData.claudeBranch ? `- If you created anything in your branch, your comment must include the PR URL with prefilled title and body mentioned above.` : ""}
|
||||
|
||||
Important Notes:
|
||||
- All communication must happen through GitHub PR comments.
|
||||
- Never create new comments. Only update the existing comment using mcp__github_file_ops__update_claude_comment.
|
||||
- This includes ALL responses: code reviews, answers to questions, progress updates, and final results.${eventData.isPR ? "\n- PR CRITICAL: After reading files and forming your response, you MUST post it by calling mcp__github_file_ops__update_claude_comment. Do NOT just respond with a normal response, the user will not see it." : ""}
|
||||
- Never create new comments. Only update the existing comment using mcp__github_comment__update_claude_comment.
|
||||
- This includes ALL responses: code reviews, answers to questions, progress updates, and final results.${eventData.isPR ? `\n- PR CRITICAL: After reading files and forming your response, you MUST post it by calling mcp__github_comment__update_claude_comment. Do NOT just respond with a normal response, the user will not see it.` : ""}
|
||||
- You communicate exclusively by editing your single comment - not through any other means.
|
||||
- Use this spinner HTML when work is in progress: <img src="https://github.com/user-attachments/assets/5ac382c7-e004-429b-8e35-7feb3e8f9c6f" width="14px" height="14px" style="vertical-align: middle; margin-left: 4px;" />
|
||||
${eventData.isPR && !eventData.claudeBranch ? `- Always push to the existing branch when triggered on a PR.` : `- IMPORTANT: You are already on the correct branch (${eventData.claudeBranch || "the created branch"}). Never create new branches when triggered on issues or closed/merged PRs.`}
|
||||
- Use mcp__github_file_ops__commit_files for making commits (works for both new and existing files, single or multiple). Use mcp__github_file_ops__delete_files for deleting files (supports deleting single or multiple files atomically), or mcp__github__delete_file for deleting a single file. Edit files locally, and the tool will read the content from the same path on disk.
|
||||
${
|
||||
useCommitSigning
|
||||
? `- Use mcp__github_file_ops__commit_files for making commits (works for both new and existing files, single or multiple). Use mcp__github_file_ops__delete_files for deleting files (supports deleting single or multiple files atomically), or mcp__github__delete_file for deleting a single file. Edit files locally, and the tool will read the content from the same path on disk.
|
||||
Tool usage examples:
|
||||
- mcp__github_file_ops__commit_files: {"files": ["path/to/file1.js", "path/to/file2.py"], "message": "feat: add new feature"}
|
||||
- mcp__github_file_ops__delete_files: {"files": ["path/to/old.js"], "message": "chore: remove deprecated file"}
|
||||
- mcp__github_file_ops__delete_files: {"files": ["path/to/old.js"], "message": "chore: remove deprecated file"}`
|
||||
: `- Use git commands via the Bash tool for version control (you have access to specific git commands only):
|
||||
- Stage files: Bash(git add <files>)
|
||||
- Commit changes: Bash(git commit -m "<message>")
|
||||
- Push to remote: Bash(git push origin <branch>) (NEVER force push)
|
||||
- Delete files: Bash(git rm <files>) followed by commit and push
|
||||
- Check status: Bash(git status)
|
||||
- View diff: Bash(git diff)
|
||||
- Configure git user: Bash(git config user.name "...") and Bash(git config user.email "...")`
|
||||
}
|
||||
- Display the todo list as a checklist in the GitHub comment and mark things off as you go.
|
||||
- REPOSITORY SETUP INSTRUCTIONS: The repository's CLAUDE.md file(s) contain critical repo-specific setup instructions, development guidelines, and preferences. Always read and follow these files, particularly the root CLAUDE.md, as they provide essential context for working with the codebase effectively.
|
||||
- Use h3 headers (###) for section titles in your comments, not h1 headers (#).
|
||||
@@ -582,8 +761,7 @@ What You CANNOT Do:
|
||||
- Submit formal GitHub PR reviews
|
||||
- Approve pull requests (for security reasons)
|
||||
- Post multiple comments (you only update your initial comment)
|
||||
- Execute commands outside the repository context
|
||||
- Run arbitrary Bash commands (unless explicitly allowed via allowed_tools configuration)
|
||||
- Execute commands outside the repository context${useCommitSigning ? "\n- Run arbitrary Bash commands (unless explicitly allowed via allowed_tools configuration)" : ""}
|
||||
- Perform branch operations (cannot merge branches, rebase, or perform other git operations beyond pushing commits)
|
||||
- Modify files in the .github/workflows directory (GitHub App permissions do not allow workflow modifications)
|
||||
- View CI/CD results or workflow run outputs (cannot access GitHub Actions logs or test results)
|
||||
@@ -629,7 +807,11 @@ export async function createPrompt(
|
||||
});
|
||||
|
||||
// Generate the prompt
|
||||
const promptContent = generatePrompt(preparedContext, githubData);
|
||||
const promptContent = generatePrompt(
|
||||
preparedContext,
|
||||
githubData,
|
||||
context.inputs.useCommitSigning,
|
||||
);
|
||||
|
||||
// Log the final prompt to console
|
||||
console.log("===== FINAL PROMPT =====");
|
||||
@@ -643,8 +825,13 @@ export async function createPrompt(
|
||||
);
|
||||
|
||||
// Set allowed tools
|
||||
const hasActionsReadPermission =
|
||||
context.inputs.additionalPermissions.get("actions") === "read" &&
|
||||
context.isPR;
|
||||
const allAllowedTools = buildAllowedToolsString(
|
||||
context.inputs.allowedTools,
|
||||
hasActionsReadPermission,
|
||||
context.inputs.useCommitSigning,
|
||||
);
|
||||
const allDisallowedTools = buildDisallowedToolsString(
|
||||
context.inputs.disallowedTools,
|
||||
|
||||
@@ -7,6 +7,7 @@ export type CommonFields = {
|
||||
allowedTools?: string;
|
||||
disallowedTools?: string;
|
||||
directPrompt?: string;
|
||||
overridePrompt?: string;
|
||||
};
|
||||
|
||||
type PullRequestReviewCommentEvent = {
|
||||
@@ -65,7 +66,17 @@ type IssueAssignedEvent = {
|
||||
issueNumber: string;
|
||||
baseBranch: string;
|
||||
claudeBranch: string;
|
||||
assigneeTrigger: string;
|
||||
assigneeTrigger?: string;
|
||||
};
|
||||
|
||||
type IssueLabeledEvent = {
|
||||
eventName: "issues";
|
||||
eventAction: "labeled";
|
||||
isPR: false;
|
||||
issueNumber: string;
|
||||
baseBranch: string;
|
||||
claudeBranch: string;
|
||||
labelTrigger: string;
|
||||
};
|
||||
|
||||
type PullRequestEvent = {
|
||||
@@ -85,6 +96,7 @@ export type EventData =
|
||||
| IssueCommentEvent
|
||||
| IssueOpenedEvent
|
||||
| IssueAssignedEvent
|
||||
| IssueLabeledEvent
|
||||
| PullRequestEvent;
|
||||
|
||||
// Combined type with separate eventData field
|
||||
|
||||
461
src/entrypoints/format-turns.ts
Executable file
461
src/entrypoints/format-turns.ts
Executable file
@@ -0,0 +1,461 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { readFileSync, existsSync } from "fs";
|
||||
import { exit } from "process";
|
||||
|
||||
export interface ToolUse {
|
||||
type: string;
|
||||
name?: string;
|
||||
input?: Record<string, any>;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface ToolResult {
|
||||
type: string;
|
||||
tool_use_id?: string;
|
||||
content?: any;
|
||||
is_error?: boolean;
|
||||
}
|
||||
|
||||
export interface ContentItem {
|
||||
type: string;
|
||||
text?: string;
|
||||
tool_use_id?: string;
|
||||
content?: any;
|
||||
is_error?: boolean;
|
||||
name?: string;
|
||||
input?: Record<string, any>;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
content: ContentItem[];
|
||||
usage?: {
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Turn {
|
||||
type: string;
|
||||
subtype?: string;
|
||||
message?: Message;
|
||||
tools?: any[];
|
||||
cost_usd?: number;
|
||||
duration_ms?: number;
|
||||
result?: string;
|
||||
}
|
||||
|
||||
export interface GroupedContent {
|
||||
type: string;
|
||||
tools_count?: number;
|
||||
data?: Turn;
|
||||
text_parts?: string[];
|
||||
tool_calls?: { tool_use: ToolUse; tool_result?: ToolResult }[];
|
||||
usage?: Record<string, number>;
|
||||
}
|
||||
|
||||
export function detectContentType(content: any): string {
|
||||
const contentStr = String(content).trim();
|
||||
|
||||
// Check for JSON
|
||||
if (contentStr.startsWith("{") && contentStr.endsWith("}")) {
|
||||
try {
|
||||
JSON.parse(contentStr);
|
||||
return "json";
|
||||
} catch {
|
||||
// Fall through
|
||||
}
|
||||
}
|
||||
|
||||
if (contentStr.startsWith("[") && contentStr.endsWith("]")) {
|
||||
try {
|
||||
JSON.parse(contentStr);
|
||||
return "json";
|
||||
} catch {
|
||||
// Fall through
|
||||
}
|
||||
}
|
||||
|
||||
// Check for code-like content
|
||||
const codeKeywords = [
|
||||
"def ",
|
||||
"class ",
|
||||
"import ",
|
||||
"from ",
|
||||
"function ",
|
||||
"const ",
|
||||
"let ",
|
||||
"var ",
|
||||
];
|
||||
if (codeKeywords.some((keyword) => contentStr.includes(keyword))) {
|
||||
if (
|
||||
contentStr.includes("def ") ||
|
||||
contentStr.includes("import ") ||
|
||||
contentStr.includes("from ")
|
||||
) {
|
||||
return "python";
|
||||
} else if (
|
||||
["function ", "const ", "let ", "var ", "=>"].some((js) =>
|
||||
contentStr.includes(js),
|
||||
)
|
||||
) {
|
||||
return "javascript";
|
||||
} else {
|
||||
return "python"; // default for code
|
||||
}
|
||||
}
|
||||
|
||||
// Check for shell/bash output
|
||||
const shellIndicators = ["ls -", "cd ", "mkdir ", "rm ", "$ ", "# "];
|
||||
if (
|
||||
contentStr.startsWith("/") ||
|
||||
contentStr.includes("Error:") ||
|
||||
contentStr.startsWith("total ") ||
|
||||
shellIndicators.some((indicator) => contentStr.includes(indicator))
|
||||
) {
|
||||
return "bash";
|
||||
}
|
||||
|
||||
// Check for diff format
|
||||
if (
|
||||
contentStr.startsWith("@@") ||
|
||||
contentStr.includes("+++ ") ||
|
||||
contentStr.includes("--- ")
|
||||
) {
|
||||
return "diff";
|
||||
}
|
||||
|
||||
// Check for HTML/XML
|
||||
if (contentStr.startsWith("<") && contentStr.endsWith(">")) {
|
||||
return "html";
|
||||
}
|
||||
|
||||
// Check for markdown
|
||||
const mdIndicators = ["# ", "## ", "### ", "- ", "* ", "```"];
|
||||
if (mdIndicators.some((indicator) => contentStr.includes(indicator))) {
|
||||
return "markdown";
|
||||
}
|
||||
|
||||
// Default to plain text
|
||||
return "text";
|
||||
}
|
||||
|
||||
export function formatResultContent(content: any): string {
|
||||
if (!content) {
|
||||
return "*(No output)*\n\n";
|
||||
}
|
||||
|
||||
let contentStr: string;
|
||||
|
||||
// Check if content is a list with "type": "text" structure
|
||||
try {
|
||||
let parsedContent: any;
|
||||
if (typeof content === "string") {
|
||||
parsedContent = JSON.parse(content);
|
||||
} else {
|
||||
parsedContent = content;
|
||||
}
|
||||
|
||||
if (
|
||||
Array.isArray(parsedContent) &&
|
||||
parsedContent.length > 0 &&
|
||||
typeof parsedContent[0] === "object" &&
|
||||
parsedContent[0]?.type === "text"
|
||||
) {
|
||||
// Extract the text field from the first item
|
||||
contentStr = parsedContent[0]?.text || "";
|
||||
} else {
|
||||
contentStr = String(content).trim();
|
||||
}
|
||||
} catch {
|
||||
contentStr = String(content).trim();
|
||||
}
|
||||
|
||||
// Truncate very long results
|
||||
if (contentStr.length > 3000) {
|
||||
contentStr = contentStr.substring(0, 2997) + "...";
|
||||
}
|
||||
|
||||
// Detect content type
|
||||
const contentType = detectContentType(contentStr);
|
||||
|
||||
// Handle JSON content specially - pretty print it
|
||||
if (contentType === "json") {
|
||||
try {
|
||||
// Try to parse and pretty print JSON
|
||||
const parsed = JSON.parse(contentStr);
|
||||
contentStr = JSON.stringify(parsed, null, 2);
|
||||
} catch {
|
||||
// Keep original if parsing fails
|
||||
}
|
||||
}
|
||||
|
||||
// Format with appropriate syntax highlighting
|
||||
if (
|
||||
contentType === "text" &&
|
||||
contentStr.length < 100 &&
|
||||
!contentStr.includes("\n")
|
||||
) {
|
||||
// Short text results don't need code blocks
|
||||
return `**→** ${contentStr}\n\n`;
|
||||
} else {
|
||||
return `**Result:**\n\`\`\`${contentType}\n${contentStr}\n\`\`\`\n\n`;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatToolWithResult(
|
||||
toolUse: ToolUse,
|
||||
toolResult?: ToolResult,
|
||||
): string {
|
||||
const toolName = toolUse.name || "unknown_tool";
|
||||
const toolInput = toolUse.input || {};
|
||||
|
||||
let result = `### 🔧 \`${toolName}\`\n\n`;
|
||||
|
||||
// Add parameters if they exist and are not empty
|
||||
if (Object.keys(toolInput).length > 0) {
|
||||
result += "**Parameters:**\n```json\n";
|
||||
result += JSON.stringify(toolInput, null, 2);
|
||||
result += "\n```\n\n";
|
||||
}
|
||||
|
||||
// Add result if available
|
||||
if (toolResult) {
|
||||
const content = toolResult.content || "";
|
||||
const isError = toolResult.is_error || false;
|
||||
|
||||
if (isError) {
|
||||
result += `❌ **Error:** \`${content}\`\n\n`;
|
||||
} else {
|
||||
result += formatResultContent(content);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function groupTurnsNaturally(data: Turn[]): GroupedContent[] {
|
||||
const groupedContent: GroupedContent[] = [];
|
||||
const toolResultsMap = new Map<string, ToolResult>();
|
||||
|
||||
// First pass: collect all tool results by tool_use_id
|
||||
for (const turn of data) {
|
||||
if (turn.type === "user") {
|
||||
const content = turn.message?.content || [];
|
||||
for (const item of content) {
|
||||
if (item.type === "tool_result" && item.tool_use_id) {
|
||||
toolResultsMap.set(item.tool_use_id, {
|
||||
type: item.type,
|
||||
tool_use_id: item.tool_use_id,
|
||||
content: item.content,
|
||||
is_error: item.is_error,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: process turns and group naturally
|
||||
for (const turn of data) {
|
||||
const turnType = turn.type || "unknown";
|
||||
|
||||
if (turnType === "system") {
|
||||
const subtype = turn.subtype || "";
|
||||
if (subtype === "init") {
|
||||
const tools = turn.tools || [];
|
||||
groupedContent.push({
|
||||
type: "system_init",
|
||||
tools_count: tools.length,
|
||||
});
|
||||
} else {
|
||||
groupedContent.push({
|
||||
type: "system_other",
|
||||
data: turn,
|
||||
});
|
||||
}
|
||||
} else if (turnType === "assistant") {
|
||||
const message = turn.message || { content: [] };
|
||||
const content = message.content || [];
|
||||
const usage = message.usage || {};
|
||||
|
||||
// Process content items
|
||||
const textParts: string[] = [];
|
||||
const toolCalls: { tool_use: ToolUse; tool_result?: ToolResult }[] = [];
|
||||
|
||||
for (const item of content) {
|
||||
const itemType = item.type || "";
|
||||
|
||||
if (itemType === "text") {
|
||||
textParts.push(item.text || "");
|
||||
} else if (itemType === "tool_use") {
|
||||
const toolUseId = item.id;
|
||||
const toolResult = toolUseId
|
||||
? toolResultsMap.get(toolUseId)
|
||||
: undefined;
|
||||
toolCalls.push({
|
||||
tool_use: {
|
||||
type: item.type,
|
||||
name: item.name,
|
||||
input: item.input,
|
||||
id: item.id,
|
||||
},
|
||||
tool_result: toolResult,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (textParts.length > 0 || toolCalls.length > 0) {
|
||||
groupedContent.push({
|
||||
type: "assistant_action",
|
||||
text_parts: textParts,
|
||||
tool_calls: toolCalls,
|
||||
usage: usage,
|
||||
});
|
||||
}
|
||||
} else if (turnType === "user") {
|
||||
// Handle user messages that aren't tool results
|
||||
const message = turn.message || { content: [] };
|
||||
const content = message.content || [];
|
||||
const textParts: string[] = [];
|
||||
|
||||
for (const item of content) {
|
||||
if (item.type === "text") {
|
||||
textParts.push(item.text || "");
|
||||
}
|
||||
}
|
||||
|
||||
if (textParts.length > 0) {
|
||||
groupedContent.push({
|
||||
type: "user_message",
|
||||
text_parts: textParts,
|
||||
});
|
||||
}
|
||||
} else if (turnType === "result") {
|
||||
groupedContent.push({
|
||||
type: "final_result",
|
||||
data: turn,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return groupedContent;
|
||||
}
|
||||
|
||||
export function formatGroupedContent(groupedContent: GroupedContent[]): string {
|
||||
let markdown = "## Claude Code Report\n\n";
|
||||
|
||||
for (const item of groupedContent) {
|
||||
const itemType = item.type;
|
||||
|
||||
if (itemType === "system_init") {
|
||||
markdown += `## 🚀 System Initialization\n\n**Available Tools:** ${item.tools_count} tools loaded\n\n---\n\n`;
|
||||
} else if (itemType === "system_other") {
|
||||
markdown += `## ⚙️ System Message\n\n${JSON.stringify(item.data, null, 2)}\n\n---\n\n`;
|
||||
} else if (itemType === "assistant_action") {
|
||||
// Add text content first (if any) - no header needed
|
||||
for (const text of item.text_parts || []) {
|
||||
if (text.trim()) {
|
||||
markdown += `${text}\n\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// Add tool calls with their results
|
||||
for (const toolCall of item.tool_calls || []) {
|
||||
markdown += formatToolWithResult(
|
||||
toolCall.tool_use,
|
||||
toolCall.tool_result,
|
||||
);
|
||||
}
|
||||
|
||||
// Add usage info if available
|
||||
const usage = item.usage || {};
|
||||
if (Object.keys(usage).length > 0) {
|
||||
const inputTokens = usage.input_tokens || 0;
|
||||
const outputTokens = usage.output_tokens || 0;
|
||||
markdown += `*Token usage: ${inputTokens} input, ${outputTokens} output*\n\n`;
|
||||
}
|
||||
|
||||
// Only add separator if this section had content
|
||||
if (
|
||||
(item.text_parts && item.text_parts.length > 0) ||
|
||||
(item.tool_calls && item.tool_calls.length > 0)
|
||||
) {
|
||||
markdown += "---\n\n";
|
||||
}
|
||||
} else if (itemType === "user_message") {
|
||||
markdown += "## 👤 User\n\n";
|
||||
for (const text of item.text_parts || []) {
|
||||
if (text.trim()) {
|
||||
markdown += `${text}\n\n`;
|
||||
}
|
||||
}
|
||||
markdown += "---\n\n";
|
||||
} else if (itemType === "final_result") {
|
||||
const data = item.data || {};
|
||||
const cost = (data as any).cost_usd || 0;
|
||||
const duration = (data as any).duration_ms || 0;
|
||||
const resultText = (data as any).result || "";
|
||||
|
||||
markdown += "## ✅ Final Result\n\n";
|
||||
if (resultText) {
|
||||
markdown += `${resultText}\n\n`;
|
||||
}
|
||||
markdown += `**Cost:** $${cost.toFixed(4)} | **Duration:** ${(duration / 1000).toFixed(1)}s\n\n`;
|
||||
}
|
||||
}
|
||||
|
||||
return markdown;
|
||||
}
|
||||
|
||||
export function formatTurnsFromData(data: Turn[]): string {
|
||||
// Group turns naturally
|
||||
const groupedContent = groupTurnsNaturally(data);
|
||||
|
||||
// Generate markdown
|
||||
const markdown = formatGroupedContent(groupedContent);
|
||||
|
||||
return markdown;
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
// Get the JSON file path from command line arguments
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length === 0) {
|
||||
console.error("Usage: format-turns.ts <json-file>");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
const jsonFile = args[0];
|
||||
if (!jsonFile) {
|
||||
console.error("Error: No JSON file provided");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if (!existsSync(jsonFile)) {
|
||||
console.error(`Error: ${jsonFile} not found`);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
// Read the JSON file
|
||||
const fileContent = readFileSync(jsonFile, "utf-8");
|
||||
const data: Turn[] = JSON.parse(fileContent);
|
||||
|
||||
// Group turns naturally
|
||||
const groupedContent = groupTurnsNaturally(data);
|
||||
|
||||
// Generate markdown
|
||||
const markdown = formatGroupedContent(groupedContent);
|
||||
|
||||
// Print to stdout (so it can be captured by shell)
|
||||
console.log(markdown);
|
||||
} catch (error) {
|
||||
console.error(`Error processing file: ${error}`);
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
main();
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import { checkHumanActor } from "../github/validation/actor";
|
||||
import { checkWritePermissions } from "../github/validation/permissions";
|
||||
import { createInitialComment } from "../github/operations/comments/create-initial";
|
||||
import { setupBranch } from "../github/operations/branch";
|
||||
import { updateTrackingComment } from "../github/operations/comments/update-with-branch";
|
||||
import { configureGitAuth } from "../github/operations/git-config";
|
||||
import { prepareMcpConfig } from "../mcp/install-mcp-server";
|
||||
import { createPrompt } from "../create-prompt";
|
||||
import { createOctokit } from "../github/api/client";
|
||||
@@ -51,7 +51,8 @@ async function run() {
|
||||
await checkHumanActor(octokit.rest, context);
|
||||
|
||||
// Step 6: Create initial tracking comment
|
||||
const commentId = await createInitialComment(octokit.rest, context);
|
||||
const commentData = await createInitialComment(octokit.rest, context);
|
||||
const commentId = commentData.id;
|
||||
|
||||
// Step 7: Fetch GitHub data (once for both branch setup and prompt creation)
|
||||
const githubData = await fetchGitHubData({
|
||||
@@ -65,14 +66,14 @@ async function run() {
|
||||
// Step 8: Setup branch
|
||||
const branchInfo = await setupBranch(octokit, githubData, context);
|
||||
|
||||
// Step 9: Update initial comment with branch link (only for issues that created a new branch)
|
||||
if (branchInfo.claudeBranch) {
|
||||
await updateTrackingComment(
|
||||
octokit,
|
||||
context,
|
||||
commentId,
|
||||
branchInfo.claudeBranch,
|
||||
);
|
||||
// Step 9: Configure git authentication if not using commit signing
|
||||
if (!context.inputs.useCommitSigning) {
|
||||
try {
|
||||
await configureGitAuth(githubToken, context, commentData.user);
|
||||
} catch (error) {
|
||||
console.error("Failed to configure git authentication:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 10: Create prompt file
|
||||
@@ -90,10 +91,12 @@ async function run() {
|
||||
githubToken,
|
||||
owner: context.repository.owner,
|
||||
repo: context.repository.repo,
|
||||
branch: branchInfo.currentBranch,
|
||||
branch: branchInfo.claudeBranch || branchInfo.currentBranch,
|
||||
baseBranch: branchInfo.baseBranch,
|
||||
additionalMcpConfig,
|
||||
claudeCommentId: commentId.toString(),
|
||||
allowedTools: context.inputs.allowedTools,
|
||||
context,
|
||||
});
|
||||
core.setOutput("mcp_config", mcpConfig);
|
||||
} catch (error) {
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
isPullRequestReviewCommentEvent,
|
||||
} from "../github/context";
|
||||
import { GITHUB_SERVER_URL } from "../github/api/config";
|
||||
import { checkAndDeleteEmptyBranch } from "../github/operations/branch-cleanup";
|
||||
import { checkAndCommitOrDeleteBranch } from "../github/operations/branch-cleanup";
|
||||
import { updateClaudeComment } from "../github/operations/comments/update-claude-comment";
|
||||
|
||||
async function run() {
|
||||
@@ -88,13 +88,16 @@ async function run() {
|
||||
const currentBody = comment.body ?? "";
|
||||
|
||||
// Check if we need to add branch link for new branches
|
||||
const { shouldDeleteBranch, branchLink } = await checkAndDeleteEmptyBranch(
|
||||
octokit,
|
||||
owner,
|
||||
repo,
|
||||
claudeBranch,
|
||||
baseBranch,
|
||||
);
|
||||
const useCommitSigning = process.env.USE_COMMIT_SIGNING === "true";
|
||||
const { shouldDeleteBranch, branchLink } =
|
||||
await checkAndCommitOrDeleteBranch(
|
||||
octokit,
|
||||
owner,
|
||||
repo,
|
||||
claudeBranch,
|
||||
baseBranch,
|
||||
useCommitSigning,
|
||||
);
|
||||
|
||||
// Check if we need to add PR URL when we have a new branch
|
||||
let prLink = "";
|
||||
@@ -198,7 +201,7 @@ async function run() {
|
||||
jobUrl,
|
||||
branchLink,
|
||||
prLink,
|
||||
branchName: shouldDeleteBranch ? undefined : claudeBranch,
|
||||
branchName: shouldDeleteBranch || !branchLink ? undefined : claudeBranch,
|
||||
triggerUsername,
|
||||
errorDetails,
|
||||
};
|
||||
|
||||
@@ -29,11 +29,17 @@ export type ParsedGitHubContext = {
|
||||
inputs: {
|
||||
triggerPhrase: string;
|
||||
assigneeTrigger: string;
|
||||
labelTrigger: string;
|
||||
allowedTools: string[];
|
||||
disallowedTools: string[];
|
||||
customInstructions: string;
|
||||
directPrompt: string;
|
||||
overridePrompt: string;
|
||||
baseBranch?: string;
|
||||
branchPrefix: string;
|
||||
useStickyComment: boolean;
|
||||
additionalPermissions: Map<string, string>;
|
||||
useCommitSigning: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -53,11 +59,19 @@ export function parseGitHubContext(): ParsedGitHubContext {
|
||||
inputs: {
|
||||
triggerPhrase: process.env.TRIGGER_PHRASE ?? "@claude",
|
||||
assigneeTrigger: process.env.ASSIGNEE_TRIGGER ?? "",
|
||||
labelTrigger: process.env.LABEL_TRIGGER ?? "",
|
||||
allowedTools: parseMultilineInput(process.env.ALLOWED_TOOLS ?? ""),
|
||||
disallowedTools: parseMultilineInput(process.env.DISALLOWED_TOOLS ?? ""),
|
||||
customInstructions: process.env.CUSTOM_INSTRUCTIONS ?? "",
|
||||
directPrompt: process.env.DIRECT_PROMPT ?? "",
|
||||
overridePrompt: process.env.OVERRIDE_PROMPT ?? "",
|
||||
baseBranch: process.env.BASE_BRANCH,
|
||||
branchPrefix: process.env.BRANCH_PREFIX ?? "claude/",
|
||||
useStickyComment: process.env.USE_STICKY_COMMENT === "true",
|
||||
additionalPermissions: parseAdditionalPermissions(
|
||||
process.env.ADDITIONAL_PERMISSIONS ?? "",
|
||||
),
|
||||
useCommitSigning: process.env.USE_COMMIT_SIGNING === "true",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -119,6 +133,25 @@ export function parseMultilineInput(s: string): string[] {
|
||||
.filter((tool) => tool.length > 0);
|
||||
}
|
||||
|
||||
export function parseAdditionalPermissions(s: string): Map<string, string> {
|
||||
const permissions = new Map<string, string>();
|
||||
if (!s || !s.trim()) {
|
||||
return permissions;
|
||||
}
|
||||
|
||||
const lines = s.trim().split("\n");
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
if (trimmedLine) {
|
||||
const [key, value] = trimmedLine.split(":").map((part) => part.trim());
|
||||
if (key && value) {
|
||||
permissions.set(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return permissions;
|
||||
}
|
||||
|
||||
export function isIssuesEvent(
|
||||
context: ParsedGitHubContext,
|
||||
): context is ParsedGitHubContext & { payload: IssuesEvent } {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { execSync } from "child_process";
|
||||
import { execFileSync } from "child_process";
|
||||
import type { Octokits } from "../api/client";
|
||||
import { ISSUE_QUERY, PR_QUERY, USER_QUERY } from "../api/queries/github";
|
||||
import type {
|
||||
@@ -114,7 +114,7 @@ export async function fetchGitHubData({
|
||||
|
||||
try {
|
||||
// Use git hash-object to compute the SHA for the current file content
|
||||
const sha = execSync(`git hash-object "${file.path}"`, {
|
||||
const sha = execFileSync("git", ["hash-object", file.path], {
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
return {
|
||||
|
||||
@@ -1,17 +1,44 @@
|
||||
import type { Octokits } from "../api/client";
|
||||
import { GITHUB_SERVER_URL } from "../api/config";
|
||||
import { $ } from "bun";
|
||||
|
||||
export async function checkAndDeleteEmptyBranch(
|
||||
export async function checkAndCommitOrDeleteBranch(
|
||||
octokit: Octokits,
|
||||
owner: string,
|
||||
repo: string,
|
||||
claudeBranch: string | undefined,
|
||||
baseBranch: string,
|
||||
useCommitSigning: boolean,
|
||||
): Promise<{ shouldDeleteBranch: boolean; branchLink: string }> {
|
||||
let branchLink = "";
|
||||
let shouldDeleteBranch = false;
|
||||
|
||||
if (claudeBranch) {
|
||||
// First check if the branch exists remotely
|
||||
let branchExistsRemotely = false;
|
||||
try {
|
||||
await octokit.rest.repos.getBranch({
|
||||
owner,
|
||||
repo,
|
||||
branch: claudeBranch,
|
||||
});
|
||||
branchExistsRemotely = true;
|
||||
} catch (error: any) {
|
||||
if (error.status === 404) {
|
||||
console.log(`Branch ${claudeBranch} does not exist remotely`);
|
||||
} else {
|
||||
console.error("Error checking if branch exists:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Only proceed if branch exists remotely
|
||||
if (!branchExistsRemotely) {
|
||||
console.log(
|
||||
`Branch ${claudeBranch} does not exist remotely, no branch link will be added`,
|
||||
);
|
||||
return { shouldDeleteBranch: false, branchLink: "" };
|
||||
}
|
||||
|
||||
// Check if Claude made any commits to the branch
|
||||
try {
|
||||
const { data: comparison } =
|
||||
@@ -21,20 +48,66 @@ export async function checkAndDeleteEmptyBranch(
|
||||
basehead: `${baseBranch}...${claudeBranch}`,
|
||||
});
|
||||
|
||||
// If there are no commits, mark branch for deletion
|
||||
// If there are no commits, check for uncommitted changes if not using commit signing
|
||||
if (comparison.total_commits === 0) {
|
||||
console.log(
|
||||
`Branch ${claudeBranch} has no commits from Claude, will delete it`,
|
||||
);
|
||||
shouldDeleteBranch = true;
|
||||
if (!useCommitSigning) {
|
||||
console.log(
|
||||
`Branch ${claudeBranch} has no commits from Claude, checking for uncommitted changes...`,
|
||||
);
|
||||
|
||||
// Check for uncommitted changes using git status
|
||||
try {
|
||||
const gitStatus = await $`git status --porcelain`.quiet();
|
||||
const hasUncommittedChanges =
|
||||
gitStatus.stdout.toString().trim().length > 0;
|
||||
|
||||
if (hasUncommittedChanges) {
|
||||
console.log("Found uncommitted changes, committing them...");
|
||||
|
||||
// Add all changes
|
||||
await $`git add -A`;
|
||||
|
||||
// Commit with a descriptive message
|
||||
const runId = process.env.GITHUB_RUN_ID || "unknown";
|
||||
const commitMessage = `Auto-commit: Save uncommitted changes from Claude\n\nRun ID: ${runId}`;
|
||||
await $`git commit -m ${commitMessage}`;
|
||||
|
||||
// Push the changes
|
||||
await $`git push origin ${claudeBranch}`;
|
||||
|
||||
console.log(
|
||||
"✅ Successfully committed and pushed uncommitted changes",
|
||||
);
|
||||
|
||||
// Set branch link since we now have commits
|
||||
const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${claudeBranch}`;
|
||||
branchLink = `\n[View branch](${branchUrl})`;
|
||||
} else {
|
||||
console.log(
|
||||
"No uncommitted changes found, marking branch for deletion",
|
||||
);
|
||||
shouldDeleteBranch = true;
|
||||
}
|
||||
} catch (gitError) {
|
||||
console.error("Error checking/committing changes:", gitError);
|
||||
// If we can't check git status, assume the branch might have changes
|
||||
const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${claudeBranch}`;
|
||||
branchLink = `\n[View branch](${branchUrl})`;
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
`Branch ${claudeBranch} has no commits from Claude, will delete it`,
|
||||
);
|
||||
shouldDeleteBranch = true;
|
||||
}
|
||||
} else {
|
||||
// Only add branch link if there are commits
|
||||
const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${claudeBranch}`;
|
||||
branchLink = `\n[View branch](${branchUrl})`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error checking for commits on Claude branch:", error);
|
||||
// If we can't check, assume the branch has commits to be safe
|
||||
console.error("Error comparing commits on Claude branch:", error);
|
||||
// If we can't compare but the branch exists remotely, include the branch link
|
||||
const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${claudeBranch}`;
|
||||
branchLink = `\n[View branch](${branchUrl})`;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export async function setupBranch(
|
||||
): Promise<BranchInfo> {
|
||||
const { owner, repo } = context.repository;
|
||||
const entityNumber = context.entityNumber;
|
||||
const { baseBranch } = context.inputs;
|
||||
const { baseBranch, branchPrefix } = context.inputs;
|
||||
const isPR = context.isPR;
|
||||
|
||||
if (isPR) {
|
||||
@@ -84,23 +84,23 @@ export async function setupBranch(
|
||||
sourceBranch = repoResponse.data.default_branch;
|
||||
}
|
||||
|
||||
// Creating a new branch for either an issue or closed/merged PR
|
||||
// Generate branch name for either an issue or closed/merged PR
|
||||
const entityType = isPR ? "pr" : "issue";
|
||||
console.log(
|
||||
`Creating new branch for ${entityType} #${entityNumber} from source branch: ${sourceBranch}...`,
|
||||
);
|
||||
|
||||
const timestamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/[:-]/g, "")
|
||||
.replace(/\.\d{3}Z/, "")
|
||||
.split("T")
|
||||
.join("_");
|
||||
// Create Kubernetes-compatible timestamp: lowercase, hyphens only, shorter format
|
||||
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")}`;
|
||||
|
||||
const newBranch = `claude/${entityType}-${entityNumber}-${timestamp}`;
|
||||
// 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 {
|
||||
// Get the SHA of the source branch
|
||||
// Get the SHA of the source branch to verify it exists
|
||||
const sourceBranchRef = await octokits.rest.git.getRef({
|
||||
owner,
|
||||
repo,
|
||||
@@ -108,23 +108,44 @@ export async function setupBranch(
|
||||
});
|
||||
|
||||
const currentSHA = sourceBranchRef.data.object.sha;
|
||||
console.log(`Source branch SHA: ${currentSHA}`);
|
||||
|
||||
console.log(`Current SHA: ${currentSHA}`);
|
||||
// For commit signing, defer branch creation to the file ops server
|
||||
if (context.inputs.useCommitSigning) {
|
||||
console.log(
|
||||
`Branch name generated: ${newBranch} (will be created by file ops server on first commit)`,
|
||||
);
|
||||
|
||||
// Create branch using GitHub API
|
||||
await octokits.rest.git.createRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: `refs/heads/${newBranch}`,
|
||||
sha: currentSHA,
|
||||
});
|
||||
// Ensure we're on the source branch
|
||||
console.log(`Fetching and checking out source branch: ${sourceBranch}`);
|
||||
await $`git fetch origin ${sourceBranch} --depth=1`;
|
||||
await $`git checkout ${sourceBranch}`;
|
||||
|
||||
// Checkout the new branch (shallow fetch for performance)
|
||||
await $`git fetch origin --depth=1 ${newBranch}`;
|
||||
await $`git checkout ${newBranch}`;
|
||||
// Set outputs for GitHub Actions
|
||||
core.setOutput("CLAUDE_BRANCH", newBranch);
|
||||
core.setOutput("BASE_BRANCH", sourceBranch);
|
||||
return {
|
||||
baseBranch: sourceBranch,
|
||||
claudeBranch: newBranch,
|
||||
currentBranch: sourceBranch, // Stay on source branch for now
|
||||
};
|
||||
}
|
||||
|
||||
// For non-signing case, create and checkout the branch locally only
|
||||
console.log(
|
||||
`Creating local branch ${newBranch} for ${entityType} #${entityNumber} from source branch: ${sourceBranch}...`,
|
||||
);
|
||||
|
||||
// Fetch and checkout the source branch first to ensure we branch from the correct base
|
||||
console.log(`Fetching and checking out source branch: ${sourceBranch}`);
|
||||
await $`git fetch origin ${sourceBranch} --depth=1`;
|
||||
await $`git checkout ${sourceBranch}`;
|
||||
|
||||
// Create and checkout the new branch from the source branch
|
||||
await $`git checkout -b ${newBranch}`;
|
||||
|
||||
console.log(
|
||||
`Successfully created and checked out new branch: ${newBranch}`,
|
||||
`Successfully created and checked out local branch: ${newBranch}`,
|
||||
);
|
||||
|
||||
// Set outputs for GitHub Actions
|
||||
@@ -136,7 +157,7 @@ export async function setupBranch(
|
||||
currentBranch: newBranch,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error creating branch:", error);
|
||||
console.error("Error in branch setup:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,13 @@ import { appendFileSync } from "fs";
|
||||
import { createJobRunLink, createCommentBody } from "./common";
|
||||
import {
|
||||
isPullRequestReviewCommentEvent,
|
||||
isPullRequestEvent,
|
||||
type ParsedGitHubContext,
|
||||
} from "../../context";
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
|
||||
const CLAUDE_APP_BOT_ID = 209825114;
|
||||
|
||||
export async function createInitialComment(
|
||||
octokit: Octokit,
|
||||
context: ParsedGitHubContext,
|
||||
@@ -25,8 +28,43 @@ export async function createInitialComment(
|
||||
try {
|
||||
let response;
|
||||
|
||||
// Only use createReplyForReviewComment if it's a PR review comment AND we have a comment_id
|
||||
if (isPullRequestReviewCommentEvent(context)) {
|
||||
if (
|
||||
context.inputs.useStickyComment &&
|
||||
context.isPR &&
|
||||
isPullRequestEvent(context)
|
||||
) {
|
||||
const comments = await octokit.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: context.entityNumber,
|
||||
});
|
||||
const existingComment = comments.data.find((comment) => {
|
||||
const idMatch = comment.user?.id === CLAUDE_APP_BOT_ID;
|
||||
const botNameMatch =
|
||||
comment.user?.type === "Bot" &&
|
||||
comment.user?.login.toLowerCase().includes("claude");
|
||||
const bodyMatch = comment.body === initialBody;
|
||||
|
||||
return idMatch || botNameMatch || bodyMatch;
|
||||
});
|
||||
if (existingComment) {
|
||||
response = await octokit.rest.issues.updateComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: existingComment.id,
|
||||
body: initialBody,
|
||||
});
|
||||
} else {
|
||||
// Create new comment if no existing one found
|
||||
response = await octokit.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: context.entityNumber,
|
||||
body: initialBody,
|
||||
});
|
||||
}
|
||||
} else if (isPullRequestReviewCommentEvent(context)) {
|
||||
// Only use createReplyForReviewComment if it's a PR review comment AND we have a comment_id
|
||||
response = await octokit.rest.pulls.createReplyForReviewComment({
|
||||
owner,
|
||||
repo,
|
||||
@@ -48,7 +86,7 @@ export async function createInitialComment(
|
||||
const githubOutput = process.env.GITHUB_OUTPUT!;
|
||||
appendFileSync(githubOutput, `claude_comment_id=${response.data.id}\n`);
|
||||
console.log(`✅ Created initial comment with ID: ${response.data.id}`);
|
||||
return response.data.id;
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error("Error in initial comment:", error);
|
||||
|
||||
@@ -64,7 +102,7 @@ export async function createInitialComment(
|
||||
const githubOutput = process.env.GITHUB_OUTPUT!;
|
||||
appendFileSync(githubOutput, `claude_comment_id=${response.data.id}\n`);
|
||||
console.log(`✅ Created fallback comment with ID: ${response.data.id}`);
|
||||
return response.data.id;
|
||||
return response.data;
|
||||
} catch (fallbackError) {
|
||||
console.error("Error creating fallback comment:", fallbackError);
|
||||
throw fallbackError;
|
||||
|
||||
62
src/github/operations/git-config.ts
Normal file
62
src/github/operations/git-config.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
/**
|
||||
* Configure git authentication for non-signing mode
|
||||
* Sets up git user and authentication to work with GitHub App tokens
|
||||
*/
|
||||
|
||||
import { $ } from "bun";
|
||||
import type { ParsedGitHubContext } from "../context";
|
||||
import { GITHUB_SERVER_URL } from "../api/config";
|
||||
|
||||
type GitUser = {
|
||||
login: string;
|
||||
id: number;
|
||||
};
|
||||
|
||||
export async function configureGitAuth(
|
||||
githubToken: string,
|
||||
context: ParsedGitHubContext,
|
||||
user: GitUser | null,
|
||||
) {
|
||||
console.log("Configuring git authentication for non-signing mode");
|
||||
|
||||
// Determine the noreply email domain based on GITHUB_SERVER_URL
|
||||
const serverUrl = new URL(GITHUB_SERVER_URL);
|
||||
const noreplyDomain =
|
||||
serverUrl.hostname === "github.com"
|
||||
? "users.noreply.github.com"
|
||||
: `users.noreply.${serverUrl.hostname}`;
|
||||
|
||||
// Configure git user based on the comment creator
|
||||
console.log("Configuring git user...");
|
||||
if (user) {
|
||||
const botName = user.login;
|
||||
const botId = user.id;
|
||||
console.log(`Setting git user as ${botName}...`);
|
||||
await $`git config user.name "${botName}"`;
|
||||
await $`git config user.email "${botId}+${botName}@${noreplyDomain}"`;
|
||||
console.log(`✓ Set git user as ${botName}`);
|
||||
} else {
|
||||
console.log("No user data in comment, using default bot user");
|
||||
await $`git config user.name "github-actions[bot]"`;
|
||||
await $`git config user.email "41898282+github-actions[bot]@${noreplyDomain}"`;
|
||||
}
|
||||
|
||||
// Remove the authorization header that actions/checkout sets
|
||||
console.log("Removing existing git authentication headers...");
|
||||
try {
|
||||
await $`git config --unset-all http.${GITHUB_SERVER_URL}/.extraheader`;
|
||||
console.log("✓ Removed existing authentication headers");
|
||||
} catch (e) {
|
||||
console.log("No existing authentication headers to remove");
|
||||
}
|
||||
|
||||
// Update the remote URL to include the token for authentication
|
||||
console.log("Updating remote URL with authentication...");
|
||||
const remoteUrl = `https://x-access-token:${githubToken}@${serverUrl.host}/${context.repository.owner}/${context.repository.repo}.git`;
|
||||
await $`git remote set-url origin ${remoteUrl}`;
|
||||
console.log("✓ Updated remote URL with authentication token");
|
||||
|
||||
console.log("Git authentication configured successfully");
|
||||
}
|
||||
@@ -1,47 +1,7 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import * as core from "@actions/core";
|
||||
|
||||
type RetryOptions = {
|
||||
maxAttempts?: number;
|
||||
initialDelayMs?: number;
|
||||
maxDelayMs?: number;
|
||||
backoffFactor?: number;
|
||||
};
|
||||
|
||||
async function retryWithBackoff<T>(
|
||||
operation: () => Promise<T>,
|
||||
options: RetryOptions = {},
|
||||
): Promise<T> {
|
||||
const {
|
||||
maxAttempts = 3,
|
||||
initialDelayMs = 5000,
|
||||
maxDelayMs = 20000,
|
||||
backoffFactor = 2,
|
||||
} = options;
|
||||
|
||||
let delayMs = initialDelayMs;
|
||||
let lastError: Error | undefined;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
console.log(`Attempt ${attempt} of ${maxAttempts}...`);
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
console.error(`Attempt ${attempt} failed:`, lastError.message);
|
||||
|
||||
if (attempt < maxAttempts) {
|
||||
console.log(`Retrying in ${delayMs / 1000} seconds...`);
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
delayMs = Math.min(delayMs * backoffFactor, maxDelayMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`Operation failed after ${maxAttempts} attempts`);
|
||||
throw lastError;
|
||||
}
|
||||
import { retryWithBackoff } from "../utils/retry";
|
||||
|
||||
async function getOidcToken(): Promise<string> {
|
||||
try {
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { ParsedGitHubContext } from "../context";
|
||||
|
||||
export function checkContainsTrigger(context: ParsedGitHubContext): boolean {
|
||||
const {
|
||||
inputs: { assigneeTrigger, triggerPhrase, directPrompt },
|
||||
inputs: { assigneeTrigger, labelTrigger, triggerPhrase, directPrompt },
|
||||
} = context;
|
||||
|
||||
// If direct prompt is provided, always trigger
|
||||
@@ -34,6 +34,16 @@ export function checkContainsTrigger(context: ParsedGitHubContext): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for label trigger
|
||||
if (isIssuesEvent(context) && context.eventAction === "labeled") {
|
||||
const labelName = (context.payload as any).label?.name || "";
|
||||
|
||||
if (labelTrigger && labelName === labelTrigger) {
|
||||
console.log(`Issue labeled with trigger label '${labelTrigger}'`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for issue body and title trigger on issue creation
|
||||
if (isIssuesEvent(context) && context.eventAction === "opened") {
|
||||
const issueBody = context.payload.issue.body || "";
|
||||
|
||||
279
src/mcp/github-actions-server.ts
Normal file
279
src/mcp/github-actions-server.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { z } from "zod";
|
||||
import { GITHUB_API_URL } from "../github/api/config";
|
||||
import { mkdir, writeFile } from "fs/promises";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
|
||||
const REPO_OWNER = process.env.REPO_OWNER;
|
||||
const REPO_NAME = process.env.REPO_NAME;
|
||||
const PR_NUMBER = process.env.PR_NUMBER;
|
||||
const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
|
||||
const RUNNER_TEMP = process.env.RUNNER_TEMP || "/tmp";
|
||||
|
||||
if (!REPO_OWNER || !REPO_NAME || !PR_NUMBER || !GITHUB_TOKEN) {
|
||||
console.error(
|
||||
"[GitHub CI Server] Error: REPO_OWNER, REPO_NAME, PR_NUMBER, and GITHUB_TOKEN environment variables are required",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const server = new McpServer({
|
||||
name: "GitHub CI Server",
|
||||
version: "0.0.1",
|
||||
});
|
||||
|
||||
console.error("[GitHub CI Server] MCP Server instance created");
|
||||
|
||||
server.tool(
|
||||
"get_ci_status",
|
||||
"Get CI status summary for this PR",
|
||||
{
|
||||
status: z
|
||||
.enum([
|
||||
"completed",
|
||||
"action_required",
|
||||
"cancelled",
|
||||
"failure",
|
||||
"neutral",
|
||||
"skipped",
|
||||
"stale",
|
||||
"success",
|
||||
"timed_out",
|
||||
"in_progress",
|
||||
"queued",
|
||||
"requested",
|
||||
"waiting",
|
||||
"pending",
|
||||
])
|
||||
.optional()
|
||||
.describe("Filter workflow runs by status"),
|
||||
},
|
||||
async ({ status }) => {
|
||||
try {
|
||||
const client = new Octokit({
|
||||
auth: GITHUB_TOKEN,
|
||||
baseUrl: GITHUB_API_URL,
|
||||
});
|
||||
|
||||
// Get the PR to find the head SHA
|
||||
const { data: prData } = await client.pulls.get({
|
||||
owner: REPO_OWNER!,
|
||||
repo: REPO_NAME!,
|
||||
pull_number: parseInt(PR_NUMBER!, 10),
|
||||
});
|
||||
const headSha = prData.head.sha;
|
||||
|
||||
const { data: runsData } = await client.actions.listWorkflowRunsForRepo({
|
||||
owner: REPO_OWNER!,
|
||||
repo: REPO_NAME!,
|
||||
head_sha: headSha,
|
||||
...(status && { status }),
|
||||
});
|
||||
|
||||
// Process runs to create summary
|
||||
const runs = runsData.workflow_runs || [];
|
||||
const summary = {
|
||||
total_runs: runs.length,
|
||||
failed: 0,
|
||||
passed: 0,
|
||||
pending: 0,
|
||||
};
|
||||
|
||||
const processedRuns = runs.map((run: any) => {
|
||||
// Update summary counts
|
||||
if (run.status === "completed") {
|
||||
if (run.conclusion === "success") {
|
||||
summary.passed++;
|
||||
} else if (run.conclusion === "failure") {
|
||||
summary.failed++;
|
||||
}
|
||||
} else {
|
||||
summary.pending++;
|
||||
}
|
||||
|
||||
return {
|
||||
id: run.id,
|
||||
name: run.name,
|
||||
status: run.status,
|
||||
conclusion: run.conclusion,
|
||||
html_url: run.html_url,
|
||||
created_at: run.created_at,
|
||||
};
|
||||
});
|
||||
|
||||
const result = {
|
||||
summary,
|
||||
runs: processedRuns,
|
||||
};
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(result, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error: ${errorMessage}`,
|
||||
},
|
||||
],
|
||||
error: errorMessage,
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"get_workflow_run_details",
|
||||
"Get job and step details for a workflow run",
|
||||
{
|
||||
run_id: z.number().describe("The workflow run ID"),
|
||||
},
|
||||
async ({ run_id }) => {
|
||||
try {
|
||||
const client = new Octokit({
|
||||
auth: GITHUB_TOKEN,
|
||||
baseUrl: GITHUB_API_URL,
|
||||
});
|
||||
|
||||
// Get jobs for this workflow run
|
||||
const { data: jobsData } = await client.actions.listJobsForWorkflowRun({
|
||||
owner: REPO_OWNER!,
|
||||
repo: REPO_NAME!,
|
||||
run_id,
|
||||
});
|
||||
|
||||
const processedJobs = jobsData.jobs.map((job: any) => {
|
||||
// Extract failed steps
|
||||
const failedSteps = (job.steps || [])
|
||||
.filter((step: any) => step.conclusion === "failure")
|
||||
.map((step: any) => ({
|
||||
name: step.name,
|
||||
number: step.number,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: job.id,
|
||||
name: job.name,
|
||||
conclusion: job.conclusion,
|
||||
html_url: job.html_url,
|
||||
failed_steps: failedSteps,
|
||||
};
|
||||
});
|
||||
|
||||
const result = {
|
||||
jobs: processedJobs,
|
||||
};
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(result, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error: ${errorMessage}`,
|
||||
},
|
||||
],
|
||||
error: errorMessage,
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"download_job_log",
|
||||
"Download job logs to disk",
|
||||
{
|
||||
job_id: z.number().describe("The job ID"),
|
||||
},
|
||||
async ({ job_id }) => {
|
||||
try {
|
||||
const client = new Octokit({
|
||||
auth: GITHUB_TOKEN,
|
||||
baseUrl: GITHUB_API_URL,
|
||||
});
|
||||
|
||||
const response = await client.actions.downloadJobLogsForWorkflowRun({
|
||||
owner: REPO_OWNER!,
|
||||
repo: REPO_NAME!,
|
||||
job_id,
|
||||
});
|
||||
|
||||
const logsText = response.data as unknown as string;
|
||||
|
||||
const logsDir = `${RUNNER_TEMP}/github-ci-logs`;
|
||||
await mkdir(logsDir, { recursive: true });
|
||||
|
||||
const logPath = `${logsDir}/job-${job_id}.log`;
|
||||
await writeFile(logPath, logsText, "utf-8");
|
||||
|
||||
const result = {
|
||||
path: logPath,
|
||||
size_bytes: Buffer.byteLength(logsText, "utf-8"),
|
||||
};
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(result, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error: ${errorMessage}`,
|
||||
},
|
||||
],
|
||||
error: errorMessage,
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
async function runServer() {
|
||||
try {
|
||||
const transport = new StdioServerTransport();
|
||||
|
||||
await server.connect(transport);
|
||||
|
||||
process.on("exit", () => {
|
||||
server.close();
|
||||
});
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
runServer().catch(() => {
|
||||
process.exit(1);
|
||||
});
|
||||
98
src/mcp/github-comment-server.ts
Normal file
98
src/mcp/github-comment-server.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env node
|
||||
// GitHub Comment MCP Server - Minimal server that only provides comment update functionality
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { z } from "zod";
|
||||
import { GITHUB_API_URL } from "../github/api/config";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import { updateClaudeComment } from "../github/operations/comments/update-claude-comment";
|
||||
|
||||
// Get repository information from environment variables
|
||||
const REPO_OWNER = process.env.REPO_OWNER;
|
||||
const REPO_NAME = process.env.REPO_NAME;
|
||||
|
||||
if (!REPO_OWNER || !REPO_NAME) {
|
||||
console.error(
|
||||
"Error: REPO_OWNER and REPO_NAME environment variables are required",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const server = new McpServer({
|
||||
name: "GitHub Comment Server",
|
||||
version: "0.0.1",
|
||||
});
|
||||
|
||||
server.tool(
|
||||
"update_claude_comment",
|
||||
"Update the Claude comment with progress and results (automatically handles both issue and PR comments)",
|
||||
{
|
||||
body: z.string().describe("The updated comment content"),
|
||||
},
|
||||
async ({ body }) => {
|
||||
try {
|
||||
const githubToken = process.env.GITHUB_TOKEN;
|
||||
const claudeCommentId = process.env.CLAUDE_COMMENT_ID;
|
||||
const eventName = process.env.GITHUB_EVENT_NAME;
|
||||
|
||||
if (!githubToken) {
|
||||
throw new Error("GITHUB_TOKEN environment variable is required");
|
||||
}
|
||||
if (!claudeCommentId) {
|
||||
throw new Error("CLAUDE_COMMENT_ID environment variable is required");
|
||||
}
|
||||
|
||||
const owner = REPO_OWNER;
|
||||
const repo = REPO_NAME;
|
||||
const commentId = parseInt(claudeCommentId, 10);
|
||||
|
||||
const octokit = new Octokit({
|
||||
auth: githubToken,
|
||||
baseUrl: GITHUB_API_URL,
|
||||
});
|
||||
|
||||
const isPullRequestReviewComment =
|
||||
eventName === "pull_request_review_comment";
|
||||
|
||||
const result = await updateClaudeComment(octokit, {
|
||||
owner,
|
||||
repo,
|
||||
commentId,
|
||||
body,
|
||||
isPullRequestReviewComment,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(result, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error: ${errorMessage}`,
|
||||
},
|
||||
],
|
||||
error: errorMessage,
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
async function runServer() {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
process.on("exit", () => {
|
||||
server.close();
|
||||
});
|
||||
}
|
||||
|
||||
runServer().catch(console.error);
|
||||
@@ -7,8 +7,7 @@ import { readFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import fetch from "node-fetch";
|
||||
import { GITHUB_API_URL } from "../github/api/config";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import { updateClaudeComment } from "../github/operations/comments/update-claude-comment";
|
||||
import { retryWithBackoff } from "../utils/retry";
|
||||
|
||||
type GitHubRef = {
|
||||
object: {
|
||||
@@ -53,6 +52,116 @@ const server = new McpServer({
|
||||
version: "0.0.1",
|
||||
});
|
||||
|
||||
// Helper function to get or create branch reference
|
||||
async function getOrCreateBranchRef(
|
||||
owner: string,
|
||||
repo: string,
|
||||
branch: string,
|
||||
githubToken: string,
|
||||
): Promise<string> {
|
||||
// Try to get the branch reference
|
||||
const refUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs/heads/${branch}`;
|
||||
const refResponse = await fetch(refUrl, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${githubToken}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
});
|
||||
|
||||
if (refResponse.ok) {
|
||||
const refData = (await refResponse.json()) as GitHubRef;
|
||||
return refData.object.sha;
|
||||
}
|
||||
|
||||
if (refResponse.status !== 404) {
|
||||
throw new Error(`Failed to get branch reference: ${refResponse.status}`);
|
||||
}
|
||||
|
||||
const baseBranch = process.env.BASE_BRANCH!;
|
||||
|
||||
// Get the SHA of the base branch
|
||||
const baseRefUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs/heads/${baseBranch}`;
|
||||
const baseRefResponse = await fetch(baseRefUrl, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${githubToken}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
});
|
||||
|
||||
let baseSha: string;
|
||||
|
||||
if (!baseRefResponse.ok) {
|
||||
// If base branch doesn't exist, try default branch
|
||||
const repoUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}`;
|
||||
const repoResponse = await fetch(repoUrl, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${githubToken}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
});
|
||||
|
||||
if (!repoResponse.ok) {
|
||||
throw new Error(`Failed to get repository info: ${repoResponse.status}`);
|
||||
}
|
||||
|
||||
const repoData = (await repoResponse.json()) as {
|
||||
default_branch: string;
|
||||
};
|
||||
const defaultBranch = repoData.default_branch;
|
||||
|
||||
// Try default branch
|
||||
const defaultRefUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs/heads/${defaultBranch}`;
|
||||
const defaultRefResponse = await fetch(defaultRefUrl, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${githubToken}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
});
|
||||
|
||||
if (!defaultRefResponse.ok) {
|
||||
throw new Error(
|
||||
`Failed to get default branch reference: ${defaultRefResponse.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
const defaultRefData = (await defaultRefResponse.json()) as GitHubRef;
|
||||
baseSha = defaultRefData.object.sha;
|
||||
} else {
|
||||
const baseRefData = (await baseRefResponse.json()) as GitHubRef;
|
||||
baseSha = baseRefData.object.sha;
|
||||
}
|
||||
|
||||
// Create the new branch using the same pattern as octokit
|
||||
const createRefUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs`;
|
||||
const createRefResponse = await fetch(createRefUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${githubToken}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
ref: `refs/heads/${branch}`,
|
||||
sha: baseSha,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!createRefResponse.ok) {
|
||||
const errorText = await createRefResponse.text();
|
||||
throw new Error(
|
||||
`Failed to create branch: ${createRefResponse.status} - ${errorText}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`Successfully created branch ${branch}`);
|
||||
return baseSha;
|
||||
}
|
||||
|
||||
// Commit files tool
|
||||
server.tool(
|
||||
"commit_files",
|
||||
@@ -82,24 +191,13 @@ server.tool(
|
||||
return filePath;
|
||||
});
|
||||
|
||||
// 1. Get the branch reference
|
||||
const refUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs/heads/${branch}`;
|
||||
const refResponse = await fetch(refUrl, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${githubToken}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
});
|
||||
|
||||
if (!refResponse.ok) {
|
||||
throw new Error(
|
||||
`Failed to get branch reference: ${refResponse.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
const refData = (await refResponse.json()) as GitHubRef;
|
||||
const baseSha = refData.object.sha;
|
||||
// 1. Get the branch reference (create if doesn't exist)
|
||||
const baseSha = await getOrCreateBranchRef(
|
||||
owner,
|
||||
repo,
|
||||
branch,
|
||||
githubToken,
|
||||
);
|
||||
|
||||
// 2. Get the base commit
|
||||
const commitUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/commits/${baseSha}`;
|
||||
@@ -125,13 +223,58 @@ server.tool(
|
||||
? filePath
|
||||
: join(REPO_DIR, filePath);
|
||||
|
||||
const content = await readFile(fullPath, "utf-8");
|
||||
return {
|
||||
path: filePath,
|
||||
mode: "100644",
|
||||
type: "blob",
|
||||
content: content,
|
||||
};
|
||||
// Check if file is binary (images, etc.)
|
||||
const isBinaryFile =
|
||||
/\.(png|jpg|jpeg|gif|webp|ico|pdf|zip|tar|gz|exe|bin|woff|woff2|ttf|eot)$/i.test(
|
||||
filePath,
|
||||
);
|
||||
|
||||
if (isBinaryFile) {
|
||||
// For binary files, create a blob first using the Blobs API
|
||||
const binaryContent = await readFile(fullPath);
|
||||
|
||||
// Create blob using Blobs API (supports encoding parameter)
|
||||
const blobUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/blobs`;
|
||||
const blobResponse = await fetch(blobUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${githubToken}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: binaryContent.toString("base64"),
|
||||
encoding: "base64",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!blobResponse.ok) {
|
||||
const errorText = await blobResponse.text();
|
||||
throw new Error(
|
||||
`Failed to create blob for ${filePath}: ${blobResponse.status} - ${errorText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const blobData = (await blobResponse.json()) as { sha: string };
|
||||
|
||||
// Return tree entry with blob SHA
|
||||
return {
|
||||
path: filePath,
|
||||
mode: "100644",
|
||||
type: "blob",
|
||||
sha: blobData.sha,
|
||||
};
|
||||
} else {
|
||||
// For text files, include content directly in tree
|
||||
const content = await readFile(fullPath, "utf-8");
|
||||
return {
|
||||
path: filePath,
|
||||
mode: "100644",
|
||||
type: "blob",
|
||||
content: content,
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -188,26 +331,49 @@ server.tool(
|
||||
|
||||
// 6. Update the reference to point to the new commit
|
||||
const updateRefUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs/heads/${branch}`;
|
||||
const updateRefResponse = await fetch(updateRefUrl, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${githubToken}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sha: newCommitData.sha,
|
||||
force: false,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!updateRefResponse.ok) {
|
||||
const errorText = await updateRefResponse.text();
|
||||
throw new Error(
|
||||
`Failed to update reference: ${updateRefResponse.status} - ${errorText}`,
|
||||
);
|
||||
}
|
||||
// We're seeing intermittent 403 "Resource not accessible by integration" errors
|
||||
// on certain repos when updating git references. These appear to be transient
|
||||
// GitHub API issues that succeed on retry.
|
||||
await retryWithBackoff(
|
||||
async () => {
|
||||
const updateRefResponse = await fetch(updateRefUrl, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${githubToken}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sha: newCommitData.sha,
|
||||
force: false,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!updateRefResponse.ok) {
|
||||
const errorText = await updateRefResponse.text();
|
||||
const error = new Error(
|
||||
`Failed to update reference: ${updateRefResponse.status} - ${errorText}`,
|
||||
);
|
||||
|
||||
// Only retry on 403 errors - these are the intermittent failures we're targeting
|
||||
if (updateRefResponse.status === 403) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// For non-403 errors, fail immediately without retry
|
||||
console.error("Non-retryable error:", updateRefResponse.status);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{
|
||||
maxAttempts: 3,
|
||||
initialDelayMs: 1000, // Start with 1 second delay
|
||||
maxDelayMs: 5000, // Max 5 seconds delay
|
||||
backoffFactor: 2, // Double the delay each time
|
||||
},
|
||||
);
|
||||
|
||||
const simplifiedResult = {
|
||||
commit: {
|
||||
@@ -285,24 +451,13 @@ server.tool(
|
||||
return filePath;
|
||||
});
|
||||
|
||||
// 1. Get the branch reference
|
||||
const refUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs/heads/${branch}`;
|
||||
const refResponse = await fetch(refUrl, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${githubToken}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
});
|
||||
|
||||
if (!refResponse.ok) {
|
||||
throw new Error(
|
||||
`Failed to get branch reference: ${refResponse.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
const refData = (await refResponse.json()) as GitHubRef;
|
||||
const baseSha = refData.object.sha;
|
||||
// 1. Get the branch reference (create if doesn't exist)
|
||||
const baseSha = await getOrCreateBranchRef(
|
||||
owner,
|
||||
repo,
|
||||
branch,
|
||||
githubToken,
|
||||
);
|
||||
|
||||
// 2. Get the base commit
|
||||
const commitUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/commits/${baseSha}`;
|
||||
@@ -382,26 +537,50 @@ server.tool(
|
||||
|
||||
// 6. Update the reference to point to the new commit
|
||||
const updateRefUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs/heads/${branch}`;
|
||||
const updateRefResponse = await fetch(updateRefUrl, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${githubToken}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sha: newCommitData.sha,
|
||||
force: false,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!updateRefResponse.ok) {
|
||||
const errorText = await updateRefResponse.text();
|
||||
throw new Error(
|
||||
`Failed to update reference: ${updateRefResponse.status} - ${errorText}`,
|
||||
);
|
||||
}
|
||||
// We're seeing intermittent 403 "Resource not accessible by integration" errors
|
||||
// on certain repos when updating git references. These appear to be transient
|
||||
// GitHub API issues that succeed on retry.
|
||||
await retryWithBackoff(
|
||||
async () => {
|
||||
const updateRefResponse = await fetch(updateRefUrl, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${githubToken}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sha: newCommitData.sha,
|
||||
force: false,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!updateRefResponse.ok) {
|
||||
const errorText = await updateRefResponse.text();
|
||||
const error = new Error(
|
||||
`Failed to update reference: ${updateRefResponse.status} - ${errorText}`,
|
||||
);
|
||||
|
||||
// Only retry on 403 errors - these are the intermittent failures we're targeting
|
||||
if (updateRefResponse.status === 403) {
|
||||
console.log("Received 403 error, will retry...");
|
||||
throw error;
|
||||
}
|
||||
|
||||
// For non-403 errors, fail immediately without retry
|
||||
console.error("Non-retryable error:", updateRefResponse.status);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{
|
||||
maxAttempts: 3,
|
||||
initialDelayMs: 1000, // Start with 1 second delay
|
||||
maxDelayMs: 5000, // Max 5 seconds delay
|
||||
backoffFactor: 2, // Double the delay each time
|
||||
},
|
||||
);
|
||||
|
||||
const simplifiedResult = {
|
||||
commit: {
|
||||
@@ -441,70 +620,6 @@ server.tool(
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"update_claude_comment",
|
||||
"Update the Claude comment with progress and results (automatically handles both issue and PR comments)",
|
||||
{
|
||||
body: z.string().describe("The updated comment content"),
|
||||
},
|
||||
async ({ body }) => {
|
||||
try {
|
||||
const githubToken = process.env.GITHUB_TOKEN;
|
||||
const claudeCommentId = process.env.CLAUDE_COMMENT_ID;
|
||||
const eventName = process.env.GITHUB_EVENT_NAME;
|
||||
|
||||
if (!githubToken) {
|
||||
throw new Error("GITHUB_TOKEN environment variable is required");
|
||||
}
|
||||
if (!claudeCommentId) {
|
||||
throw new Error("CLAUDE_COMMENT_ID environment variable is required");
|
||||
}
|
||||
|
||||
const owner = REPO_OWNER;
|
||||
const repo = REPO_NAME;
|
||||
const commentId = parseInt(claudeCommentId, 10);
|
||||
|
||||
const octokit = new Octokit({
|
||||
auth: githubToken,
|
||||
baseUrl: GITHUB_API_URL,
|
||||
});
|
||||
|
||||
const isPullRequestReviewComment =
|
||||
eventName === "pull_request_review_comment";
|
||||
|
||||
const result = await updateClaudeComment(octokit, {
|
||||
owner,
|
||||
repo,
|
||||
commentId,
|
||||
body,
|
||||
isPullRequestReviewComment,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(result, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error: ${errorMessage}`,
|
||||
},
|
||||
],
|
||||
error: errorMessage,
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
async function runServer() {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
|
||||
@@ -1,16 +1,52 @@
|
||||
import * as core from "@actions/core";
|
||||
import { GITHUB_API_URL } from "../github/api/config";
|
||||
import type { ParsedGitHubContext } from "../github/context";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
|
||||
type PrepareConfigParams = {
|
||||
githubToken: string;
|
||||
owner: string;
|
||||
repo: string;
|
||||
branch: string;
|
||||
baseBranch: string;
|
||||
additionalMcpConfig?: string;
|
||||
claudeCommentId?: string;
|
||||
allowedTools: string[];
|
||||
context: ParsedGitHubContext;
|
||||
};
|
||||
|
||||
async function checkActionsReadPermission(
|
||||
token: string,
|
||||
owner: string,
|
||||
repo: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const client = new Octokit({ auth: token, baseUrl: GITHUB_API_URL });
|
||||
|
||||
// Try to list workflow runs - this requires actions:read
|
||||
// We use per_page=1 to minimize the response size
|
||||
await client.actions.listWorkflowRunsForRepo({
|
||||
owner,
|
||||
repo,
|
||||
per_page: 1,
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
// Check if it's a permission error
|
||||
if (
|
||||
error.status === 403 &&
|
||||
error.message?.includes("Resource not accessible")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// For other errors (network issues, etc), log but don't fail
|
||||
core.debug(`Failed to check actions permission: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function prepareMcpConfig(
|
||||
params: PrepareConfigParams,
|
||||
): Promise<string> {
|
||||
@@ -19,9 +55,11 @@ export async function prepareMcpConfig(
|
||||
owner,
|
||||
repo,
|
||||
branch,
|
||||
baseBranch,
|
||||
additionalMcpConfig,
|
||||
claudeCommentId,
|
||||
allowedTools,
|
||||
context,
|
||||
} = params;
|
||||
try {
|
||||
const allowedToolsList = allowedTools || [];
|
||||
@@ -31,28 +69,84 @@ export async function prepareMcpConfig(
|
||||
);
|
||||
|
||||
const baseMcpConfig: { mcpServers: Record<string, unknown> } = {
|
||||
mcpServers: {
|
||||
github_file_ops: {
|
||||
command: "bun",
|
||||
args: [
|
||||
"run",
|
||||
`${process.env.GITHUB_ACTION_PATH}/src/mcp/github-file-ops-server.ts`,
|
||||
],
|
||||
env: {
|
||||
GITHUB_TOKEN: githubToken,
|
||||
REPO_OWNER: owner,
|
||||
REPO_NAME: repo,
|
||||
BRANCH_NAME: branch,
|
||||
REPO_DIR: process.env.GITHUB_WORKSPACE || process.cwd(),
|
||||
...(claudeCommentId && { CLAUDE_COMMENT_ID: claudeCommentId }),
|
||||
GITHUB_EVENT_NAME: process.env.GITHUB_EVENT_NAME || "",
|
||||
IS_PR: process.env.IS_PR || "false",
|
||||
GITHUB_API_URL: GITHUB_API_URL,
|
||||
},
|
||||
},
|
||||
mcpServers: {},
|
||||
};
|
||||
|
||||
// Always include comment server for updating Claude comments
|
||||
baseMcpConfig.mcpServers.github_comment = {
|
||||
command: "bun",
|
||||
args: [
|
||||
"run",
|
||||
`${process.env.GITHUB_ACTION_PATH}/src/mcp/github-comment-server.ts`,
|
||||
],
|
||||
env: {
|
||||
GITHUB_TOKEN: githubToken,
|
||||
REPO_OWNER: owner,
|
||||
REPO_NAME: repo,
|
||||
...(claudeCommentId && { CLAUDE_COMMENT_ID: claudeCommentId }),
|
||||
GITHUB_EVENT_NAME: process.env.GITHUB_EVENT_NAME || "",
|
||||
GITHUB_API_URL: GITHUB_API_URL,
|
||||
},
|
||||
};
|
||||
|
||||
// Include file ops server when commit signing is enabled
|
||||
if (context.inputs.useCommitSigning) {
|
||||
baseMcpConfig.mcpServers.github_file_ops = {
|
||||
command: "bun",
|
||||
args: [
|
||||
"run",
|
||||
`${process.env.GITHUB_ACTION_PATH}/src/mcp/github-file-ops-server.ts`,
|
||||
],
|
||||
env: {
|
||||
GITHUB_TOKEN: githubToken,
|
||||
REPO_OWNER: owner,
|
||||
REPO_NAME: repo,
|
||||
BRANCH_NAME: branch,
|
||||
BASE_BRANCH: baseBranch,
|
||||
REPO_DIR: process.env.GITHUB_WORKSPACE || process.cwd(),
|
||||
GITHUB_EVENT_NAME: process.env.GITHUB_EVENT_NAME || "",
|
||||
IS_PR: process.env.IS_PR || "false",
|
||||
GITHUB_API_URL: GITHUB_API_URL,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Only add CI server if we have actions:read permission and we're in a PR context
|
||||
const hasActionsReadPermission =
|
||||
context.inputs.additionalPermissions.get("actions") === "read";
|
||||
|
||||
if (context.isPR && hasActionsReadPermission) {
|
||||
// Verify the token actually has actions:read permission
|
||||
const actuallyHasPermission = await checkActionsReadPermission(
|
||||
process.env.ACTIONS_TOKEN || "",
|
||||
owner,
|
||||
repo,
|
||||
);
|
||||
|
||||
if (!actuallyHasPermission) {
|
||||
core.warning(
|
||||
"The github_ci MCP server requires 'actions: read' permission. " +
|
||||
"Please ensure your GitHub token has this permission. " +
|
||||
"See: https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token",
|
||||
);
|
||||
}
|
||||
baseMcpConfig.mcpServers.github_ci = {
|
||||
command: "bun",
|
||||
args: [
|
||||
"run",
|
||||
`${process.env.GITHUB_ACTION_PATH}/src/mcp/github-actions-server.ts`,
|
||||
],
|
||||
env: {
|
||||
// Use workflow github token, not app token
|
||||
GITHUB_TOKEN: process.env.ACTIONS_TOKEN,
|
||||
REPO_OWNER: owner,
|
||||
REPO_NAME: repo,
|
||||
PR_NUMBER: context.entityNumber.toString(),
|
||||
RUNNER_TEMP: process.env.RUNNER_TEMP || "/tmp",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (hasGitHubMcpTools) {
|
||||
baseMcpConfig.mcpServers.github = {
|
||||
command: "docker",
|
||||
@@ -62,7 +156,7 @@ export async function prepareMcpConfig(
|
||||
"--rm",
|
||||
"-e",
|
||||
"GITHUB_PERSONAL_ACCESS_TOKEN",
|
||||
"ghcr.io/github/github-mcp-server:sha-6d69797", // https://github.com/github/github-mcp-server/releases/tag/v0.5.0
|
||||
"ghcr.io/github/github-mcp-server:sha-721fd3e", // https://github.com/github/github-mcp-server/releases/tag/v0.6.0
|
||||
],
|
||||
env: {
|
||||
GITHUB_PERSONAL_ACCESS_TOKEN: githubToken,
|
||||
|
||||
40
src/utils/retry.ts
Normal file
40
src/utils/retry.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
export type RetryOptions = {
|
||||
maxAttempts?: number;
|
||||
initialDelayMs?: number;
|
||||
maxDelayMs?: number;
|
||||
backoffFactor?: number;
|
||||
};
|
||||
|
||||
export async function retryWithBackoff<T>(
|
||||
operation: () => Promise<T>,
|
||||
options: RetryOptions = {},
|
||||
): Promise<T> {
|
||||
const {
|
||||
maxAttempts = 3,
|
||||
initialDelayMs = 5000,
|
||||
maxDelayMs = 20000,
|
||||
backoffFactor = 2,
|
||||
} = options;
|
||||
|
||||
let delayMs = initialDelayMs;
|
||||
let lastError: Error | undefined;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
console.log(`Attempt ${attempt} of ${maxAttempts}...`);
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
console.error(`Attempt ${attempt} failed:`, lastError.message);
|
||||
|
||||
if (attempt < maxAttempts) {
|
||||
console.log(`Retrying in ${delayMs / 1000} seconds...`);
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
delayMs = Math.min(delayMs * backoffFactor, maxDelayMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`Operation failed after ${maxAttempts} attempts`);
|
||||
throw lastError;
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test";
|
||||
import { checkAndDeleteEmptyBranch } from "../src/github/operations/branch-cleanup";
|
||||
import { checkAndCommitOrDeleteBranch } from "../src/github/operations/branch-cleanup";
|
||||
import type { Octokits } from "../src/github/api/client";
|
||||
import { GITHUB_SERVER_URL } from "../src/github/api/config";
|
||||
|
||||
describe("checkAndDeleteEmptyBranch", () => {
|
||||
describe("checkAndCommitOrDeleteBranch", () => {
|
||||
let consoleLogSpy: any;
|
||||
let consoleErrorSpy: any;
|
||||
|
||||
@@ -21,6 +21,7 @@ describe("checkAndDeleteEmptyBranch", () => {
|
||||
const createMockOctokit = (
|
||||
compareResponse?: any,
|
||||
deleteRefError?: Error,
|
||||
branchExists: boolean = true,
|
||||
): Octokits => {
|
||||
return {
|
||||
rest: {
|
||||
@@ -28,6 +29,14 @@ describe("checkAndDeleteEmptyBranch", () => {
|
||||
compareCommitsWithBasehead: async () => ({
|
||||
data: compareResponse || { total_commits: 0 },
|
||||
}),
|
||||
getBranch: async () => {
|
||||
if (!branchExists) {
|
||||
const error: any = new Error("Not Found");
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
return { data: {} };
|
||||
},
|
||||
},
|
||||
git: {
|
||||
deleteRef: async () => {
|
||||
@@ -43,12 +52,13 @@ describe("checkAndDeleteEmptyBranch", () => {
|
||||
|
||||
test("should return no branch link and not delete when branch is undefined", async () => {
|
||||
const mockOctokit = createMockOctokit();
|
||||
const result = await checkAndDeleteEmptyBranch(
|
||||
const result = await checkAndCommitOrDeleteBranch(
|
||||
mockOctokit,
|
||||
"owner",
|
||||
"repo",
|
||||
undefined,
|
||||
"main",
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.shouldDeleteBranch).toBe(false);
|
||||
@@ -56,39 +66,38 @@ describe("checkAndDeleteEmptyBranch", () => {
|
||||
expect(consoleLogSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should delete branch and return no link when branch has no commits", async () => {
|
||||
test("should mark branch for deletion when commit signing is enabled and no commits", async () => {
|
||||
const mockOctokit = createMockOctokit({ total_commits: 0 });
|
||||
const result = await checkAndDeleteEmptyBranch(
|
||||
const result = await checkAndCommitOrDeleteBranch(
|
||||
mockOctokit,
|
||||
"owner",
|
||||
"repo",
|
||||
"claude/issue-123-20240101_123456",
|
||||
"claude/issue-123-20240101-1234",
|
||||
"main",
|
||||
true, // commit signing enabled
|
||||
);
|
||||
|
||||
expect(result.shouldDeleteBranch).toBe(true);
|
||||
expect(result.branchLink).toBe("");
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
"Branch claude/issue-123-20240101_123456 has no commits from Claude, will delete it",
|
||||
);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
"✅ Deleted empty branch: claude/issue-123-20240101_123456",
|
||||
"Branch claude/issue-123-20240101-1234 has no commits from Claude, will delete it",
|
||||
);
|
||||
});
|
||||
|
||||
test("should not delete branch and return link when branch has commits", async () => {
|
||||
const mockOctokit = createMockOctokit({ total_commits: 3 });
|
||||
const result = await checkAndDeleteEmptyBranch(
|
||||
const result = await checkAndCommitOrDeleteBranch(
|
||||
mockOctokit,
|
||||
"owner",
|
||||
"repo",
|
||||
"claude/issue-123-20240101_123456",
|
||||
"claude/issue-123-20240101-1234",
|
||||
"main",
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.shouldDeleteBranch).toBe(false);
|
||||
expect(result.branchLink).toBe(
|
||||
`\n[View branch](${GITHUB_SERVER_URL}/owner/repo/tree/claude/issue-123-20240101_123456)`,
|
||||
`\n[View branch](${GITHUB_SERVER_URL}/owner/repo/tree/claude/issue-123-20240101-1234)`,
|
||||
);
|
||||
expect(consoleLogSpy).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("has no commits"),
|
||||
@@ -102,6 +111,7 @@ describe("checkAndDeleteEmptyBranch", () => {
|
||||
compareCommitsWithBasehead: async () => {
|
||||
throw new Error("API error");
|
||||
},
|
||||
getBranch: async () => ({ data: {} }), // Branch exists
|
||||
},
|
||||
git: {
|
||||
deleteRef: async () => ({ data: {} }),
|
||||
@@ -109,20 +119,21 @@ describe("checkAndDeleteEmptyBranch", () => {
|
||||
},
|
||||
} as any as Octokits;
|
||||
|
||||
const result = await checkAndDeleteEmptyBranch(
|
||||
const result = await checkAndCommitOrDeleteBranch(
|
||||
mockOctokit,
|
||||
"owner",
|
||||
"repo",
|
||||
"claude/issue-123-20240101_123456",
|
||||
"claude/issue-123-20240101-1234",
|
||||
"main",
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.shouldDeleteBranch).toBe(false);
|
||||
expect(result.branchLink).toBe(
|
||||
`\n[View branch](${GITHUB_SERVER_URL}/owner/repo/tree/claude/issue-123-20240101_123456)`,
|
||||
`\n[View branch](${GITHUB_SERVER_URL}/owner/repo/tree/claude/issue-123-20240101-1234)`,
|
||||
);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Error checking for commits on Claude branch:",
|
||||
"Error comparing commits on Claude branch:",
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
@@ -131,19 +142,46 @@ describe("checkAndDeleteEmptyBranch", () => {
|
||||
const deleteError = new Error("Delete failed");
|
||||
const mockOctokit = createMockOctokit({ total_commits: 0 }, deleteError);
|
||||
|
||||
const result = await checkAndDeleteEmptyBranch(
|
||||
const result = await checkAndCommitOrDeleteBranch(
|
||||
mockOctokit,
|
||||
"owner",
|
||||
"repo",
|
||||
"claude/issue-123-20240101_123456",
|
||||
"claude/issue-123-20240101-1234",
|
||||
"main",
|
||||
true, // commit signing enabled - will try to delete
|
||||
);
|
||||
|
||||
expect(result.shouldDeleteBranch).toBe(true);
|
||||
expect(result.branchLink).toBe("");
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Failed to delete branch claude/issue-123-20240101_123456:",
|
||||
"Failed to delete branch claude/issue-123-20240101-1234:",
|
||||
deleteError,
|
||||
);
|
||||
});
|
||||
|
||||
test("should return no branch link when branch doesn't exist remotely", async () => {
|
||||
const mockOctokit = createMockOctokit(
|
||||
{ total_commits: 0 },
|
||||
undefined,
|
||||
false, // branch doesn't exist
|
||||
);
|
||||
|
||||
const result = await checkAndCommitOrDeleteBranch(
|
||||
mockOctokit,
|
||||
"owner",
|
||||
"repo",
|
||||
"claude/issue-123-20240101-1234",
|
||||
"main",
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.shouldDeleteBranch).toBe(false);
|
||||
expect(result.branchLink).toBe("");
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
"Branch claude/issue-123-20240101-1234 does not exist remotely",
|
||||
);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
"Branch claude/issue-123-20240101-1234 does not exist remotely, no branch link will be added",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { updateCommentBody } from "../src/github/operations/comment-logic";
|
||||
import {
|
||||
updateCommentBody,
|
||||
type CommentUpdateInput,
|
||||
} from "../src/github/operations/comment-logic";
|
||||
|
||||
describe("updateCommentBody", () => {
|
||||
const baseInput = {
|
||||
@@ -100,12 +103,12 @@ describe("updateCommentBody", () => {
|
||||
it("adds branch name with link to header when provided", () => {
|
||||
const input = {
|
||||
...baseInput,
|
||||
branchName: "claude/issue-123-20240101_120000",
|
||||
branchName: "claude/issue-123-20240101-1200",
|
||||
};
|
||||
|
||||
const result = updateCommentBody(input);
|
||||
expect(result).toContain(
|
||||
"• [`claude/issue-123-20240101_120000`](https://github.com/owner/repo/tree/claude/issue-123-20240101_120000)",
|
||||
"• [`claude/issue-123-20240101-1200`](https://github.com/owner/repo/tree/claude/issue-123-20240101-1200)",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -381,9 +384,9 @@ describe("updateCommentBody", () => {
|
||||
const input = {
|
||||
...baseInput,
|
||||
currentBody: "Claude Code is working… <img src='spinner.gif' />",
|
||||
branchName: "claude/pr-456-20240101_120000",
|
||||
branchName: "claude/pr-456-20240101-1200",
|
||||
prLink:
|
||||
"\n[Create a PR](https://github.com/owner/repo/compare/main...claude/pr-456-20240101_120000)",
|
||||
"\n[Create a PR](https://github.com/owner/repo/compare/main...claude/pr-456-20240101-1200)",
|
||||
triggerUsername: "jane-doe",
|
||||
};
|
||||
|
||||
@@ -391,7 +394,7 @@ describe("updateCommentBody", () => {
|
||||
|
||||
// Should include the PR link in the formatted style
|
||||
expect(result).toContain(
|
||||
"• [Create PR ➔](https://github.com/owner/repo/compare/main...claude/pr-456-20240101_120000)",
|
||||
"• [Create PR ➔](https://github.com/owner/repo/compare/main...claude/pr-456-20240101-1200)",
|
||||
);
|
||||
expect(result).toContain("**Claude finished @jane-doe's task**");
|
||||
});
|
||||
@@ -400,22 +403,44 @@ describe("updateCommentBody", () => {
|
||||
const input = {
|
||||
...baseInput,
|
||||
currentBody: "Claude Code is working…",
|
||||
branchName: "claude/issue-123-20240101_120000",
|
||||
branchName: "claude/issue-123-20240101-1200",
|
||||
branchLink:
|
||||
"\n[View branch](https://github.com/owner/repo/tree/claude/issue-123-20240101_120000)",
|
||||
"\n[View branch](https://github.com/owner/repo/tree/claude/issue-123-20240101-1200)",
|
||||
prLink:
|
||||
"\n[Create a PR](https://github.com/owner/repo/compare/main...claude/issue-123-20240101_120000)",
|
||||
"\n[Create a PR](https://github.com/owner/repo/compare/main...claude/issue-123-20240101-1200)",
|
||||
};
|
||||
|
||||
const result = updateCommentBody(input);
|
||||
|
||||
// Should include both links in formatted style
|
||||
expect(result).toContain(
|
||||
"• [`claude/issue-123-20240101_120000`](https://github.com/owner/repo/tree/claude/issue-123-20240101_120000)",
|
||||
"• [`claude/issue-123-20240101-1200`](https://github.com/owner/repo/tree/claude/issue-123-20240101-1200)",
|
||||
);
|
||||
expect(result).toContain(
|
||||
"• [Create PR ➔](https://github.com/owner/repo/compare/main...claude/issue-123-20240101_120000)",
|
||||
"• [Create PR ➔](https://github.com/owner/repo/compare/main...claude/issue-123-20240101-1200)",
|
||||
);
|
||||
});
|
||||
|
||||
it("should not show branch name when branch doesn't exist remotely", () => {
|
||||
const input: CommentUpdateInput = {
|
||||
currentBody: "@claude can you help with this?",
|
||||
actionFailed: false,
|
||||
executionDetails: { duration_ms: 90000 },
|
||||
jobUrl: "https://github.com/owner/repo/actions/runs/123",
|
||||
branchLink: "", // Empty branch link means branch doesn't exist remotely
|
||||
branchName: undefined, // Should be undefined when branchLink is empty
|
||||
triggerUsername: "claude",
|
||||
prLink: "",
|
||||
};
|
||||
|
||||
const result = updateCommentBody(input);
|
||||
|
||||
expect(result).toContain("Claude finished @claude's task in 1m 30s");
|
||||
expect(result).toContain(
|
||||
"[View job](https://github.com/owner/repo/actions/runs/123)",
|
||||
);
|
||||
expect(result).not.toContain("claude/issue-123");
|
||||
expect(result).not.toContain("tree/claude/issue-123");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -127,13 +127,13 @@ describe("generatePrompt", () => {
|
||||
commentId: "67890",
|
||||
isPR: false,
|
||||
baseBranch: "main",
|
||||
claudeBranch: "claude/issue-67890-20240101_120000",
|
||||
claudeBranch: "claude/issue-67890-20240101-1200",
|
||||
issueNumber: "67890",
|
||||
commentBody: "@claude please fix this",
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
|
||||
expect(prompt).toContain("You are Claude, an AI assistant");
|
||||
expect(prompt).toContain("<event_type>GENERAL_COMMENT</event_type>");
|
||||
@@ -161,7 +161,7 @@ describe("generatePrompt", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
|
||||
expect(prompt).toContain("<event_type>PR_REVIEW</event_type>");
|
||||
expect(prompt).toContain("<is_pr>true</is_pr>");
|
||||
@@ -183,11 +183,11 @@ describe("generatePrompt", () => {
|
||||
isPR: false,
|
||||
issueNumber: "789",
|
||||
baseBranch: "main",
|
||||
claudeBranch: "claude/issue-789-20240101_120000",
|
||||
claudeBranch: "claude/issue-789-20240101-1200",
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
|
||||
expect(prompt).toContain("<event_type>ISSUE_CREATED</event_type>");
|
||||
expect(prompt).toContain(
|
||||
@@ -210,12 +210,12 @@ describe("generatePrompt", () => {
|
||||
isPR: false,
|
||||
issueNumber: "999",
|
||||
baseBranch: "develop",
|
||||
claudeBranch: "claude/issue-999-20240101_120000",
|
||||
claudeBranch: "claude/issue-999-20240101-1200",
|
||||
assigneeTrigger: "claude-bot",
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
|
||||
expect(prompt).toContain("<event_type>ISSUE_ASSIGNED</event_type>");
|
||||
expect(prompt).toContain(
|
||||
@@ -226,6 +226,33 @@ describe("generatePrompt", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("should generate prompt for issue labeled event", () => {
|
||||
const envVars: PreparedContext = {
|
||||
repository: "owner/repo",
|
||||
claudeCommentId: "12345",
|
||||
triggerPhrase: "@claude",
|
||||
eventData: {
|
||||
eventName: "issues",
|
||||
eventAction: "labeled",
|
||||
isPR: false,
|
||||
issueNumber: "888",
|
||||
baseBranch: "main",
|
||||
claudeBranch: "claude/issue-888-20240101-1200",
|
||||
labelTrigger: "claude-task",
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
|
||||
expect(prompt).toContain("<event_type>ISSUE_LABELED</event_type>");
|
||||
expect(prompt).toContain(
|
||||
"<trigger_context>issue labeled with 'claude-task'</trigger_context>",
|
||||
);
|
||||
expect(prompt).toContain(
|
||||
"[Create a PR](https://github.com/owner/repo/compare/main",
|
||||
);
|
||||
});
|
||||
|
||||
test("should include direct prompt when provided", () => {
|
||||
const envVars: PreparedContext = {
|
||||
repository: "owner/repo",
|
||||
@@ -238,11 +265,11 @@ describe("generatePrompt", () => {
|
||||
isPR: false,
|
||||
issueNumber: "789",
|
||||
baseBranch: "main",
|
||||
claudeBranch: "claude/issue-789-20240101_120000",
|
||||
claudeBranch: "claude/issue-789-20240101-1200",
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
|
||||
expect(prompt).toContain("<direct_prompt>");
|
||||
expect(prompt).toContain("Fix the bug in the login form");
|
||||
@@ -265,7 +292,7 @@ describe("generatePrompt", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
|
||||
expect(prompt).toContain("<event_type>PULL_REQUEST</event_type>");
|
||||
expect(prompt).toContain("<is_pr>true</is_pr>");
|
||||
@@ -285,16 +312,158 @@ describe("generatePrompt", () => {
|
||||
isPR: false,
|
||||
issueNumber: "123",
|
||||
baseBranch: "main",
|
||||
claudeBranch: "claude/issue-67890-20240101_120000",
|
||||
claudeBranch: "claude/issue-67890-20240101-1200",
|
||||
commentBody: "@claude please fix this",
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
|
||||
expect(prompt).toContain("CUSTOM INSTRUCTIONS:\nAlways use TypeScript");
|
||||
});
|
||||
|
||||
test("should use override_prompt when provided", () => {
|
||||
const envVars: PreparedContext = {
|
||||
repository: "owner/repo",
|
||||
claudeCommentId: "12345",
|
||||
triggerPhrase: "@claude",
|
||||
overridePrompt: "Simple prompt for $REPOSITORY PR #$PR_NUMBER",
|
||||
eventData: {
|
||||
eventName: "pull_request",
|
||||
eventAction: "opened",
|
||||
isPR: true,
|
||||
prNumber: "123",
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
|
||||
expect(prompt).toBe("Simple prompt for owner/repo PR #123");
|
||||
expect(prompt).not.toContain("You are Claude, an AI assistant");
|
||||
});
|
||||
|
||||
test("should substitute all variables in override_prompt", () => {
|
||||
const envVars: PreparedContext = {
|
||||
repository: "test/repo",
|
||||
claudeCommentId: "12345",
|
||||
triggerPhrase: "@claude",
|
||||
triggerUsername: "john-doe",
|
||||
overridePrompt: `Repository: $REPOSITORY
|
||||
PR: $PR_NUMBER
|
||||
Title: $PR_TITLE
|
||||
Body: $PR_BODY
|
||||
Comments: $PR_COMMENTS
|
||||
Review Comments: $REVIEW_COMMENTS
|
||||
Changed Files: $CHANGED_FILES
|
||||
Trigger Comment: $TRIGGER_COMMENT
|
||||
Username: $TRIGGER_USERNAME
|
||||
Branch: $BRANCH_NAME
|
||||
Base: $BASE_BRANCH
|
||||
Event: $EVENT_TYPE
|
||||
Is PR: $IS_PR`,
|
||||
eventData: {
|
||||
eventName: "pull_request_review_comment",
|
||||
isPR: true,
|
||||
prNumber: "456",
|
||||
commentBody: "Please review this code",
|
||||
claudeBranch: "feature-branch",
|
||||
baseBranch: "main",
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
|
||||
expect(prompt).toContain("Repository: test/repo");
|
||||
expect(prompt).toContain("PR: 456");
|
||||
expect(prompt).toContain("Title: Test PR");
|
||||
expect(prompt).toContain("Body: This is a test PR");
|
||||
expect(prompt).toContain("Comments: ");
|
||||
expect(prompt).toContain("Review Comments: ");
|
||||
expect(prompt).toContain("Changed Files: ");
|
||||
expect(prompt).toContain("Trigger Comment: Please review this code");
|
||||
expect(prompt).toContain("Username: john-doe");
|
||||
expect(prompt).toContain("Branch: feature-branch");
|
||||
expect(prompt).toContain("Base: main");
|
||||
expect(prompt).toContain("Event: pull_request_review_comment");
|
||||
expect(prompt).toContain("Is PR: true");
|
||||
});
|
||||
|
||||
test("should handle override_prompt for issues", () => {
|
||||
const envVars: PreparedContext = {
|
||||
repository: "owner/repo",
|
||||
claudeCommentId: "12345",
|
||||
triggerPhrase: "@claude",
|
||||
overridePrompt: "Issue #$ISSUE_NUMBER: $ISSUE_TITLE in $REPOSITORY",
|
||||
eventData: {
|
||||
eventName: "issues",
|
||||
eventAction: "opened",
|
||||
isPR: false,
|
||||
issueNumber: "789",
|
||||
baseBranch: "main",
|
||||
claudeBranch: "claude/issue-789-20240101-1200",
|
||||
},
|
||||
};
|
||||
|
||||
const issueGitHubData = {
|
||||
...mockGitHubData,
|
||||
contextData: {
|
||||
title: "Bug: Login form broken",
|
||||
body: "The login form is not working",
|
||||
author: { login: "testuser" },
|
||||
state: "OPEN",
|
||||
createdAt: "2023-01-01T00:00:00Z",
|
||||
comments: {
|
||||
nodes: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, issueGitHubData, false);
|
||||
|
||||
expect(prompt).toBe("Issue #789: Bug: Login form broken in owner/repo");
|
||||
});
|
||||
|
||||
test("should handle empty values in override_prompt substitution", () => {
|
||||
const envVars: PreparedContext = {
|
||||
repository: "owner/repo",
|
||||
claudeCommentId: "12345",
|
||||
triggerPhrase: "@claude",
|
||||
overridePrompt:
|
||||
"PR: $PR_NUMBER, Issue: $ISSUE_NUMBER, Comment: $TRIGGER_COMMENT",
|
||||
eventData: {
|
||||
eventName: "pull_request",
|
||||
eventAction: "opened",
|
||||
isPR: true,
|
||||
prNumber: "123",
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
|
||||
expect(prompt).toBe("PR: 123, Issue: , Comment: ");
|
||||
});
|
||||
|
||||
test("should not substitute variables when override_prompt is not provided", () => {
|
||||
const envVars: PreparedContext = {
|
||||
repository: "owner/repo",
|
||||
claudeCommentId: "12345",
|
||||
triggerPhrase: "@claude",
|
||||
eventData: {
|
||||
eventName: "issues",
|
||||
eventAction: "opened",
|
||||
isPR: false,
|
||||
issueNumber: "123",
|
||||
baseBranch: "main",
|
||||
claudeBranch: "claude/issue-123-20240101-1200",
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
|
||||
expect(prompt).toContain("You are Claude, an AI assistant");
|
||||
expect(prompt).toContain("<event_type>ISSUE_CREATED</event_type>");
|
||||
});
|
||||
|
||||
test("should include trigger username when provided", () => {
|
||||
const envVars: PreparedContext = {
|
||||
repository: "owner/repo",
|
||||
@@ -307,16 +476,17 @@ describe("generatePrompt", () => {
|
||||
isPR: false,
|
||||
issueNumber: "123",
|
||||
baseBranch: "main",
|
||||
claudeBranch: "claude/issue-67890-20240101_120000",
|
||||
claudeBranch: "claude/issue-67890-20240101-1200",
|
||||
commentBody: "@claude please fix this",
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
|
||||
expect(prompt).toContain("<trigger_username>johndoe</trigger_username>");
|
||||
// With commit signing disabled, co-author info appears in git commit instructions
|
||||
expect(prompt).toContain(
|
||||
'Use: "Co-authored-by: johndoe <johndoe@users.noreply.github.com>"',
|
||||
"Co-authored-by: johndoe <johndoe@users.noreply.github.com>",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -333,12 +503,10 @@ describe("generatePrompt", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
|
||||
// Should contain PR-specific instructions
|
||||
expect(prompt).toContain(
|
||||
"Push directly using mcp__github_file_ops__commit_files to the existing branch",
|
||||
);
|
||||
// Should contain PR-specific instructions (git commands when not using signing)
|
||||
expect(prompt).toContain("git push");
|
||||
expect(prompt).toContain(
|
||||
"Always push to the existing branch when triggered on a PR",
|
||||
);
|
||||
@@ -362,18 +530,18 @@ describe("generatePrompt", () => {
|
||||
isPR: false,
|
||||
issueNumber: "789",
|
||||
baseBranch: "main",
|
||||
claudeBranch: "claude/issue-789-20240101_120000",
|
||||
claudeBranch: "claude/issue-789-20240101-1200",
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
|
||||
// Should contain Issue-specific instructions
|
||||
expect(prompt).toContain(
|
||||
"You are already on the correct branch (claude/issue-789-20240101_120000)",
|
||||
"You are already on the correct branch (claude/issue-789-20240101-1200)",
|
||||
);
|
||||
expect(prompt).toContain(
|
||||
"IMPORTANT: You are already on the correct branch (claude/issue-789-20240101_120000)",
|
||||
"IMPORTANT: You are already on the correct branch (claude/issue-789-20240101-1200)",
|
||||
);
|
||||
expect(prompt).toContain("Create a PR](https://github.com/");
|
||||
expect(prompt).toContain(
|
||||
@@ -400,22 +568,22 @@ describe("generatePrompt", () => {
|
||||
isPR: false,
|
||||
issueNumber: "123",
|
||||
baseBranch: "main",
|
||||
claudeBranch: "claude/issue-123-20240101_120000",
|
||||
claudeBranch: "claude/issue-123-20240101-1200",
|
||||
commentBody: "@claude please fix this",
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
|
||||
// Should contain the actual branch name with timestamp
|
||||
expect(prompt).toContain(
|
||||
"You are already on the correct branch (claude/issue-123-20240101_120000)",
|
||||
"You are already on the correct branch (claude/issue-123-20240101-1200)",
|
||||
);
|
||||
expect(prompt).toContain(
|
||||
"IMPORTANT: You are already on the correct branch (claude/issue-123-20240101_120000)",
|
||||
"IMPORTANT: You are already on the correct branch (claude/issue-123-20240101-1200)",
|
||||
);
|
||||
expect(prompt).toContain(
|
||||
"The branch-name is the current branch: claude/issue-123-20240101_120000",
|
||||
"The branch-name is the current branch: claude/issue-123-20240101-1200",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -430,22 +598,22 @@ describe("generatePrompt", () => {
|
||||
isPR: true,
|
||||
prNumber: "456",
|
||||
commentBody: "@claude please fix this",
|
||||
claudeBranch: "claude/pr-456-20240101_120000",
|
||||
claudeBranch: "claude/pr-456-20240101-1200",
|
||||
baseBranch: "main",
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
|
||||
// Should contain branch-specific instructions like issues
|
||||
expect(prompt).toContain(
|
||||
"You are already on the correct branch (claude/pr-456-20240101_120000)",
|
||||
"You are already on the correct branch (claude/pr-456-20240101-1200)",
|
||||
);
|
||||
expect(prompt).toContain(
|
||||
"Create a PR](https://github.com/owner/repo/compare/main",
|
||||
);
|
||||
expect(prompt).toContain(
|
||||
"The branch-name is the current branch: claude/pr-456-20240101_120000",
|
||||
"The branch-name is the current branch: claude/pr-456-20240101-1200",
|
||||
);
|
||||
expect(prompt).toContain("Reference to the original PR");
|
||||
expect(prompt).toContain(
|
||||
@@ -473,12 +641,10 @@ describe("generatePrompt", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
|
||||
// Should contain open PR instructions
|
||||
expect(prompt).toContain(
|
||||
"Push directly using mcp__github_file_ops__commit_files to the existing branch",
|
||||
);
|
||||
// Should contain open PR instructions (git commands when not using signing)
|
||||
expect(prompt).toContain("git push");
|
||||
expect(prompt).toContain(
|
||||
"Always push to the existing branch when triggered on a PR",
|
||||
);
|
||||
@@ -501,16 +667,16 @@ describe("generatePrompt", () => {
|
||||
isPR: true,
|
||||
prNumber: "789",
|
||||
commentBody: "@claude please update this",
|
||||
claudeBranch: "claude/pr-789-20240101_123000",
|
||||
claudeBranch: "claude/pr-789-20240101-1230",
|
||||
baseBranch: "develop",
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
|
||||
// Should contain new branch instructions
|
||||
expect(prompt).toContain(
|
||||
"You are already on the correct branch (claude/pr-789-20240101_123000)",
|
||||
"You are already on the correct branch (claude/pr-789-20240101-1230)",
|
||||
);
|
||||
expect(prompt).toContain(
|
||||
"Create a PR](https://github.com/owner/repo/compare/develop",
|
||||
@@ -529,16 +695,16 @@ describe("generatePrompt", () => {
|
||||
prNumber: "999",
|
||||
commentId: "review-comment-123",
|
||||
commentBody: "@claude fix this issue",
|
||||
claudeBranch: "claude/pr-999-20240101_140000",
|
||||
claudeBranch: "claude/pr-999-20240101-1400",
|
||||
baseBranch: "main",
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
|
||||
// Should contain new branch instructions
|
||||
expect(prompt).toContain(
|
||||
"You are already on the correct branch (claude/pr-999-20240101_140000)",
|
||||
"You are already on the correct branch (claude/pr-999-20240101-1400)",
|
||||
);
|
||||
expect(prompt).toContain("Create a PR](https://github.com/");
|
||||
expect(prompt).toContain("Reference to the original PR");
|
||||
@@ -557,20 +723,75 @@ describe("generatePrompt", () => {
|
||||
eventAction: "closed",
|
||||
isPR: true,
|
||||
prNumber: "555",
|
||||
claudeBranch: "claude/pr-555-20240101_150000",
|
||||
claudeBranch: "claude/pr-555-20240101-1500",
|
||||
baseBranch: "main",
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
|
||||
// Should contain new branch instructions
|
||||
expect(prompt).toContain(
|
||||
"You are already on the correct branch (claude/pr-555-20240101_150000)",
|
||||
"You are already on the correct branch (claude/pr-555-20240101-1500)",
|
||||
);
|
||||
expect(prompt).toContain("Create a PR](https://github.com/");
|
||||
expect(prompt).toContain("Reference to the original PR");
|
||||
});
|
||||
|
||||
test("should include git commands when useCommitSigning is false", () => {
|
||||
const envVars: PreparedContext = {
|
||||
repository: "owner/repo",
|
||||
claudeCommentId: "12345",
|
||||
triggerPhrase: "@claude",
|
||||
eventData: {
|
||||
eventName: "issue_comment",
|
||||
commentId: "67890",
|
||||
isPR: true,
|
||||
prNumber: "123",
|
||||
commentBody: "@claude fix the bug",
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
|
||||
// Should have git command instructions
|
||||
expect(prompt).toContain("Use git commands via the Bash tool");
|
||||
expect(prompt).toContain("git add");
|
||||
expect(prompt).toContain("git commit");
|
||||
expect(prompt).toContain("git push");
|
||||
|
||||
// Should use the minimal comment tool
|
||||
expect(prompt).toContain("mcp__github_comment__update_claude_comment");
|
||||
|
||||
// Should not have commit signing tool references
|
||||
expect(prompt).not.toContain("mcp__github_file_ops__commit_files");
|
||||
});
|
||||
|
||||
test("should include commit signing tools when useCommitSigning is true", () => {
|
||||
const envVars: PreparedContext = {
|
||||
repository: "owner/repo",
|
||||
claudeCommentId: "12345",
|
||||
triggerPhrase: "@claude",
|
||||
eventData: {
|
||||
eventName: "issue_comment",
|
||||
commentId: "67890",
|
||||
isPR: true,
|
||||
prNumber: "123",
|
||||
commentBody: "@claude fix the bug",
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, true);
|
||||
|
||||
// Should have commit signing tool instructions
|
||||
expect(prompt).toContain("mcp__github_file_ops__commit_files");
|
||||
expect(prompt).toContain("mcp__github_file_ops__delete_files");
|
||||
// Comment tool should always be from comment server, not file ops
|
||||
expect(prompt).toContain("mcp__github_comment__update_claude_comment");
|
||||
|
||||
// Should not have git command instructions
|
||||
expect(prompt).not.toContain("Use git commands via the Bash tool");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEventTypeAndContext", () => {
|
||||
@@ -604,7 +825,7 @@ describe("getEventTypeAndContext", () => {
|
||||
isPR: false,
|
||||
issueNumber: "999",
|
||||
baseBranch: "main",
|
||||
claudeBranch: "claude/issue-999-20240101_120000",
|
||||
claudeBranch: "claude/issue-999-20240101-1200",
|
||||
assigneeTrigger: "claude-bot",
|
||||
},
|
||||
};
|
||||
@@ -614,10 +835,55 @@ describe("getEventTypeAndContext", () => {
|
||||
expect(result.eventType).toBe("ISSUE_ASSIGNED");
|
||||
expect(result.triggerContext).toBe("issue assigned to 'claude-bot'");
|
||||
});
|
||||
|
||||
test("should return correct type and context for issue labeled", () => {
|
||||
const envVars: PreparedContext = {
|
||||
repository: "owner/repo",
|
||||
claudeCommentId: "12345",
|
||||
triggerPhrase: "@claude",
|
||||
eventData: {
|
||||
eventName: "issues",
|
||||
eventAction: "labeled",
|
||||
isPR: false,
|
||||
issueNumber: "888",
|
||||
baseBranch: "main",
|
||||
claudeBranch: "claude/issue-888-20240101-1200",
|
||||
labelTrigger: "claude-task",
|
||||
},
|
||||
};
|
||||
|
||||
const result = getEventTypeAndContext(envVars);
|
||||
|
||||
expect(result.eventType).toBe("ISSUE_LABELED");
|
||||
expect(result.triggerContext).toBe("issue labeled with 'claude-task'");
|
||||
});
|
||||
|
||||
test("should return correct type and context for issue assigned without assigneeTrigger", () => {
|
||||
const envVars: PreparedContext = {
|
||||
repository: "owner/repo",
|
||||
claudeCommentId: "12345",
|
||||
triggerPhrase: "@claude",
|
||||
directPrompt: "Please assess this issue",
|
||||
eventData: {
|
||||
eventName: "issues",
|
||||
eventAction: "assigned",
|
||||
isPR: false,
|
||||
issueNumber: "999",
|
||||
baseBranch: "main",
|
||||
claudeBranch: "claude/issue-999-20240101-1200",
|
||||
// No assigneeTrigger when using directPrompt
|
||||
},
|
||||
};
|
||||
|
||||
const result = getEventTypeAndContext(envVars);
|
||||
|
||||
expect(result.eventType).toBe("ISSUE_ASSIGNED");
|
||||
expect(result.triggerContext).toBe("issue assigned event");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAllowedToolsString", () => {
|
||||
test("should return issue comment tool for regular events", () => {
|
||||
test("should return correct tools for regular events (default no signing)", () => {
|
||||
const result = buildAllowedToolsString();
|
||||
|
||||
// The base tools should be in the result
|
||||
@@ -627,15 +893,20 @@ describe("buildAllowedToolsString", () => {
|
||||
expect(result).toContain("LS");
|
||||
expect(result).toContain("Read");
|
||||
expect(result).toContain("Write");
|
||||
expect(result).toContain("mcp__github_file_ops__update_claude_comment");
|
||||
expect(result).not.toContain("mcp__github__update_issue_comment");
|
||||
expect(result).not.toContain("mcp__github__update_pull_request_comment");
|
||||
expect(result).toContain("mcp__github_file_ops__commit_files");
|
||||
expect(result).toContain("mcp__github_file_ops__delete_files");
|
||||
|
||||
// Default is no commit signing, so should have specific Bash git commands
|
||||
expect(result).toContain("Bash(git add:*)");
|
||||
expect(result).toContain("Bash(git commit:*)");
|
||||
expect(result).toContain("Bash(git push:*)");
|
||||
expect(result).toContain("mcp__github_comment__update_claude_comment");
|
||||
|
||||
// Should not have commit signing tools
|
||||
expect(result).not.toContain("mcp__github_file_ops__commit_files");
|
||||
expect(result).not.toContain("mcp__github_file_ops__delete_files");
|
||||
});
|
||||
|
||||
test("should return PR comment tool for inline review comments", () => {
|
||||
const result = buildAllowedToolsString();
|
||||
test("should return correct tools with default parameters", () => {
|
||||
const result = buildAllowedToolsString([], false, false);
|
||||
|
||||
// The base tools should be in the result
|
||||
expect(result).toContain("Edit");
|
||||
@@ -644,11 +915,15 @@ describe("buildAllowedToolsString", () => {
|
||||
expect(result).toContain("LS");
|
||||
expect(result).toContain("Read");
|
||||
expect(result).toContain("Write");
|
||||
expect(result).toContain("mcp__github_file_ops__update_claude_comment");
|
||||
expect(result).not.toContain("mcp__github__update_issue_comment");
|
||||
expect(result).not.toContain("mcp__github__update_pull_request_comment");
|
||||
expect(result).toContain("mcp__github_file_ops__commit_files");
|
||||
expect(result).toContain("mcp__github_file_ops__delete_files");
|
||||
|
||||
// Should have specific Bash git commands for non-signing mode
|
||||
expect(result).toContain("Bash(git add:*)");
|
||||
expect(result).toContain("Bash(git commit:*)");
|
||||
expect(result).toContain("mcp__github_comment__update_claude_comment");
|
||||
|
||||
// Should not have commit signing tools
|
||||
expect(result).not.toContain("mcp__github_file_ops__commit_files");
|
||||
expect(result).not.toContain("mcp__github_file_ops__delete_files");
|
||||
});
|
||||
|
||||
test("should append custom tools when provided", () => {
|
||||
@@ -671,6 +946,109 @@ describe("buildAllowedToolsString", () => {
|
||||
expect(basePlusCustom).toContain("Tool2");
|
||||
expect(basePlusCustom).toContain("Tool3");
|
||||
});
|
||||
|
||||
test("should include GitHub Actions tools when includeActionsTools is true", () => {
|
||||
const result = buildAllowedToolsString([], true);
|
||||
|
||||
// Base tools should be present
|
||||
expect(result).toContain("Edit");
|
||||
expect(result).toContain("Glob");
|
||||
|
||||
// GitHub Actions tools should be included
|
||||
expect(result).toContain("mcp__github_ci__get_ci_status");
|
||||
expect(result).toContain("mcp__github_ci__get_workflow_run_details");
|
||||
expect(result).toContain("mcp__github_ci__download_job_log");
|
||||
});
|
||||
|
||||
test("should include both custom and Actions tools when both provided", () => {
|
||||
const customTools = ["Tool1", "Tool2"];
|
||||
const result = buildAllowedToolsString(customTools, true);
|
||||
|
||||
// Base tools should be present
|
||||
expect(result).toContain("Edit");
|
||||
|
||||
// Custom tools should be included
|
||||
expect(result).toContain("Tool1");
|
||||
expect(result).toContain("Tool2");
|
||||
|
||||
// GitHub Actions tools should be included
|
||||
expect(result).toContain("mcp__github_ci__get_ci_status");
|
||||
expect(result).toContain("mcp__github_ci__get_workflow_run_details");
|
||||
expect(result).toContain("mcp__github_ci__download_job_log");
|
||||
});
|
||||
|
||||
test("should include commit signing tools when useCommitSigning is true", () => {
|
||||
const result = buildAllowedToolsString([], false, true);
|
||||
|
||||
// Base tools should be present
|
||||
expect(result).toContain("Edit");
|
||||
expect(result).toContain("Glob");
|
||||
expect(result).toContain("Grep");
|
||||
expect(result).toContain("LS");
|
||||
expect(result).toContain("Read");
|
||||
expect(result).toContain("Write");
|
||||
|
||||
// Commit signing tools should be included
|
||||
expect(result).toContain("mcp__github_file_ops__commit_files");
|
||||
expect(result).toContain("mcp__github_file_ops__delete_files");
|
||||
// Comment tool should always be from github_comment server
|
||||
expect(result).toContain("mcp__github_comment__update_claude_comment");
|
||||
|
||||
// Bash should NOT be included when using commit signing (except in comment tool name)
|
||||
expect(result).not.toContain("Bash(");
|
||||
});
|
||||
|
||||
test("should include specific Bash git commands when useCommitSigning is false", () => {
|
||||
const result = buildAllowedToolsString([], false, false);
|
||||
|
||||
// Base tools should be present
|
||||
expect(result).toContain("Edit");
|
||||
expect(result).toContain("Glob");
|
||||
expect(result).toContain("Grep");
|
||||
expect(result).toContain("LS");
|
||||
expect(result).toContain("Read");
|
||||
expect(result).toContain("Write");
|
||||
|
||||
// Specific Bash git commands should be included
|
||||
expect(result).toContain("Bash(git add:*)");
|
||||
expect(result).toContain("Bash(git commit:*)");
|
||||
expect(result).toContain("Bash(git push:*)");
|
||||
expect(result).toContain("Bash(git status:*)");
|
||||
expect(result).toContain("Bash(git diff:*)");
|
||||
expect(result).toContain("Bash(git log:*)");
|
||||
expect(result).toContain("Bash(git rm:*)");
|
||||
expect(result).toContain("Bash(git config user.name:*)");
|
||||
expect(result).toContain("Bash(git config user.email:*)");
|
||||
|
||||
// Comment tool from minimal server should be included
|
||||
expect(result).toContain("mcp__github_comment__update_claude_comment");
|
||||
|
||||
// Commit signing tools should NOT be included
|
||||
expect(result).not.toContain("mcp__github_file_ops__commit_files");
|
||||
expect(result).not.toContain("mcp__github_file_ops__delete_files");
|
||||
});
|
||||
|
||||
test("should handle all combinations of options", () => {
|
||||
const customTools = ["CustomTool1", "CustomTool2"];
|
||||
const result = buildAllowedToolsString(customTools, true, false);
|
||||
|
||||
// Base tools should be present
|
||||
expect(result).toContain("Edit");
|
||||
expect(result).toContain("Bash(git add:*)");
|
||||
|
||||
// Custom tools should be included
|
||||
expect(result).toContain("CustomTool1");
|
||||
expect(result).toContain("CustomTool2");
|
||||
|
||||
// GitHub Actions tools should be included
|
||||
expect(result).toContain("mcp__github_ci__get_ci_status");
|
||||
|
||||
// Comment tool from minimal server should be included
|
||||
expect(result).toContain("mcp__github_comment__update_claude_comment");
|
||||
|
||||
// Commit signing tools should NOT be included
|
||||
expect(result).not.toContain("mcp__github_file_ops__commit_files");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDisallowedToolsString", () => {
|
||||
|
||||
95
test/fixtures/sample-turns-expected-output.md
vendored
Normal file
95
test/fixtures/sample-turns-expected-output.md
vendored
Normal file
@@ -0,0 +1,95 @@
|
||||
## Claude Code Report
|
||||
|
||||
## 🚀 System Initialization
|
||||
|
||||
**Available Tools:** 8 tools loaded
|
||||
|
||||
---
|
||||
|
||||
I'll help you with this task. Let me start by examining the file to understand what needs to be changed.
|
||||
|
||||
### 🔧 `Read`
|
||||
|
||||
**Parameters:**
|
||||
```json
|
||||
{
|
||||
"file_path": "/path/to/sample/file.py"
|
||||
}
|
||||
```
|
||||
|
||||
**Result:**
|
||||
```python
|
||||
def example_function():
|
||||
print("Debug message") # This should be removed
|
||||
return "Hello World"
|
||||
|
||||
if __name__ == "__main__":
|
||||
result = example_function()
|
||||
print(result)
|
||||
```
|
||||
|
||||
*Token usage: 100 input, 75 output*
|
||||
|
||||
---
|
||||
|
||||
I can see the debug print statement that needs to be removed. Let me fix this by editing the file.
|
||||
|
||||
### 🔧 `Edit`
|
||||
|
||||
**Parameters:**
|
||||
```json
|
||||
{
|
||||
"file_path": "/path/to/sample/file.py",
|
||||
"old_string": "def example_function():\n print(\"Debug message\") # This should be removed\n return \"Hello World\"",
|
||||
"new_string": "def example_function():\n return \"Hello World\""
|
||||
}
|
||||
```
|
||||
|
||||
**→** File successfully edited. The debug print statement has been removed.
|
||||
|
||||
*Token usage: 200 input, 50 output*
|
||||
|
||||
---
|
||||
|
||||
Perfect! I've successfully removed the debug print statement from the function. Let me now create a pull request comment to document this change.
|
||||
|
||||
### 🔧 `mcp__github__add_pull_request_review_comment`
|
||||
|
||||
**Parameters:**
|
||||
```json
|
||||
{
|
||||
"owner": "example-org",
|
||||
"repo": "example-repo",
|
||||
"pull_number": 123,
|
||||
"body": "Removed debug print statement as requested.",
|
||||
"commit_id": "abc123def456",
|
||||
"path": "sample/file.py",
|
||||
"line": 2
|
||||
}
|
||||
```
|
||||
|
||||
**→** Successfully posted review comment to PR #123
|
||||
|
||||
*Token usage: 150 input, 80 output*
|
||||
|
||||
---
|
||||
|
||||
Great! I've successfully completed the requested task:
|
||||
|
||||
1. ✅ Located the debug print statement in the file
|
||||
2. ✅ Removed the print statement while preserving the function logic
|
||||
3. ✅ Added a review comment documenting the change
|
||||
|
||||
The debug print statement has been removed as requested by the reviewers.
|
||||
|
||||
*Token usage: 180 input, 60 output*
|
||||
|
||||
---
|
||||
|
||||
## ✅ Final Result
|
||||
|
||||
Successfully removed debug print statement from file and added review comment to document the change.
|
||||
|
||||
**Cost:** $0.0347 | **Duration:** 18.8s
|
||||
|
||||
|
||||
196
test/fixtures/sample-turns.json
vendored
Normal file
196
test/fixtures/sample-turns.json
vendored
Normal file
@@ -0,0 +1,196 @@
|
||||
[
|
||||
{
|
||||
"type": "system",
|
||||
"subtype": "init",
|
||||
"session_id": "sample-session-id",
|
||||
"tools": [
|
||||
"Task",
|
||||
"Bash",
|
||||
"Read",
|
||||
"Edit",
|
||||
"Write",
|
||||
"mcp__github__get_file_contents",
|
||||
"mcp__github__create_or_update_file",
|
||||
"mcp__github__add_pull_request_review_comment"
|
||||
],
|
||||
"mcp_servers": [
|
||||
{
|
||||
"name": "github",
|
||||
"status": "connected"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"id": "msg_sample123",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "claude-test-model",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "I'll help you with this task. Let me start by examining the file to understand what needs to be changed."
|
||||
},
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "tool_call_1",
|
||||
"name": "Read",
|
||||
"input": {
|
||||
"file_path": "/path/to/sample/file.py"
|
||||
}
|
||||
}
|
||||
],
|
||||
"stop_reason": "tool_use",
|
||||
"stop_sequence": null,
|
||||
"usage": {
|
||||
"input_tokens": 100,
|
||||
"cache_creation_input_tokens": 0,
|
||||
"cache_read_input_tokens": 50,
|
||||
"output_tokens": 75
|
||||
}
|
||||
},
|
||||
"session_id": "sample-session-id"
|
||||
},
|
||||
{
|
||||
"type": "user",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "tool_call_1",
|
||||
"content": "def example_function():\n print(\"Debug message\") # This should be removed\n return \"Hello World\"\n\nif __name__ == \"__main__\":\n result = example_function()\n print(result)",
|
||||
"is_error": false
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"id": "msg_sample124",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "claude-test-model",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "I can see the debug print statement that needs to be removed. Let me fix this by editing the file."
|
||||
},
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "tool_call_2",
|
||||
"name": "Edit",
|
||||
"input": {
|
||||
"file_path": "/path/to/sample/file.py",
|
||||
"old_string": "def example_function():\n print(\"Debug message\") # This should be removed\n return \"Hello World\"",
|
||||
"new_string": "def example_function():\n return \"Hello World\""
|
||||
}
|
||||
}
|
||||
],
|
||||
"stop_reason": "tool_use",
|
||||
"stop_sequence": null,
|
||||
"usage": {
|
||||
"input_tokens": 200,
|
||||
"cache_creation_input_tokens": 0,
|
||||
"cache_read_input_tokens": 100,
|
||||
"output_tokens": 50
|
||||
}
|
||||
},
|
||||
"session_id": "sample-session-id"
|
||||
},
|
||||
{
|
||||
"type": "user",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "tool_call_2",
|
||||
"content": "File successfully edited. The debug print statement has been removed.",
|
||||
"is_error": false
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"id": "msg_sample125",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "claude-test-model",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Perfect! I've successfully removed the debug print statement from the function. Let me now create a pull request comment to document this change."
|
||||
},
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "tool_call_3",
|
||||
"name": "mcp__github__add_pull_request_review_comment",
|
||||
"input": {
|
||||
"owner": "example-org",
|
||||
"repo": "example-repo",
|
||||
"pull_number": 123,
|
||||
"body": "Removed debug print statement as requested.",
|
||||
"commit_id": "abc123def456",
|
||||
"path": "sample/file.py",
|
||||
"line": 2
|
||||
}
|
||||
}
|
||||
],
|
||||
"stop_reason": "tool_use",
|
||||
"stop_sequence": null,
|
||||
"usage": {
|
||||
"input_tokens": 150,
|
||||
"cache_creation_input_tokens": 0,
|
||||
"cache_read_input_tokens": 75,
|
||||
"output_tokens": 80
|
||||
}
|
||||
},
|
||||
"session_id": "sample-session-id"
|
||||
},
|
||||
{
|
||||
"type": "user",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "tool_call_3",
|
||||
"content": "Successfully posted review comment to PR #123",
|
||||
"is_error": false
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"id": "msg_sample126",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "claude-test-model",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Great! I've successfully completed the requested task:\n\n1. ✅ Located the debug print statement in the file\n2. ✅ Removed the print statement while preserving the function logic\n3. ✅ Added a review comment documenting the change\n\nThe debug print statement has been removed as requested by the reviewers."
|
||||
}
|
||||
],
|
||||
"stop_reason": "end_turn",
|
||||
"stop_sequence": null,
|
||||
"usage": {
|
||||
"input_tokens": 180,
|
||||
"cache_creation_input_tokens": 0,
|
||||
"cache_read_input_tokens": 90,
|
||||
"output_tokens": 60
|
||||
}
|
||||
},
|
||||
"session_id": "sample-session-id"
|
||||
},
|
||||
{
|
||||
"type": "result",
|
||||
"cost_usd": 0.0347,
|
||||
"duration_ms": 18750,
|
||||
"result": "Successfully removed debug print statement from file and added review comment to document the change."
|
||||
}
|
||||
]
|
||||
439
test/format-turns.test.ts
Normal file
439
test/format-turns.test.ts
Normal file
@@ -0,0 +1,439 @@
|
||||
import { expect, test, describe } from "bun:test";
|
||||
import { readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import {
|
||||
formatTurnsFromData,
|
||||
groupTurnsNaturally,
|
||||
formatGroupedContent,
|
||||
detectContentType,
|
||||
formatResultContent,
|
||||
formatToolWithResult,
|
||||
type Turn,
|
||||
type ToolUse,
|
||||
type ToolResult,
|
||||
} from "../src/entrypoints/format-turns";
|
||||
|
||||
describe("detectContentType", () => {
|
||||
test("detects JSON objects", () => {
|
||||
expect(detectContentType('{"key": "value"}')).toBe("json");
|
||||
expect(detectContentType('{"number": 42}')).toBe("json");
|
||||
});
|
||||
|
||||
test("detects JSON arrays", () => {
|
||||
expect(detectContentType("[1, 2, 3]")).toBe("json");
|
||||
expect(detectContentType('["a", "b"]')).toBe("json");
|
||||
});
|
||||
|
||||
test("detects Python code", () => {
|
||||
expect(detectContentType("def hello():\n pass")).toBe("python");
|
||||
expect(detectContentType("import os")).toBe("python");
|
||||
expect(detectContentType("from math import pi")).toBe("python");
|
||||
});
|
||||
|
||||
test("detects JavaScript code", () => {
|
||||
expect(detectContentType("function test() {}")).toBe("javascript");
|
||||
expect(detectContentType("const x = 5")).toBe("javascript");
|
||||
expect(detectContentType("let y = 10")).toBe("javascript");
|
||||
expect(detectContentType("const fn = () => console.log()")).toBe(
|
||||
"javascript",
|
||||
);
|
||||
});
|
||||
|
||||
test("detects bash/shell content", () => {
|
||||
expect(detectContentType("/usr/bin/test")).toBe("bash");
|
||||
expect(detectContentType("Error: command not found")).toBe("bash");
|
||||
expect(detectContentType("ls -la")).toBe("bash");
|
||||
expect(detectContentType("$ echo hello")).toBe("bash");
|
||||
});
|
||||
|
||||
test("detects diff format", () => {
|
||||
expect(detectContentType("@@ -1,3 +1,3 @@")).toBe("diff");
|
||||
expect(detectContentType("+++ file.txt")).toBe("diff");
|
||||
expect(detectContentType("--- file.txt")).toBe("diff");
|
||||
});
|
||||
|
||||
test("detects HTML/XML", () => {
|
||||
expect(detectContentType("<div>hello</div>")).toBe("html");
|
||||
expect(detectContentType("<xml>content</xml>")).toBe("html");
|
||||
});
|
||||
|
||||
test("detects markdown", () => {
|
||||
expect(detectContentType("- List item")).toBe("markdown");
|
||||
expect(detectContentType("* List item")).toBe("markdown");
|
||||
expect(detectContentType("```code```")).toBe("markdown");
|
||||
});
|
||||
|
||||
test("defaults to text", () => {
|
||||
expect(detectContentType("plain text")).toBe("text");
|
||||
expect(detectContentType("just some words")).toBe("text");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatResultContent", () => {
|
||||
test("handles empty content", () => {
|
||||
expect(formatResultContent("")).toBe("*(No output)*\n\n");
|
||||
expect(formatResultContent(null)).toBe("*(No output)*\n\n");
|
||||
expect(formatResultContent(undefined)).toBe("*(No output)*\n\n");
|
||||
});
|
||||
|
||||
test("formats short text without code blocks", () => {
|
||||
const result = formatResultContent("success");
|
||||
expect(result).toBe("**→** success\n\n");
|
||||
});
|
||||
|
||||
test("formats long text with code blocks", () => {
|
||||
const longText =
|
||||
"This is a longer piece of text that should be formatted in a code block because it exceeds the short text threshold";
|
||||
const result = formatResultContent(longText);
|
||||
expect(result).toContain("**Result:**");
|
||||
expect(result).toContain("```text");
|
||||
expect(result).toContain(longText);
|
||||
});
|
||||
|
||||
test("pretty prints JSON content", () => {
|
||||
const jsonContent = '{"key": "value", "number": 42}';
|
||||
const result = formatResultContent(jsonContent);
|
||||
expect(result).toContain("```json");
|
||||
expect(result).toContain('"key": "value"');
|
||||
expect(result).toContain('"number": 42');
|
||||
});
|
||||
|
||||
test("truncates very long content", () => {
|
||||
const veryLongContent = "A".repeat(4000);
|
||||
const result = formatResultContent(veryLongContent);
|
||||
expect(result).toContain("...");
|
||||
// Should not contain the full long content
|
||||
expect(result.length).toBeLessThan(veryLongContent.length);
|
||||
});
|
||||
|
||||
test("handles type:text structure", () => {
|
||||
const structuredContent = [{ type: "text", text: "Hello world" }];
|
||||
const result = formatResultContent(JSON.stringify(structuredContent));
|
||||
expect(result).toBe("**→** Hello world\n\n");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatToolWithResult", () => {
|
||||
test("formats tool with parameters and result", () => {
|
||||
const toolUse: ToolUse = {
|
||||
type: "tool_use",
|
||||
name: "read_file",
|
||||
input: { file_path: "/path/to/file.txt" },
|
||||
id: "tool_123",
|
||||
};
|
||||
|
||||
const toolResult: ToolResult = {
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool_123",
|
||||
content: "File content here",
|
||||
is_error: false,
|
||||
};
|
||||
|
||||
const result = formatToolWithResult(toolUse, toolResult);
|
||||
|
||||
expect(result).toContain("### 🔧 `read_file`");
|
||||
expect(result).toContain("**Parameters:**");
|
||||
expect(result).toContain('"file_path": "/path/to/file.txt"');
|
||||
expect(result).toContain("**→** File content here");
|
||||
});
|
||||
|
||||
test("formats tool with error result", () => {
|
||||
const toolUse: ToolUse = {
|
||||
type: "tool_use",
|
||||
name: "failing_tool",
|
||||
input: { param: "value" },
|
||||
};
|
||||
|
||||
const toolResult: ToolResult = {
|
||||
type: "tool_result",
|
||||
content: "Permission denied",
|
||||
is_error: true,
|
||||
};
|
||||
|
||||
const result = formatToolWithResult(toolUse, toolResult);
|
||||
|
||||
expect(result).toContain("### 🔧 `failing_tool`");
|
||||
expect(result).toContain("❌ **Error:** `Permission denied`");
|
||||
});
|
||||
|
||||
test("formats tool without parameters", () => {
|
||||
const toolUse: ToolUse = {
|
||||
type: "tool_use",
|
||||
name: "simple_tool",
|
||||
};
|
||||
|
||||
const result = formatToolWithResult(toolUse);
|
||||
|
||||
expect(result).toContain("### 🔧 `simple_tool`");
|
||||
expect(result).not.toContain("**Parameters:**");
|
||||
});
|
||||
|
||||
test("handles unknown tool name", () => {
|
||||
const toolUse: ToolUse = {
|
||||
type: "tool_use",
|
||||
};
|
||||
|
||||
const result = formatToolWithResult(toolUse);
|
||||
|
||||
expect(result).toContain("### 🔧 `unknown_tool`");
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupTurnsNaturally", () => {
|
||||
test("groups system initialization", () => {
|
||||
const data: Turn[] = [
|
||||
{
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
tools: [{ name: "tool1" }, { name: "tool2" }],
|
||||
},
|
||||
];
|
||||
|
||||
const result = groupTurnsNaturally(data);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.type).toBe("system_init");
|
||||
expect(result[0]?.tools_count).toBe(2);
|
||||
});
|
||||
|
||||
test("groups assistant actions with tool calls", () => {
|
||||
const data: Turn[] = [
|
||||
{
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [
|
||||
{ type: "text", text: "I'll help you" },
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tool_123",
|
||||
name: "read_file",
|
||||
input: { file_path: "/test.txt" },
|
||||
},
|
||||
],
|
||||
usage: { input_tokens: 100, output_tokens: 50 },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "user",
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool_123",
|
||||
content: "file content",
|
||||
is_error: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const result = groupTurnsNaturally(data);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.type).toBe("assistant_action");
|
||||
expect(result[0]?.text_parts).toEqual(["I'll help you"]);
|
||||
expect(result[0]?.tool_calls).toHaveLength(1);
|
||||
expect(result[0]?.tool_calls?.[0]?.tool_use.name).toBe("read_file");
|
||||
expect(result[0]?.tool_calls?.[0]?.tool_result?.content).toBe(
|
||||
"file content",
|
||||
);
|
||||
expect(result[0]?.usage).toEqual({ input_tokens: 100, output_tokens: 50 });
|
||||
});
|
||||
|
||||
test("groups user messages", () => {
|
||||
const data: Turn[] = [
|
||||
{
|
||||
type: "user",
|
||||
message: {
|
||||
content: [{ type: "text", text: "Please help me" }],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const result = groupTurnsNaturally(data);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.type).toBe("user_message");
|
||||
expect(result[0]?.text_parts).toEqual(["Please help me"]);
|
||||
});
|
||||
|
||||
test("groups final results", () => {
|
||||
const data: Turn[] = [
|
||||
{
|
||||
type: "result",
|
||||
cost_usd: 0.1234,
|
||||
duration_ms: 5000,
|
||||
result: "Task completed",
|
||||
},
|
||||
];
|
||||
|
||||
const result = groupTurnsNaturally(data);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.type).toBe("final_result");
|
||||
expect(result[0]?.data).toEqual(data[0]!);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatGroupedContent", () => {
|
||||
test("formats system initialization", () => {
|
||||
const groupedContent = [
|
||||
{
|
||||
type: "system_init",
|
||||
tools_count: 3,
|
||||
},
|
||||
];
|
||||
|
||||
const result = formatGroupedContent(groupedContent);
|
||||
|
||||
expect(result).toContain("## Claude Code Report");
|
||||
expect(result).toContain("## 🚀 System Initialization");
|
||||
expect(result).toContain("**Available Tools:** 3 tools loaded");
|
||||
});
|
||||
|
||||
test("formats assistant actions", () => {
|
||||
const groupedContent = [
|
||||
{
|
||||
type: "assistant_action",
|
||||
text_parts: ["I'll help you with that"],
|
||||
tool_calls: [
|
||||
{
|
||||
tool_use: {
|
||||
type: "tool_use",
|
||||
name: "test_tool",
|
||||
input: { param: "value" },
|
||||
},
|
||||
tool_result: {
|
||||
type: "tool_result",
|
||||
content: "result",
|
||||
is_error: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
usage: { input_tokens: 100, output_tokens: 50 },
|
||||
},
|
||||
];
|
||||
|
||||
const result = formatGroupedContent(groupedContent);
|
||||
|
||||
expect(result).toContain("I'll help you with that");
|
||||
expect(result).toContain("### 🔧 `test_tool`");
|
||||
expect(result).toContain("*Token usage: 100 input, 50 output*");
|
||||
});
|
||||
|
||||
test("formats user messages", () => {
|
||||
const groupedContent = [
|
||||
{
|
||||
type: "user_message",
|
||||
text_parts: ["Help me please"],
|
||||
},
|
||||
];
|
||||
|
||||
const result = formatGroupedContent(groupedContent);
|
||||
|
||||
expect(result).toContain("## 👤 User");
|
||||
expect(result).toContain("Help me please");
|
||||
});
|
||||
|
||||
test("formats final results", () => {
|
||||
const groupedContent = [
|
||||
{
|
||||
type: "final_result",
|
||||
data: {
|
||||
type: "result",
|
||||
cost_usd: 0.1234,
|
||||
duration_ms: 5678,
|
||||
result: "Success!",
|
||||
} as Turn,
|
||||
},
|
||||
];
|
||||
|
||||
const result = formatGroupedContent(groupedContent);
|
||||
|
||||
expect(result).toContain("## ✅ Final Result");
|
||||
expect(result).toContain("Success!");
|
||||
expect(result).toContain("**Cost:** $0.1234");
|
||||
expect(result).toContain("**Duration:** 5.7s");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatTurnsFromData", () => {
|
||||
test("handles empty data", () => {
|
||||
const result = formatTurnsFromData([]);
|
||||
expect(result).toBe("## Claude Code Report\n\n");
|
||||
});
|
||||
|
||||
test("formats complete conversation", () => {
|
||||
const data: Turn[] = [
|
||||
{
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
tools: [{ name: "tool1" }],
|
||||
},
|
||||
{
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [
|
||||
{ type: "text", text: "I'll help you" },
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tool_123",
|
||||
name: "read_file",
|
||||
input: { file_path: "/test.txt" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "user",
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool_123",
|
||||
content: "file content",
|
||||
is_error: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "result",
|
||||
cost_usd: 0.05,
|
||||
duration_ms: 2000,
|
||||
result: "Done",
|
||||
},
|
||||
];
|
||||
|
||||
const result = formatTurnsFromData(data);
|
||||
|
||||
expect(result).toContain("## Claude Code Report");
|
||||
expect(result).toContain("## 🚀 System Initialization");
|
||||
expect(result).toContain("I'll help you");
|
||||
expect(result).toContain("### 🔧 `read_file`");
|
||||
expect(result).toContain("## ✅ Final Result");
|
||||
expect(result).toContain("Done");
|
||||
});
|
||||
});
|
||||
|
||||
describe("integration tests", () => {
|
||||
test("formats real conversation data correctly", () => {
|
||||
// Load the sample JSON data
|
||||
const jsonPath = join(__dirname, "fixtures", "sample-turns.json");
|
||||
const expectedPath = join(
|
||||
__dirname,
|
||||
"fixtures",
|
||||
"sample-turns-expected-output.md",
|
||||
);
|
||||
|
||||
const jsonData = JSON.parse(readFileSync(jsonPath, "utf-8"));
|
||||
const expectedOutput = readFileSync(expectedPath, "utf-8").trim();
|
||||
|
||||
// Format the data using our function
|
||||
const actualOutput = formatTurnsFromData(jsonData).trim();
|
||||
|
||||
// Compare the outputs
|
||||
expect(actualOutput).toBe(expectedOutput);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { parseMultilineInput } from "../../src/github/context";
|
||||
import {
|
||||
parseMultilineInput,
|
||||
parseAdditionalPermissions,
|
||||
} from "../../src/github/context";
|
||||
|
||||
describe("parseMultilineInput", () => {
|
||||
it("should parse a comma-separated string", () => {
|
||||
@@ -55,3 +58,58 @@ Bash(bun typecheck)
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseAdditionalPermissions", () => {
|
||||
it("should parse single permission", () => {
|
||||
const input = "actions: read";
|
||||
const result = parseAdditionalPermissions(input);
|
||||
expect(result.get("actions")).toBe("read");
|
||||
expect(result.size).toBe(1);
|
||||
});
|
||||
|
||||
it("should parse multiple permissions", () => {
|
||||
const input = `actions: read
|
||||
packages: write
|
||||
contents: read`;
|
||||
const result = parseAdditionalPermissions(input);
|
||||
expect(result.get("actions")).toBe("read");
|
||||
expect(result.get("packages")).toBe("write");
|
||||
expect(result.get("contents")).toBe("read");
|
||||
expect(result.size).toBe(3);
|
||||
});
|
||||
|
||||
it("should handle empty string", () => {
|
||||
const input = "";
|
||||
const result = parseAdditionalPermissions(input);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it("should handle whitespace and empty lines", () => {
|
||||
const input = `
|
||||
actions: read
|
||||
|
||||
packages: write
|
||||
`;
|
||||
const result = parseAdditionalPermissions(input);
|
||||
expect(result.get("actions")).toBe("read");
|
||||
expect(result.get("packages")).toBe("write");
|
||||
expect(result.size).toBe(2);
|
||||
});
|
||||
|
||||
it("should ignore lines without colon separator", () => {
|
||||
const input = `actions: read
|
||||
invalid line
|
||||
packages: write`;
|
||||
const result = parseAdditionalPermissions(input);
|
||||
expect(result.get("actions")).toBe("read");
|
||||
expect(result.get("packages")).toBe("write");
|
||||
expect(result.size).toBe(2);
|
||||
});
|
||||
|
||||
it("should trim whitespace around keys and values", () => {
|
||||
const input = " actions : read ";
|
||||
const result = parseAdditionalPermissions(input);
|
||||
expect(result.get("actions")).toBe("read");
|
||||
expect(result.size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test";
|
||||
import { prepareMcpConfig } from "../src/mcp/install-mcp-server";
|
||||
import * as core from "@actions/core";
|
||||
import type { ParsedGitHubContext } from "../src/github/context";
|
||||
|
||||
describe("prepareMcpConfig", () => {
|
||||
let consoleInfoSpy: any;
|
||||
@@ -8,6 +9,59 @@ describe("prepareMcpConfig", () => {
|
||||
let setFailedSpy: any;
|
||||
let processExitSpy: any;
|
||||
|
||||
// Create a mock context for tests
|
||||
const mockContext: ParsedGitHubContext = {
|
||||
runId: "test-run-id",
|
||||
eventName: "issue_comment",
|
||||
eventAction: "created",
|
||||
repository: {
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
full_name: "test-owner/test-repo",
|
||||
},
|
||||
actor: "test-actor",
|
||||
payload: {} as any,
|
||||
entityNumber: 123,
|
||||
isPR: false,
|
||||
inputs: {
|
||||
triggerPhrase: "@claude",
|
||||
assigneeTrigger: "",
|
||||
labelTrigger: "",
|
||||
allowedTools: [],
|
||||
disallowedTools: [],
|
||||
customInstructions: "",
|
||||
directPrompt: "",
|
||||
overridePrompt: "",
|
||||
branchPrefix: "",
|
||||
useStickyComment: false,
|
||||
additionalPermissions: new Map(),
|
||||
useCommitSigning: false,
|
||||
},
|
||||
};
|
||||
|
||||
const mockPRContext: ParsedGitHubContext = {
|
||||
...mockContext,
|
||||
eventName: "pull_request",
|
||||
isPR: true,
|
||||
entityNumber: 456,
|
||||
};
|
||||
|
||||
const mockContextWithSigning: ParsedGitHubContext = {
|
||||
...mockContext,
|
||||
inputs: {
|
||||
...mockContext.inputs,
|
||||
useCommitSigning: true,
|
||||
},
|
||||
};
|
||||
|
||||
const mockPRContextWithSigning: ParsedGitHubContext = {
|
||||
...mockPRContext,
|
||||
inputs: {
|
||||
...mockPRContext.inputs,
|
||||
useCommitSigning: true,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
consoleInfoSpy = spyOn(core, "info").mockImplementation(() => {});
|
||||
consoleWarningSpy = spyOn(core, "warning").mockImplementation(() => {});
|
||||
@@ -15,6 +69,11 @@ describe("prepareMcpConfig", () => {
|
||||
processExitSpy = spyOn(process, "exit").mockImplementation(() => {
|
||||
throw new Error("Process exit");
|
||||
});
|
||||
|
||||
// Set up required environment variables
|
||||
if (!process.env.GITHUB_ACTION_PATH) {
|
||||
process.env.GITHUB_ACTION_PATH = "/test/action/path";
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -24,18 +83,52 @@ describe("prepareMcpConfig", () => {
|
||||
processExitSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("should return base config when no additional config is provided and no allowed_tools", async () => {
|
||||
test("should return comment server when commit signing is disabled", async () => {
|
||||
const result = await prepareMcpConfig({
|
||||
githubToken: "test-token",
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
allowedTools: [],
|
||||
context: mockContext,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers).toBeDefined();
|
||||
expect(parsed.mcpServers.github).not.toBeDefined();
|
||||
expect(parsed.mcpServers.github_file_ops).not.toBeDefined();
|
||||
expect(parsed.mcpServers.github_comment).toBeDefined();
|
||||
expect(parsed.mcpServers.github_comment.env.GITHUB_TOKEN).toBe(
|
||||
"test-token",
|
||||
);
|
||||
expect(parsed.mcpServers.github_comment.env.REPO_OWNER).toBe("test-owner");
|
||||
expect(parsed.mcpServers.github_comment.env.REPO_NAME).toBe("test-repo");
|
||||
});
|
||||
|
||||
test("should return file ops server when commit signing is enabled", async () => {
|
||||
const contextWithSigning = {
|
||||
...mockContext,
|
||||
inputs: {
|
||||
...mockContext.inputs,
|
||||
useCommitSigning: true,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await prepareMcpConfig({
|
||||
githubToken: "test-token",
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
allowedTools: [],
|
||||
context: contextWithSigning,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers).toBeDefined();
|
||||
expect(parsed.mcpServers.github).not.toBeDefined();
|
||||
expect(parsed.mcpServers.github_comment).toBeDefined();
|
||||
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
||||
expect(parsed.mcpServers.github_file_ops.env.GITHUB_TOKEN).toBe(
|
||||
"test-token",
|
||||
@@ -53,31 +146,44 @@ describe("prepareMcpConfig", () => {
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
allowedTools: [
|
||||
"mcp__github__create_issue",
|
||||
"mcp__github_file_ops__commit_files",
|
||||
],
|
||||
context: mockContext,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers).toBeDefined();
|
||||
expect(parsed.mcpServers.github).toBeDefined();
|
||||
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
||||
expect(parsed.mcpServers.github_comment).toBeDefined();
|
||||
expect(parsed.mcpServers.github_file_ops).not.toBeDefined();
|
||||
expect(parsed.mcpServers.github.env.GITHUB_PERSONAL_ACCESS_TOKEN).toBe(
|
||||
"test-token",
|
||||
);
|
||||
});
|
||||
|
||||
test("should not include github MCP server when only file_ops tools are allowed", async () => {
|
||||
const contextWithSigning = {
|
||||
...mockContext,
|
||||
inputs: {
|
||||
...mockContext.inputs,
|
||||
useCommitSigning: true,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await prepareMcpConfig({
|
||||
githubToken: "test-token",
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
allowedTools: [
|
||||
"mcp__github_file_ops__commit_files",
|
||||
"mcp__github_file_ops__update_claude_comment",
|
||||
],
|
||||
context: contextWithSigning,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
@@ -86,19 +192,22 @@ describe("prepareMcpConfig", () => {
|
||||
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
||||
});
|
||||
|
||||
test("should include file_ops server even when no GitHub tools are allowed", async () => {
|
||||
test("should include comment server when no GitHub tools are allowed and signing disabled", async () => {
|
||||
const result = await prepareMcpConfig({
|
||||
githubToken: "test-token",
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
allowedTools: ["Edit", "Read", "Write"],
|
||||
context: mockContext,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers).toBeDefined();
|
||||
expect(parsed.mcpServers.github).not.toBeDefined();
|
||||
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
||||
expect(parsed.mcpServers.github_file_ops).not.toBeDefined();
|
||||
expect(parsed.mcpServers.github_comment).toBeDefined();
|
||||
});
|
||||
|
||||
test("should return base config when additional config is empty string", async () => {
|
||||
@@ -107,14 +216,16 @@ describe("prepareMcpConfig", () => {
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
additionalMcpConfig: "",
|
||||
allowedTools: [],
|
||||
context: mockContext,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers).toBeDefined();
|
||||
expect(parsed.mcpServers.github).not.toBeDefined();
|
||||
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
||||
expect(parsed.mcpServers.github_comment).toBeDefined();
|
||||
expect(consoleWarningSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -124,14 +235,16 @@ describe("prepareMcpConfig", () => {
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
additionalMcpConfig: " \n\t ",
|
||||
allowedTools: [],
|
||||
context: mockContext,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers).toBeDefined();
|
||||
expect(parsed.mcpServers.github).not.toBeDefined();
|
||||
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
||||
expect(parsed.mcpServers.github_comment).toBeDefined();
|
||||
expect(consoleWarningSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -153,11 +266,13 @@ describe("prepareMcpConfig", () => {
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
additionalMcpConfig: additionalConfig,
|
||||
allowedTools: [
|
||||
"mcp__github__create_issue",
|
||||
"mcp__github_file_ops__commit_files",
|
||||
],
|
||||
context: mockContextWithSigning,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
@@ -190,11 +305,13 @@ describe("prepareMcpConfig", () => {
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
additionalMcpConfig: additionalConfig,
|
||||
allowedTools: [
|
||||
"mcp__github__create_issue",
|
||||
"mcp__github_file_ops__commit_files",
|
||||
],
|
||||
context: mockContextWithSigning,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
@@ -230,8 +347,10 @@ describe("prepareMcpConfig", () => {
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
additionalMcpConfig: additionalConfig,
|
||||
allowedTools: [],
|
||||
context: mockContextWithSigning,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
@@ -249,8 +368,10 @@ describe("prepareMcpConfig", () => {
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
additionalMcpConfig: invalidJson,
|
||||
allowedTools: [],
|
||||
context: mockContextWithSigning,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
@@ -269,8 +390,10 @@ describe("prepareMcpConfig", () => {
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
additionalMcpConfig: nonObjectJson,
|
||||
allowedTools: [],
|
||||
context: mockContextWithSigning,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
@@ -292,8 +415,10 @@ describe("prepareMcpConfig", () => {
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
additionalMcpConfig: nullJson,
|
||||
allowedTools: [],
|
||||
context: mockContextWithSigning,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
@@ -315,8 +440,10 @@ describe("prepareMcpConfig", () => {
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
additionalMcpConfig: arrayJson,
|
||||
allowedTools: [],
|
||||
context: mockContextWithSigning,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
@@ -361,8 +488,10 @@ describe("prepareMcpConfig", () => {
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
additionalMcpConfig: additionalConfig,
|
||||
allowedTools: [],
|
||||
context: mockContextWithSigning,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
@@ -383,7 +512,9 @@ describe("prepareMcpConfig", () => {
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
allowedTools: [],
|
||||
context: mockContextWithSigning,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
@@ -403,7 +534,9 @@ describe("prepareMcpConfig", () => {
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
allowedTools: [],
|
||||
context: mockContextWithSigning,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
@@ -411,4 +544,138 @@ describe("prepareMcpConfig", () => {
|
||||
|
||||
process.env.GITHUB_WORKSPACE = oldEnv;
|
||||
});
|
||||
|
||||
test("should include github_ci server when context.isPR is true and actions:read permission is granted", async () => {
|
||||
const oldEnv = process.env.ACTIONS_TOKEN;
|
||||
process.env.ACTIONS_TOKEN = "workflow-token";
|
||||
|
||||
const contextWithPermissions = {
|
||||
...mockPRContext,
|
||||
inputs: {
|
||||
...mockPRContext.inputs,
|
||||
additionalPermissions: new Map([["actions", "read"]]),
|
||||
useCommitSigning: true,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await prepareMcpConfig({
|
||||
githubToken: "test-token",
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
allowedTools: [],
|
||||
context: contextWithPermissions,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers.github_ci).toBeDefined();
|
||||
expect(parsed.mcpServers.github_ci.env.GITHUB_TOKEN).toBe("workflow-token");
|
||||
expect(parsed.mcpServers.github_ci.env.PR_NUMBER).toBe("456");
|
||||
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
||||
|
||||
process.env.ACTIONS_TOKEN = oldEnv;
|
||||
});
|
||||
|
||||
test("should not include github_ci server when context.isPR is false", async () => {
|
||||
const result = await prepareMcpConfig({
|
||||
githubToken: "test-token",
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
allowedTools: [],
|
||||
context: mockContextWithSigning,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers.github_ci).not.toBeDefined();
|
||||
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
||||
});
|
||||
|
||||
test("should not include github_ci server when actions:read permission is not granted", async () => {
|
||||
const oldTokenEnv = process.env.ACTIONS_TOKEN;
|
||||
process.env.ACTIONS_TOKEN = "workflow-token";
|
||||
|
||||
const result = await prepareMcpConfig({
|
||||
githubToken: "test-token",
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
allowedTools: [],
|
||||
context: mockPRContextWithSigning,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers.github_ci).not.toBeDefined();
|
||||
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
||||
|
||||
process.env.ACTIONS_TOKEN = oldTokenEnv;
|
||||
});
|
||||
|
||||
test("should parse additional_permissions with multiple lines correctly", async () => {
|
||||
const oldTokenEnv = process.env.ACTIONS_TOKEN;
|
||||
process.env.ACTIONS_TOKEN = "workflow-token";
|
||||
|
||||
const contextWithPermissions = {
|
||||
...mockPRContext,
|
||||
inputs: {
|
||||
...mockPRContext.inputs,
|
||||
additionalPermissions: new Map([
|
||||
["actions", "read"],
|
||||
["future", "permission"],
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await prepareMcpConfig({
|
||||
githubToken: "test-token",
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
allowedTools: [],
|
||||
context: contextWithPermissions,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers.github_ci).toBeDefined();
|
||||
expect(parsed.mcpServers.github_ci.env.GITHUB_TOKEN).toBe("workflow-token");
|
||||
|
||||
process.env.ACTIONS_TOKEN = oldTokenEnv;
|
||||
});
|
||||
|
||||
test("should warn when actions:read is requested but token lacks permission", async () => {
|
||||
const oldTokenEnv = process.env.ACTIONS_TOKEN;
|
||||
process.env.ACTIONS_TOKEN = "invalid-token";
|
||||
|
||||
const contextWithPermissions = {
|
||||
...mockPRContext,
|
||||
inputs: {
|
||||
...mockPRContext.inputs,
|
||||
additionalPermissions: new Map([["actions", "read"]]),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await prepareMcpConfig({
|
||||
githubToken: "test-token",
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
allowedTools: [],
|
||||
context: contextWithPermissions,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers.github_ci).toBeDefined();
|
||||
expect(consoleWarningSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"The github_ci MCP server requires 'actions: read' permission",
|
||||
),
|
||||
);
|
||||
|
||||
process.env.ACTIONS_TOKEN = oldTokenEnv;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,14 +10,20 @@ import type {
|
||||
const defaultInputs = {
|
||||
triggerPhrase: "/claude",
|
||||
assigneeTrigger: "",
|
||||
labelTrigger: "",
|
||||
anthropicModel: "claude-3-7-sonnet-20250219",
|
||||
allowedTools: [] as string[],
|
||||
disallowedTools: [] as string[],
|
||||
customInstructions: "",
|
||||
directPrompt: "",
|
||||
overridePrompt: "",
|
||||
useBedrock: false,
|
||||
useVertex: false,
|
||||
timeoutMinutes: 30,
|
||||
branchPrefix: "claude/",
|
||||
useStickyComment: false,
|
||||
additionalPermissions: new Map<string, string>(),
|
||||
useCommitSigning: false,
|
||||
};
|
||||
|
||||
const defaultRepository = {
|
||||
@@ -128,6 +134,46 @@ export const mockIssueAssignedContext: ParsedGitHubContext = {
|
||||
inputs: { ...defaultInputs, assigneeTrigger: "@claude-bot" },
|
||||
};
|
||||
|
||||
export const mockIssueLabeledContext: ParsedGitHubContext = {
|
||||
runId: "1234567890",
|
||||
eventName: "issues",
|
||||
eventAction: "labeled",
|
||||
repository: defaultRepository,
|
||||
actor: "admin-user",
|
||||
payload: {
|
||||
action: "labeled",
|
||||
issue: {
|
||||
number: 1234,
|
||||
title: "Enhancement: Improve search functionality",
|
||||
body: "The current search is too slow and needs optimization",
|
||||
user: {
|
||||
login: "alice-wonder",
|
||||
id: 54321,
|
||||
avatar_url: "https://avatars.githubusercontent.com/u/54321",
|
||||
html_url: "https://github.com/alice-wonder",
|
||||
},
|
||||
assignee: null,
|
||||
},
|
||||
label: {
|
||||
id: 987654321,
|
||||
name: "claude-task",
|
||||
color: "f29513",
|
||||
description: "Label for Claude AI interactions",
|
||||
},
|
||||
repository: {
|
||||
name: "test-repo",
|
||||
full_name: "test-owner/test-repo",
|
||||
private: false,
|
||||
owner: {
|
||||
login: "test-owner",
|
||||
},
|
||||
},
|
||||
} as IssuesEvent,
|
||||
entityNumber: 1234,
|
||||
isPR: false,
|
||||
inputs: { ...defaultInputs, labelTrigger: "claude-task" },
|
||||
};
|
||||
|
||||
// Issue comment on issue event
|
||||
export const mockIssueCommentContext: ParsedGitHubContext = {
|
||||
runId: "1234567890",
|
||||
|
||||
@@ -62,10 +62,16 @@ describe("checkWritePermissions", () => {
|
||||
inputs: {
|
||||
triggerPhrase: "@claude",
|
||||
assigneeTrigger: "",
|
||||
labelTrigger: "",
|
||||
allowedTools: [],
|
||||
disallowedTools: [],
|
||||
customInstructions: "",
|
||||
directPrompt: "",
|
||||
overridePrompt: "",
|
||||
branchPrefix: "claude/",
|
||||
useStickyComment: false,
|
||||
additionalPermissions: new Map(),
|
||||
useCommitSigning: false,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ describe("parseEnvVarsWithContext", () => {
|
||||
process.env = {
|
||||
...BASE_ENV,
|
||||
BASE_BRANCH: "main",
|
||||
CLAUDE_BRANCH: "claude/issue-67890-20240101_120000",
|
||||
CLAUDE_BRANCH: "claude/issue-67890-20240101-1200",
|
||||
};
|
||||
});
|
||||
|
||||
@@ -44,7 +44,7 @@ describe("parseEnvVarsWithContext", () => {
|
||||
mockIssueCommentContext,
|
||||
"12345",
|
||||
"main",
|
||||
"claude/issue-67890-20240101_120000",
|
||||
"claude/issue-67890-20240101-1200",
|
||||
);
|
||||
|
||||
expect(result.repository).toBe("test-owner/test-repo");
|
||||
@@ -60,7 +60,7 @@ describe("parseEnvVarsWithContext", () => {
|
||||
expect(result.eventData.issueNumber).toBe("55");
|
||||
expect(result.eventData.commentId).toBe("12345678");
|
||||
expect(result.eventData.claudeBranch).toBe(
|
||||
"claude/issue-67890-20240101_120000",
|
||||
"claude/issue-67890-20240101-1200",
|
||||
);
|
||||
expect(result.eventData.baseBranch).toBe("main");
|
||||
expect(result.eventData.commentBody).toBe(
|
||||
@@ -81,7 +81,7 @@ describe("parseEnvVarsWithContext", () => {
|
||||
mockIssueCommentContext,
|
||||
"12345",
|
||||
undefined,
|
||||
"claude/issue-67890-20240101_120000",
|
||||
"claude/issue-67890-20240101-1200",
|
||||
),
|
||||
).toThrow("BASE_BRANCH is required for issue_comment event");
|
||||
});
|
||||
@@ -152,7 +152,7 @@ describe("parseEnvVarsWithContext", () => {
|
||||
process.env = {
|
||||
...BASE_ENV,
|
||||
BASE_BRANCH: "main",
|
||||
CLAUDE_BRANCH: "claude/issue-42-20240101_120000",
|
||||
CLAUDE_BRANCH: "claude/issue-42-20240101-1200",
|
||||
};
|
||||
});
|
||||
|
||||
@@ -161,7 +161,7 @@ describe("parseEnvVarsWithContext", () => {
|
||||
mockIssueOpenedContext,
|
||||
"12345",
|
||||
"main",
|
||||
"claude/issue-42-20240101_120000",
|
||||
"claude/issue-42-20240101-1200",
|
||||
);
|
||||
|
||||
expect(result.eventData.eventName).toBe("issues");
|
||||
@@ -174,7 +174,7 @@ describe("parseEnvVarsWithContext", () => {
|
||||
expect(result.eventData.issueNumber).toBe("42");
|
||||
expect(result.eventData.baseBranch).toBe("main");
|
||||
expect(result.eventData.claudeBranch).toBe(
|
||||
"claude/issue-42-20240101_120000",
|
||||
"claude/issue-42-20240101-1200",
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -184,7 +184,7 @@ describe("parseEnvVarsWithContext", () => {
|
||||
mockIssueAssignedContext,
|
||||
"12345",
|
||||
"main",
|
||||
"claude/issue-123-20240101_120000",
|
||||
"claude/issue-123-20240101-1200",
|
||||
);
|
||||
|
||||
expect(result.eventData.eventName).toBe("issues");
|
||||
@@ -197,7 +197,7 @@ describe("parseEnvVarsWithContext", () => {
|
||||
expect(result.eventData.issueNumber).toBe("123");
|
||||
expect(result.eventData.baseBranch).toBe("main");
|
||||
expect(result.eventData.claudeBranch).toBe(
|
||||
"claude/issue-123-20240101_120000",
|
||||
"claude/issue-123-20240101-1200",
|
||||
);
|
||||
expect(result.eventData.assigneeTrigger).toBe("@claude-bot");
|
||||
}
|
||||
@@ -215,10 +215,59 @@ describe("parseEnvVarsWithContext", () => {
|
||||
mockIssueOpenedContext,
|
||||
"12345",
|
||||
undefined,
|
||||
"claude/issue-42-20240101_120000",
|
||||
"claude/issue-42-20240101-1200",
|
||||
),
|
||||
).toThrow("BASE_BRANCH is required for issues event");
|
||||
});
|
||||
|
||||
test("should allow issue assigned event with direct_prompt and no assigneeTrigger", () => {
|
||||
const contextWithDirectPrompt = createMockContext({
|
||||
...mockIssueAssignedContext,
|
||||
inputs: {
|
||||
...mockIssueAssignedContext.inputs,
|
||||
assigneeTrigger: "", // No assignee trigger
|
||||
directPrompt: "Please assess this issue", // But direct prompt is provided
|
||||
},
|
||||
});
|
||||
|
||||
const result = prepareContext(
|
||||
contextWithDirectPrompt,
|
||||
"12345",
|
||||
"main",
|
||||
"claude/issue-123-20240101-1200",
|
||||
);
|
||||
|
||||
expect(result.eventData.eventName).toBe("issues");
|
||||
expect(result.eventData.isPR).toBe(false);
|
||||
expect(result.directPrompt).toBe("Please assess this issue");
|
||||
if (
|
||||
result.eventData.eventName === "issues" &&
|
||||
result.eventData.eventAction === "assigned"
|
||||
) {
|
||||
expect(result.eventData.issueNumber).toBe("123");
|
||||
expect(result.eventData.assigneeTrigger).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("should throw error when neither assigneeTrigger nor directPrompt provided for issue assigned event", () => {
|
||||
const contextWithoutTriggers = createMockContext({
|
||||
...mockIssueAssignedContext,
|
||||
inputs: {
|
||||
...mockIssueAssignedContext.inputs,
|
||||
assigneeTrigger: "", // No assignee trigger
|
||||
directPrompt: "", // No direct prompt
|
||||
},
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
prepareContext(
|
||||
contextWithoutTriggers,
|
||||
"12345",
|
||||
"main",
|
||||
"claude/issue-123-20240101-1200",
|
||||
),
|
||||
).toThrow("ASSIGNEE_TRIGGER is required for issue assigned event");
|
||||
});
|
||||
});
|
||||
|
||||
describe("optional fields", () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { describe, it, expect } from "bun:test";
|
||||
import {
|
||||
createMockContext,
|
||||
mockIssueAssignedContext,
|
||||
mockIssueLabeledContext,
|
||||
mockIssueCommentContext,
|
||||
mockIssueOpenedContext,
|
||||
mockPullRequestReviewContext,
|
||||
@@ -29,10 +30,16 @@ describe("checkContainsTrigger", () => {
|
||||
inputs: {
|
||||
triggerPhrase: "/claude",
|
||||
assigneeTrigger: "",
|
||||
labelTrigger: "",
|
||||
directPrompt: "Fix the bug in the login form",
|
||||
overridePrompt: "",
|
||||
allowedTools: [],
|
||||
disallowedTools: [],
|
||||
customInstructions: "",
|
||||
branchPrefix: "claude/",
|
||||
useStickyComment: false,
|
||||
additionalPermissions: new Map(),
|
||||
useCommitSigning: false,
|
||||
},
|
||||
});
|
||||
expect(checkContainsTrigger(context)).toBe(true);
|
||||
@@ -55,10 +62,16 @@ describe("checkContainsTrigger", () => {
|
||||
inputs: {
|
||||
triggerPhrase: "/claude",
|
||||
assigneeTrigger: "",
|
||||
labelTrigger: "",
|
||||
directPrompt: "",
|
||||
overridePrompt: "",
|
||||
allowedTools: [],
|
||||
disallowedTools: [],
|
||||
customInstructions: "",
|
||||
branchPrefix: "claude/",
|
||||
useStickyComment: false,
|
||||
additionalPermissions: new Map(),
|
||||
useCommitSigning: false,
|
||||
},
|
||||
});
|
||||
expect(checkContainsTrigger(context)).toBe(false);
|
||||
@@ -107,6 +120,39 @@ describe("checkContainsTrigger", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("label trigger", () => {
|
||||
it("should return true when issue is labeled with the trigger label", () => {
|
||||
const context = mockIssueLabeledContext;
|
||||
expect(checkContainsTrigger(context)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when issue is labeled with a different label", () => {
|
||||
const context = {
|
||||
...mockIssueLabeledContext,
|
||||
payload: {
|
||||
...mockIssueLabeledContext.payload,
|
||||
label: {
|
||||
...(mockIssueLabeledContext.payload as any).label,
|
||||
name: "bug",
|
||||
},
|
||||
},
|
||||
} as ParsedGitHubContext;
|
||||
expect(checkContainsTrigger(context)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for non-labeled events", () => {
|
||||
const context = {
|
||||
...mockIssueLabeledContext,
|
||||
eventAction: "opened",
|
||||
payload: {
|
||||
...mockIssueLabeledContext.payload,
|
||||
action: "opened",
|
||||
},
|
||||
} as ParsedGitHubContext;
|
||||
expect(checkContainsTrigger(context)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue body and title trigger", () => {
|
||||
it("should return true when issue body contains trigger phrase", () => {
|
||||
const context = mockIssueOpenedContext;
|
||||
@@ -232,10 +278,16 @@ describe("checkContainsTrigger", () => {
|
||||
inputs: {
|
||||
triggerPhrase: "@claude",
|
||||
assigneeTrigger: "",
|
||||
labelTrigger: "",
|
||||
directPrompt: "",
|
||||
overridePrompt: "",
|
||||
allowedTools: [],
|
||||
disallowedTools: [],
|
||||
customInstructions: "",
|
||||
branchPrefix: "claude/",
|
||||
useStickyComment: false,
|
||||
additionalPermissions: new Map(),
|
||||
useCommitSigning: false,
|
||||
},
|
||||
});
|
||||
expect(checkContainsTrigger(context)).toBe(true);
|
||||
@@ -259,10 +311,16 @@ describe("checkContainsTrigger", () => {
|
||||
inputs: {
|
||||
triggerPhrase: "@claude",
|
||||
assigneeTrigger: "",
|
||||
labelTrigger: "",
|
||||
directPrompt: "",
|
||||
overridePrompt: "",
|
||||
allowedTools: [],
|
||||
disallowedTools: [],
|
||||
customInstructions: "",
|
||||
branchPrefix: "claude/",
|
||||
useStickyComment: false,
|
||||
additionalPermissions: new Map(),
|
||||
useCommitSigning: false,
|
||||
},
|
||||
});
|
||||
expect(checkContainsTrigger(context)).toBe(true);
|
||||
@@ -286,10 +344,16 @@ describe("checkContainsTrigger", () => {
|
||||
inputs: {
|
||||
triggerPhrase: "@claude",
|
||||
assigneeTrigger: "",
|
||||
labelTrigger: "",
|
||||
directPrompt: "",
|
||||
overridePrompt: "",
|
||||
allowedTools: [],
|
||||
disallowedTools: [],
|
||||
customInstructions: "",
|
||||
branchPrefix: "claude/",
|
||||
useStickyComment: false,
|
||||
additionalPermissions: new Map(),
|
||||
useCommitSigning: false,
|
||||
},
|
||||
});
|
||||
expect(checkContainsTrigger(context)).toBe(false);
|
||||
|
||||
@@ -25,6 +25,6 @@
|
||||
"noUnusedParameters": true,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
},
|
||||
"include": ["src/**/*", "test/**/*"],
|
||||
"include": ["src/**/*", "base-action/**/*", "test/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user