mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-24 07:24:12 +08:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2804b4174b | ||
|
|
2316a9a8db | ||
|
|
49cfcf8107 | ||
|
|
e208124d29 | ||
|
|
ba60ef7ba2 | ||
|
|
f3c892ca8d | ||
|
|
6e896a06bb | ||
|
|
a017b830c0 | ||
|
|
75f52e56b2 | ||
|
|
1bbc9e7ff7 | ||
|
|
625ea1519c | ||
|
|
a9171f0ced | ||
|
|
4778aeae4c | ||
|
|
b6e5a9f27a | ||
|
|
5d91d7d217 | ||
|
|
90006bcae7 | ||
|
|
005436f51d | ||
|
|
1b8ee3b941 | ||
|
|
c247cb152d | ||
|
|
cefa60067a | ||
|
|
7a708f68fa | ||
|
|
5da7ba548c | ||
|
|
964b8355fb | ||
|
|
c83d67a9b9 |
37
.github/workflows/ci-all.yml
vendored
Normal file
37
.github/workflows/ci-all.yml
vendored
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Orchestrates all CI workflows - runs on PRs, pushes to main, and manual dispatch
|
||||||
|
# Individual test workflows are called as reusable workflows
|
||||||
|
name: CI All
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ci:
|
||||||
|
uses: ./.github/workflows/ci.yml
|
||||||
|
|
||||||
|
test-base-action:
|
||||||
|
uses: ./.github/workflows/test-base-action.yml
|
||||||
|
secrets: inherit # Required for ANTHROPIC_API_KEY
|
||||||
|
|
||||||
|
test-custom-executables:
|
||||||
|
uses: ./.github/workflows/test-custom-executables.yml
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
|
test-mcp-servers:
|
||||||
|
uses: ./.github/workflows/test-mcp-servers.yml
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
|
test-settings:
|
||||||
|
uses: ./.github/workflows/test-settings.yml
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
|
test-structured-output:
|
||||||
|
uses: ./.github/workflows/test-structured-output.yml
|
||||||
|
secrets: inherit
|
||||||
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@@ -1,9 +1,8 @@
|
|||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
pull_request:
|
||||||
|
workflow_call:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
|
|||||||
54
.github/workflows/release.yml
vendored
54
.github/workflows/release.yml
vendored
@@ -8,10 +8,23 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
type: boolean
|
type: boolean
|
||||||
default: false
|
default: false
|
||||||
|
workflow_run:
|
||||||
|
workflows: ["CI All"]
|
||||||
|
types:
|
||||||
|
- completed
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
create-release:
|
create-release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
# Run if: manual dispatch OR (CI All succeeded AND commit is a version bump)
|
||||||
|
if: |
|
||||||
|
github.event_name == 'workflow_dispatch' ||
|
||||||
|
(github.event.workflow_run.conclusion == 'success' &&
|
||||||
|
github.event.workflow_run.head_branch == 'main' &&
|
||||||
|
github.event.workflow_run.event == 'push' &&
|
||||||
|
startsWith(github.event.workflow_run.head_commit.message, 'chore: bump Claude Code to'))
|
||||||
environment: production
|
environment: production
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -84,7 +97,8 @@ jobs:
|
|||||||
|
|
||||||
update-major-tag:
|
update-major-tag:
|
||||||
needs: create-release
|
needs: create-release
|
||||||
if: ${{ !inputs.dry_run }}
|
# Skip for dry runs (workflow_run events are never dry runs)
|
||||||
|
if: github.event_name == 'workflow_run' || !inputs.dry_run
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment: production
|
environment: production
|
||||||
permissions:
|
permissions:
|
||||||
@@ -109,47 +123,47 @@ jobs:
|
|||||||
|
|
||||||
echo "Updated $major_version tag to point to $next_version"
|
echo "Updated $major_version tag to point to $next_version"
|
||||||
|
|
||||||
release-base-action:
|
# release-base-action:
|
||||||
needs: create-release
|
# needs: create-release
|
||||||
if: ${{ !inputs.dry_run }}
|
# if: ${{ !inputs.dry_run }}
|
||||||
runs-on: ubuntu-latest
|
# runs-on: ubuntu-latest
|
||||||
environment: production
|
# environment: production
|
||||||
steps:
|
# steps:
|
||||||
- name: Checkout base-action repo
|
# - name: Checkout base-action repo
|
||||||
uses: actions/checkout@v5
|
# uses: actions/checkout@v5
|
||||||
with:
|
# with:
|
||||||
repository: anthropics/claude-code-base-action
|
# repository: anthropics/claude-code-base-action
|
||||||
token: ${{ secrets.CLAUDE_CODE_BASE_ACTION_PAT }}
|
# token: ${{ secrets.CLAUDE_CODE_BASE_ACTION_PAT }}
|
||||||
fetch-depth: 0
|
# fetch-depth: 0
|
||||||
|
#
|
||||||
# - name: Create and push tag
|
# - name: Create and push tag
|
||||||
# run: |
|
# run: |
|
||||||
# next_version="${{ needs.create-release.outputs.next_version }}"
|
# next_version="${{ needs.create-release.outputs.next_version }}"
|
||||||
|
#
|
||||||
# git config user.name "github-actions[bot]"
|
# git config user.name "github-actions[bot]"
|
||||||
# git config user.email "github-actions[bot]@users.noreply.github.com"
|
# git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
#
|
||||||
# # Create the version tag
|
# # Create the version tag
|
||||||
# git tag -a "$next_version" -m "Release $next_version - synced from claude-code-action"
|
# git tag -a "$next_version" -m "Release $next_version - synced from claude-code-action"
|
||||||
# git push origin "$next_version"
|
# git push origin "$next_version"
|
||||||
|
#
|
||||||
# # Update the beta tag
|
# # Update the beta tag
|
||||||
# git tag -fa beta -m "Update beta tag to ${next_version}"
|
# git tag -fa beta -m "Update beta tag to ${next_version}"
|
||||||
# git push origin beta --force
|
# git push origin beta --force
|
||||||
|
#
|
||||||
# - name: Create GitHub release
|
# - name: Create GitHub release
|
||||||
# env:
|
# env:
|
||||||
# GH_TOKEN: ${{ secrets.CLAUDE_CODE_BASE_ACTION_PAT }}
|
# GH_TOKEN: ${{ secrets.CLAUDE_CODE_BASE_ACTION_PAT }}
|
||||||
# run: |
|
# run: |
|
||||||
# next_version="${{ needs.create-release.outputs.next_version }}"
|
# next_version="${{ needs.create-release.outputs.next_version }}"
|
||||||
|
#
|
||||||
# # Create the release
|
# # Create the release
|
||||||
# gh release create "$next_version" \
|
# gh release create "$next_version" \
|
||||||
# --repo anthropics/claude-code-base-action \
|
# --repo anthropics/claude-code-base-action \
|
||||||
# --title "$next_version" \
|
# --title "$next_version" \
|
||||||
# --notes "Release $next_version - synced from anthropics/claude-code-action" \
|
# --notes "Release $next_version - synced from anthropics/claude-code-action" \
|
||||||
# --latest=false
|
# --latest=false
|
||||||
|
#
|
||||||
# # Update beta release to be latest
|
# # Update beta release to be latest
|
||||||
# gh release edit beta \
|
# gh release edit beta \
|
||||||
# --repo anthropics/claude-code-base-action \
|
# --repo anthropics/claude-code-base-action \
|
||||||
|
|||||||
62
.github/workflows/test-base-action.yml
vendored
62
.github/workflows/test-base-action.yml
vendored
@@ -1,9 +1,6 @@
|
|||||||
name: Test Claude Code Action
|
name: Test Claude Code Action
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
pull_request:
|
pull_request:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
@@ -11,6 +8,7 @@ on:
|
|||||||
description: "Test prompt for Claude"
|
description: "Test prompt for Claude"
|
||||||
required: false
|
required: false
|
||||||
default: "List the files in the current directory starting with 'package'"
|
default: "List the files in the current directory starting with 'package'"
|
||||||
|
workflow_call:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test-inline-prompt:
|
test-inline-prompt:
|
||||||
@@ -118,61 +116,3 @@ jobs:
|
|||||||
echo "❌ Execution log file not found"
|
echo "❌ Execution log file not found"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
test-agent-sdk:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
|
||||||
|
|
||||||
- name: Test with Agent SDK
|
|
||||||
id: sdk-test
|
|
||||||
uses: ./base-action
|
|
||||||
env:
|
|
||||||
USE_AGENT_SDK: "true"
|
|
||||||
with:
|
|
||||||
prompt: ${{ github.event.inputs.test_prompt || 'List the files in the current directory starting with "package"' }}
|
|
||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
||||||
allowed_tools: "LS,Read"
|
|
||||||
|
|
||||||
- name: Verify SDK output
|
|
||||||
run: |
|
|
||||||
OUTPUT_FILE="${{ steps.sdk-test.outputs.execution_file }}"
|
|
||||||
CONCLUSION="${{ steps.sdk-test.outputs.conclusion }}"
|
|
||||||
|
|
||||||
echo "Conclusion: $CONCLUSION"
|
|
||||||
echo "Output file: $OUTPUT_FILE"
|
|
||||||
|
|
||||||
if [ "$CONCLUSION" = "success" ]; then
|
|
||||||
echo "✅ Action completed successfully with Agent SDK"
|
|
||||||
else
|
|
||||||
echo "❌ Action failed with Agent SDK"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -f "$OUTPUT_FILE" ]; then
|
|
||||||
if [ -s "$OUTPUT_FILE" ]; then
|
|
||||||
echo "✅ Execution log file created successfully with content"
|
|
||||||
echo "Validating JSON format:"
|
|
||||||
if jq . "$OUTPUT_FILE" > /dev/null 2>&1; then
|
|
||||||
echo "✅ Output is valid JSON"
|
|
||||||
# Verify SDK output contains total_cost_usd (SDK field name)
|
|
||||||
if jq -e '.[] | select(.type == "result") | .total_cost_usd' "$OUTPUT_FILE" > /dev/null 2>&1; then
|
|
||||||
echo "✅ SDK output contains total_cost_usd field"
|
|
||||||
else
|
|
||||||
echo "❌ SDK output missing total_cost_usd field"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "Content preview:"
|
|
||||||
head -c 500 "$OUTPUT_FILE"
|
|
||||||
else
|
|
||||||
echo "❌ Output is not valid JSON"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "❌ Execution log file is empty"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "❌ Execution log file not found"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
name: Test Custom Executables
|
name: Test Custom Executables
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
pull_request:
|
pull_request:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
workflow_call:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test-custom-executables:
|
test-custom-executables:
|
||||||
|
|||||||
4
.github/workflows/test-mcp-servers.yml
vendored
4
.github/workflows/test-mcp-servers.yml
vendored
@@ -1,11 +1,9 @@
|
|||||||
name: Test MCP Servers
|
name: Test MCP Servers
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
workflow_call:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test-mcp-integration:
|
test-mcp-integration:
|
||||||
|
|||||||
4
.github/workflows/test-settings.yml
vendored
4
.github/workflows/test-settings.yml
vendored
@@ -1,11 +1,9 @@
|
|||||||
name: Test Settings Feature
|
name: Test Settings Feature
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
pull_request:
|
pull_request:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
workflow_call:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test-settings-inline-allow:
|
test-settings-inline-allow:
|
||||||
|
|||||||
4
.github/workflows/test-structured-output.yml
vendored
4
.github/workflows/test-structured-output.yml
vendored
@@ -1,11 +1,9 @@
|
|||||||
name: Test Structured Outputs
|
name: Test Structured Outputs
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
pull_request:
|
pull_request:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
workflow_call:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|||||||
11
action.yml
11
action.yml
@@ -23,6 +23,10 @@ inputs:
|
|||||||
description: "The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format)"
|
description: "The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format)"
|
||||||
required: false
|
required: false
|
||||||
default: "claude/"
|
default: "claude/"
|
||||||
|
branch_name_template:
|
||||||
|
description: "Template for branch naming. Available variables: {{prefix}}, {{entityType}}, {{entityNumber}}, {{timestamp}}, {{sha}}, {{label}}, {{description}}. {{label}} will be first label from the issue/PR, or {{entityType}} as a fallback. {{description}} will be the first 5 words of the issue/PR title in kebab-case. Default: '{{prefix}}{{entityType}}-{{entityNumber}}-{{timestamp}}'"
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
allowed_bots:
|
allowed_bots:
|
||||||
description: "Comma-separated list of allowed bot usernames, or '*' to allow all bots. Empty string (default) allows no bots."
|
description: "Comma-separated list of allowed bot usernames, or '*' to allow all bots. Empty string (default) allows no bots."
|
||||||
required: false
|
required: false
|
||||||
@@ -144,9 +148,9 @@ runs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Install Bun
|
- name: Install Bun
|
||||||
if: inputs.path_to_bun_executable == ''
|
if: inputs.path_to_bun_executable == ''
|
||||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # https://github.com/oven-sh/setup-bun/releases/tag/v2.0.2
|
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # https://github.com/oven-sh/setup-bun/releases/tag/v2.1.2
|
||||||
with:
|
with:
|
||||||
bun-version: 1.2.11
|
bun-version: 1.3.6
|
||||||
|
|
||||||
- name: Setup Custom Bun Path
|
- name: Setup Custom Bun Path
|
||||||
if: inputs.path_to_bun_executable != ''
|
if: inputs.path_to_bun_executable != ''
|
||||||
@@ -178,6 +182,7 @@ runs:
|
|||||||
LABEL_TRIGGER: ${{ inputs.label_trigger }}
|
LABEL_TRIGGER: ${{ inputs.label_trigger }}
|
||||||
BASE_BRANCH: ${{ inputs.base_branch }}
|
BASE_BRANCH: ${{ inputs.base_branch }}
|
||||||
BRANCH_PREFIX: ${{ inputs.branch_prefix }}
|
BRANCH_PREFIX: ${{ inputs.branch_prefix }}
|
||||||
|
BRANCH_NAME_TEMPLATE: ${{ inputs.branch_name_template }}
|
||||||
OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }}
|
OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }}
|
||||||
ALLOWED_BOTS: ${{ inputs.allowed_bots }}
|
ALLOWED_BOTS: ${{ inputs.allowed_bots }}
|
||||||
ALLOWED_NON_WRITE_USERS: ${{ inputs.allowed_non_write_users }}
|
ALLOWED_NON_WRITE_USERS: ${{ inputs.allowed_non_write_users }}
|
||||||
@@ -208,7 +213,7 @@ runs:
|
|||||||
|
|
||||||
# Install Claude Code if no custom executable is provided
|
# Install Claude Code if no custom executable is provided
|
||||||
if [ -z "$PATH_TO_CLAUDE_CODE_EXECUTABLE" ]; then
|
if [ -z "$PATH_TO_CLAUDE_CODE_EXECUTABLE" ]; then
|
||||||
CLAUDE_CODE_VERSION="2.0.76"
|
CLAUDE_CODE_VERSION="2.1.16"
|
||||||
echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..."
|
echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..."
|
||||||
for attempt in 1 2 3; do
|
for attempt in 1 2 3; do
|
||||||
echo "Installation attempt $attempt..."
|
echo "Installation attempt $attempt..."
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ This is a GitHub Action that allows running Claude Code within GitHub workflows.
|
|||||||
### Key Design Patterns
|
### Key Design Patterns
|
||||||
|
|
||||||
- Uses Bun runtime for development and execution
|
- Uses Bun runtime for development and execution
|
||||||
- Named pipes for IPC between prompt input and Claude process
|
|
||||||
- JSON streaming output format for execution logs
|
- JSON streaming output format for execution logs
|
||||||
- Composite action pattern to orchestrate multiple steps
|
- Composite action pattern to orchestrate multiple steps
|
||||||
- Provider-agnostic design supporting Anthropic API, AWS Bedrock, and Google Vertex AI
|
- Provider-agnostic design supporting Anthropic API, AWS Bedrock, and Google Vertex AI
|
||||||
@@ -54,7 +53,6 @@ This is a GitHub Action that allows running Claude Code within GitHub workflows.
|
|||||||
|
|
||||||
## Important Technical Details
|
## Important Technical Details
|
||||||
|
|
||||||
- Uses `mkfifo` to create named pipes for prompt input
|
|
||||||
- Outputs execution logs as JSON to `/tmp/claude-execution-output.json`
|
- Outputs execution logs as JSON to `/tmp/claude-execution-output.json`
|
||||||
- Timeout enforcement via `timeout` command wrapper
|
- Timeout enforcement via `timeout` command wrapper
|
||||||
- Strict TypeScript configuration with Bun-specific settings
|
- Strict TypeScript configuration with Bun-specific settings
|
||||||
|
|||||||
@@ -97,9 +97,9 @@ runs:
|
|||||||
|
|
||||||
- name: Install Bun
|
- name: Install Bun
|
||||||
if: inputs.path_to_bun_executable == ''
|
if: inputs.path_to_bun_executable == ''
|
||||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # https://github.com/oven-sh/setup-bun/releases/tag/v2.0.2
|
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # https://github.com/oven-sh/setup-bun/releases/tag/v2.1.2
|
||||||
with:
|
with:
|
||||||
bun-version: 1.2.11
|
bun-version: 1.3.6
|
||||||
|
|
||||||
- name: Setup Custom Bun Path
|
- name: Setup Custom Bun Path
|
||||||
if: inputs.path_to_bun_executable != ''
|
if: inputs.path_to_bun_executable != ''
|
||||||
@@ -124,7 +124,7 @@ runs:
|
|||||||
PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
|
PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
|
||||||
run: |
|
run: |
|
||||||
if [ -z "$PATH_TO_CLAUDE_CODE_EXECUTABLE" ]; then
|
if [ -z "$PATH_TO_CLAUDE_CODE_EXECUTABLE" ]; then
|
||||||
CLAUDE_CODE_VERSION="2.0.76"
|
CLAUDE_CODE_VERSION="2.1.16"
|
||||||
echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..."
|
echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..."
|
||||||
for attempt in 1 2 3; do
|
for attempt in 1 2 3; do
|
||||||
echo "Installation attempt $attempt..."
|
echo "Installation attempt $attempt..."
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"name": "@anthropic-ai/claude-code-base-action",
|
"name": "@anthropic-ai/claude-code-base-action",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "^1.10.1",
|
"@actions/core": "^1.10.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.1.76",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.16",
|
||||||
"shell-quote": "^1.8.3",
|
"shell-quote": "^1.8.3",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
|
|
||||||
"@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="],
|
"@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="],
|
||||||
|
|
||||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.1.76", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-linuxmusl-arm64": "^0.33.5", "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^3.24.1 || ^4.0.0" } }, "sha512-s7RvpXoFaLXLG7A1cJBAPD8ilwOhhc/12fb5mJXRuD561o4FmPtQ+WRfuy9akMmrFRfLsKv8Ornw3ClGAPL2fw=="],
|
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.16", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-linuxmusl-arm64": "^0.33.5", "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-8sG7rvJZ7rc+oj0ZvWMTAtnYYTsh5gP5pCXiG21wYbwHqgEPod/oOIu5DCC/PWhwzN0sAmDbVURgCTDmimYlXw=="],
|
||||||
|
|
||||||
"@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="],
|
"@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="],
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "^1.10.1",
|
"@actions/core": "^1.10.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.1.76",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.16",
|
||||||
"shell-quote": "^1.8.3"
|
"shell-quote": "^1.8.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ async function run() {
|
|||||||
mcpConfig: process.env.INPUT_MCP_CONFIG,
|
mcpConfig: process.env.INPUT_MCP_CONFIG,
|
||||||
systemPrompt: process.env.INPUT_SYSTEM_PROMPT,
|
systemPrompt: process.env.INPUT_SYSTEM_PROMPT,
|
||||||
appendSystemPrompt: process.env.INPUT_APPEND_SYSTEM_PROMPT,
|
appendSystemPrompt: process.env.INPUT_APPEND_SYSTEM_PROMPT,
|
||||||
claudeEnv: process.env.INPUT_CLAUDE_ENV,
|
|
||||||
fallbackModel: process.env.INPUT_FALLBACK_MODEL,
|
fallbackModel: process.env.INPUT_FALLBACK_MODEL,
|
||||||
model: process.env.ANTHROPIC_MODEL,
|
model: process.env.ANTHROPIC_MODEL,
|
||||||
pathToClaudeCodeExecutable:
|
pathToClaudeCodeExecutable:
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ export function parseSdkOptions(options: ClaudeOptions): ParsedSdkOptions {
|
|||||||
if (process.env.INPUT_ACTION_INPUTS_PRESENT) {
|
if (process.env.INPUT_ACTION_INPUTS_PRESENT) {
|
||||||
env.GITHUB_ACTION_INPUTS = process.env.INPUT_ACTION_INPUTS_PRESENT;
|
env.GITHUB_ACTION_INPUTS = process.env.INPUT_ACTION_INPUTS_PRESENT;
|
||||||
}
|
}
|
||||||
// Ensure SDK path uses the same entrypoint as the CLI path
|
// Set the entrypoint for Claude Code to identify this as the GitHub Action
|
||||||
env.CLAUDE_CODE_ENTRYPOINT = "claude-code-github-action";
|
env.CLAUDE_CODE_ENTRYPOINT = "claude-code-github-action";
|
||||||
|
|
||||||
// Build system prompt option - default to claude_code preset
|
// Build system prompt option - default to claude_code preset
|
||||||
|
|||||||
@@ -178,6 +178,15 @@ export async function runClaudeWithSdk(
|
|||||||
core.warning(`Failed to write execution file: ${error}`);
|
core.warning(`Failed to write execution file: ${error}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract and set session_id from system.init message
|
||||||
|
const initMessage = messages.find(
|
||||||
|
(m) => m.type === "system" && "subtype" in m && m.subtype === "init",
|
||||||
|
);
|
||||||
|
if (initMessage && "session_id" in initMessage && initMessage.session_id) {
|
||||||
|
core.setOutput("session_id", initMessage.session_id);
|
||||||
|
core.info(`Set session_id: ${initMessage.session_id}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (!resultMessage) {
|
if (!resultMessage) {
|
||||||
core.setOutput("conclusion", "failure");
|
core.setOutput("conclusion", "failure");
|
||||||
core.error("No result message received from Claude");
|
core.error("No result message received from Claude");
|
||||||
|
|||||||
@@ -1,72 +1,6 @@
|
|||||||
import * as core from "@actions/core";
|
|
||||||
import { exec } from "child_process";
|
|
||||||
import { promisify } from "util";
|
|
||||||
import { unlink, writeFile, stat, readFile } from "fs/promises";
|
|
||||||
import { createWriteStream } from "fs";
|
|
||||||
import { spawn } from "child_process";
|
|
||||||
import { parse as parseShellArgs } from "shell-quote";
|
|
||||||
import { runClaudeWithSdk } from "./run-claude-sdk";
|
import { runClaudeWithSdk } from "./run-claude-sdk";
|
||||||
import { parseSdkOptions } from "./parse-sdk-options";
|
import { parseSdkOptions } from "./parse-sdk-options";
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
|
||||||
|
|
||||||
const PIPE_PATH = `${process.env.RUNNER_TEMP}/claude_prompt_pipe`;
|
|
||||||
const EXECUTION_FILE = `${process.env.RUNNER_TEMP}/claude-execution-output.json`;
|
|
||||||
const BASE_ARGS = ["--verbose", "--output-format", "stream-json"];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitizes JSON output to remove sensitive information when full output is disabled
|
|
||||||
* Returns a safe summary message or null if the message should be completely suppressed
|
|
||||||
*/
|
|
||||||
function sanitizeJsonOutput(
|
|
||||||
jsonObj: any,
|
|
||||||
showFullOutput: boolean,
|
|
||||||
): string | null {
|
|
||||||
if (showFullOutput) {
|
|
||||||
// In full output mode, return the full JSON
|
|
||||||
return JSON.stringify(jsonObj, null, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// In non-full-output mode, provide minimal safe output
|
|
||||||
const type = jsonObj.type;
|
|
||||||
const subtype = jsonObj.subtype;
|
|
||||||
|
|
||||||
// System initialization - safe to show
|
|
||||||
if (type === "system" && subtype === "init") {
|
|
||||||
return JSON.stringify(
|
|
||||||
{
|
|
||||||
type: "system",
|
|
||||||
subtype: "init",
|
|
||||||
message: "Claude Code initialized",
|
|
||||||
model: jsonObj.model || "unknown",
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Result messages - Always show the final result
|
|
||||||
if (type === "result") {
|
|
||||||
// These messages contain the final result and should always be visible
|
|
||||||
return JSON.stringify(
|
|
||||||
{
|
|
||||||
type: "result",
|
|
||||||
subtype: jsonObj.subtype,
|
|
||||||
is_error: jsonObj.is_error,
|
|
||||||
duration_ms: jsonObj.duration_ms,
|
|
||||||
num_turns: jsonObj.num_turns,
|
|
||||||
total_cost_usd: jsonObj.total_cost_usd,
|
|
||||||
permission_denials: jsonObj.permission_denials,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// For any other message types, suppress completely in non-full-output mode
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ClaudeOptions = {
|
export type ClaudeOptions = {
|
||||||
claudeArgs?: string;
|
claudeArgs?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
@@ -77,363 +11,11 @@ export type ClaudeOptions = {
|
|||||||
mcpConfig?: string;
|
mcpConfig?: string;
|
||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
appendSystemPrompt?: string;
|
appendSystemPrompt?: string;
|
||||||
claudeEnv?: string;
|
|
||||||
fallbackModel?: string;
|
fallbackModel?: string;
|
||||||
showFullOutput?: string;
|
showFullOutput?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PreparedConfig = {
|
|
||||||
claudeArgs: string[];
|
|
||||||
promptPath: string;
|
|
||||||
env: Record<string, string>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function prepareRunConfig(
|
|
||||||
promptPath: string,
|
|
||||||
options: ClaudeOptions,
|
|
||||||
): PreparedConfig {
|
|
||||||
// Build Claude CLI arguments:
|
|
||||||
// 1. Prompt flag (always first)
|
|
||||||
// 2. User's claudeArgs (full control)
|
|
||||||
// 3. BASE_ARGS (always last, cannot be overridden)
|
|
||||||
|
|
||||||
const claudeArgs = ["-p"];
|
|
||||||
|
|
||||||
// Parse and add user's custom Claude arguments
|
|
||||||
if (options.claudeArgs?.trim()) {
|
|
||||||
const parsed = parseShellArgs(options.claudeArgs);
|
|
||||||
const customArgs = parsed.filter(
|
|
||||||
(arg): arg is string => typeof arg === "string",
|
|
||||||
);
|
|
||||||
claudeArgs.push(...customArgs);
|
|
||||||
}
|
|
||||||
|
|
||||||
// BASE_ARGS are always appended last (cannot be overridden)
|
|
||||||
claudeArgs.push(...BASE_ARGS);
|
|
||||||
|
|
||||||
const customEnv: Record<string, string> = {};
|
|
||||||
|
|
||||||
if (process.env.INPUT_ACTION_INPUTS_PRESENT) {
|
|
||||||
customEnv.GITHUB_ACTION_INPUTS = process.env.INPUT_ACTION_INPUTS_PRESENT;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
claudeArgs,
|
|
||||||
promptPath,
|
|
||||||
env: customEnv,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses session_id from execution file and sets GitHub Action output
|
|
||||||
* Exported for testing
|
|
||||||
*/
|
|
||||||
export async function parseAndSetSessionId(
|
|
||||||
executionFile: string,
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
const content = await readFile(executionFile, "utf-8");
|
|
||||||
const messages = JSON.parse(content) as {
|
|
||||||
type: string;
|
|
||||||
subtype?: string;
|
|
||||||
session_id?: string;
|
|
||||||
}[];
|
|
||||||
|
|
||||||
// Find the system.init message which contains session_id
|
|
||||||
const initMessage = messages.find(
|
|
||||||
(m) => m.type === "system" && m.subtype === "init",
|
|
||||||
);
|
|
||||||
|
|
||||||
if (initMessage?.session_id) {
|
|
||||||
core.setOutput("session_id", initMessage.session_id);
|
|
||||||
core.info(`Set session_id: ${initMessage.session_id}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Don't fail the action if session_id extraction fails
|
|
||||||
core.warning(`Failed to extract session_id: ${error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses structured_output from execution file and sets GitHub Action outputs
|
|
||||||
* Only runs if --json-schema was explicitly provided in claude_args
|
|
||||||
* Exported for testing
|
|
||||||
*/
|
|
||||||
export async function parseAndSetStructuredOutputs(
|
|
||||||
executionFile: string,
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
const content = await readFile(executionFile, "utf-8");
|
|
||||||
const messages = JSON.parse(content) as {
|
|
||||||
type: string;
|
|
||||||
structured_output?: Record<string, unknown>;
|
|
||||||
}[];
|
|
||||||
|
|
||||||
// Search backwards - result is typically last or second-to-last message
|
|
||||||
const result = messages.findLast(
|
|
||||||
(m) => m.type === "result" && m.structured_output,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!result?.structured_output) {
|
|
||||||
throw new Error(
|
|
||||||
`--json-schema was provided but Claude did not return structured_output.\n` +
|
|
||||||
`Found ${messages.length} messages. Result exists: ${!!result}\n`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the complete structured output as a single JSON string
|
|
||||||
// This works around GitHub Actions limitation that composite actions can't have dynamic outputs
|
|
||||||
const structuredOutputJson = JSON.stringify(result.structured_output);
|
|
||||||
core.setOutput("structured_output", structuredOutputJson);
|
|
||||||
core.info(
|
|
||||||
`Set structured_output with ${Object.keys(result.structured_output).length} field(s)`,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
throw error; // Preserve original error and stack trace
|
|
||||||
}
|
|
||||||
throw new Error(`Failed to parse structured outputs: ${error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runClaude(promptPath: string, options: ClaudeOptions) {
|
export async function runClaude(promptPath: string, options: ClaudeOptions) {
|
||||||
// Feature flag: use SDK path by default, set USE_AGENT_SDK=false to use CLI
|
|
||||||
const useAgentSdk = process.env.USE_AGENT_SDK !== "false";
|
|
||||||
console.log(
|
|
||||||
`Using ${useAgentSdk ? "Agent SDK" : "CLI"} path (USE_AGENT_SDK=${process.env.USE_AGENT_SDK ?? "unset"})`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (useAgentSdk) {
|
|
||||||
const parsedOptions = parseSdkOptions(options);
|
const parsedOptions = parseSdkOptions(options);
|
||||||
return runClaudeWithSdk(promptPath, parsedOptions);
|
return runClaudeWithSdk(promptPath, parsedOptions);
|
||||||
}
|
|
||||||
|
|
||||||
const config = prepareRunConfig(promptPath, options);
|
|
||||||
|
|
||||||
// Detect if --json-schema is present in claude args
|
|
||||||
const hasJsonSchema = options.claudeArgs?.includes("--json-schema") ?? false;
|
|
||||||
|
|
||||||
// Create a named pipe
|
|
||||||
try {
|
|
||||||
await unlink(PIPE_PATH);
|
|
||||||
} catch (e) {
|
|
||||||
// Ignore if file doesn't exist
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the named pipe
|
|
||||||
await execAsync(`mkfifo "${PIPE_PATH}"`);
|
|
||||||
|
|
||||||
// Log prompt file size
|
|
||||||
let promptSize = "unknown";
|
|
||||||
try {
|
|
||||||
const stats = await stat(config.promptPath);
|
|
||||||
promptSize = stats.size.toString();
|
|
||||||
} catch (e) {
|
|
||||||
// Ignore error
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Prompt file size: ${promptSize} bytes`);
|
|
||||||
|
|
||||||
// Log custom environment variables if any
|
|
||||||
const customEnvKeys = Object.keys(config.env).filter(
|
|
||||||
(key) => key !== "CLAUDE_ACTION_INPUTS_PRESENT",
|
|
||||||
);
|
|
||||||
if (customEnvKeys.length > 0) {
|
|
||||||
console.log(`Custom environment variables: ${customEnvKeys.join(", ")}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log custom arguments if any
|
|
||||||
if (options.claudeArgs && options.claudeArgs.trim() !== "") {
|
|
||||||
console.log(`Custom Claude arguments: ${options.claudeArgs}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Output to console
|
|
||||||
console.log(`Running Claude with prompt from file: ${config.promptPath}`);
|
|
||||||
console.log(`Full command: claude ${config.claudeArgs.join(" ")}`);
|
|
||||||
|
|
||||||
// Start sending prompt to pipe in background
|
|
||||||
const catProcess = spawn("cat", [config.promptPath], {
|
|
||||||
stdio: ["ignore", "pipe", "inherit"],
|
|
||||||
});
|
|
||||||
const pipeStream = createWriteStream(PIPE_PATH);
|
|
||||||
catProcess.stdout.pipe(pipeStream);
|
|
||||||
|
|
||||||
catProcess.on("error", (error) => {
|
|
||||||
console.error("Error reading prompt file:", error);
|
|
||||||
pipeStream.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Use custom executable path if provided, otherwise default to "claude"
|
|
||||||
const claudeExecutable = options.pathToClaudeCodeExecutable || "claude";
|
|
||||||
|
|
||||||
const claudeProcess = spawn(claudeExecutable, config.claudeArgs, {
|
|
||||||
stdio: ["pipe", "pipe", "inherit"],
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
...config.env,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle Claude process errors
|
|
||||||
claudeProcess.on("error", (error) => {
|
|
||||||
console.error("Error spawning Claude process:", error);
|
|
||||||
pipeStream.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Determine if full output should be shown
|
|
||||||
// Show full output if explicitly set to "true" OR if GitHub Actions debug mode is enabled
|
|
||||||
const isDebugMode = process.env.ACTIONS_STEP_DEBUG === "true";
|
|
||||||
let showFullOutput = options.showFullOutput === "true" || isDebugMode;
|
|
||||||
|
|
||||||
if (isDebugMode && options.showFullOutput !== "false") {
|
|
||||||
console.log("Debug mode detected - showing full output");
|
|
||||||
showFullOutput = true;
|
|
||||||
} else if (!showFullOutput) {
|
|
||||||
console.log("Running Claude Code (full output hidden for security)...");
|
|
||||||
console.log(
|
|
||||||
"Rerun in debug mode or enable `show_full_output: true` in your workflow file for full output.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Capture output for parsing execution metrics
|
|
||||||
let output = "";
|
|
||||||
claudeProcess.stdout.on("data", (data) => {
|
|
||||||
const text = data.toString();
|
|
||||||
|
|
||||||
// Try to parse as JSON and handle based on verbose setting
|
|
||||||
const lines = text.split("\n");
|
|
||||||
lines.forEach((line: string, index: number) => {
|
|
||||||
if (line.trim() === "") return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Check if this line is a JSON object
|
|
||||||
const parsed = JSON.parse(line);
|
|
||||||
const sanitizedOutput = sanitizeJsonOutput(parsed, showFullOutput);
|
|
||||||
|
|
||||||
if (sanitizedOutput) {
|
|
||||||
process.stdout.write(sanitizedOutput);
|
|
||||||
if (index < lines.length - 1 || text.endsWith("\n")) {
|
|
||||||
process.stdout.write("\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Not a JSON object
|
|
||||||
if (showFullOutput) {
|
|
||||||
// In full output mode, print as is
|
|
||||||
process.stdout.write(line);
|
|
||||||
if (index < lines.length - 1 || text.endsWith("\n")) {
|
|
||||||
process.stdout.write("\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// In non-full-output mode, suppress non-JSON output
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
output += text;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle stdout errors
|
|
||||||
claudeProcess.stdout.on("error", (error) => {
|
|
||||||
console.error("Error reading Claude stdout:", error);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pipe from named pipe to Claude
|
|
||||||
const pipeProcess = spawn("cat", [PIPE_PATH]);
|
|
||||||
pipeProcess.stdout.pipe(claudeProcess.stdin);
|
|
||||||
|
|
||||||
// Handle pipe process errors
|
|
||||||
pipeProcess.on("error", (error) => {
|
|
||||||
console.error("Error reading from named pipe:", error);
|
|
||||||
claudeProcess.kill("SIGTERM");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wait for Claude to finish
|
|
||||||
const exitCode = await new Promise<number>((resolve) => {
|
|
||||||
claudeProcess.on("close", (code) => {
|
|
||||||
resolve(code || 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
claudeProcess.on("error", (error) => {
|
|
||||||
console.error("Claude process error:", error);
|
|
||||||
resolve(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clean up processes
|
|
||||||
try {
|
|
||||||
catProcess.kill("SIGTERM");
|
|
||||||
} catch (e) {
|
|
||||||
// Process may already be dead
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
pipeProcess.kill("SIGTERM");
|
|
||||||
} catch (e) {
|
|
||||||
// Process may already be dead
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up pipe file
|
|
||||||
try {
|
|
||||||
await unlink(PIPE_PATH);
|
|
||||||
} catch (e) {
|
|
||||||
// Ignore errors during cleanup
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set conclusion based on exit code
|
|
||||||
if (exitCode === 0) {
|
|
||||||
// Try to process the output and save execution metrics
|
|
||||||
try {
|
|
||||||
await writeFile("output.txt", output);
|
|
||||||
|
|
||||||
// Process output.txt into JSON and save to execution file
|
|
||||||
// Increase maxBuffer from Node.js default of 1MB to 10MB to handle large Claude outputs
|
|
||||||
const { stdout: jsonOutput } = await execAsync("jq -s '.' output.txt", {
|
|
||||||
maxBuffer: 10 * 1024 * 1024,
|
|
||||||
});
|
|
||||||
await writeFile(EXECUTION_FILE, jsonOutput);
|
|
||||||
|
|
||||||
console.log(`Log saved to ${EXECUTION_FILE}`);
|
|
||||||
} catch (e) {
|
|
||||||
core.warning(`Failed to process output for execution metrics: ${e}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
core.setOutput("execution_file", EXECUTION_FILE);
|
|
||||||
|
|
||||||
// Extract and set session_id
|
|
||||||
await parseAndSetSessionId(EXECUTION_FILE);
|
|
||||||
|
|
||||||
// Parse and set structured outputs only if user provided --json-schema in claude_args
|
|
||||||
if (hasJsonSchema) {
|
|
||||||
try {
|
|
||||||
await parseAndSetStructuredOutputs(EXECUTION_FILE);
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage =
|
|
||||||
error instanceof Error ? error.message : String(error);
|
|
||||||
core.setFailed(errorMessage);
|
|
||||||
core.setOutput("conclusion", "failure");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set conclusion to success if we reached here
|
|
||||||
core.setOutput("conclusion", "success");
|
|
||||||
} else {
|
|
||||||
core.setOutput("conclusion", "failure");
|
|
||||||
|
|
||||||
// Still try to save execution file if we have output
|
|
||||||
if (output) {
|
|
||||||
try {
|
|
||||||
await writeFile("output.txt", output);
|
|
||||||
// Increase maxBuffer from Node.js default of 1MB to 10MB to handle large Claude outputs
|
|
||||||
const { stdout: jsonOutput } = await execAsync("jq -s '.' output.txt", {
|
|
||||||
maxBuffer: 10 * 1024 * 1024,
|
|
||||||
});
|
|
||||||
await writeFile(EXECUTION_FILE, jsonOutput);
|
|
||||||
core.setOutput("execution_file", EXECUTION_FILE);
|
|
||||||
} catch (e) {
|
|
||||||
// Ignore errors when processing output during failure
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
process.exit(exitCode);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,6 @@
|
|||||||
"name": "mcp-test",
|
"name": "mcp-test",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.11.0"
|
"@modelcontextprotocol/sdk": "^1.24.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
#!/usr/bin/env bun
|
|
||||||
|
|
||||||
import { describe, test, expect } from "bun:test";
|
|
||||||
import { prepareRunConfig, type ClaudeOptions } from "../src/run-claude";
|
|
||||||
|
|
||||||
describe("prepareRunConfig", () => {
|
|
||||||
test("should prepare config with basic arguments", () => {
|
|
||||||
const options: ClaudeOptions = {};
|
|
||||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
|
||||||
|
|
||||||
expect(prepared.claudeArgs).toEqual([
|
|
||||||
"-p",
|
|
||||||
"--verbose",
|
|
||||||
"--output-format",
|
|
||||||
"stream-json",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should include promptPath", () => {
|
|
||||||
const options: ClaudeOptions = {};
|
|
||||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
|
||||||
|
|
||||||
expect(prepared.promptPath).toBe("/tmp/test-prompt.txt");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should use provided prompt path", () => {
|
|
||||||
const options: ClaudeOptions = {};
|
|
||||||
const prepared = prepareRunConfig("/custom/prompt/path.txt", options);
|
|
||||||
|
|
||||||
expect(prepared.promptPath).toBe("/custom/prompt/path.txt");
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("claudeArgs handling", () => {
|
|
||||||
test("should parse and include custom claude arguments", () => {
|
|
||||||
const options: ClaudeOptions = {
|
|
||||||
claudeArgs: "--max-turns 10 --model claude-3-opus-20240229",
|
|
||||||
};
|
|
||||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
|
||||||
|
|
||||||
expect(prepared.claudeArgs).toEqual([
|
|
||||||
"-p",
|
|
||||||
"--max-turns",
|
|
||||||
"10",
|
|
||||||
"--model",
|
|
||||||
"claude-3-opus-20240229",
|
|
||||||
"--verbose",
|
|
||||||
"--output-format",
|
|
||||||
"stream-json",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle empty claudeArgs", () => {
|
|
||||||
const options: ClaudeOptions = {
|
|
||||||
claudeArgs: "",
|
|
||||||
};
|
|
||||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
|
||||||
|
|
||||||
expect(prepared.claudeArgs).toEqual([
|
|
||||||
"-p",
|
|
||||||
"--verbose",
|
|
||||||
"--output-format",
|
|
||||||
"stream-json",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle claudeArgs with quoted strings", () => {
|
|
||||||
const options: ClaudeOptions = {
|
|
||||||
claudeArgs: '--system-prompt "You are a helpful assistant"',
|
|
||||||
};
|
|
||||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
|
||||||
|
|
||||||
expect(prepared.claudeArgs).toEqual([
|
|
||||||
"-p",
|
|
||||||
"--system-prompt",
|
|
||||||
"You are a helpful assistant",
|
|
||||||
"--verbose",
|
|
||||||
"--output-format",
|
|
||||||
"stream-json",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should include json-schema flag when provided", () => {
|
|
||||||
const options: ClaudeOptions = {
|
|
||||||
claudeArgs:
|
|
||||||
'--json-schema \'{"type":"object","properties":{"result":{"type":"boolean"}}}\'',
|
|
||||||
};
|
|
||||||
|
|
||||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
|
||||||
|
|
||||||
expect(prepared.claudeArgs).toContain("--json-schema");
|
|
||||||
expect(prepared.claudeArgs).toContain(
|
|
||||||
'{"type":"object","properties":{"result":{"type":"boolean"}}}',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
#!/usr/bin/env bun
|
|
||||||
|
|
||||||
import { describe, test, expect, afterEach, beforeEach, spyOn } from "bun:test";
|
|
||||||
import { writeFile, unlink } from "fs/promises";
|
|
||||||
import { tmpdir } from "os";
|
|
||||||
import { join } from "path";
|
|
||||||
import {
|
|
||||||
parseAndSetStructuredOutputs,
|
|
||||||
parseAndSetSessionId,
|
|
||||||
} from "../src/run-claude";
|
|
||||||
import * as core from "@actions/core";
|
|
||||||
|
|
||||||
// Mock execution file path
|
|
||||||
const TEST_EXECUTION_FILE = join(tmpdir(), "test-execution-output.json");
|
|
||||||
|
|
||||||
// Helper to create mock execution file with structured output
|
|
||||||
async function createMockExecutionFile(
|
|
||||||
structuredOutput?: Record<string, unknown>,
|
|
||||||
includeResult: boolean = true,
|
|
||||||
): Promise<void> {
|
|
||||||
const messages: any[] = [
|
|
||||||
{ type: "system", subtype: "init" },
|
|
||||||
{ type: "turn", content: "test" },
|
|
||||||
];
|
|
||||||
|
|
||||||
if (includeResult) {
|
|
||||||
messages.push({
|
|
||||||
type: "result",
|
|
||||||
cost_usd: 0.01,
|
|
||||||
duration_ms: 1000,
|
|
||||||
structured_output: structuredOutput,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Spy on core functions
|
|
||||||
let setOutputSpy: any;
|
|
||||||
let infoSpy: any;
|
|
||||||
let warningSpy: any;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
setOutputSpy = spyOn(core, "setOutput").mockImplementation(() => {});
|
|
||||||
infoSpy = spyOn(core, "info").mockImplementation(() => {});
|
|
||||||
warningSpy = spyOn(core, "warning").mockImplementation(() => {});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("parseAndSetStructuredOutputs", () => {
|
|
||||||
afterEach(async () => {
|
|
||||||
setOutputSpy?.mockRestore();
|
|
||||||
infoSpy?.mockRestore();
|
|
||||||
warningSpy?.mockRestore();
|
|
||||||
try {
|
|
||||||
await unlink(TEST_EXECUTION_FILE);
|
|
||||||
} catch {
|
|
||||||
// Ignore if file doesn't exist
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should set structured_output with valid data", async () => {
|
|
||||||
await createMockExecutionFile({
|
|
||||||
is_flaky: true,
|
|
||||||
confidence: 0.85,
|
|
||||||
summary: "Test looks flaky",
|
|
||||||
});
|
|
||||||
|
|
||||||
await parseAndSetStructuredOutputs(TEST_EXECUTION_FILE);
|
|
||||||
|
|
||||||
expect(setOutputSpy).toHaveBeenCalledWith(
|
|
||||||
"structured_output",
|
|
||||||
'{"is_flaky":true,"confidence":0.85,"summary":"Test looks flaky"}',
|
|
||||||
);
|
|
||||||
expect(infoSpy).toHaveBeenCalledWith(
|
|
||||||
"Set structured_output with 3 field(s)",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle arrays and nested objects", async () => {
|
|
||||||
await createMockExecutionFile({
|
|
||||||
items: ["a", "b", "c"],
|
|
||||||
config: { key: "value", nested: { deep: true } },
|
|
||||||
});
|
|
||||||
|
|
||||||
await parseAndSetStructuredOutputs(TEST_EXECUTION_FILE);
|
|
||||||
|
|
||||||
const callArgs = setOutputSpy.mock.calls[0];
|
|
||||||
expect(callArgs[0]).toBe("structured_output");
|
|
||||||
const parsed = JSON.parse(callArgs[1]);
|
|
||||||
expect(parsed).toEqual({
|
|
||||||
items: ["a", "b", "c"],
|
|
||||||
config: { key: "value", nested: { deep: true } },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle special characters in field names", async () => {
|
|
||||||
await createMockExecutionFile({
|
|
||||||
"test-result": "passed",
|
|
||||||
"item.count": 10,
|
|
||||||
"user@email": "test",
|
|
||||||
});
|
|
||||||
|
|
||||||
await parseAndSetStructuredOutputs(TEST_EXECUTION_FILE);
|
|
||||||
|
|
||||||
const callArgs = setOutputSpy.mock.calls[0];
|
|
||||||
const parsed = JSON.parse(callArgs[1]);
|
|
||||||
expect(parsed["test-result"]).toBe("passed");
|
|
||||||
expect(parsed["item.count"]).toBe(10);
|
|
||||||
expect(parsed["user@email"]).toBe("test");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should throw error when result exists but structured_output is undefined", async () => {
|
|
||||||
const messages = [
|
|
||||||
{ type: "system", subtype: "init" },
|
|
||||||
{ type: "result", cost_usd: 0.01, duration_ms: 1000 },
|
|
||||||
];
|
|
||||||
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
parseAndSetStructuredOutputs(TEST_EXECUTION_FILE),
|
|
||||||
).rejects.toThrow(
|
|
||||||
"--json-schema was provided but Claude did not return structured_output",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should throw error when no result message exists", async () => {
|
|
||||||
const messages = [
|
|
||||||
{ type: "system", subtype: "init" },
|
|
||||||
{ type: "turn", content: "test" },
|
|
||||||
];
|
|
||||||
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
parseAndSetStructuredOutputs(TEST_EXECUTION_FILE),
|
|
||||||
).rejects.toThrow(
|
|
||||||
"--json-schema was provided but Claude did not return structured_output",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should throw error with malformed JSON", async () => {
|
|
||||||
await writeFile(TEST_EXECUTION_FILE, "{ invalid json");
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
parseAndSetStructuredOutputs(TEST_EXECUTION_FILE),
|
|
||||||
).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should throw error when file does not exist", async () => {
|
|
||||||
await expect(
|
|
||||||
parseAndSetStructuredOutputs("/nonexistent/file.json"),
|
|
||||||
).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle empty structured_output object", async () => {
|
|
||||||
await createMockExecutionFile({});
|
|
||||||
|
|
||||||
await parseAndSetStructuredOutputs(TEST_EXECUTION_FILE);
|
|
||||||
|
|
||||||
expect(setOutputSpy).toHaveBeenCalledWith("structured_output", "{}");
|
|
||||||
expect(infoSpy).toHaveBeenCalledWith(
|
|
||||||
"Set structured_output with 0 field(s)",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("parseAndSetSessionId", () => {
|
|
||||||
afterEach(async () => {
|
|
||||||
setOutputSpy?.mockRestore();
|
|
||||||
infoSpy?.mockRestore();
|
|
||||||
warningSpy?.mockRestore();
|
|
||||||
try {
|
|
||||||
await unlink(TEST_EXECUTION_FILE);
|
|
||||||
} catch {
|
|
||||||
// Ignore if file doesn't exist
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should extract session_id from system.init message", async () => {
|
|
||||||
const messages = [
|
|
||||||
{ type: "system", subtype: "init", session_id: "test-session-123" },
|
|
||||||
{ type: "result", cost_usd: 0.01 },
|
|
||||||
];
|
|
||||||
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
|
|
||||||
|
|
||||||
await parseAndSetSessionId(TEST_EXECUTION_FILE);
|
|
||||||
|
|
||||||
expect(setOutputSpy).toHaveBeenCalledWith("session_id", "test-session-123");
|
|
||||||
expect(infoSpy).toHaveBeenCalledWith("Set session_id: test-session-123");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle missing session_id gracefully", async () => {
|
|
||||||
const messages = [
|
|
||||||
{ type: "system", subtype: "init" },
|
|
||||||
{ type: "result", cost_usd: 0.01 },
|
|
||||||
];
|
|
||||||
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
|
|
||||||
|
|
||||||
await parseAndSetSessionId(TEST_EXECUTION_FILE);
|
|
||||||
|
|
||||||
expect(setOutputSpy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle missing system.init message gracefully", async () => {
|
|
||||||
const messages = [{ type: "result", cost_usd: 0.01 }];
|
|
||||||
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
|
|
||||||
|
|
||||||
await parseAndSetSessionId(TEST_EXECUTION_FILE);
|
|
||||||
|
|
||||||
expect(setOutputSpy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle malformed JSON gracefully with warning", async () => {
|
|
||||||
await writeFile(TEST_EXECUTION_FILE, "{ invalid json");
|
|
||||||
|
|
||||||
await parseAndSetSessionId(TEST_EXECUTION_FILE);
|
|
||||||
|
|
||||||
expect(setOutputSpy).not.toHaveBeenCalled();
|
|
||||||
expect(warningSpy).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle non-existent file gracefully with warning", async () => {
|
|
||||||
await parseAndSetSessionId("/nonexistent/file.json");
|
|
||||||
|
|
||||||
expect(setOutputSpy).not.toHaveBeenCalled();
|
|
||||||
expect(warningSpy).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
4
bun.lock
4
bun.lock
@@ -7,7 +7,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "^1.10.1",
|
"@actions/core": "^1.10.1",
|
||||||
"@actions/github": "^6.0.1",
|
"@actions/github": "^6.0.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.1.76",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.16",
|
||||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||||
"@octokit/graphql": "^8.2.2",
|
"@octokit/graphql": "^8.2.2",
|
||||||
"@octokit/rest": "^21.1.1",
|
"@octokit/rest": "^21.1.1",
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
|
|
||||||
"@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="],
|
"@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="],
|
||||||
|
|
||||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.1.76", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-linuxmusl-arm64": "^0.33.5", "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^3.24.1 || ^4.0.0" } }, "sha512-s7RvpXoFaLXLG7A1cJBAPD8ilwOhhc/12fb5mJXRuD561o4FmPtQ+WRfuy9akMmrFRfLsKv8Ornw3ClGAPL2fw=="],
|
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.16", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-linuxmusl-arm64": "^0.33.5", "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-8sG7rvJZ7rc+oj0ZvWMTAtnYYTsh5gP5pCXiG21wYbwHqgEPod/oOIu5DCC/PWhwzN0sAmDbVURgCTDmimYlXw=="],
|
||||||
|
|
||||||
"@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="],
|
"@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="],
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,16 @@
|
|||||||
- **No Cross-Repository Access**: Each action invocation is limited to the repository where it was triggered
|
- **No Cross-Repository Access**: Each action invocation is limited to the repository where it was triggered
|
||||||
- **Limited Scope**: The token cannot access other repositories or perform actions beyond the configured permissions
|
- **Limited Scope**: The token cannot access other repositories or perform actions beyond the configured permissions
|
||||||
|
|
||||||
|
## Pull Request Creation
|
||||||
|
|
||||||
|
In its default configuration, **Claude does not create pull requests automatically** when responding to `@claude` mentions. Instead:
|
||||||
|
|
||||||
|
- Claude commits code changes to a new branch
|
||||||
|
- Claude provides a **link to the GitHub PR creation page** in its response
|
||||||
|
- **The user must click the link and create the PR themselves**, ensuring human oversight before any code is proposed for merging
|
||||||
|
|
||||||
|
This design ensures that users retain full control over what pull requests are created and can review the changes before initiating the PR workflow.
|
||||||
|
|
||||||
## ⚠️ Prompt Injection Risks
|
## ⚠️ Prompt Injection Risks
|
||||||
|
|
||||||
**Beware of potential hidden markdown when tagging Claude on untrusted content.** External contributors may include hidden instructions through HTML comments, invisible characters, hidden attributes, or other techniques. The action sanitizes content by stripping HTML comments, invisible characters, markdown image alt text, hidden HTML attributes, and HTML entities, but new bypass techniques may emerge. We recommend reviewing the raw content of all input coming from external contributors before allowing Claude to process it.
|
**Beware of potential hidden markdown when tagging Claude on untrusted content.** External contributors may include hidden instructions through HTML comments, invisible characters, hidden attributes, or other techniques. The action sanitizes content by stripping HTML comments, invisible characters, markdown image alt text, hidden HTML attributes, and HTML entities, but new bypass techniques may emerge. We recommend reviewing the raw content of all input coming from external contributors before allowing Claude to process it.
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "^1.10.1",
|
"@actions/core": "^1.10.1",
|
||||||
"@actions/github": "^6.0.1",
|
"@actions/github": "^6.0.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.1.76",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.16",
|
||||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||||
"@octokit/graphql": "^8.2.2",
|
"@octokit/graphql": "^8.2.2",
|
||||||
"@octokit/rest": "^21.1.1",
|
"@octokit/rest": "^21.1.1",
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ export const PR_QUERY = `
|
|||||||
additions
|
additions
|
||||||
deletions
|
deletions
|
||||||
state
|
state
|
||||||
|
labels(first: 1) {
|
||||||
|
nodes {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
commits(first: 100) {
|
commits(first: 100) {
|
||||||
totalCount
|
totalCount
|
||||||
nodes {
|
nodes {
|
||||||
@@ -101,6 +106,11 @@ export const ISSUE_QUERY = `
|
|||||||
updatedAt
|
updatedAt
|
||||||
lastEditedAt
|
lastEditedAt
|
||||||
state
|
state
|
||||||
|
labels(first: 1) {
|
||||||
|
nodes {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
comments(first: 100) {
|
comments(first: 100) {
|
||||||
nodes {
|
nodes {
|
||||||
id
|
id
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ type BaseContext = {
|
|||||||
labelTrigger: string;
|
labelTrigger: string;
|
||||||
baseBranch?: string;
|
baseBranch?: string;
|
||||||
branchPrefix: string;
|
branchPrefix: string;
|
||||||
|
branchNameTemplate?: string;
|
||||||
useStickyComment: boolean;
|
useStickyComment: boolean;
|
||||||
useCommitSigning: boolean;
|
useCommitSigning: boolean;
|
||||||
sshSigningKey: string;
|
sshSigningKey: string;
|
||||||
@@ -145,6 +146,7 @@ export function parseGitHubContext(): GitHubContext {
|
|||||||
labelTrigger: process.env.LABEL_TRIGGER ?? "",
|
labelTrigger: process.env.LABEL_TRIGGER ?? "",
|
||||||
baseBranch: process.env.BASE_BRANCH,
|
baseBranch: process.env.BASE_BRANCH,
|
||||||
branchPrefix: process.env.BRANCH_PREFIX ?? "claude/",
|
branchPrefix: process.env.BRANCH_PREFIX ?? "claude/",
|
||||||
|
branchNameTemplate: process.env.BRANCH_NAME_TEMPLATE,
|
||||||
useStickyComment: process.env.USE_STICKY_COMMENT === "true",
|
useStickyComment: process.env.USE_STICKY_COMMENT === "true",
|
||||||
useCommitSigning: process.env.USE_COMMIT_SIGNING === "true",
|
useCommitSigning: process.env.USE_COMMIT_SIGNING === "true",
|
||||||
sshSigningKey: process.env.SSH_SIGNING_KEY || "",
|
sshSigningKey: process.env.SSH_SIGNING_KEY || "",
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import type { Octokits } from "../api/client";
|
|||||||
import { ISSUE_QUERY, PR_QUERY, USER_QUERY } from "../api/queries/github";
|
import { ISSUE_QUERY, PR_QUERY, USER_QUERY } from "../api/queries/github";
|
||||||
import {
|
import {
|
||||||
isIssueCommentEvent,
|
isIssueCommentEvent,
|
||||||
|
isIssuesEvent,
|
||||||
|
isPullRequestEvent,
|
||||||
isPullRequestReviewEvent,
|
isPullRequestReviewEvent,
|
||||||
isPullRequestReviewCommentEvent,
|
isPullRequestReviewCommentEvent,
|
||||||
type ParsedGitHubContext,
|
type ParsedGitHubContext,
|
||||||
@@ -40,6 +42,31 @@ export function extractTriggerTimestamp(
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the original title from the GitHub webhook payload.
|
||||||
|
* This is the title as it existed when the trigger event occurred.
|
||||||
|
*
|
||||||
|
* @param context - Parsed GitHub context from webhook
|
||||||
|
* @returns The original title string or undefined if not available
|
||||||
|
*/
|
||||||
|
export function extractOriginalTitle(
|
||||||
|
context: ParsedGitHubContext,
|
||||||
|
): string | undefined {
|
||||||
|
if (isIssueCommentEvent(context)) {
|
||||||
|
return context.payload.issue?.title;
|
||||||
|
} else if (isPullRequestEvent(context)) {
|
||||||
|
return context.payload.pull_request?.title;
|
||||||
|
} else if (isPullRequestReviewEvent(context)) {
|
||||||
|
return context.payload.pull_request?.title;
|
||||||
|
} else if (isPullRequestReviewCommentEvent(context)) {
|
||||||
|
return context.payload.pull_request?.title;
|
||||||
|
} else if (isIssuesEvent(context)) {
|
||||||
|
return context.payload.issue?.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filters comments to only include those that existed in their final state before the trigger time.
|
* Filters comments to only include those that existed in their final state before the trigger time.
|
||||||
* This prevents malicious actors from editing comments after the trigger to inject harmful content.
|
* This prevents malicious actors from editing comments after the trigger to inject harmful content.
|
||||||
@@ -146,6 +173,7 @@ type FetchDataParams = {
|
|||||||
isPR: boolean;
|
isPR: boolean;
|
||||||
triggerUsername?: string;
|
triggerUsername?: string;
|
||||||
triggerTime?: string;
|
triggerTime?: string;
|
||||||
|
originalTitle?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GitHubFileWithSHA = GitHubFile & {
|
export type GitHubFileWithSHA = GitHubFile & {
|
||||||
@@ -169,6 +197,7 @@ export async function fetchGitHubData({
|
|||||||
isPR,
|
isPR,
|
||||||
triggerUsername,
|
triggerUsername,
|
||||||
triggerTime,
|
triggerTime,
|
||||||
|
originalTitle,
|
||||||
}: FetchDataParams): Promise<FetchDataResult> {
|
}: FetchDataParams): Promise<FetchDataResult> {
|
||||||
const [owner, repo] = repository.split("/");
|
const [owner, repo] = repository.split("/");
|
||||||
if (!owner || !repo) {
|
if (!owner || !repo) {
|
||||||
@@ -354,6 +383,11 @@ export async function fetchGitHubData({
|
|||||||
triggerDisplayName = await fetchUserDisplayName(octokits, triggerUsername);
|
triggerDisplayName = await fetchUserDisplayName(octokits, triggerUsername);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use the original title from the webhook payload if provided
|
||||||
|
if (originalTitle !== undefined) {
|
||||||
|
contextData.title = originalTitle;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
contextData,
|
contextData,
|
||||||
comments,
|
comments,
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ export function formatContext(
|
|||||||
): string {
|
): string {
|
||||||
if (isPR) {
|
if (isPR) {
|
||||||
const prData = contextData as GitHubPullRequest;
|
const prData = contextData as GitHubPullRequest;
|
||||||
return `PR Title: ${prData.title}
|
const sanitizedTitle = sanitizeContent(prData.title);
|
||||||
|
return `PR Title: ${sanitizedTitle}
|
||||||
PR Author: ${prData.author.login}
|
PR Author: ${prData.author.login}
|
||||||
PR Branch: ${prData.headRefName} -> ${prData.baseRefName}
|
PR Branch: ${prData.headRefName} -> ${prData.baseRefName}
|
||||||
PR State: ${prData.state}
|
PR State: ${prData.state}
|
||||||
@@ -24,7 +25,8 @@ Total Commits: ${prData.commits.totalCount}
|
|||||||
Changed Files: ${prData.files.nodes.length} files`;
|
Changed Files: ${prData.files.nodes.length} files`;
|
||||||
} else {
|
} else {
|
||||||
const issueData = contextData as GitHubIssue;
|
const issueData = contextData as GitHubIssue;
|
||||||
return `Issue Title: ${issueData.title}
|
const sanitizedTitle = sanitizeContent(issueData.title);
|
||||||
|
return `Issue Title: ${sanitizedTitle}
|
||||||
Issue Author: ${issueData.author.login}
|
Issue Author: ${issueData.author.login}
|
||||||
Issue State: ${issueData.state}`;
|
Issue State: ${issueData.state}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,22 @@
|
|||||||
* - For Issues: Create a new branch
|
* - For Issues: Create a new branch
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { $ } from "bun";
|
||||||
import { execFileSync } from "child_process";
|
import { execFileSync } from "child_process";
|
||||||
import * as core from "@actions/core";
|
import * as core from "@actions/core";
|
||||||
import type { ParsedGitHubContext } from "../context";
|
import type { ParsedGitHubContext } from "../context";
|
||||||
import type { GitHubPullRequest } from "../types";
|
import type { GitHubPullRequest } from "../types";
|
||||||
import type { Octokits } from "../api/client";
|
import type { Octokits } from "../api/client";
|
||||||
import type { FetchDataResult } from "../data/fetcher";
|
import type { FetchDataResult } from "../data/fetcher";
|
||||||
|
import { generateBranchName } from "../../utils/branch-template";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the first label from GitHub data, or returns undefined if no labels exist
|
||||||
|
*/
|
||||||
|
function extractFirstLabel(githubData: FetchDataResult): string | undefined {
|
||||||
|
const labels = githubData.contextData.labels?.nodes;
|
||||||
|
return labels && labels.length > 0 ? labels[0]?.name : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates a git branch name against a strict whitelist pattern.
|
* Validates a git branch name against a strict whitelist pattern.
|
||||||
@@ -125,7 +135,7 @@ export async function setupBranch(
|
|||||||
): Promise<BranchInfo> {
|
): Promise<BranchInfo> {
|
||||||
const { owner, repo } = context.repository;
|
const { owner, repo } = context.repository;
|
||||||
const entityNumber = context.entityNumber;
|
const entityNumber = context.entityNumber;
|
||||||
const { baseBranch, branchPrefix } = context.inputs;
|
const { baseBranch, branchPrefix, branchNameTemplate } = context.inputs;
|
||||||
const isPR = context.isPR;
|
const isPR = context.isPR;
|
||||||
|
|
||||||
if (isPR) {
|
if (isPR) {
|
||||||
@@ -191,17 +201,8 @@ export async function setupBranch(
|
|||||||
// Generate branch name for either an issue or closed/merged PR
|
// Generate branch name for either an issue or closed/merged PR
|
||||||
const entityType = isPR ? "pr" : "issue";
|
const entityType = isPR ? "pr" : "issue";
|
||||||
|
|
||||||
// Create Kubernetes-compatible timestamp: lowercase, hyphens only, shorter format
|
// Get the SHA of the source branch to use in template
|
||||||
const now = new Date();
|
let sourceSHA: string | undefined;
|
||||||
const timestamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}-${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}`;
|
|
||||||
|
|
||||||
// Ensure branch name is Kubernetes-compatible:
|
|
||||||
// - Lowercase only
|
|
||||||
// - Alphanumeric with hyphens
|
|
||||||
// - No underscores
|
|
||||||
// - Max 50 chars (to allow for prefixes)
|
|
||||||
const branchName = `${branchPrefix}${entityType}-${entityNumber}-${timestamp}`;
|
|
||||||
const newBranch = branchName.toLowerCase().substring(0, 50);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get the SHA of the source branch to verify it exists
|
// Get the SHA of the source branch to verify it exists
|
||||||
@@ -211,8 +212,46 @@ export async function setupBranch(
|
|||||||
ref: `heads/${sourceBranch}`,
|
ref: `heads/${sourceBranch}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentSHA = sourceBranchRef.data.object.sha;
|
sourceSHA = sourceBranchRef.data.object.sha;
|
||||||
console.log(`Source branch SHA: ${currentSHA}`);
|
console.log(`Source branch SHA: ${sourceSHA}`);
|
||||||
|
|
||||||
|
// Extract first label from GitHub data
|
||||||
|
const firstLabel = extractFirstLabel(githubData);
|
||||||
|
|
||||||
|
// Extract title from GitHub data
|
||||||
|
const title = githubData.contextData.title;
|
||||||
|
|
||||||
|
// Generate branch name using template or default format
|
||||||
|
let newBranch = generateBranchName(
|
||||||
|
branchNameTemplate,
|
||||||
|
branchPrefix,
|
||||||
|
entityType,
|
||||||
|
entityNumber,
|
||||||
|
sourceSHA,
|
||||||
|
firstLabel,
|
||||||
|
title,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if generated branch already exists on remote
|
||||||
|
try {
|
||||||
|
await $`git ls-remote --exit-code origin refs/heads/${newBranch}`.quiet();
|
||||||
|
|
||||||
|
// If we get here, branch exists (exit code 0)
|
||||||
|
console.log(
|
||||||
|
`Branch '${newBranch}' already exists, falling back to default format`,
|
||||||
|
);
|
||||||
|
newBranch = generateBranchName(
|
||||||
|
undefined, // Force default template
|
||||||
|
branchPrefix,
|
||||||
|
entityType,
|
||||||
|
entityNumber,
|
||||||
|
sourceSHA,
|
||||||
|
firstLabel,
|
||||||
|
title,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Branch doesn't exist (non-zero exit code), continue with generated name
|
||||||
|
}
|
||||||
|
|
||||||
// For commit signing, defer branch creation to the file ops server
|
// For commit signing, defer branch creation to the file ops server
|
||||||
if (context.inputs.useCommitSigning) {
|
if (context.inputs.useCommitSigning) {
|
||||||
|
|||||||
@@ -82,8 +82,13 @@ export async function setupSshSigning(sshSigningKey: string): Promise<void> {
|
|||||||
const sshDir = join(homedir(), ".ssh");
|
const sshDir = join(homedir(), ".ssh");
|
||||||
await mkdir(sshDir, { recursive: true, mode: 0o700 });
|
await mkdir(sshDir, { recursive: true, mode: 0o700 });
|
||||||
|
|
||||||
|
// Ensure key ends with newline (required for ssh-keygen to parse it)
|
||||||
|
const normalizedKey = sshSigningKey.endsWith("\n")
|
||||||
|
? sshSigningKey
|
||||||
|
: sshSigningKey + "\n";
|
||||||
|
|
||||||
// Write the signing key atomically with secure permissions (600)
|
// Write the signing key atomically with secure permissions (600)
|
||||||
await writeFile(SSH_SIGNING_KEY_PATH, sshSigningKey, { mode: 0o600 });
|
await writeFile(SSH_SIGNING_KEY_PATH, normalizedKey, { mode: 0o600 });
|
||||||
console.log(`✓ SSH signing key written to ${SSH_SIGNING_KEY_PATH}`);
|
console.log(`✓ SSH signing key written to ${SSH_SIGNING_KEY_PATH}`);
|
||||||
|
|
||||||
// Configure git to use SSH signing
|
// Configure git to use SSH signing
|
||||||
|
|||||||
@@ -63,6 +63,11 @@ export type GitHubPullRequest = {
|
|||||||
additions: number;
|
additions: number;
|
||||||
deletions: number;
|
deletions: number;
|
||||||
state: string;
|
state: string;
|
||||||
|
labels: {
|
||||||
|
nodes: Array<{
|
||||||
|
name: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
commits: {
|
commits: {
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
nodes: Array<{
|
nodes: Array<{
|
||||||
@@ -88,6 +93,11 @@ export type GitHubIssue = {
|
|||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
lastEditedAt?: string;
|
lastEditedAt?: string;
|
||||||
state: string;
|
state: string;
|
||||||
|
labels: {
|
||||||
|
nodes: Array<{
|
||||||
|
name: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
comments: {
|
comments: {
|
||||||
nodes: GitHubComment[];
|
nodes: GitHubComment[];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,11 +6,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Octokit } from "@octokit/rest";
|
import type { Octokit } from "@octokit/rest";
|
||||||
import type { ParsedGitHubContext } from "../context";
|
import type { GitHubContext } from "../context";
|
||||||
|
|
||||||
export async function checkHumanActor(
|
export async function checkHumanActor(
|
||||||
octokit: Octokit,
|
octokit: Octokit,
|
||||||
githubContext: ParsedGitHubContext,
|
githubContext: GitHubContext,
|
||||||
) {
|
) {
|
||||||
// Fetch user information from GitHub API
|
// Fetch user information from GitHub API
|
||||||
const { data: userData } = await octokit.users.getByUsername({
|
const { data: userData } = await octokit.users.getByUsername({
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|||||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { readFile, stat } from "fs/promises";
|
import { readFile, stat } from "fs/promises";
|
||||||
import { join } from "path";
|
import { resolve } from "path";
|
||||||
import { constants } from "fs";
|
import { constants } from "fs";
|
||||||
import fetch from "node-fetch";
|
import fetch from "node-fetch";
|
||||||
import { GITHUB_API_URL } from "../github/api/config";
|
import { GITHUB_API_URL } from "../github/api/config";
|
||||||
import { retryWithBackoff } from "../utils/retry";
|
import { retryWithBackoff } from "../utils/retry";
|
||||||
|
import { validatePathWithinRepo } from "./path-validation";
|
||||||
|
|
||||||
type GitHubRef = {
|
type GitHubRef = {
|
||||||
object: {
|
object: {
|
||||||
@@ -213,12 +214,18 @@ server.tool(
|
|||||||
throw new Error("GITHUB_TOKEN environment variable is required");
|
throw new Error("GITHUB_TOKEN environment variable is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
const processedFiles = files.map((filePath) => {
|
// Validate all paths are within repository root and get full/relative paths
|
||||||
if (filePath.startsWith("/")) {
|
const resolvedRepoDir = resolve(REPO_DIR);
|
||||||
return filePath.slice(1);
|
const validatedFiles = await Promise.all(
|
||||||
}
|
files.map(async (filePath) => {
|
||||||
return filePath;
|
const fullPath = await validatePathWithinRepo(filePath, REPO_DIR);
|
||||||
});
|
// Calculate the relative path for the git tree entry
|
||||||
|
// Use the original filePath (normalized) for the git path, not the symlink-resolved path
|
||||||
|
const normalizedPath = resolve(resolvedRepoDir, filePath);
|
||||||
|
const relativePath = normalizedPath.slice(resolvedRepoDir.length + 1);
|
||||||
|
return { fullPath, relativePath };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// 1. Get the branch reference (create if doesn't exist)
|
// 1. Get the branch reference (create if doesn't exist)
|
||||||
const baseSha = await getOrCreateBranchRef(
|
const baseSha = await getOrCreateBranchRef(
|
||||||
@@ -247,18 +254,14 @@ server.tool(
|
|||||||
|
|
||||||
// 3. Create tree entries for all files
|
// 3. Create tree entries for all files
|
||||||
const treeEntries = await Promise.all(
|
const treeEntries = await Promise.all(
|
||||||
processedFiles.map(async (filePath) => {
|
validatedFiles.map(async ({ fullPath, relativePath }) => {
|
||||||
const fullPath = filePath.startsWith("/")
|
|
||||||
? filePath
|
|
||||||
: join(REPO_DIR, filePath);
|
|
||||||
|
|
||||||
// Get the proper file mode based on file permissions
|
// Get the proper file mode based on file permissions
|
||||||
const fileMode = await getFileMode(fullPath);
|
const fileMode = await getFileMode(fullPath);
|
||||||
|
|
||||||
// Check if file is binary (images, etc.)
|
// Check if file is binary (images, etc.)
|
||||||
const isBinaryFile =
|
const isBinaryFile =
|
||||||
/\.(png|jpg|jpeg|gif|webp|ico|pdf|zip|tar|gz|exe|bin|woff|woff2|ttf|eot)$/i.test(
|
/\.(png|jpg|jpeg|gif|webp|ico|pdf|zip|tar|gz|exe|bin|woff|woff2|ttf|eot)$/i.test(
|
||||||
filePath,
|
relativePath,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isBinaryFile) {
|
if (isBinaryFile) {
|
||||||
@@ -284,7 +287,7 @@ server.tool(
|
|||||||
if (!blobResponse.ok) {
|
if (!blobResponse.ok) {
|
||||||
const errorText = await blobResponse.text();
|
const errorText = await blobResponse.text();
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to create blob for ${filePath}: ${blobResponse.status} - ${errorText}`,
|
`Failed to create blob for ${relativePath}: ${blobResponse.status} - ${errorText}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,7 +295,7 @@ server.tool(
|
|||||||
|
|
||||||
// Return tree entry with blob SHA
|
// Return tree entry with blob SHA
|
||||||
return {
|
return {
|
||||||
path: filePath,
|
path: relativePath,
|
||||||
mode: fileMode,
|
mode: fileMode,
|
||||||
type: "blob",
|
type: "blob",
|
||||||
sha: blobData.sha,
|
sha: blobData.sha,
|
||||||
@@ -301,7 +304,7 @@ server.tool(
|
|||||||
// For text files, include content directly in tree
|
// For text files, include content directly in tree
|
||||||
const content = await readFile(fullPath, "utf-8");
|
const content = await readFile(fullPath, "utf-8");
|
||||||
return {
|
return {
|
||||||
path: filePath,
|
path: relativePath,
|
||||||
mode: fileMode,
|
mode: fileMode,
|
||||||
type: "blob",
|
type: "blob",
|
||||||
content: content,
|
content: content,
|
||||||
@@ -421,7 +424,9 @@ server.tool(
|
|||||||
author: newCommitData.author.name,
|
author: newCommitData.author.name,
|
||||||
date: newCommitData.author.date,
|
date: newCommitData.author.date,
|
||||||
},
|
},
|
||||||
files: processedFiles.map((path) => ({ path })),
|
files: validatedFiles.map(({ relativePath }) => ({
|
||||||
|
path: relativePath,
|
||||||
|
})),
|
||||||
tree: {
|
tree: {
|
||||||
sha: treeData.sha,
|
sha: treeData.sha,
|
||||||
},
|
},
|
||||||
|
|||||||
64
src/mcp/path-validation.ts
Normal file
64
src/mcp/path-validation.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { realpath } from "fs/promises";
|
||||||
|
import { resolve, sep } from "path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that a file path resolves within the repository root.
|
||||||
|
* Prevents path traversal attacks via "../" sequences and symlinks.
|
||||||
|
* @param filePath - The file path to validate (can be relative or absolute)
|
||||||
|
* @param repoRoot - The repository root directory
|
||||||
|
* @returns The resolved absolute path (with symlinks resolved) if valid
|
||||||
|
* @throws Error if the path resolves outside the repository root
|
||||||
|
*/
|
||||||
|
export async function validatePathWithinRepo(
|
||||||
|
filePath: string,
|
||||||
|
repoRoot: string,
|
||||||
|
): Promise<string> {
|
||||||
|
// First resolve the path string (handles .. and . segments)
|
||||||
|
const initialPath = resolve(repoRoot, filePath);
|
||||||
|
|
||||||
|
// Resolve symlinks to get the real path
|
||||||
|
// This prevents symlink attacks where a link inside the repo points outside
|
||||||
|
let resolvedRoot: string;
|
||||||
|
let resolvedPath: string;
|
||||||
|
|
||||||
|
try {
|
||||||
|
resolvedRoot = await realpath(repoRoot);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Repository root '${repoRoot}' does not exist`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
resolvedPath = await realpath(initialPath);
|
||||||
|
} catch {
|
||||||
|
// File doesn't exist yet - fall back to checking the parent directory
|
||||||
|
// This handles the case where we're creating a new file
|
||||||
|
const parentDir = resolve(initialPath, "..");
|
||||||
|
try {
|
||||||
|
const resolvedParent = await realpath(parentDir);
|
||||||
|
if (
|
||||||
|
resolvedParent !== resolvedRoot &&
|
||||||
|
!resolvedParent.startsWith(resolvedRoot + sep)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`Path '${filePath}' resolves outside the repository root`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Parent is valid, return the initial path since file doesn't exist yet
|
||||||
|
return initialPath;
|
||||||
|
} catch {
|
||||||
|
throw new Error(
|
||||||
|
`Path '${filePath}' resolves outside the repository root`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path must be within repo root (or be the root itself)
|
||||||
|
if (
|
||||||
|
resolvedPath !== resolvedRoot &&
|
||||||
|
!resolvedPath.startsWith(resolvedRoot + sep)
|
||||||
|
) {
|
||||||
|
throw new Error(`Path '${filePath}' resolves outside the repository root`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolvedPath;
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
configureGitAuth,
|
configureGitAuth,
|
||||||
setupSshSigning,
|
setupSshSigning,
|
||||||
} from "../../github/operations/git-config";
|
} from "../../github/operations/git-config";
|
||||||
|
import { checkHumanActor } from "../../github/validation/actor";
|
||||||
import type { GitHubContext } from "../../github/context";
|
import type { GitHubContext } from "../../github/context";
|
||||||
import { isEntityContext } from "../../github/context";
|
import { isEntityContext } from "../../github/context";
|
||||||
|
|
||||||
@@ -80,7 +81,14 @@ export const agentMode: Mode = {
|
|||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
|
|
||||||
async prepare({ context, githubToken }: ModeOptions): Promise<ModeResult> {
|
async prepare({
|
||||||
|
context,
|
||||||
|
octokit,
|
||||||
|
githubToken,
|
||||||
|
}: ModeOptions): Promise<ModeResult> {
|
||||||
|
// Check if actor is human (prevents bot-triggered loops)
|
||||||
|
await checkHumanActor(octokit.rest, context);
|
||||||
|
|
||||||
// Configure git authentication for agent mode (same as tag mode)
|
// Configure git authentication for agent mode (same as tag mode)
|
||||||
// SSH signing takes precedence if provided
|
// SSH signing takes precedence if provided
|
||||||
const useSshSigning = !!context.inputs.sshSigningKey;
|
const useSshSigning = !!context.inputs.sshSigningKey;
|
||||||
|
|||||||
@@ -1,22 +1,33 @@
|
|||||||
export function parseAllowedTools(claudeArgs: string): string[] {
|
export function parseAllowedTools(claudeArgs: string): string[] {
|
||||||
// Match --allowedTools or --allowed-tools followed by the value
|
// Match --allowedTools or --allowed-tools followed by the value
|
||||||
// Handle both quoted and unquoted values
|
// Handle both quoted and unquoted values
|
||||||
|
// Use /g flag to find ALL occurrences, not just the first one
|
||||||
const patterns = [
|
const patterns = [
|
||||||
/--(?:allowedTools|allowed-tools)\s+"([^"]+)"/, // Double quoted
|
/--(?:allowedTools|allowed-tools)\s+"([^"]+)"/g, // Double quoted
|
||||||
/--(?:allowedTools|allowed-tools)\s+'([^']+)'/, // Single quoted
|
/--(?:allowedTools|allowed-tools)\s+'([^']+)'/g, // Single quoted
|
||||||
/--(?:allowedTools|allowed-tools)\s+([^\s]+)/, // Unquoted
|
/--(?:allowedTools|allowed-tools)\s+([^'"\s][^\s]*)/g, // Unquoted (must not start with quote)
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const tools: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
for (const pattern of patterns) {
|
for (const pattern of patterns) {
|
||||||
const match = claudeArgs.match(pattern);
|
for (const match of claudeArgs.matchAll(pattern)) {
|
||||||
if (match && match[1]) {
|
if (match[1]) {
|
||||||
// Don't return if the value starts with -- (another flag)
|
// Don't add if the value starts with -- (another flag)
|
||||||
if (match[1].startsWith("--")) {
|
if (match[1].startsWith("--")) {
|
||||||
return [];
|
continue;
|
||||||
|
}
|
||||||
|
for (const tool of match[1].split(",")) {
|
||||||
|
const trimmed = tool.trim();
|
||||||
|
if (trimmed && !seen.has(trimmed)) {
|
||||||
|
seen.add(trimmed);
|
||||||
|
tools.push(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return match[1].split(",").map((t) => t.trim());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
return tools;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { prepareMcpConfig } from "../../mcp/install-mcp-server";
|
|||||||
import {
|
import {
|
||||||
fetchGitHubData,
|
fetchGitHubData,
|
||||||
extractTriggerTimestamp,
|
extractTriggerTimestamp,
|
||||||
|
extractOriginalTitle,
|
||||||
} from "../../github/data/fetcher";
|
} from "../../github/data/fetcher";
|
||||||
import { createPrompt, generateDefaultPrompt } from "../../create-prompt";
|
import { createPrompt, generateDefaultPrompt } from "../../create-prompt";
|
||||||
import { isEntityContext } from "../../github/context";
|
import { isEntityContext } from "../../github/context";
|
||||||
@@ -78,6 +79,7 @@ export const tagMode: Mode = {
|
|||||||
const commentId = commentData.id;
|
const commentId = commentData.id;
|
||||||
|
|
||||||
const triggerTime = extractTriggerTimestamp(context);
|
const triggerTime = extractTriggerTimestamp(context);
|
||||||
|
const originalTitle = extractOriginalTitle(context);
|
||||||
|
|
||||||
const githubData = await fetchGitHubData({
|
const githubData = await fetchGitHubData({
|
||||||
octokits: octokit,
|
octokits: octokit,
|
||||||
@@ -86,6 +88,7 @@ export const tagMode: Mode = {
|
|||||||
isPR: context.isPR,
|
isPR: context.isPR,
|
||||||
triggerUsername: context.actor,
|
triggerUsername: context.actor,
|
||||||
triggerTime,
|
triggerTime,
|
||||||
|
originalTitle,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Setup branch
|
// Setup branch
|
||||||
|
|||||||
99
src/utils/branch-template.ts
Normal file
99
src/utils/branch-template.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Branch name template parsing and variable substitution utilities
|
||||||
|
*/
|
||||||
|
|
||||||
|
const NUM_DESCRIPTION_WORDS = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the first 5 words from a title and converts them to kebab-case
|
||||||
|
*/
|
||||||
|
function extractDescription(
|
||||||
|
title: string,
|
||||||
|
numWords: number = NUM_DESCRIPTION_WORDS,
|
||||||
|
): string {
|
||||||
|
if (!title || title.trim() === "") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return title
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)
|
||||||
|
.slice(0, numWords) // Only first `numWords` words
|
||||||
|
.join("-")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9-]/g, "") // Remove non-alphanumeric except hyphens
|
||||||
|
.replace(/-+/g, "-") // Replace multiple hyphens with single
|
||||||
|
.replace(/^-|-$/g, ""); // Remove leading/trailing hyphens
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BranchTemplateVariables {
|
||||||
|
prefix: string;
|
||||||
|
entityType: string;
|
||||||
|
entityNumber: number;
|
||||||
|
timestamp: string;
|
||||||
|
sha?: string;
|
||||||
|
label?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces template variables in a branch name template
|
||||||
|
* Template format: {{variableName}}
|
||||||
|
*/
|
||||||
|
export function applyBranchTemplate(
|
||||||
|
template: string,
|
||||||
|
variables: BranchTemplateVariables,
|
||||||
|
): string {
|
||||||
|
let result = template;
|
||||||
|
|
||||||
|
// Replace each variable
|
||||||
|
Object.entries(variables).forEach(([key, value]) => {
|
||||||
|
const placeholder = `{{${key}}}`;
|
||||||
|
const replacement = value ? String(value) : "";
|
||||||
|
result = result.replaceAll(placeholder, replacement);
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a branch name from the provided `template` and set of `variables`. Uses a default format if the template is empty or produces an empty result.
|
||||||
|
*/
|
||||||
|
export function generateBranchName(
|
||||||
|
template: string | undefined,
|
||||||
|
branchPrefix: string,
|
||||||
|
entityType: string,
|
||||||
|
entityNumber: number,
|
||||||
|
sha?: string,
|
||||||
|
label?: string,
|
||||||
|
title?: string,
|
||||||
|
): string {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const variables: BranchTemplateVariables = {
|
||||||
|
prefix: branchPrefix,
|
||||||
|
entityType,
|
||||||
|
entityNumber,
|
||||||
|
timestamp: `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}-${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}`,
|
||||||
|
sha: sha?.substring(0, 8), // First 8 characters of SHA
|
||||||
|
label: label || entityType, // Fall back to entityType if no label
|
||||||
|
description: title ? extractDescription(title) : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (template?.trim()) {
|
||||||
|
const branchName = applyBranchTemplate(template, variables);
|
||||||
|
|
||||||
|
// Some templates could produce empty results- validate
|
||||||
|
if (branchName.trim().length > 0) return branchName;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Branch template '${template}' generated empty result, falling back to default format`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const branchName = `${branchPrefix}${entityType}-${entityNumber}-${variables.timestamp}`;
|
||||||
|
// Kubernetes compatible: lowercase, max 50 chars, alphanumeric and hyphens only
|
||||||
|
return branchName.toLowerCase().substring(0, 50);
|
||||||
|
}
|
||||||
247
test/branch-template.test.ts
Normal file
247
test/branch-template.test.ts
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
|
import { describe, it, expect } from "bun:test";
|
||||||
|
import {
|
||||||
|
applyBranchTemplate,
|
||||||
|
generateBranchName,
|
||||||
|
} from "../src/utils/branch-template";
|
||||||
|
|
||||||
|
describe("branch template utilities", () => {
|
||||||
|
describe("applyBranchTemplate", () => {
|
||||||
|
it("should replace all template variables", () => {
|
||||||
|
const template =
|
||||||
|
"{{prefix}}{{entityType}}-{{entityNumber}}-{{timestamp}}";
|
||||||
|
const variables = {
|
||||||
|
prefix: "feat/",
|
||||||
|
entityType: "issue",
|
||||||
|
entityNumber: 123,
|
||||||
|
timestamp: "20240301-1430",
|
||||||
|
sha: "abcd1234",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = applyBranchTemplate(template, variables);
|
||||||
|
expect(result).toBe("feat/issue-123-20240301-1430");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle custom templates with multiple variables", () => {
|
||||||
|
const template =
|
||||||
|
"{{prefix}}fix/{{entityType}}_{{entityNumber}}_{{timestamp}}_{{sha}}";
|
||||||
|
const variables = {
|
||||||
|
prefix: "claude-",
|
||||||
|
entityType: "pr",
|
||||||
|
entityNumber: 456,
|
||||||
|
timestamp: "20240301-1430",
|
||||||
|
sha: "abcd1234",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = applyBranchTemplate(template, variables);
|
||||||
|
expect(result).toBe("claude-fix/pr_456_20240301-1430_abcd1234");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle templates with missing variables gracefully", () => {
|
||||||
|
const template = "{{prefix}}{{entityType}}-{{missing}}-{{entityNumber}}";
|
||||||
|
const variables = {
|
||||||
|
prefix: "feat/",
|
||||||
|
entityType: "issue",
|
||||||
|
entityNumber: 123,
|
||||||
|
timestamp: "20240301-1430",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = applyBranchTemplate(template, variables);
|
||||||
|
expect(result).toBe("feat/issue-{{missing}}-123");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateBranchName", () => {
|
||||||
|
it("should use custom template when provided", () => {
|
||||||
|
const template = "{{prefix}}custom-{{entityType}}_{{entityNumber}}";
|
||||||
|
const result = generateBranchName(template, "feature/", "issue", 123);
|
||||||
|
|
||||||
|
expect(result).toBe("feature/custom-issue_123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use default format when template is empty", () => {
|
||||||
|
const result = generateBranchName("", "claude/", "issue", 123);
|
||||||
|
|
||||||
|
expect(result).toMatch(/^claude\/issue-123-\d{8}-\d{4}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use default format when template is undefined", () => {
|
||||||
|
const result = generateBranchName(undefined, "claude/", "pr", 456);
|
||||||
|
|
||||||
|
expect(result).toMatch(/^claude\/pr-456-\d{8}-\d{4}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should preserve custom template formatting (no automatic lowercase/truncation)", () => {
|
||||||
|
const template = "{{prefix}}UPPERCASE_Branch-Name_{{entityNumber}}";
|
||||||
|
const result = generateBranchName(template, "Feature/", "issue", 123);
|
||||||
|
|
||||||
|
expect(result).toBe("Feature/UPPERCASE_Branch-Name_123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not truncate custom template results", () => {
|
||||||
|
const template =
|
||||||
|
"{{prefix}}very-long-branch-name-that-exceeds-the-maximum-allowed-length-{{entityNumber}}";
|
||||||
|
const result = generateBranchName(template, "feature/", "issue", 123);
|
||||||
|
|
||||||
|
expect(result).toBe(
|
||||||
|
"feature/very-long-branch-name-that-exceeds-the-maximum-allowed-length-123",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should apply Kubernetes-compatible transformations to default template only", () => {
|
||||||
|
const result = generateBranchName(undefined, "Feature/", "issue", 123);
|
||||||
|
|
||||||
|
expect(result).toMatch(/^feature\/issue-123-\d{8}-\d{4}$/);
|
||||||
|
expect(result.length).toBeLessThanOrEqual(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle SHA in template", () => {
|
||||||
|
const template = "{{prefix}}{{entityType}}-{{entityNumber}}-{{sha}}";
|
||||||
|
const result = generateBranchName(
|
||||||
|
template,
|
||||||
|
"fix/",
|
||||||
|
"pr",
|
||||||
|
789,
|
||||||
|
"abcdef123456",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe("fix/pr-789-abcdef12");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use label in template when provided", () => {
|
||||||
|
const template = "{{prefix}}{{label}}/{{entityNumber}}";
|
||||||
|
const result = generateBranchName(
|
||||||
|
template,
|
||||||
|
"feature/",
|
||||||
|
"issue",
|
||||||
|
123,
|
||||||
|
undefined,
|
||||||
|
"bug",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe("feature/bug/123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fallback to entityType when label template is used but no label provided", () => {
|
||||||
|
const template = "{{prefix}}{{label}}-{{entityNumber}}";
|
||||||
|
const result = generateBranchName(template, "fix/", "pr", 456);
|
||||||
|
|
||||||
|
expect(result).toBe("fix/pr-456");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle template with both label and entityType", () => {
|
||||||
|
const template = "{{prefix}}{{label}}-{{entityType}}_{{entityNumber}}";
|
||||||
|
const result = generateBranchName(
|
||||||
|
template,
|
||||||
|
"dev/",
|
||||||
|
"issue",
|
||||||
|
789,
|
||||||
|
undefined,
|
||||||
|
"enhancement",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe("dev/enhancement-issue_789");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use description in template when provided", () => {
|
||||||
|
const template = "{{prefix}}{{description}}/{{entityNumber}}";
|
||||||
|
const result = generateBranchName(
|
||||||
|
template,
|
||||||
|
"feature/",
|
||||||
|
"issue",
|
||||||
|
123,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
"Fix login bug with OAuth",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe("feature/fix-login-bug-with-oauth/123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle template with multiple variables including description", () => {
|
||||||
|
const template =
|
||||||
|
"{{prefix}}{{label}}/{{description}}-{{entityType}}_{{entityNumber}}";
|
||||||
|
const result = generateBranchName(
|
||||||
|
template,
|
||||||
|
"dev/",
|
||||||
|
"issue",
|
||||||
|
456,
|
||||||
|
undefined,
|
||||||
|
"bug",
|
||||||
|
"User authentication fails completely",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe(
|
||||||
|
"dev/bug/user-authentication-fails-completely-issue_456",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle description with special characters in template", () => {
|
||||||
|
const template = "{{prefix}}{{description}}-{{entityNumber}}";
|
||||||
|
const result = generateBranchName(
|
||||||
|
template,
|
||||||
|
"fix/",
|
||||||
|
"pr",
|
||||||
|
789,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
"Add: User Registration & Email Validation",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe("fix/add-user-registration-email-789");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should truncate descriptions to exactly 5 words", () => {
|
||||||
|
const result = generateBranchName(
|
||||||
|
"{{prefix}}{{description}}/{{entityNumber}}",
|
||||||
|
"feature/",
|
||||||
|
"issue",
|
||||||
|
999,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
"This is a very long title with many more than five words in it",
|
||||||
|
);
|
||||||
|
expect(result).toBe("feature/this-is-a-very-long/999");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle empty description in template", () => {
|
||||||
|
const template = "{{prefix}}{{description}}-{{entityNumber}}";
|
||||||
|
const result = generateBranchName(
|
||||||
|
template,
|
||||||
|
"test/",
|
||||||
|
"issue",
|
||||||
|
101,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe("test/-101");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fallback to default format when template produces empty result", () => {
|
||||||
|
const template = "{{description}}"; // Will be empty if no title provided
|
||||||
|
const result = generateBranchName(template, "claude/", "issue", 123);
|
||||||
|
|
||||||
|
expect(result).toMatch(/^claude\/issue-123-\d{8}-\d{4}$/);
|
||||||
|
expect(result.length).toBeLessThanOrEqual(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fallback to default format when template produces only whitespace", () => {
|
||||||
|
const template = " {{description}} "; // Will be " " if description is empty
|
||||||
|
const result = generateBranchName(
|
||||||
|
template,
|
||||||
|
"fix/",
|
||||||
|
"pr",
|
||||||
|
456,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatch(/^fix\/pr-456-\d{8}-\d{4}$/);
|
||||||
|
expect(result.length).toBeLessThanOrEqual(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -61,6 +61,7 @@ describe("generatePrompt", () => {
|
|||||||
body: "This is a test PR",
|
body: "This is a test PR",
|
||||||
author: { login: "testuser" },
|
author: { login: "testuser" },
|
||||||
state: "OPEN",
|
state: "OPEN",
|
||||||
|
labels: { nodes: [] },
|
||||||
createdAt: "2023-01-01T00:00:00Z",
|
createdAt: "2023-01-01T00:00:00Z",
|
||||||
additions: 15,
|
additions: 15,
|
||||||
deletions: 5,
|
deletions: 5,
|
||||||
@@ -475,6 +476,7 @@ describe("generatePrompt", () => {
|
|||||||
body: "The login form is not working",
|
body: "The login form is not working",
|
||||||
author: { login: "testuser" },
|
author: { login: "testuser" },
|
||||||
state: "OPEN",
|
state: "OPEN",
|
||||||
|
labels: { nodes: [] },
|
||||||
createdAt: "2023-01-01T00:00:00Z",
|
createdAt: "2023-01-01T00:00:00Z",
|
||||||
comments: {
|
comments: {
|
||||||
nodes: [],
|
nodes: [],
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it, jest } from "bun:test";
|
import { describe, expect, it, jest } from "bun:test";
|
||||||
import {
|
import {
|
||||||
extractTriggerTimestamp,
|
extractTriggerTimestamp,
|
||||||
|
extractOriginalTitle,
|
||||||
fetchGitHubData,
|
fetchGitHubData,
|
||||||
filterCommentsToTriggerTime,
|
filterCommentsToTriggerTime,
|
||||||
filterReviewsToTriggerTime,
|
filterReviewsToTriggerTime,
|
||||||
@@ -9,6 +10,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
createMockContext,
|
createMockContext,
|
||||||
mockIssueCommentContext,
|
mockIssueCommentContext,
|
||||||
|
mockPullRequestCommentContext,
|
||||||
mockPullRequestReviewContext,
|
mockPullRequestReviewContext,
|
||||||
mockPullRequestReviewCommentContext,
|
mockPullRequestReviewCommentContext,
|
||||||
mockPullRequestOpenedContext,
|
mockPullRequestOpenedContext,
|
||||||
@@ -63,6 +65,47 @@ describe("extractTriggerTimestamp", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("extractOriginalTitle", () => {
|
||||||
|
it("should extract title from IssueCommentEvent on PR", () => {
|
||||||
|
const title = extractOriginalTitle(mockPullRequestCommentContext);
|
||||||
|
expect(title).toBe("Fix: Memory leak in user service");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should extract title from PullRequestReviewEvent", () => {
|
||||||
|
const title = extractOriginalTitle(mockPullRequestReviewContext);
|
||||||
|
expect(title).toBe("Refactor: Improve error handling in API layer");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should extract title from PullRequestReviewCommentEvent", () => {
|
||||||
|
const title = extractOriginalTitle(mockPullRequestReviewCommentContext);
|
||||||
|
expect(title).toBe("Performance: Optimize search algorithm");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should extract title from pull_request event", () => {
|
||||||
|
const title = extractOriginalTitle(mockPullRequestOpenedContext);
|
||||||
|
expect(title).toBe("Feature: Add user authentication");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should extract title from issues event", () => {
|
||||||
|
const title = extractOriginalTitle(mockIssueOpenedContext);
|
||||||
|
expect(title).toBe("Bug: Application crashes on startup");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return undefined for event without title", () => {
|
||||||
|
const context = createMockContext({
|
||||||
|
eventName: "issue_comment",
|
||||||
|
payload: {
|
||||||
|
comment: {
|
||||||
|
id: 123,
|
||||||
|
body: "test",
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
});
|
||||||
|
const title = extractOriginalTitle(context);
|
||||||
|
expect(title).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("filterCommentsToTriggerTime", () => {
|
describe("filterCommentsToTriggerTime", () => {
|
||||||
const createMockComment = (
|
const createMockComment = (
|
||||||
createdAt: string,
|
createdAt: string,
|
||||||
@@ -945,4 +988,115 @@ describe("fetchGitHubData integration with time filtering", () => {
|
|||||||
);
|
);
|
||||||
expect(hasPrBodyInMap).toBe(false);
|
expect(hasPrBodyInMap).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should use originalTitle when provided instead of fetched title", async () => {
|
||||||
|
const mockOctokits = {
|
||||||
|
graphql: jest.fn().mockResolvedValue({
|
||||||
|
repository: {
|
||||||
|
pullRequest: {
|
||||||
|
number: 123,
|
||||||
|
title: "Fetched Title From GraphQL",
|
||||||
|
body: "PR body",
|
||||||
|
author: { login: "author" },
|
||||||
|
createdAt: "2024-01-15T10:00:00Z",
|
||||||
|
additions: 10,
|
||||||
|
deletions: 5,
|
||||||
|
state: "OPEN",
|
||||||
|
commits: { totalCount: 1, nodes: [] },
|
||||||
|
files: { nodes: [] },
|
||||||
|
comments: { nodes: [] },
|
||||||
|
reviews: { nodes: [] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: { login: "trigger-user" },
|
||||||
|
}),
|
||||||
|
rest: jest.fn() as any,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await fetchGitHubData({
|
||||||
|
octokits: mockOctokits as any,
|
||||||
|
repository: "test-owner/test-repo",
|
||||||
|
prNumber: "123",
|
||||||
|
isPR: true,
|
||||||
|
triggerUsername: "trigger-user",
|
||||||
|
originalTitle: "Original Title From Webhook",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.contextData.title).toBe("Original Title From Webhook");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use fetched title when originalTitle is not provided", async () => {
|
||||||
|
const mockOctokits = {
|
||||||
|
graphql: jest.fn().mockResolvedValue({
|
||||||
|
repository: {
|
||||||
|
pullRequest: {
|
||||||
|
number: 123,
|
||||||
|
title: "Fetched Title From GraphQL",
|
||||||
|
body: "PR body",
|
||||||
|
author: { login: "author" },
|
||||||
|
createdAt: "2024-01-15T10:00:00Z",
|
||||||
|
additions: 10,
|
||||||
|
deletions: 5,
|
||||||
|
state: "OPEN",
|
||||||
|
commits: { totalCount: 1, nodes: [] },
|
||||||
|
files: { nodes: [] },
|
||||||
|
comments: { nodes: [] },
|
||||||
|
reviews: { nodes: [] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: { login: "trigger-user" },
|
||||||
|
}),
|
||||||
|
rest: jest.fn() as any,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await fetchGitHubData({
|
||||||
|
octokits: mockOctokits as any,
|
||||||
|
repository: "test-owner/test-repo",
|
||||||
|
prNumber: "123",
|
||||||
|
isPR: true,
|
||||||
|
triggerUsername: "trigger-user",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.contextData.title).toBe("Fetched Title From GraphQL");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use original title from webhook even if title was edited after trigger", async () => {
|
||||||
|
const mockOctokits = {
|
||||||
|
graphql: jest.fn().mockResolvedValue({
|
||||||
|
repository: {
|
||||||
|
pullRequest: {
|
||||||
|
number: 123,
|
||||||
|
title: "Edited Title (from GraphQL)",
|
||||||
|
body: "PR body",
|
||||||
|
author: { login: "author" },
|
||||||
|
createdAt: "2024-01-15T10:00:00Z",
|
||||||
|
lastEditedAt: "2024-01-15T12:30:00Z", // Edited after trigger
|
||||||
|
additions: 10,
|
||||||
|
deletions: 5,
|
||||||
|
state: "OPEN",
|
||||||
|
commits: { totalCount: 1, nodes: [] },
|
||||||
|
files: { nodes: [] },
|
||||||
|
comments: { nodes: [] },
|
||||||
|
reviews: { nodes: [] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: { login: "trigger-user" },
|
||||||
|
}),
|
||||||
|
rest: jest.fn() as any,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await fetchGitHubData({
|
||||||
|
octokits: mockOctokits as any,
|
||||||
|
repository: "test-owner/test-repo",
|
||||||
|
prNumber: "123",
|
||||||
|
isPR: true,
|
||||||
|
triggerUsername: "trigger-user",
|
||||||
|
triggerTime: "2024-01-15T12:00:00Z",
|
||||||
|
originalTitle: "Original Title (from webhook at trigger time)",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.contextData.title).toBe(
|
||||||
|
"Original Title (from webhook at trigger time)",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ describe("formatContext", () => {
|
|||||||
additions: 50,
|
additions: 50,
|
||||||
deletions: 30,
|
deletions: 30,
|
||||||
state: "OPEN",
|
state: "OPEN",
|
||||||
|
labels: {
|
||||||
|
nodes: [],
|
||||||
|
},
|
||||||
commits: {
|
commits: {
|
||||||
totalCount: 3,
|
totalCount: 3,
|
||||||
nodes: [],
|
nodes: [],
|
||||||
@@ -63,6 +66,9 @@ Changed Files: 2 files`,
|
|||||||
author: { login: "test-user" },
|
author: { login: "test-user" },
|
||||||
createdAt: "2023-01-01T00:00:00Z",
|
createdAt: "2023-01-01T00:00:00Z",
|
||||||
state: "OPEN",
|
state: "OPEN",
|
||||||
|
labels: {
|
||||||
|
nodes: [],
|
||||||
|
},
|
||||||
comments: {
|
comments: {
|
||||||
nodes: [],
|
nodes: [],
|
||||||
},
|
},
|
||||||
|
|||||||
214
test/github-file-ops-path-validation.test.ts
Normal file
214
test/github-file-ops-path-validation.test.ts
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import { describe, expect, it, beforeAll, afterAll } from "bun:test";
|
||||||
|
import { validatePathWithinRepo } from "../src/mcp/path-validation";
|
||||||
|
import { resolve } from "path";
|
||||||
|
import { mkdir, writeFile, symlink, rm, realpath } from "fs/promises";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
|
||||||
|
describe("validatePathWithinRepo", () => {
|
||||||
|
// Use a real temp directory for tests that need filesystem access
|
||||||
|
let testDir: string;
|
||||||
|
let repoRoot: string;
|
||||||
|
let outsideDir: string;
|
||||||
|
// Real paths after symlink resolution (e.g., /tmp -> /private/tmp on macOS)
|
||||||
|
let realRepoRoot: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Create test directory structure
|
||||||
|
testDir = resolve(tmpdir(), `path-validation-test-${Date.now()}`);
|
||||||
|
repoRoot = resolve(testDir, "repo");
|
||||||
|
outsideDir = resolve(testDir, "outside");
|
||||||
|
|
||||||
|
await mkdir(repoRoot, { recursive: true });
|
||||||
|
await mkdir(resolve(repoRoot, "src"), { recursive: true });
|
||||||
|
await mkdir(outsideDir, { recursive: true });
|
||||||
|
|
||||||
|
// Create test files
|
||||||
|
await writeFile(resolve(repoRoot, "file.txt"), "inside repo");
|
||||||
|
await writeFile(resolve(repoRoot, "src", "main.js"), "console.log('hi')");
|
||||||
|
await writeFile(resolve(outsideDir, "secret.txt"), "sensitive data");
|
||||||
|
|
||||||
|
// Get real paths after symlink resolution
|
||||||
|
realRepoRoot = await realpath(repoRoot);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// Cleanup
|
||||||
|
await rm(testDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("valid paths", () => {
|
||||||
|
it("should accept simple relative paths", async () => {
|
||||||
|
const result = await validatePathWithinRepo("file.txt", repoRoot);
|
||||||
|
expect(result).toBe(resolve(realRepoRoot, "file.txt"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept nested relative paths", async () => {
|
||||||
|
const result = await validatePathWithinRepo("src/main.js", repoRoot);
|
||||||
|
expect(result).toBe(resolve(realRepoRoot, "src/main.js"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept paths with single dot segments", async () => {
|
||||||
|
const result = await validatePathWithinRepo("./src/main.js", repoRoot);
|
||||||
|
expect(result).toBe(resolve(realRepoRoot, "src/main.js"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept paths that use .. but resolve inside repo", async () => {
|
||||||
|
// src/../file.txt resolves to file.txt which is still inside repo
|
||||||
|
const result = await validatePathWithinRepo("src/../file.txt", repoRoot);
|
||||||
|
expect(result).toBe(resolve(realRepoRoot, "file.txt"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept absolute paths within the repo root", async () => {
|
||||||
|
const absolutePath = resolve(repoRoot, "file.txt");
|
||||||
|
const result = await validatePathWithinRepo(absolutePath, repoRoot);
|
||||||
|
expect(result).toBe(resolve(realRepoRoot, "file.txt"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept the repo root itself", async () => {
|
||||||
|
const result = await validatePathWithinRepo(".", repoRoot);
|
||||||
|
expect(result).toBe(realRepoRoot);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle new files (non-existent) in valid directories", async () => {
|
||||||
|
const result = await validatePathWithinRepo("src/newfile.js", repoRoot);
|
||||||
|
// For non-existent files, we validate the parent but return the initial path
|
||||||
|
// (can't realpath a file that doesn't exist yet)
|
||||||
|
expect(result).toBe(resolve(repoRoot, "src/newfile.js"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("path traversal attacks", () => {
|
||||||
|
it("should reject simple parent directory traversal", async () => {
|
||||||
|
await expect(
|
||||||
|
validatePathWithinRepo("../outside/secret.txt", repoRoot),
|
||||||
|
).rejects.toThrow(/resolves outside the repository root/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject deeply nested parent directory traversal", async () => {
|
||||||
|
await expect(
|
||||||
|
validatePathWithinRepo("../../../etc/passwd", repoRoot),
|
||||||
|
).rejects.toThrow(/resolves outside the repository root/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject traversal hidden within path", async () => {
|
||||||
|
await expect(
|
||||||
|
validatePathWithinRepo("src/../../outside/secret.txt", repoRoot),
|
||||||
|
).rejects.toThrow(/resolves outside the repository root/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject traversal at the end of path", async () => {
|
||||||
|
await expect(
|
||||||
|
validatePathWithinRepo("src/../..", repoRoot),
|
||||||
|
).rejects.toThrow(/resolves outside the repository root/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject absolute paths outside the repo root", async () => {
|
||||||
|
await expect(
|
||||||
|
validatePathWithinRepo("/etc/passwd", repoRoot),
|
||||||
|
).rejects.toThrow(/resolves outside the repository root/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject absolute paths to sibling directories", async () => {
|
||||||
|
await expect(
|
||||||
|
validatePathWithinRepo(resolve(outsideDir, "secret.txt"), repoRoot),
|
||||||
|
).rejects.toThrow(/resolves outside the repository root/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("symlink attacks", () => {
|
||||||
|
it("should reject symlinks pointing outside the repo", async () => {
|
||||||
|
// Create a symlink inside the repo that points to a file outside
|
||||||
|
const symlinkPath = resolve(repoRoot, "evil-link");
|
||||||
|
await symlink(resolve(outsideDir, "secret.txt"), symlinkPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// The symlink path looks like it's inside the repo, but points outside
|
||||||
|
await expect(
|
||||||
|
validatePathWithinRepo("evil-link", repoRoot),
|
||||||
|
).rejects.toThrow(/resolves outside the repository root/);
|
||||||
|
} finally {
|
||||||
|
await rm(symlinkPath, { force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject symlinks to parent directories", async () => {
|
||||||
|
// Create a symlink to the parent directory
|
||||||
|
const symlinkPath = resolve(repoRoot, "parent-link");
|
||||||
|
await symlink(testDir, symlinkPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expect(
|
||||||
|
validatePathWithinRepo("parent-link/outside/secret.txt", repoRoot),
|
||||||
|
).rejects.toThrow(/resolves outside the repository root/);
|
||||||
|
} finally {
|
||||||
|
await rm(symlinkPath, { force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept symlinks that resolve within the repo", async () => {
|
||||||
|
// Create a symlink inside the repo that points to another file inside
|
||||||
|
const symlinkPath = resolve(repoRoot, "good-link");
|
||||||
|
await symlink(resolve(repoRoot, "file.txt"), symlinkPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await validatePathWithinRepo("good-link", repoRoot);
|
||||||
|
// Should resolve to the actual file location
|
||||||
|
expect(result).toBe(resolve(realRepoRoot, "file.txt"));
|
||||||
|
} finally {
|
||||||
|
await rm(symlinkPath, { force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject directory symlinks that escape the repo", async () => {
|
||||||
|
// Create a symlink to outside directory
|
||||||
|
const symlinkPath = resolve(repoRoot, "escape-dir");
|
||||||
|
await symlink(outsideDir, symlinkPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expect(
|
||||||
|
validatePathWithinRepo("escape-dir/secret.txt", repoRoot),
|
||||||
|
).rejects.toThrow(/resolves outside the repository root/);
|
||||||
|
} finally {
|
||||||
|
await rm(symlinkPath, { force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("edge cases", () => {
|
||||||
|
it("should handle empty path (current directory)", async () => {
|
||||||
|
const result = await validatePathWithinRepo("", repoRoot);
|
||||||
|
expect(result).toBe(realRepoRoot);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle paths with multiple consecutive slashes", async () => {
|
||||||
|
const result = await validatePathWithinRepo("src//main.js", repoRoot);
|
||||||
|
expect(result).toBe(resolve(realRepoRoot, "src/main.js"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle paths with trailing slashes", async () => {
|
||||||
|
const result = await validatePathWithinRepo("src/", repoRoot);
|
||||||
|
expect(result).toBe(resolve(realRepoRoot, "src"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject prefix attack (repo root as prefix but not parent)", async () => {
|
||||||
|
// Create a sibling directory with repo name as prefix
|
||||||
|
const evilDir = repoRoot + "-evil";
|
||||||
|
await mkdir(evilDir, { recursive: true });
|
||||||
|
await writeFile(resolve(evilDir, "file.txt"), "evil");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expect(
|
||||||
|
validatePathWithinRepo(resolve(evilDir, "file.txt"), repoRoot),
|
||||||
|
).rejects.toThrow(/resolves outside the repository root/);
|
||||||
|
} finally {
|
||||||
|
await rm(evilDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error for non-existent repo root", async () => {
|
||||||
|
await expect(
|
||||||
|
validatePathWithinRepo("file.txt", "/nonexistent/repo"),
|
||||||
|
).rejects.toThrow(/does not exist/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -145,12 +145,12 @@ describe("Agent Mode", () => {
|
|||||||
users: {
|
users: {
|
||||||
getAuthenticated: mock(() =>
|
getAuthenticated: mock(() =>
|
||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
data: { login: "test-user", id: 12345 },
|
data: { login: "test-user", id: 12345, type: "User" },
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
getByUsername: mock(() =>
|
getByUsername: mock(() =>
|
||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
data: { login: "test-user", id: 12345 },
|
data: { login: "test-user", id: 12345, type: "User" },
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -187,6 +187,65 @@ describe("Agent Mode", () => {
|
|||||||
process.env.GITHUB_REF_NAME = originalRefName;
|
process.env.GITHUB_REF_NAME = originalRefName;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("prepare method rejects bot actors without allowed_bots", async () => {
|
||||||
|
const contextWithPrompts = createMockAutomationContext({
|
||||||
|
eventName: "workflow_dispatch",
|
||||||
|
});
|
||||||
|
contextWithPrompts.actor = "claude[bot]";
|
||||||
|
contextWithPrompts.inputs.allowedBots = "";
|
||||||
|
|
||||||
|
const mockOctokit = {
|
||||||
|
rest: {
|
||||||
|
users: {
|
||||||
|
getByUsername: mock(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
data: { login: "claude[bot]", id: 12345, type: "Bot" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
agentMode.prepare({
|
||||||
|
context: contextWithPrompts,
|
||||||
|
octokit: mockOctokit,
|
||||||
|
githubToken: "test-token",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(
|
||||||
|
"Workflow initiated by non-human actor: claude (type: Bot)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("prepare method allows bot actors when in allowed_bots list", async () => {
|
||||||
|
const contextWithPrompts = createMockAutomationContext({
|
||||||
|
eventName: "workflow_dispatch",
|
||||||
|
});
|
||||||
|
contextWithPrompts.actor = "dependabot[bot]";
|
||||||
|
contextWithPrompts.inputs.allowedBots = "dependabot";
|
||||||
|
|
||||||
|
const mockOctokit = {
|
||||||
|
rest: {
|
||||||
|
users: {
|
||||||
|
getByUsername: mock(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
data: { login: "dependabot[bot]", id: 12345, type: "Bot" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
// Should not throw - bot is in allowed list
|
||||||
|
await expect(
|
||||||
|
agentMode.prepare({
|
||||||
|
context: contextWithPrompts,
|
||||||
|
octokit: mockOctokit,
|
||||||
|
githubToken: "test-token",
|
||||||
|
}),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
test("prepare method creates prompt file with correct content", async () => {
|
test("prepare method creates prompt file with correct content", async () => {
|
||||||
const contextWithPrompts = createMockAutomationContext({
|
const contextWithPrompts = createMockAutomationContext({
|
||||||
eventName: "workflow_dispatch",
|
eventName: "workflow_dispatch",
|
||||||
@@ -199,12 +258,12 @@ describe("Agent Mode", () => {
|
|||||||
users: {
|
users: {
|
||||||
getAuthenticated: mock(() =>
|
getAuthenticated: mock(() =>
|
||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
data: { login: "test-user", id: 12345 },
|
data: { login: "test-user", id: 12345, type: "User" },
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
getByUsername: mock(() =>
|
getByUsername: mock(() =>
|
||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
data: { login: "test-user", id: 12345 },
|
data: { login: "test-user", id: 12345, type: "User" },
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -35,12 +35,44 @@ describe("parseAllowedTools", () => {
|
|||||||
expect(parseAllowedTools("")).toEqual([]);
|
expect(parseAllowedTools("")).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("handles duplicate --allowedTools flags", () => {
|
test("handles --allowedTools followed by another --allowedTools flag", () => {
|
||||||
const args = "--allowedTools --allowedTools mcp__github__*";
|
const args = "--allowedTools --allowedTools mcp__github__*";
|
||||||
// Should not match the first one since the value is another flag
|
// The second --allowedTools is consumed as a value of the first, then skipped.
|
||||||
|
// This is an edge case with malformed input - returns empty.
|
||||||
expect(parseAllowedTools(args)).toEqual([]);
|
expect(parseAllowedTools(args)).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("parses multiple separate --allowed-tools flags", () => {
|
||||||
|
const args =
|
||||||
|
"--allowed-tools 'mcp__context7__*' --allowed-tools 'Read,Glob' --allowed-tools 'mcp__github_inline_comment__*'";
|
||||||
|
expect(parseAllowedTools(args)).toEqual([
|
||||||
|
"mcp__context7__*",
|
||||||
|
"Read",
|
||||||
|
"Glob",
|
||||||
|
"mcp__github_inline_comment__*",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses multiple --allowed-tools flags on separate lines", () => {
|
||||||
|
const args = `--model 'claude-haiku'
|
||||||
|
--allowed-tools 'mcp__context7__*'
|
||||||
|
--allowed-tools 'Read,Glob,Grep'
|
||||||
|
--allowed-tools 'mcp__github_inline_comment__create_inline_comment'`;
|
||||||
|
expect(parseAllowedTools(args)).toEqual([
|
||||||
|
"mcp__context7__*",
|
||||||
|
"Read",
|
||||||
|
"Glob",
|
||||||
|
"Grep",
|
||||||
|
"mcp__github_inline_comment__create_inline_comment",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deduplicates tools from multiple flags", () => {
|
||||||
|
const args =
|
||||||
|
"--allowed-tools 'Read,Glob' --allowed-tools 'Glob,Grep' --allowed-tools 'Read'";
|
||||||
|
expect(parseAllowedTools(args)).toEqual(["Read", "Glob", "Grep"]);
|
||||||
|
});
|
||||||
|
|
||||||
test("handles typo --alloedTools", () => {
|
test("handles typo --alloedTools", () => {
|
||||||
const args = "--alloedTools mcp__github__*";
|
const args = "--alloedTools mcp__github__*";
|
||||||
expect(parseAllowedTools(args)).toEqual([]);
|
expect(parseAllowedTools(args)).toEqual([]);
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ describe("pull_request_target event support", () => {
|
|||||||
},
|
},
|
||||||
comments: { nodes: [] },
|
comments: { nodes: [] },
|
||||||
reviews: { nodes: [] },
|
reviews: { nodes: [] },
|
||||||
|
labels: { nodes: [] },
|
||||||
},
|
},
|
||||||
comments: [],
|
comments: [],
|
||||||
changedFiles: [],
|
changedFiles: [],
|
||||||
|
|||||||
@@ -55,6 +55,47 @@ describe("SSH Signing", () => {
|
|||||||
expect(permissions).toBe(0o600);
|
expect(permissions).toBe(0o600);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should normalize key to have trailing newline", async () => {
|
||||||
|
// ssh-keygen requires a trailing newline to parse the key
|
||||||
|
const keyWithoutNewline =
|
||||||
|
"-----BEGIN OPENSSH PRIVATE KEY-----\ntest-key-content\n-----END OPENSSH PRIVATE KEY-----";
|
||||||
|
const keyWithNewline = keyWithoutNewline + "\n";
|
||||||
|
|
||||||
|
// Create directory
|
||||||
|
await mkdir(testSshDir, { recursive: true, mode: 0o700 });
|
||||||
|
|
||||||
|
// Normalize the key (same logic as setupSshSigning)
|
||||||
|
const normalizedKey = keyWithoutNewline.endsWith("\n")
|
||||||
|
? keyWithoutNewline
|
||||||
|
: keyWithoutNewline + "\n";
|
||||||
|
|
||||||
|
await writeFile(testKeyPath, normalizedKey, { mode: 0o600 });
|
||||||
|
|
||||||
|
// Verify the written key ends with newline
|
||||||
|
const keyContent = await readFile(testKeyPath, "utf-8");
|
||||||
|
expect(keyContent).toBe(keyWithNewline);
|
||||||
|
expect(keyContent.endsWith("\n")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should not add extra newline if key already has one", async () => {
|
||||||
|
const keyWithNewline =
|
||||||
|
"-----BEGIN OPENSSH PRIVATE KEY-----\ntest-key-content\n-----END OPENSSH PRIVATE KEY-----\n";
|
||||||
|
|
||||||
|
await mkdir(testSshDir, { recursive: true, mode: 0o700 });
|
||||||
|
|
||||||
|
// Normalize the key (same logic as setupSshSigning)
|
||||||
|
const normalizedKey = keyWithNewline.endsWith("\n")
|
||||||
|
? keyWithNewline
|
||||||
|
: keyWithNewline + "\n";
|
||||||
|
|
||||||
|
await writeFile(testKeyPath, normalizedKey, { mode: 0o600 });
|
||||||
|
|
||||||
|
// Verify no double newline
|
||||||
|
const keyContent = await readFile(testKeyPath, "utf-8");
|
||||||
|
expect(keyContent).toBe(keyWithNewline);
|
||||||
|
expect(keyContent.endsWith("\n\n")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
test("should create .ssh directory with secure permissions", async () => {
|
test("should create .ssh directory with secure permissions", async () => {
|
||||||
// Clean up first
|
// Clean up first
|
||||||
await rm(testSshDir, { recursive: true, force: true });
|
await rm(testSshDir, { recursive: true, force: true });
|
||||||
|
|||||||
Reference in New Issue
Block a user