mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-09-18 23:20:36 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4f54ef2c5 | ||
|
|
2261fcfc88 | ||
|
|
3b8197d3d4 | ||
|
|
7b0b255830 | ||
|
|
bf38e86e58 | ||
|
|
51db78a4b8 | ||
|
|
9cdae7f0d9 | ||
|
|
56cf60fde4 | ||
|
|
0a8d3c9443 | ||
|
|
19dda84776 | ||
|
|
5ccc3a35a6 | ||
|
|
0d0e0876d3 | ||
|
|
9c5ddab2e6 | ||
|
|
d75b94d5ad | ||
|
|
ef8bb1e43b | ||
|
|
fa2b2666b7 | ||
|
|
8251c103ac | ||
|
|
781d62e9d5 | ||
|
|
833fb0f8c9 | ||
|
|
a874e9ecd7 | ||
|
|
a60f3e1db3 | ||
|
|
e8c2d7c16c | ||
|
|
70fec18385 | ||
|
|
1f291e1cfe | ||
|
|
76ac41a83e | ||
|
|
8ef9699156 | ||
|
|
791545dab1 | ||
|
|
2d7a787fbd | ||
|
|
b58c16b325 | ||
|
|
16b3b310c3 | ||
|
|
6bcfb8263a | ||
|
|
b62c7454dc | ||
|
|
e5ad3c7725 | ||
|
|
c81e3bc69d | ||
|
|
24dcd50c05 | ||
|
|
dcb57747bf | ||
|
|
492d2d78ee | ||
|
|
2ca5fb4027 | ||
|
|
f3f2789f0a | ||
|
|
6a5f1d8e0a | ||
|
|
39ad3c8977 | ||
|
|
3f854a8fb5 | ||
|
|
5ee796a55f | ||
|
|
cff8d3c8f0 | ||
|
|
e2a4b761cd | ||
|
|
65b50df083 | ||
|
|
0a80d21df7 | ||
|
|
54eadc2f72 | ||
|
|
d40ddef4c0 | ||
|
|
459ad358ae | ||
|
|
d721746d68 | ||
|
|
5da4c76dde | ||
|
|
a2cac87e27 | ||
|
|
b49813d0e7 | ||
|
|
9678fce999 | ||
|
|
ed186becce | ||
|
|
05ee4b30d7 | ||
|
|
9d7150bc8a | ||
|
|
e63208cb98 | ||
|
|
dc33e8a15b | ||
|
|
c58ad32088 | ||
|
|
dfb8fc798e | ||
|
|
a2489efcb9 | ||
|
|
8b8745859f | ||
|
|
239e3a7308 | ||
|
|
5ef2e550a4 | ||
|
|
6b082c4193 | ||
|
|
7ff6806c8e | ||
|
|
751e003832 | ||
|
|
b704dd3960 | ||
|
|
ecf573bd65 | ||
|
|
7764306e92 | ||
|
|
5dd098c551 | ||
|
|
4c4309a064 | ||
|
|
c4190dbd78 | ||
|
|
9a2db97708 | ||
|
|
2df67d2c33 | ||
|
|
d573b167d3 | ||
|
|
0a5f191964 | ||
|
|
6ef6450f51 | ||
|
|
1623c36729 | ||
|
|
96e281f4d9 | ||
|
|
e1fc925862 | ||
|
|
0aee57ab82 |
@@ -0,0 +1,3 @@
|
||||
# Keep the working tree LF on every platform: Prettier defaults to
|
||||
# endOfLine "lf" and several tests match LF-terminated content.
|
||||
* text=auto eol=lf
|
||||
@@ -6,6 +6,8 @@ on:
|
||||
|
||||
jobs:
|
||||
review:
|
||||
# Skip on fork PRs since they can't mint the OIDC token used for Claude API auth
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -19,6 +19,8 @@ permissions:
|
||||
|
||||
jobs:
|
||||
test-inline-prompt:
|
||||
# Skip on fork PRs since they can't mint the OIDC token used for Claude API auth
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -31,7 +33,7 @@ jobs:
|
||||
anthropic_federation_rule_id: ${{ vars.ANTHROPIC_FEDERATION_RULE_ID }}
|
||||
anthropic_organization_id: ${{ vars.ANTHROPIC_ORGANIZATION_ID }}
|
||||
anthropic_service_account_id: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }}
|
||||
allowed_tools: "LS,Read"
|
||||
claude_args: '--allowedTools "LS,Read"'
|
||||
|
||||
- name: Verify inline prompt output
|
||||
run: |
|
||||
@@ -70,6 +72,8 @@ jobs:
|
||||
fi
|
||||
|
||||
test-prompt-file:
|
||||
# Skip on fork PRs since they can't mint the OIDC token used for Claude API auth
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -90,7 +94,7 @@ jobs:
|
||||
anthropic_federation_rule_id: ${{ vars.ANTHROPIC_FEDERATION_RULE_ID }}
|
||||
anthropic_organization_id: ${{ vars.ANTHROPIC_ORGANIZATION_ID }}
|
||||
anthropic_service_account_id: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }}
|
||||
allowed_tools: "LS,Read"
|
||||
claude_args: '--allowedTools "LS,Read"'
|
||||
|
||||
- name: Verify prompt file output
|
||||
run: |
|
||||
|
||||
@@ -14,6 +14,8 @@ permissions:
|
||||
|
||||
jobs:
|
||||
test-custom-executables:
|
||||
# Skip on fork PRs since they can't mint the OIDC token used for Claude API auth
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -59,7 +61,7 @@ jobs:
|
||||
anthropic_service_account_id: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }}
|
||||
path_to_claude_code_executable: /home/runner/.local/bin/claude
|
||||
path_to_bun_executable: /home/runner/.bun/bin/bun
|
||||
allowed_tools: "LS,Read"
|
||||
claude_args: '--allowedTools "LS,Read"'
|
||||
|
||||
- name: Verify custom executables worked
|
||||
run: |
|
||||
|
||||
@@ -14,6 +14,8 @@ permissions:
|
||||
|
||||
jobs:
|
||||
test-mcp-integration:
|
||||
# Skip on fork PRs since they can't mint the OIDC token used for Claude API auth
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -94,6 +96,8 @@ jobs:
|
||||
echo "✓ All MCP server checks passed!"
|
||||
|
||||
test-mcp-config-flag:
|
||||
# Skip on fork PRs since they can't mint the OIDC token used for Claude API auth
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -14,6 +14,8 @@ permissions:
|
||||
|
||||
jobs:
|
||||
test-settings-inline-allow:
|
||||
# Skip on fork PRs since they can't mint the OIDC token used for Claude API auth
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -65,6 +67,8 @@ jobs:
|
||||
fi
|
||||
|
||||
test-settings-inline-deny:
|
||||
# Skip on fork PRs since they can't mint the OIDC token used for Claude API auth
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -99,6 +103,8 @@ jobs:
|
||||
fi
|
||||
|
||||
test-settings-file-allow:
|
||||
# Skip on fork PRs since they can't mint the OIDC token used for Claude API auth
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -155,6 +161,8 @@ jobs:
|
||||
fi
|
||||
|
||||
test-settings-file-deny:
|
||||
# Skip on fork PRs since they can't mint the OIDC token used for Claude API auth
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
@@ -15,6 +15,8 @@ permissions:
|
||||
jobs:
|
||||
test-basic-types:
|
||||
name: Test Basic Type Conversions
|
||||
# Skip on fork PRs since they can't mint the OIDC token used for Claude API auth
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -76,6 +78,8 @@ jobs:
|
||||
|
||||
test-complex-types:
|
||||
name: Test Arrays and Objects
|
||||
# Skip on fork PRs since they can't mint the OIDC token used for Claude API auth
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -129,6 +133,8 @@ jobs:
|
||||
|
||||
test-edge-cases:
|
||||
name: Test Edge Cases
|
||||
# Skip on fork PRs since they can't mint the OIDC token used for Claude API auth
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -190,6 +196,8 @@ jobs:
|
||||
|
||||
test-name-sanitization:
|
||||
name: Test Output Name Sanitization
|
||||
# Skip on fork PRs since they can't mint the OIDC token used for Claude API auth
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -232,6 +240,8 @@ jobs:
|
||||
|
||||
test-execution-file-structure:
|
||||
name: Test Execution File Format
|
||||
# Skip on fork PRs since they can't mint the OIDC token used for Claude API auth
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -284,7 +294,7 @@ jobs:
|
||||
- test-edge-cases
|
||||
- test-name-sanitization
|
||||
- test-execution-file-structure
|
||||
if: always()
|
||||
if: ${{ always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
|
||||
steps:
|
||||
- name: Generate Summary
|
||||
run: |
|
||||
|
||||
+12
-1
@@ -134,7 +134,7 @@ inputs:
|
||||
required: false
|
||||
default: "claude[bot]"
|
||||
track_progress:
|
||||
description: "Force tag mode with tracking comments for pull_request and issue events. Only applicable to pull_request (opened, synchronize, ready_for_review, reopened) and issue (opened, edited, labeled, assigned) events."
|
||||
description: "Force tag mode with tracking comments for pull_request and issue events. Only applicable to pull_request (opened, synchronize, ready_for_review, reopened, labeled) and issue (opened, edited, labeled, assigned) events."
|
||||
required: false
|
||||
default: "false"
|
||||
include_fix_links:
|
||||
@@ -167,6 +167,9 @@ inputs:
|
||||
default: ""
|
||||
|
||||
outputs:
|
||||
conclusion:
|
||||
description: "Execution status of Claude Code ('success' or 'failure')"
|
||||
value: ${{ steps.run.outputs.conclusion }}
|
||||
execution_file:
|
||||
description: "Path to the Claude Code execution output file"
|
||||
value: ${{ steps.run.outputs.execution_file }}
|
||||
@@ -193,6 +196,14 @@ runs:
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
token: ${{ inputs.github_token || github.token }}
|
||||
# Disable setup-bun's cache. The upstream save step uses a deterministic
|
||||
# key (Bun version) and isn't ref-aware: on every second-and-subsequent
|
||||
# run against the same PR ref the GitHub cache API rejects the duplicate
|
||||
# key+ref with a 409 (HTML body), and @actions/cache treats the unparsable
|
||||
# response as transient and burns ~20-30s on 5 retries before warning.
|
||||
# The 35 MB Bun binary downloads in 2-3s, so disabling the cache is a net
|
||||
# wallclock win and removes the noisy warning. See issue #1252.
|
||||
no-cache: true
|
||||
|
||||
- name: Setup Custom Bun Path
|
||||
if: inputs.path_to_bun_executable != ''
|
||||
|
||||
+81
-110
@@ -22,7 +22,7 @@ Add the following to your workflow file:
|
||||
uses: anthropics/claude-code-base-action@beta
|
||||
with:
|
||||
prompt: "Your prompt here"
|
||||
allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool"
|
||||
claude_args: '--allowedTools "Bash(git:*),Read,Glob,Grep"'
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
# Or using a prompt from a file
|
||||
@@ -30,7 +30,7 @@ Add the following to your workflow file:
|
||||
uses: anthropics/claude-code-base-action@beta
|
||||
with:
|
||||
prompt_file: "/path/to/prompt.txt"
|
||||
allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool"
|
||||
claude_args: '--allowedTools "Bash(git:*),Read,Glob,Grep"'
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
# Or limiting the conversation turns
|
||||
@@ -38,8 +38,9 @@ Add the following to your workflow file:
|
||||
uses: anthropics/claude-code-base-action@beta
|
||||
with:
|
||||
prompt: "Your prompt here"
|
||||
allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool"
|
||||
max_turns: "5" # Limit conversation to 5 turns
|
||||
claude_args: |
|
||||
--allowedTools "Bash(git:*),Read,Glob,Grep"
|
||||
--max-turns 5
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
# Using custom system prompts
|
||||
@@ -47,8 +48,9 @@ Add the following to your workflow file:
|
||||
uses: anthropics/claude-code-base-action@beta
|
||||
with:
|
||||
prompt: "Build a REST API"
|
||||
system_prompt: "You are a senior backend engineer. Focus on security, performance, and maintainability."
|
||||
allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool"
|
||||
claude_args: |
|
||||
--system-prompt "You are a senior backend engineer. Focus on security, performance, and maintainability."
|
||||
--allowedTools "Bash(git:*),Read,Glob,Grep"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
# Or appending to the default system prompt
|
||||
@@ -56,8 +58,9 @@ Add the following to your workflow file:
|
||||
uses: anthropics/claude-code-base-action@beta
|
||||
with:
|
||||
prompt: "Create a database schema"
|
||||
append_system_prompt: "After writing code, be sure to code review yourself."
|
||||
allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool"
|
||||
claude_args: |
|
||||
--append-system-prompt "After writing code, be sure to code review yourself."
|
||||
--allowedTools "Bash(git:*),Read,Glob,Grep"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
# Using custom environment variables
|
||||
@@ -65,11 +68,15 @@ Add the following to your workflow file:
|
||||
uses: anthropics/claude-code-base-action@beta
|
||||
with:
|
||||
prompt: "Deploy to staging environment"
|
||||
claude_env: |
|
||||
ENVIRONMENT: staging
|
||||
API_URL: https://api-staging.example.com
|
||||
DEBUG: true
|
||||
allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool"
|
||||
settings: |
|
||||
{
|
||||
"env": {
|
||||
"ENVIRONMENT": "staging",
|
||||
"API_URL": "https://api-staging.example.com",
|
||||
"DEBUG": "true"
|
||||
}
|
||||
}
|
||||
claude_args: '--allowedTools "Bash(git:*),Read,Glob,Grep"'
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
# Using fallback model for handling API errors
|
||||
@@ -77,9 +84,10 @@ Add the following to your workflow file:
|
||||
uses: anthropics/claude-code-base-action@beta
|
||||
with:
|
||||
prompt: "Review and fix TypeScript errors"
|
||||
model: "claude-opus-4-1-20250805"
|
||||
fallback_model: "claude-sonnet-4-20250514"
|
||||
allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool"
|
||||
claude_args: |
|
||||
--model "claude-opus-4-1-20250805"
|
||||
--fallback-model "claude-sonnet-4-20250514"
|
||||
--allowedTools "Bash(git:*),Read,Glob,Grep"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
# Using OAuth token instead of API key
|
||||
@@ -87,7 +95,7 @@ Add the following to your workflow file:
|
||||
uses: anthropics/claude-code-base-action@beta
|
||||
with:
|
||||
prompt: "Update dependencies"
|
||||
allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool"
|
||||
claude_args: '--allowedTools "Bash(git:*),Read,Glob,Grep"'
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
```
|
||||
|
||||
@@ -114,32 +122,28 @@ Do not set `anthropic_api_key` or `claude_code_oauth_token` alongside the federa
|
||||
|
||||
## Inputs
|
||||
|
||||
| Input | Description | Required | Default |
|
||||
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------- | -------- | ---------------------------- |
|
||||
| `prompt` | The prompt to send to Claude Code | No\* | '' |
|
||||
| `prompt_file` | Path to a file containing the prompt to send to Claude Code | No\* | '' |
|
||||
| `allowed_tools` | Comma-separated list of allowed tools for Claude Code to use | No | '' |
|
||||
| `disallowed_tools` | Comma-separated list of disallowed tools that Claude Code cannot use | No | '' |
|
||||
| `max_turns` | Maximum number of conversation turns (default: no limit) | No | '' |
|
||||
| `mcp_config` | Path to the MCP configuration JSON file, or MCP configuration JSON string | No | '' |
|
||||
| `settings` | Path to Claude Code settings JSON file, or settings JSON string | No | '' |
|
||||
| `system_prompt` | Override system prompt | No | '' |
|
||||
| `append_system_prompt` | Append to system prompt | No | '' |
|
||||
| `claude_env` | Custom environment variables to pass to Claude Code execution (YAML multiline format) | No | '' |
|
||||
| `model` | Model to use (provider-specific format required for Bedrock/Vertex) | No | 'claude-4-0-sonnet-20250219' |
|
||||
| `anthropic_model` | DEPRECATED: Use 'model' instead | No | 'claude-4-0-sonnet-20250219' |
|
||||
| `fallback_model` | Enable automatic fallback to specified model when default model is overloaded | No | '' |
|
||||
| `anthropic_api_key` | Anthropic API key (required for direct Anthropic API) | No | '' |
|
||||
| `claude_code_oauth_token` | Claude Code OAuth token (alternative to anthropic_api_key) | No | '' |
|
||||
| `anthropic_federation_rule_id` | Workload identity federation rule ID (fdrl\_...). Requires `id-token: write` permission | No | '' |
|
||||
| `anthropic_organization_id` | Anthropic organization UUID used for workload identity federation | No | '' |
|
||||
| `anthropic_service_account_id` | Service account ID (svac\_...) the federated token acts as (optional) | No | '' |
|
||||
| `anthropic_workspace_id` | Workspace ID (wrkspc\_...) for federation. Optional when the rule targets a single workspace | No | '' |
|
||||
| `anthropic_oidc_audience` | Audience to request on the GitHub OIDC token. Defaults to https://api.anthropic.com | No | '' |
|
||||
| `use_bedrock` | Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API | No | 'false' |
|
||||
| `use_vertex` | Use Google Vertex AI with OIDC authentication instead of direct Anthropic API | No | 'false' |
|
||||
| `use_node_cache` | Whether to use Node.js dependency caching (set to true only for Node.js projects with lock files) | No | 'false' |
|
||||
| `show_full_output` | Show full JSON output (⚠️ May expose secrets - see [security docs](../docs/security.md#️-full-output-security-warning)) | No | 'false'\*\* |
|
||||
| Input | Description | Required | Default |
|
||||
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -------- | ------------- |
|
||||
| `prompt` | The prompt to send to Claude Code | No\* | `''` |
|
||||
| `prompt_file` | Path to a file containing the prompt to send to Claude Code | No\* | `''` |
|
||||
| `settings` | Claude Code settings as a JSON string or path to a settings JSON file | No | `''` |
|
||||
| `claude_args` | Additional arguments to pass directly to the Claude CLI | No | `''` |
|
||||
| `anthropic_api_key` | Anthropic API key for direct Anthropic API authentication | No | `''` |
|
||||
| `claude_code_oauth_token` | Claude Code OAuth token as an alternative to an Anthropic API key | No | `''` |
|
||||
| `anthropic_federation_rule_id` | Workload identity federation rule ID (fdrl\_...). Requires `id-token: write` permission | No | `''` |
|
||||
| `anthropic_organization_id` | Anthropic organization UUID used for workload identity federation | No | `''` |
|
||||
| `anthropic_service_account_id` | Service account ID (svac\_...) the federated token acts as | No | `''` |
|
||||
| `anthropic_workspace_id` | Workspace ID (wrkspc\_...) for federation | No | `''` |
|
||||
| `anthropic_oidc_audience` | Audience for the GitHub OIDC token request | No | `''` |
|
||||
| `use_bedrock` | Use Amazon Bedrock with OIDC authentication | No | `'false'` |
|
||||
| `use_vertex` | Use Google Vertex AI with OIDC authentication | No | `'false'` |
|
||||
| `use_foundry` | Use Microsoft Foundry with OIDC authentication | No | `'false'` |
|
||||
| `use_node_cache` | Enable Node.js dependency caching for projects with lock files | No | `'false'` |
|
||||
| `path_to_claude_code_executable` | Path to a custom Claude Code executable | No | `''` |
|
||||
| `path_to_bun_executable` | Path to a custom Bun executable | No | `''` |
|
||||
| `show_full_output` | Show full JSON output (⚠️ May expose secrets - see [security docs](../docs/security.md#️-full-output-security-warning)) | No | `'false'`\*\* |
|
||||
| `plugins` | Newline-separated Claude Code plugin names to install | No | `''` |
|
||||
| `plugin_marketplaces` | Newline-separated plugin marketplace Git URLs to install | No | `''` |
|
||||
|
||||
\*Either `prompt` or `prompt_file` must be provided, but not both.
|
||||
|
||||
@@ -176,55 +180,28 @@ Example usage:
|
||||
|
||||
## Custom Environment Variables
|
||||
|
||||
You can pass custom environment variables to Claude Code execution using the `claude_env` input. This allows Claude to access environment-specific configuration during its execution.
|
||||
|
||||
The `claude_env` input accepts YAML multiline format with key-value pairs:
|
||||
You can pass custom environment variables to Claude Code through the `env` object in `settings`:
|
||||
|
||||
```yaml
|
||||
- name: Deploy with custom environment
|
||||
uses: anthropics/claude-code-base-action@beta
|
||||
with:
|
||||
prompt: "Deploy the application to the staging environment"
|
||||
claude_env: |
|
||||
ENVIRONMENT: staging
|
||||
API_BASE_URL: https://api-staging.example.com
|
||||
DATABASE_URL: ${{ secrets.STAGING_DB_URL }}
|
||||
DEBUG: true
|
||||
LOG_LEVEL: debug
|
||||
allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool"
|
||||
settings: |
|
||||
{
|
||||
"env": {
|
||||
"ENVIRONMENT": "staging",
|
||||
"API_BASE_URL": "https://api-staging.example.com",
|
||||
"DATABASE_URL": "${{ secrets.STAGING_DB_URL }}",
|
||||
"DEBUG": "true",
|
||||
"LOG_LEVEL": "debug"
|
||||
}
|
||||
}
|
||||
claude_args: '--allowedTools "Bash(git:*),Read,Glob,Grep"'
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
```
|
||||
|
||||
### Features:
|
||||
|
||||
- **YAML Format**: Use standard YAML key-value syntax (`KEY: value`)
|
||||
- **Multiline Support**: Define multiple environment variables in a single input
|
||||
- **Comments**: Lines starting with `#` are ignored
|
||||
- **GitHub Secrets**: Can reference GitHub secrets using `${{ secrets.SECRET_NAME }}`
|
||||
- **Runtime Access**: Environment variables are available to Claude during execution
|
||||
|
||||
### Example Use Cases:
|
||||
|
||||
```yaml
|
||||
# Development configuration
|
||||
claude_env: |
|
||||
NODE_ENV: development
|
||||
API_URL: http://localhost:3000
|
||||
DEBUG: true
|
||||
|
||||
# Production deployment
|
||||
claude_env: |
|
||||
NODE_ENV: production
|
||||
API_URL: https://api.example.com
|
||||
DATABASE_URL: ${{ secrets.PROD_DB_URL }}
|
||||
REDIS_URL: ${{ secrets.REDIS_URL }}
|
||||
|
||||
# Feature flags and configuration
|
||||
claude_env: |
|
||||
FEATURE_NEW_UI: enabled
|
||||
MAX_RETRIES: 3
|
||||
TIMEOUT_MS: 5000
|
||||
```
|
||||
The `settings` input accepts either inline JSON or a path to a settings JSON file. Values in the `env` object are available during the Claude Code session and can reference GitHub secrets.
|
||||
|
||||
## Using Settings Configuration
|
||||
|
||||
@@ -240,7 +217,7 @@ Provide a path to a JSON file containing Claude Code settings:
|
||||
with:
|
||||
prompt: "Your prompt here"
|
||||
settings: "path/to/settings.json"
|
||||
allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool"
|
||||
claude_args: '--allowedTools "Bash(git:*),Read,Glob,Grep"'
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
```
|
||||
|
||||
@@ -274,7 +251,7 @@ Provide the settings configuration directly as a JSON string:
|
||||
}]
|
||||
}
|
||||
}
|
||||
allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool"
|
||||
claude_args: '--allowedTools "Bash(git:*),Read,Glob,Grep"'
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
```
|
||||
|
||||
@@ -302,8 +279,9 @@ Provide a path to a JSON file containing MCP configuration:
|
||||
uses: anthropics/claude-code-base-action@beta
|
||||
with:
|
||||
prompt: "Your prompt here"
|
||||
mcp_config: "path/to/mcp-config.json"
|
||||
allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool"
|
||||
claude_args: |
|
||||
--mcp-config "path/to/mcp-config.json"
|
||||
--allowedTools "Bash(git:*),Read,Glob,Grep"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
```
|
||||
|
||||
@@ -316,19 +294,9 @@ Provide the MCP configuration directly as a JSON string:
|
||||
uses: anthropics/claude-code-base-action@beta
|
||||
with:
|
||||
prompt: "Your prompt here"
|
||||
mcp_config: |
|
||||
{
|
||||
"mcpServers": {
|
||||
"server-name": {
|
||||
"command": "node",
|
||||
"args": ["./server.js"],
|
||||
"env": {
|
||||
"API_KEY": "your-api-key"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool"
|
||||
claude_args: >-
|
||||
--mcp-config '{"mcpServers":{"server-name":{"command":"node","args":["./server.js"],"env":{"API_KEY":"your-api-key"}}}}'
|
||||
--allowedTools "Bash(git:*),Read,Glob,Grep"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
```
|
||||
|
||||
@@ -356,8 +324,9 @@ You can combine MCP config with other inputs like allowed tools:
|
||||
uses: anthropics/claude-code-base-action@beta
|
||||
with:
|
||||
prompt: "Access the custom MCP server and use its tools"
|
||||
mcp_config: "mcp-config.json"
|
||||
allowed_tools: "Bash(git:*),View,mcp__server-name__custom_tool"
|
||||
claude_args: |
|
||||
--mcp-config "mcp-config.json"
|
||||
--allowedTools "Bash(git:*),Read,mcp__server-name__custom_tool"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
```
|
||||
|
||||
@@ -384,7 +353,7 @@ jobs:
|
||||
uses: anthropics/claude-code-base-action@beta
|
||||
with:
|
||||
prompt: "Review the PR changes. Focus on code quality, potential bugs, and performance issues. Suggest improvements where appropriate. Write your review as markdown text."
|
||||
allowed_tools: "Bash(git diff --name-only HEAD~1),Bash(git diff HEAD~1),View,GlobTool,GrepTool,Write"
|
||||
claude_args: '--allowedTools "Bash(git diff --name-only HEAD~1),Bash(git diff HEAD~1),Read,Glob,Grep,Write"'
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
- name: Extract and Comment PR Review
|
||||
@@ -472,7 +441,7 @@ Use provider-specific model names based on your chosen provider:
|
||||
uses: anthropics/claude-code-base-action@beta
|
||||
with:
|
||||
prompt: "Your prompt here"
|
||||
model: "claude-3-7-sonnet-20250219"
|
||||
claude_args: "--model claude-3-7-sonnet-20250219"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
# For Amazon Bedrock (requires OIDC authentication)
|
||||
@@ -486,7 +455,7 @@ Use provider-specific model names based on your chosen provider:
|
||||
uses: anthropics/claude-code-base-action@beta
|
||||
with:
|
||||
prompt: "Your prompt here"
|
||||
model: "anthropic.claude-3-7-sonnet-20250219-v1:0"
|
||||
claude_args: "--model anthropic.claude-3-7-sonnet-20250219-v1:0"
|
||||
use_bedrock: "true"
|
||||
|
||||
# For Google Vertex AI (requires OIDC authentication)
|
||||
@@ -500,7 +469,7 @@ Use provider-specific model names based on your chosen provider:
|
||||
uses: anthropics/claude-code-base-action@beta
|
||||
with:
|
||||
prompt: "Your prompt here"
|
||||
model: "claude-3-7-sonnet@20250219"
|
||||
claude_args: "--model claude-3-7-sonnet@20250219"
|
||||
use_vertex: "true"
|
||||
```
|
||||
|
||||
@@ -520,8 +489,9 @@ This example shows how to use OIDC authentication with AWS Bedrock:
|
||||
with:
|
||||
prompt: "Your prompt here"
|
||||
use_bedrock: "true"
|
||||
model: "anthropic.claude-3-7-sonnet-20250219-v1:0"
|
||||
allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool"
|
||||
claude_args: |
|
||||
--model "anthropic.claude-3-7-sonnet-20250219-v1:0"
|
||||
--allowedTools "Bash(git:*),Read,Glob,Grep"
|
||||
```
|
||||
|
||||
## Example: Using OIDC Authentication for GCP Vertex AI
|
||||
@@ -540,8 +510,9 @@ This example shows how to use OIDC authentication with GCP Vertex AI:
|
||||
with:
|
||||
prompt: "Your prompt here"
|
||||
use_vertex: "true"
|
||||
model: "claude-3-7-sonnet@20250219"
|
||||
allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool"
|
||||
claude_args: |
|
||||
--model "claude-3-7-sonnet@20250219"
|
||||
--allowedTools "Bash(git:*),Read,Glob,Grep"
|
||||
```
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
@@ -121,6 +121,8 @@ runs:
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # https://github.com/oven-sh/setup-bun/releases/tag/v2.2.0
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
# Disable setup-bun's cache. See action.yml for details and issue #1252.
|
||||
no-cache: true
|
||||
|
||||
- name: Setup Custom Bun Path
|
||||
if: inputs.path_to_bun_executable != ''
|
||||
@@ -145,7 +147,7 @@ runs:
|
||||
PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
|
||||
run: |
|
||||
if [ -z "$PATH_TO_CLAUDE_CODE_EXECUTABLE" ]; then
|
||||
CLAUDE_CODE_VERSION="2.1.223"
|
||||
CLAUDE_CODE_VERSION="2.1.276"
|
||||
echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..."
|
||||
for attempt in 1 2 3; do
|
||||
echo "Installation attempt $attempt..."
|
||||
@@ -163,6 +165,8 @@ runs:
|
||||
sleep 5
|
||||
done
|
||||
echo "Claude Code installed successfully"
|
||||
# Add ~/.local/bin to PATH so the claude executable is available in subsequent steps
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
else
|
||||
echo "Using custom Claude Code executable: $PATH_TO_CLAUDE_CODE_EXECUTABLE"
|
||||
# Add the directory containing the custom executable to PATH
|
||||
|
||||
+12
-12
@@ -6,8 +6,8 @@
|
||||
"name": "@anthropic-ai/claude-code-base-action",
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.10.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.223",
|
||||
"shell-quote": "^1.8.3",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.276",
|
||||
"shell-quote": "^1.8.4",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.2.12",
|
||||
@@ -27,23 +27,23 @@
|
||||
|
||||
"@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.3.223", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.223", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.223", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.223", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.223", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.223", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.223", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.223", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.223" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-r2BpOfxaEjPj0xpRQBukrBWG7n2QERVk10hstc+AXmm4JBii1OqH50sfewU8a8E7uhoJazA3WnZoZoaTFnV/2A=="],
|
||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.276", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.276", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.276", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.276", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.276", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.276", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.276", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.276", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.276" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-Dic43v4uuGLhibPAArWy3RSfq3zHdn+4Oh2AI2/18a5tlbNKkvChEpazyp8c+grUJvzxkzt0jETCa1wrADbkyw=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.223", "", { "os": "darwin", "cpu": "arm64" }, "sha512-y9PcAkK7JHfzBC1yyLIhJwlF/x7XYLcFReiKkxm53mtBp+ASgpxNoBDvGqx7Q+IVB5xwlhnprcaXc2PxsLUKuA=="],
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.276", "", { "os": "darwin", "cpu": "arm64" }, "sha512-WdpZ7UXoIDGYeb97OWRFADqkMDLv/bB3YrRxvomqdxfRZC2RLAPLUxsBat9zVL9B5dsiJmZN8h1D0NlMe92Uzw=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.223", "", { "os": "darwin", "cpu": "x64" }, "sha512-JAkGQCat4FroNdG9XQiKQ+M+R/mggt3fFVQR5KcsbOrGFNynCdnVhi9CPxU8AXDhiIbk+Y3254qHhKTZbcZtGw=="],
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.276", "", { "os": "darwin", "cpu": "x64" }, "sha512-y5J+K1q7llwYkf85OhjG71R5AqsulHzC+HpgUOrg6klO+jBKsesGGS7tsEGzsM4YJtDntr6Rylw/sH3ZmXvjhg=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.223", "", { "os": "linux", "cpu": "arm64" }, "sha512-mFPm66MIiFtb/XiHR39/ifzv0nlho1KNCjH+1cP6lJvVXm57kugxsKDndGXWEBI0Wh7DxKhTnjRxEKVhwwdYWw=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.276", "", { "os": "linux", "cpu": "arm64" }, "sha512-IaBni1kRiUKEsSRTHocVW8jqzl4KyC6ms6fr6KAJPPcjJTu2Ptpb1ohj/OfDixTc/AErrNVL0ye8ICpZ+88k7w=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.223", "", { "os": "linux", "cpu": "arm64" }, "sha512-HKpj+0quFtaH/fXQm56HipslOyhyTdB2vM4nvxmG3oube2KtB9FNJqqnTwSTeGbJc5dm4PjoAyN+oQV8Rh4RGw=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.276", "", { "os": "linux", "cpu": "arm64" }, "sha512-Pb7PVWLktFCgD1cUtF61lx9riT2bh3ZSE6rq94EjaesyCnpzddlQ59/LLwLVUwGunBq9N2WayXaElOsk8+pK/g=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.223", "", { "os": "linux", "cpu": "x64" }, "sha512-8x+BvnuNr8iTMqwBAf0KvklF/GRLXYiXMb+umo+N2bxOlE7wFYpFqVACeW44KsfRgdnbVnF9sJ00w1s01W/9eA=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.276", "", { "os": "linux", "cpu": "x64" }, "sha512-MR/HQn8xi3RGujnzgJhyVolKR7JO+BiPhBBJ0GqbIlM4huso1aIDFhYhetdGIPb4shr7R1OZzSj1QFJoJQQ8/A=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.223", "", { "os": "linux", "cpu": "x64" }, "sha512-ukw6GAneAsCc/Lp24cA4yk2/vPgDjooZGB/TTvLsyfy8yto3b/WpfaktubQE/tNigF+mWo6+VytUOeVil8HwnQ=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.276", "", { "os": "linux", "cpu": "x64" }, "sha512-OaZAlVBhAUFkivWgb5ZsJYCT08qTo9LyomaN/2jNwsvVkpurr8kXEGncsTk8sM5JyIzSSiWuR+Sfxphq071XPA=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.223", "", { "os": "win32", "cpu": "arm64" }, "sha512-Am8q5al0BBIbxOlfLSjaXQqCOjOg/4XvCiSt8mxvocGy/6W60fV/RFrEs86RlN6DATUGxTSNTqye6cn8MTlgOA=="],
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.276", "", { "os": "win32", "cpu": "arm64" }, "sha512-dk7lxCdvRwXVf5LAh9nQ2/MLLedfT3K1HIQsv5KBgexUE1F7JYYd5/R3eYs/qT9UQOeKrMmKq7GG9RxDDXCgjA=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.223", "", { "os": "win32", "cpu": "x64" }, "sha512-ZG53F8YtUTr97OGdnIbSqHuRHL1eja928M1xwbuAiM1cJ5I8VpFODVjHpM0ioKiurr9Tmjzns/nHcafc18VovA=="],
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.276", "", { "os": "win32", "cpu": "x64" }, "sha512-CKPz5K94qpVHdP0hmSugntpiTshHrPFs4RtQYcFvP4PVltZv/wtth090Rff21XJna3Lxt4Lb5ce3tMqTnDRu9Q=="],
|
||||
|
||||
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.93.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-q9vaSZQVFx6B/gPxetGYfLXSJD5v0sOmh0OpZDq7yCrTSA+Rscvrtyol7JJTW40wEpQB4U1B4JXzxQitbQ3CAA=="],
|
||||
|
||||
@@ -223,7 +223,7 @@
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
|
||||
"shell-quote": ["shell-quote@1.8.4", "", {}, "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ=="],
|
||||
|
||||
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
|
||||
|
||||
|
||||
Generated
+4
-4
@@ -9,7 +9,7 @@
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.10.1",
|
||||
"shell-quote": "^1.8.3"
|
||||
"shell-quote": "^1.8.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.2.12",
|
||||
@@ -139,9 +139,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
||||
"version": "1.8.4",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz",
|
||||
"integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.10.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.223",
|
||||
"shell-quote": "^1.8.3"
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.276",
|
||||
"shell-quote": "^1.8.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.2.12",
|
||||
|
||||
@@ -204,6 +204,9 @@ export function parseSdkOptions(options: ClaudeOptions): ParsedSdkOptions {
|
||||
const modelFromClaudeArgs = extraArgs["model"] || undefined;
|
||||
delete extraArgs["model"];
|
||||
|
||||
const maxTurnsFromClaudeArgs = extraArgs["max-turns"] || undefined;
|
||||
delete extraArgs["max-turns"];
|
||||
|
||||
const additionalDirectories = extraArgs["add-dir"]
|
||||
? extraArgs["add-dir"]
|
||||
.split(ACCUMULATE_DELIMITER)
|
||||
@@ -286,6 +289,10 @@ export function parseSdkOptions(options: ClaudeOptions): ParsedSdkOptions {
|
||||
delete env.ACTIONS_ID_TOKEN_REQUEST_URL;
|
||||
delete env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
|
||||
|
||||
// Remove ALL_INPUTS as it is only needed during initial setup to determine
|
||||
// input presence (collectActionInputsPresence) and contains serialized workflow inputs.
|
||||
delete env.ALL_INPUTS;
|
||||
|
||||
// Build system prompt option - default to claude_code preset
|
||||
let systemPrompt: SdkOptions["systemPrompt"];
|
||||
if (options.systemPrompt) {
|
||||
@@ -308,7 +315,11 @@ export function parseSdkOptions(options: ClaudeOptions): ParsedSdkOptions {
|
||||
const sdkOptions: SdkOptions = {
|
||||
// Direct options from ClaudeOptions inputs
|
||||
model: options.model || modelFromClaudeArgs,
|
||||
maxTurns: options.maxTurns ? parseInt(options.maxTurns, 10) : undefined,
|
||||
maxTurns: options.maxTurns
|
||||
? parseInt(options.maxTurns, 10)
|
||||
: maxTurnsFromClaudeArgs
|
||||
? parseInt(maxTurnsFromClaudeArgs, 10)
|
||||
: undefined,
|
||||
allowedTools:
|
||||
mergedAllowedTools.length > 0 ? mergedAllowedTools : undefined,
|
||||
disallowedTools:
|
||||
|
||||
@@ -82,6 +82,35 @@ async function createPromptConfig(
|
||||
return createMultiBlockMessage();
|
||||
}
|
||||
|
||||
type ModelUsageSummary = Record<
|
||||
string,
|
||||
{
|
||||
contextWindow: number;
|
||||
maxOutputTokens: number;
|
||||
}
|
||||
>;
|
||||
|
||||
/**
|
||||
* Keep resolved model limits visible without exposing token usage or cost details.
|
||||
*/
|
||||
function sanitizeModelUsage(
|
||||
modelUsage: SDKResultMessage["modelUsage"] | undefined,
|
||||
): ModelUsageSummary | undefined {
|
||||
if (!modelUsage) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(modelUsage).map(([model, usage]) => [
|
||||
model,
|
||||
{
|
||||
contextWindow: usage.contextWindow,
|
||||
maxOutputTokens: usage.maxOutputTokens,
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes SDK output to match CLI sanitization behavior
|
||||
*/
|
||||
@@ -119,6 +148,7 @@ function sanitizeSdkOutput(
|
||||
num_turns: resultMsg.num_turns,
|
||||
total_cost_usd: resultMsg.total_cost_usd,
|
||||
permission_denials_count: resultMsg.permission_denials?.length ?? 0,
|
||||
modelUsage: sanitizeModelUsage(resultMsg.modelUsage),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
@@ -208,6 +238,17 @@ export async function runClaudeWithSdk(
|
||||
throw new Error("No result message received from Claude");
|
||||
}
|
||||
|
||||
if (
|
||||
resultMessage.subtype === "success" &&
|
||||
!resultMessage.is_error &&
|
||||
sdkOptions.maxTurns !== undefined &&
|
||||
resultMessage.num_turns > sdkOptions.maxTurns
|
||||
) {
|
||||
const message = `Claude reported a successful result after ${resultMessage.num_turns} turns, exceeding the configured maximum of ${sdkOptions.maxTurns}`;
|
||||
core.error(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
// subtype "success" with is_error:true means the run errored without producing
|
||||
// a real result — treat it as failure so CI does not show a misleading green check.
|
||||
const isSuccess =
|
||||
|
||||
@@ -521,6 +521,31 @@ describe("parseSdkOptions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("max turns handling", () => {
|
||||
test("should map --max-turns from claudeArgs to sdkOptions.maxTurns", () => {
|
||||
const options: ClaudeOptions = {
|
||||
claudeArgs: "--max-turns 60",
|
||||
};
|
||||
|
||||
const result = parseSdkOptions(options);
|
||||
|
||||
expect(result.sdkOptions.maxTurns).toBe(60);
|
||||
expect(result.sdkOptions.extraArgs?.["max-turns"]).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should prefer the direct maxTurns option", () => {
|
||||
const options: ClaudeOptions = {
|
||||
maxTurns: "25",
|
||||
claudeArgs: "--max-turns 60",
|
||||
};
|
||||
|
||||
const result = parseSdkOptions(options);
|
||||
|
||||
expect(result.sdkOptions.maxTurns).toBe(25);
|
||||
expect(result.sdkOptions.extraArgs?.["max-turns"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("environment variables passthrough", () => {
|
||||
test("should include OTEL environment variables in sdkOptions.env", () => {
|
||||
// Set up test environment variables
|
||||
@@ -595,5 +620,22 @@ describe("parseSdkOptions", () => {
|
||||
process.env = originalEnv;
|
||||
}
|
||||
});
|
||||
|
||||
test("should strip ALL_INPUTS from env", () => {
|
||||
const originalEnv = { ...process.env };
|
||||
process.env.ALL_INPUTS = JSON.stringify({
|
||||
anthropic_api_key: "sk-ant-test-key",
|
||||
github_token: "ghp_test_token",
|
||||
});
|
||||
|
||||
try {
|
||||
const options: ClaudeOptions = {};
|
||||
const result = parseSdkOptions(options);
|
||||
|
||||
expect(result.sdkOptions.env?.ALL_INPUTS).toBeUndefined();
|
||||
} finally {
|
||||
process.env = originalEnv;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
const actionMetadata = readFileSync(
|
||||
new URL("../action.yml", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const readme = readFileSync(new URL("../README.md", import.meta.url), "utf8");
|
||||
|
||||
describe("base action README", () => {
|
||||
test("should document every input declared in the action metadata", () => {
|
||||
const inputMetadata = actionMetadata.match(
|
||||
/^inputs:\n([\s\S]*?)^outputs:/m,
|
||||
)?.[1];
|
||||
const inputReference = readme.match(
|
||||
/^## Inputs\n([\s\S]*?)^## Outputs/m,
|
||||
)?.[1];
|
||||
|
||||
expect(inputMetadata).toBeDefined();
|
||||
expect(inputReference).toBeDefined();
|
||||
|
||||
const declaredInputs = [
|
||||
...(inputMetadata?.matchAll(/^ ([a-z0-9_]+):$/gm) ?? []),
|
||||
].map((match) => match[1]);
|
||||
const documentedInputs = [
|
||||
...(inputReference?.matchAll(/^\| `([^`]+)`/gm) ?? []),
|
||||
].map((match) => match[1]);
|
||||
|
||||
expect(documentedInputs).toEqual(declaredInputs);
|
||||
});
|
||||
|
||||
test("should not use removed legacy inputs in workflow examples", () => {
|
||||
const removedInputs = [
|
||||
"allowed_tools",
|
||||
"disallowed_tools",
|
||||
"max_turns",
|
||||
"mcp_config",
|
||||
"system_prompt",
|
||||
"append_system_prompt",
|
||||
"claude_env",
|
||||
"model",
|
||||
"anthropic_model",
|
||||
"fallback_model",
|
||||
];
|
||||
|
||||
for (const input of removedInputs) {
|
||||
expect(readme).not.toMatch(new RegExp(`^\\s+${input}:`, "m"));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -64,6 +64,95 @@ describe("runClaudeWithSdk", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("logs resolved model limits without exposing token usage", async () => {
|
||||
const consoleLogSpy = spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
tempDir = await mkdtemp(join(tmpdir(), "claude-sdk-"));
|
||||
process.env.RUNNER_TEMP = tempDir;
|
||||
|
||||
const promptPath = join(tempDir, "prompt.txt");
|
||||
await writeFile(promptPath, "test prompt");
|
||||
|
||||
const initMessage = {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "session-123",
|
||||
model: "claude-opus-5",
|
||||
};
|
||||
|
||||
const resultMessage = {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
is_error: false,
|
||||
duration_ms: 434,
|
||||
num_turns: 1,
|
||||
total_cost_usd: 1.23,
|
||||
permission_denials: [],
|
||||
modelUsage: {
|
||||
"claude-opus-5": {
|
||||
inputTokens: 96209,
|
||||
outputTokens: 55324,
|
||||
cacheReadInputTokens: 1135701,
|
||||
cacheCreationInputTokens: 149043,
|
||||
webSearchRequests: 0,
|
||||
costUSD: 1.23,
|
||||
contextWindow: 200000,
|
||||
maxOutputTokens: 64000,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
mock.module("@anthropic-ai/claude-agent-sdk", () => ({
|
||||
query: async function* () {
|
||||
yield initMessage;
|
||||
yield resultMessage;
|
||||
},
|
||||
}));
|
||||
|
||||
try {
|
||||
const { runClaudeWithSdk } = await import("../src/run-claude-sdk");
|
||||
|
||||
await expect(
|
||||
runClaudeWithSdk(promptPath, {
|
||||
sdkOptions: {},
|
||||
showFullOutput: false,
|
||||
hasJsonSchema: false,
|
||||
}),
|
||||
).resolves.toMatchObject({ conclusion: "success" });
|
||||
|
||||
const sanitizedResult = consoleLogSpy.mock.calls
|
||||
.map(([message]) => message)
|
||||
.find(
|
||||
(message) =>
|
||||
typeof message === "string" && message.includes('"type": "result"'),
|
||||
);
|
||||
|
||||
expect(sanitizedResult).toBeDefined();
|
||||
if (typeof sanitizedResult !== "string") {
|
||||
throw new Error("Sanitized result output was not logged");
|
||||
}
|
||||
expect(JSON.parse(sanitizedResult)).toEqual({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
is_error: false,
|
||||
duration_ms: 434,
|
||||
num_turns: 1,
|
||||
total_cost_usd: 1.23,
|
||||
permission_denials_count: 0,
|
||||
modelUsage: {
|
||||
"claude-opus-5": {
|
||||
contextWindow: 200000,
|
||||
maxOutputTokens: 64000,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(sanitizedResult).not.toContain("inputTokens");
|
||||
expect(sanitizedResult).not.toContain("costUSD");
|
||||
} finally {
|
||||
consoleLogSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("fails when result subtype is success but is_error is true", async () => {
|
||||
const consoleErrorSpy = spyOn(console, "error").mockImplementation(
|
||||
() => {},
|
||||
@@ -128,4 +217,71 @@ describe("runClaudeWithSdk", () => {
|
||||
coreErrorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("fails closed when a successful result exceeds maxTurns", async () => {
|
||||
const consoleErrorSpy = spyOn(console, "error").mockImplementation(
|
||||
() => {},
|
||||
);
|
||||
const consoleLogSpy = spyOn(console, "log").mockImplementation(() => {});
|
||||
const coreErrorSpy = spyOn(
|
||||
await import("@actions/core"),
|
||||
"error",
|
||||
).mockImplementation(() => {});
|
||||
|
||||
tempDir = await mkdtemp(join(tmpdir(), "claude-sdk-"));
|
||||
process.env.RUNNER_TEMP = tempDir;
|
||||
|
||||
const promptPath = join(tempDir, "prompt.txt");
|
||||
await writeFile(promptPath, "test prompt");
|
||||
|
||||
const initMessage = {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "session-123",
|
||||
model: "claude-opus-4-7",
|
||||
};
|
||||
|
||||
const successResultMessage = {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
is_error: false,
|
||||
duration_ms: 960000,
|
||||
num_turns: 73,
|
||||
total_cost_usd: 0,
|
||||
permission_denials: [],
|
||||
};
|
||||
|
||||
mock.module("@anthropic-ai/claude-agent-sdk", () => ({
|
||||
query: async function* () {
|
||||
yield initMessage;
|
||||
yield successResultMessage;
|
||||
},
|
||||
}));
|
||||
|
||||
try {
|
||||
const { runClaudeWithSdk } = await import("../src/run-claude-sdk");
|
||||
|
||||
await expect(
|
||||
runClaudeWithSdk(promptPath, {
|
||||
sdkOptions: { maxTurns: 60 },
|
||||
showFullOutput: false,
|
||||
hasJsonSchema: false,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"Claude reported a successful result after 73 turns, exceeding the configured maximum of 60",
|
||||
);
|
||||
|
||||
const executionFile = join(tempDir, "claude-execution-output.json");
|
||||
await expect(readFile(executionFile, "utf-8")).resolves.toBe(
|
||||
JSON.stringify([initMessage, successResultMessage], null, 2),
|
||||
);
|
||||
expect(coreErrorSpy).toHaveBeenCalledWith(
|
||||
"Claude reported a successful result after 73 turns, exceeding the configured maximum of 60",
|
||||
);
|
||||
} finally {
|
||||
consoleErrorSpy.mockRestore();
|
||||
consoleLogSpy.mockRestore();
|
||||
coreErrorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.10.1",
|
||||
"@actions/github": "^6.0.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.223",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.276",
|
||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||
"@octokit/graphql": "^8.2.2",
|
||||
"@octokit/rest": "^21.1.1",
|
||||
"@octokit/webhooks-types": "^7.6.1",
|
||||
"node-fetch": "^3.3.2",
|
||||
"shell-quote": "^1.8.3",
|
||||
"shell-quote": "^1.8.4",
|
||||
"zod": "^3.24.4",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37,23 +37,23 @@
|
||||
|
||||
"@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.3.223", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.223", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.223", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.223", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.223", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.223", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.223", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.223", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.223" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-r2BpOfxaEjPj0xpRQBukrBWG7n2QERVk10hstc+AXmm4JBii1OqH50sfewU8a8E7uhoJazA3WnZoZoaTFnV/2A=="],
|
||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.276", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.276", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.276", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.276", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.276", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.276", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.276", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.276", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.276" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-Dic43v4uuGLhibPAArWy3RSfq3zHdn+4Oh2AI2/18a5tlbNKkvChEpazyp8c+grUJvzxkzt0jETCa1wrADbkyw=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.223", "", { "os": "darwin", "cpu": "arm64" }, "sha512-y9PcAkK7JHfzBC1yyLIhJwlF/x7XYLcFReiKkxm53mtBp+ASgpxNoBDvGqx7Q+IVB5xwlhnprcaXc2PxsLUKuA=="],
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.276", "", { "os": "darwin", "cpu": "arm64" }, "sha512-WdpZ7UXoIDGYeb97OWRFADqkMDLv/bB3YrRxvomqdxfRZC2RLAPLUxsBat9zVL9B5dsiJmZN8h1D0NlMe92Uzw=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.223", "", { "os": "darwin", "cpu": "x64" }, "sha512-JAkGQCat4FroNdG9XQiKQ+M+R/mggt3fFVQR5KcsbOrGFNynCdnVhi9CPxU8AXDhiIbk+Y3254qHhKTZbcZtGw=="],
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.276", "", { "os": "darwin", "cpu": "x64" }, "sha512-y5J+K1q7llwYkf85OhjG71R5AqsulHzC+HpgUOrg6klO+jBKsesGGS7tsEGzsM4YJtDntr6Rylw/sH3ZmXvjhg=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.223", "", { "os": "linux", "cpu": "arm64" }, "sha512-mFPm66MIiFtb/XiHR39/ifzv0nlho1KNCjH+1cP6lJvVXm57kugxsKDndGXWEBI0Wh7DxKhTnjRxEKVhwwdYWw=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.276", "", { "os": "linux", "cpu": "arm64" }, "sha512-IaBni1kRiUKEsSRTHocVW8jqzl4KyC6ms6fr6KAJPPcjJTu2Ptpb1ohj/OfDixTc/AErrNVL0ye8ICpZ+88k7w=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.223", "", { "os": "linux", "cpu": "arm64" }, "sha512-HKpj+0quFtaH/fXQm56HipslOyhyTdB2vM4nvxmG3oube2KtB9FNJqqnTwSTeGbJc5dm4PjoAyN+oQV8Rh4RGw=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.276", "", { "os": "linux", "cpu": "arm64" }, "sha512-Pb7PVWLktFCgD1cUtF61lx9riT2bh3ZSE6rq94EjaesyCnpzddlQ59/LLwLVUwGunBq9N2WayXaElOsk8+pK/g=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.223", "", { "os": "linux", "cpu": "x64" }, "sha512-8x+BvnuNr8iTMqwBAf0KvklF/GRLXYiXMb+umo+N2bxOlE7wFYpFqVACeW44KsfRgdnbVnF9sJ00w1s01W/9eA=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.276", "", { "os": "linux", "cpu": "x64" }, "sha512-MR/HQn8xi3RGujnzgJhyVolKR7JO+BiPhBBJ0GqbIlM4huso1aIDFhYhetdGIPb4shr7R1OZzSj1QFJoJQQ8/A=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.223", "", { "os": "linux", "cpu": "x64" }, "sha512-ukw6GAneAsCc/Lp24cA4yk2/vPgDjooZGB/TTvLsyfy8yto3b/WpfaktubQE/tNigF+mWo6+VytUOeVil8HwnQ=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.276", "", { "os": "linux", "cpu": "x64" }, "sha512-OaZAlVBhAUFkivWgb5ZsJYCT08qTo9LyomaN/2jNwsvVkpurr8kXEGncsTk8sM5JyIzSSiWuR+Sfxphq071XPA=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.223", "", { "os": "win32", "cpu": "arm64" }, "sha512-Am8q5al0BBIbxOlfLSjaXQqCOjOg/4XvCiSt8mxvocGy/6W60fV/RFrEs86RlN6DATUGxTSNTqye6cn8MTlgOA=="],
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.276", "", { "os": "win32", "cpu": "arm64" }, "sha512-dk7lxCdvRwXVf5LAh9nQ2/MLLedfT3K1HIQsv5KBgexUE1F7JYYd5/R3eYs/qT9UQOeKrMmKq7GG9RxDDXCgjA=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.223", "", { "os": "win32", "cpu": "x64" }, "sha512-ZG53F8YtUTr97OGdnIbSqHuRHL1eja928M1xwbuAiM1cJ5I8VpFODVjHpM0ioKiurr9Tmjzns/nHcafc18VovA=="],
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.276", "", { "os": "win32", "cpu": "x64" }, "sha512-CKPz5K94qpVHdP0hmSugntpiTshHrPFs4RtQYcFvP4PVltZv/wtth090Rff21XJna3Lxt4Lb5ce3tMqTnDRu9Q=="],
|
||||
|
||||
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.93.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-q9vaSZQVFx6B/gPxetGYfLXSJD5v0sOmh0OpZDq7yCrTSA+Rscvrtyol7JJTW40wEpQB4U1B4JXzxQitbQ3CAA=="],
|
||||
|
||||
@@ -275,7 +275,7 @@
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
|
||||
"shell-quote": ["shell-quote@1.8.4", "", {}, "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ=="],
|
||||
|
||||
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ You can authenticate with Claude using any of these four methods:
|
||||
3. Google Vertex AI with OIDC authentication
|
||||
4. Microsoft Foundry with OIDC authentication
|
||||
|
||||
For detailed setup instructions for AWS Bedrock and Google Vertex AI, see the [official documentation](https://code.claude.com/docs/en/github-actions#for-aws-bedrock:).
|
||||
For detailed setup instructions for AWS Bedrock and Google Vertex AI, see the [official documentation](https://code.claude.com/docs/en/github-actions#using-with-amazon-bedrock-and-google-cloud).
|
||||
|
||||
**Note**:
|
||||
|
||||
|
||||
@@ -275,6 +275,29 @@ For provider-specific models:
|
||||
# ... other inputs
|
||||
```
|
||||
|
||||
### 1M context models through an API gateway
|
||||
|
||||
When `ANTHROPIC_BASE_URL` points to an Anthropic-compatible API gateway,
|
||||
Claude Code may not be able to verify that the gateway supports a model's native
|
||||
1M context window and can budget the session at 200K instead. Append the
|
||||
`[1m]` selector to explicitly use the 1M context window for supported models,
|
||||
including Claude Opus 5 and Claude Sonnet 5:
|
||||
|
||||
```yaml
|
||||
- uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_args: |
|
||||
--model "claude-opus-5[1m]"
|
||||
# ... other inputs
|
||||
```
|
||||
|
||||
Use the same selector when setting a model through `ANTHROPIC_MODEL` or another
|
||||
Claude Code model environment variable. The selector is resolved by Claude Code
|
||||
before requests are sent to the provider. The action's sanitized result output
|
||||
includes each model's resolved
|
||||
`contextWindow` and `maxOutputTokens` under `modelUsage`, so these limits are
|
||||
visible without enabling `show_full_output`.
|
||||
|
||||
## Claude Code Settings
|
||||
|
||||
You can provide Claude Code settings to customize behavior such as model selection, environment variables, permissions, and hooks. Settings can be provided either as a JSON string or a path to a settings file.
|
||||
|
||||
+2
-2
@@ -12,13 +12,13 @@
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.10.1",
|
||||
"@actions/github": "^6.0.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.223",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.276",
|
||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||
"@octokit/graphql": "^8.2.2",
|
||||
"@octokit/rest": "^21.1.1",
|
||||
"@octokit/webhooks-types": "^7.6.1",
|
||||
"node-fetch": "^3.3.2",
|
||||
"shell-quote": "^1.8.3",
|
||||
"shell-quote": "^1.8.4",
|
||||
"zod": "^3.24.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -824,7 +824,7 @@ ${
|
||||
? `- Use mcp__github_file_ops__commit_files for making commits (works for both new and existing files, single or multiple). Use mcp__github_file_ops__delete_files for deleting files (supports deleting single or multiple files atomically), or mcp__github__delete_file for deleting a single file. Edit files locally, and the tool will read the content from the same path on disk.
|
||||
Tool usage examples:
|
||||
- mcp__github_file_ops__commit_files: {"files": ["path/to/file1.js", "path/to/file2.py"], "message": "feat: add new feature"}
|
||||
- mcp__github_file_ops__delete_files: {"files": ["path/to/old.js"], "message": "chore: remove deprecated file"}`
|
||||
- mcp__github_file_ops__delete_files: {"paths": ["path/to/old.js"], "message": "chore: remove deprecated file"}`
|
||||
: `- Use git commands via the Bash tool for version control (remember that you have access to these git commands):
|
||||
- Stage files: Bash(git add <files>)
|
||||
- Commit changes: Bash(git commit -m "<message>")
|
||||
@@ -856,7 +856,7 @@ What You CANNOT Do:
|
||||
- Submit formal GitHub PR reviews
|
||||
- Approve pull requests (for security reasons)
|
||||
- Post multiple comments (you only update your initial comment)
|
||||
- Execute commands outside the repository context${useCommitSigning ? "\n- Run arbitrary Bash commands (unless explicitly allowed via allowed_tools configuration)" : ""}
|
||||
- Execute commands outside the repository context${useCommitSigning ? "\n- Run arbitrary Bash commands (unless explicitly allowed via claude_args with --allowedTools)" : ""}
|
||||
- Perform branch operations (cannot merge branches, rebase, or perform other git operations beyond creating and pushing commits)
|
||||
- Modify files in the .github/workflows directory (GitHub App permissions do not allow workflow modifications)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { readFileSync, existsSync } from "fs";
|
||||
import { exit } from "process";
|
||||
import { redactSecrets } from "../github/utils/sanitizer";
|
||||
|
||||
export type ToolUse = {
|
||||
type: string;
|
||||
@@ -163,8 +164,15 @@ export function formatResultContent(content: any): string {
|
||||
typeof parsedContent[0] === "object" &&
|
||||
parsedContent[0]?.type === "text"
|
||||
) {
|
||||
// Extract the text field from the first item
|
||||
contentStr = parsedContent[0]?.text || "";
|
||||
// Keep every text block, not just the first: a tool result may split its
|
||||
// output across several, and dropping the rest silently loses findings,
|
||||
// file paths and follow-up instructions from the rendered summary. Blocks
|
||||
// of other types (for example images) are skipped. Tool output is
|
||||
// arbitrary, so `text` is not guaranteed to be a string.
|
||||
contentStr = parsedContent
|
||||
.filter((block: any) => block?.type === "text")
|
||||
.map((block: any) => String(block?.text || ""))
|
||||
.join("\n");
|
||||
} else {
|
||||
contentStr = String(content).trim();
|
||||
}
|
||||
@@ -172,6 +180,10 @@ export function formatResultContent(content: any): string {
|
||||
contentStr = String(content).trim();
|
||||
}
|
||||
|
||||
// Redact before truncating so a credential cannot be split at the cut and
|
||||
// slip past the final redaction pass.
|
||||
contentStr = redactSecrets(contentStr);
|
||||
|
||||
// Truncate very long results
|
||||
if (contentStr.length > 3000) {
|
||||
contentStr = contentStr.substring(0, 2997) + "...";
|
||||
@@ -420,7 +432,9 @@ export function formatTurnsFromData(data: Turn[]): string {
|
||||
// Generate markdown
|
||||
const markdown = formatGroupedContent(groupedContent);
|
||||
|
||||
return markdown;
|
||||
// Runtime output may contain credentials that are not registered as
|
||||
// workflow secrets, so redact known formats before this gets published.
|
||||
return redactSecrets(markdown);
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
@@ -447,14 +461,8 @@ function main(): void {
|
||||
const fileContent = readFileSync(jsonFile, "utf-8");
|
||||
const data: Turn[] = JSON.parse(fileContent);
|
||||
|
||||
// Group turns naturally
|
||||
const groupedContent = groupTurnsNaturally(data);
|
||||
|
||||
// Generate markdown
|
||||
const markdown = formatGroupedContent(groupedContent);
|
||||
|
||||
// Print to stdout (so it can be captured by shell)
|
||||
console.log(markdown);
|
||||
console.log(formatTurnsFromData(data));
|
||||
} catch (error) {
|
||||
console.error(`Error processing file: ${error}`);
|
||||
exit(1);
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
import { readFileSync } from "fs";
|
||||
import { createOctokit } from "../github/api/client";
|
||||
import { redactSecrets } from "../github/utils/sanitizer";
|
||||
|
||||
const BUFFER_PATH = "/tmp/inline-comments-buffer.jsonl";
|
||||
|
||||
@@ -120,7 +121,7 @@ async function postComment(
|
||||
owner,
|
||||
repo,
|
||||
pull_number,
|
||||
body: c.body,
|
||||
body: redactSecrets(c.body),
|
||||
path: c.path,
|
||||
side: c.side || "RIGHT",
|
||||
commit_id: c.commit_id || headSha,
|
||||
|
||||
@@ -34,6 +34,7 @@ import { collectActionInputsPresence } from "./collect-inputs";
|
||||
import { updateCommentLink } from "./update-comment-link";
|
||||
import { formatTurnsFromData } from "./format-turns";
|
||||
import type { Turn } from "./format-turns";
|
||||
import { redactSecrets } from "../github/utils/sanitizer";
|
||||
// Base-action imports (used directly instead of subprocess)
|
||||
import { setupWorkloadIdentity } from "../../base-action/src/workload-identity";
|
||||
import type { WorkloadIdentityHandle } from "../../base-action/src/workload-identity";
|
||||
@@ -76,7 +77,7 @@ async function installClaudeCode(): Promise<string> {
|
||||
return customExecutable;
|
||||
}
|
||||
|
||||
const claudeCodeVersion = "2.1.223";
|
||||
const claudeCodeVersion = "2.1.276";
|
||||
console.log(`Installing Claude Code v${claudeCodeVersion}...`);
|
||||
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
@@ -137,7 +138,7 @@ async function writeStepSummary(executionFile: string): Promise<void> {
|
||||
fallback +=
|
||||
"Failed to format output (please report). Here's the raw JSON:\n\n";
|
||||
fallback += "```json\n";
|
||||
fallback += readFileSync(executionFile, "utf-8");
|
||||
fallback += redactSecrets(readFileSync(executionFile, "utf-8"));
|
||||
fallback += "\n```\n";
|
||||
await appendFile(summaryFile, fallback);
|
||||
} catch {
|
||||
@@ -158,6 +159,9 @@ async function run() {
|
||||
let context: GitHubContext | undefined;
|
||||
let octokit: Octokits | undefined;
|
||||
let workloadIdentity: WorkloadIdentityHandle | undefined;
|
||||
// Paths reverted to the PR base branch, which cleanup must not commit back
|
||||
// onto the PR author's branch. Empty unless restoreConfigFromBase ran.
|
||||
let restoredConfigPaths: string[] = [];
|
||||
// Track whether we've completed prepare phase, so we can attribute errors correctly
|
||||
let prepareCompleted = false;
|
||||
try {
|
||||
@@ -267,7 +271,7 @@ async function run() {
|
||||
validateBranchName(restoreBase);
|
||||
}
|
||||
if (restoreBase) {
|
||||
restoreConfigFromBase(restoreBase);
|
||||
restoredConfigPaths = restoreConfigFromBase(restoreBase);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,7 +321,7 @@ async function run() {
|
||||
prepareSuccess = false;
|
||||
prepareError = errorMessage;
|
||||
}
|
||||
core.setFailed(`Action failed with error: ${errorMessage}`);
|
||||
core.setFailed(`Action failed with error: ${redactSecrets(errorMessage)}`);
|
||||
} finally {
|
||||
// Phase 4: Cleanup (always runs)
|
||||
|
||||
@@ -347,6 +351,7 @@ async function run() {
|
||||
prepareSuccess,
|
||||
prepareError,
|
||||
useCommitSigning: context.inputs.useCommitSigning,
|
||||
restoredConfigPaths,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error updating comment with job link:", error);
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { ParsedGitHubContext } from "../github/context";
|
||||
import { GITHUB_SERVER_URL } from "../github/api/config";
|
||||
import { checkAndCommitOrDeleteBranch } from "../github/operations/branch-cleanup";
|
||||
import { updateClaudeComment } from "../github/operations/comments/update-claude-comment";
|
||||
import { encodeBranchNameForUrl } from "../github/operations/comments/common";
|
||||
|
||||
export type UpdateCommentLinkParams = {
|
||||
commentId: number;
|
||||
@@ -30,6 +31,12 @@ export type UpdateCommentLinkParams = {
|
||||
prepareSuccess: boolean;
|
||||
prepareError?: string;
|
||||
useCommitSigning: boolean;
|
||||
/**
|
||||
* Paths restored from the PR base branch by restoreConfigFromBase. The
|
||||
* auto-commit in checkAndCommitOrDeleteBranch must leave these alone, or it
|
||||
* commits the revert onto the PR author's branch.
|
||||
*/
|
||||
restoredConfigPaths?: string[];
|
||||
};
|
||||
|
||||
export async function updateCommentLink(
|
||||
@@ -43,6 +50,7 @@ export async function updateCommentLink(
|
||||
context,
|
||||
octokit,
|
||||
useCommitSigning,
|
||||
restoredConfigPaths = [],
|
||||
} = params;
|
||||
|
||||
const { owner, repo } = context.repository;
|
||||
@@ -116,6 +124,7 @@ export async function updateCommentLink(
|
||||
claudeBranch,
|
||||
baseBranch,
|
||||
useCommitSigning,
|
||||
restoredConfigPaths,
|
||||
);
|
||||
|
||||
// Check if we need to add PR URL when we have a new branch
|
||||
@@ -151,7 +160,7 @@ export async function updateCommentLink(
|
||||
const prBody = encodeURIComponent(
|
||||
`This PR addresses ${entityType.toLowerCase()} #${context.entityNumber}\n\nGenerated with [Claude Code](https://claude.ai/code)`,
|
||||
);
|
||||
const prUrl = `${serverUrl}/${owner}/${repo}/compare/${baseBranch}...${claudeBranch}?quick_pull=1&title=${prTitle}&body=${prBody}`;
|
||||
const prUrl = `${serverUrl}/${owner}/${repo}/compare/${encodeBranchNameForUrl(baseBranch)}...${encodeBranchNameForUrl(claudeBranch)}?quick_pull=1&title=${prTitle}&body=${prBody}`;
|
||||
prLink = `\n[Create a PR](${prUrl})`;
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import { graphql } from "@octokit/graphql";
|
||||
import { GITHUB_API_URL } from "./config";
|
||||
import { GITHUB_API_URL, GITHUB_GRAPHQL_URL } from "./config";
|
||||
|
||||
export type Octokits = {
|
||||
rest: Octokit;
|
||||
@@ -14,7 +14,7 @@ export function createOctokit(token: string): Octokits {
|
||||
baseUrl: GITHUB_API_URL,
|
||||
}),
|
||||
graphql: graphql.defaults({
|
||||
baseUrl: GITHUB_API_URL,
|
||||
baseUrl: GITHUB_GRAPHQL_URL,
|
||||
headers: {
|
||||
authorization: `token ${token}`,
|
||||
},
|
||||
|
||||
@@ -2,3 +2,16 @@ export const GITHUB_API_URL =
|
||||
process.env.GITHUB_API_URL || "https://api.github.com";
|
||||
export const GITHUB_SERVER_URL =
|
||||
process.env.GITHUB_SERVER_URL || "https://github.com";
|
||||
|
||||
// GraphQL base URL for @octokit/graphql. GitHub Actions exposes the full GraphQL
|
||||
// endpoint in GITHUB_GRAPHQL_URL (e.g. "https://HOST/api/graphql"), while
|
||||
// @octokit/graphql appends "/graphql" to whatever baseUrl it is given, so a
|
||||
// single trailing "/graphql" is stripped here to avoid "/graphql/graphql".
|
||||
// When GITHUB_GRAPHQL_URL is unset we fall back to GITHUB_API_URL, preserving the
|
||||
// existing behavior where @octokit/graphql rewrites a REST ".../api/v3" base to
|
||||
// ".../api/graphql". The trailing-slash trim keeps that rewrite working.
|
||||
export const GITHUB_GRAPHQL_URL = (
|
||||
process.env.GITHUB_GRAPHQL_URL || GITHUB_API_URL
|
||||
)
|
||||
.replace(/\/+$/, "")
|
||||
.replace(/\/graphql$/, "");
|
||||
|
||||
@@ -7,6 +7,7 @@ export const PR_QUERY = `
|
||||
title
|
||||
body
|
||||
author {
|
||||
__typename
|
||||
login
|
||||
}
|
||||
baseRefName
|
||||
@@ -57,6 +58,7 @@ export const PR_QUERY = `
|
||||
databaseId
|
||||
body
|
||||
author {
|
||||
__typename
|
||||
login
|
||||
}
|
||||
createdAt
|
||||
@@ -70,6 +72,7 @@ export const PR_QUERY = `
|
||||
id
|
||||
databaseId
|
||||
author {
|
||||
__typename
|
||||
login
|
||||
}
|
||||
body
|
||||
@@ -84,7 +87,9 @@ export const PR_QUERY = `
|
||||
body
|
||||
path
|
||||
line
|
||||
diffHunk
|
||||
author {
|
||||
__typename
|
||||
login
|
||||
}
|
||||
createdAt
|
||||
@@ -107,6 +112,7 @@ export const ISSUE_QUERY = `
|
||||
title
|
||||
body
|
||||
author {
|
||||
__typename
|
||||
login
|
||||
}
|
||||
createdAt
|
||||
@@ -124,6 +130,7 @@ export const ISSUE_QUERY = `
|
||||
databaseId
|
||||
body
|
||||
author {
|
||||
__typename
|
||||
login
|
||||
}
|
||||
createdAt
|
||||
|
||||
@@ -23,6 +23,7 @@ import type { CommentWithImages } from "../utils/image-downloader";
|
||||
import { downloadCommentImages } from "../utils/image-downloader";
|
||||
import {
|
||||
parseActorFilter,
|
||||
resolveActorName,
|
||||
shouldIncludeCommentByActor,
|
||||
} from "../utils/actor-filter";
|
||||
|
||||
@@ -339,7 +340,7 @@ export function isBodySafeToUse(
|
||||
* @returns Filtered array of comments
|
||||
*/
|
||||
export function filterCommentsByActor<
|
||||
T extends { author: { login: string } | null },
|
||||
T extends { author: { login: string; __typename?: string } | null },
|
||||
>(comments: T[], includeActors: string = "", excludeActors: string = ""): T[] {
|
||||
const includeParsed = parseActorFilter(includeActors);
|
||||
const excludeParsed = parseActorFilter(excludeActors);
|
||||
@@ -351,9 +352,10 @@ export function filterCommentsByActor<
|
||||
|
||||
return comments.filter((comment) =>
|
||||
shouldIncludeCommentByActor(
|
||||
// author is null for comments from deleted ("ghost") accounts; treat them
|
||||
// as the "ghost" login so filtering never dereferences null and crashes.
|
||||
comment.author?.login ?? "ghost",
|
||||
// Normalizes App actors to their "[bot]"-suffixed name, which is the form
|
||||
// filter patterns are written in. Also maps deleted ("ghost") accounts,
|
||||
// whose author is null, to "ghost" so filtering never dereferences null.
|
||||
resolveActorName(comment.author),
|
||||
includeParsed,
|
||||
excludeParsed,
|
||||
),
|
||||
@@ -424,7 +426,12 @@ export async function fetchGitHubData({
|
||||
if (prResult.repository.pullRequest) {
|
||||
const pullRequest = prResult.repository.pullRequest;
|
||||
contextData = pullRequest;
|
||||
changedFiles = pullRequest.files.nodes || [];
|
||||
if (pullRequest.files === null) {
|
||||
console.warn(
|
||||
`GitHub did not return the file list for PR #${prNumber} (diff likely too large); proceeding without file-level context`,
|
||||
);
|
||||
}
|
||||
changedFiles = pullRequest.files?.nodes ?? [];
|
||||
comments = filterCommentsByActor(
|
||||
filterCommentsToTriggerTime(
|
||||
pullRequest.comments?.nodes || [],
|
||||
|
||||
@@ -28,7 +28,7 @@ PR Labels: ${formatLabels(prData.labels.nodes)}
|
||||
PR Additions: ${prData.additions}
|
||||
PR Deletions: ${prData.deletions}
|
||||
Total Commits: ${prData.commits.totalCount}
|
||||
Changed Files: ${prData.files.nodes.length} files`;
|
||||
Changed Files: ${prData.files ? `${prData.files.nodes.length} files` : "unknown (file list unavailable)"}`;
|
||||
} else {
|
||||
const issueData = contextData as GitHubIssue;
|
||||
const sanitizedTitle = sanitizeContent(issueData.title);
|
||||
@@ -118,7 +118,16 @@ export function formatReviewComments(
|
||||
|
||||
body = sanitizeContent(body);
|
||||
|
||||
return ` [Comment on ${comment.path}:${comment.line || "?"}]: ${body}`;
|
||||
let formatted = ` [Comment on ${comment.path}:${comment.line || "?"}]: ${body}`;
|
||||
|
||||
// The diff hunk is the code the comment was left on. Without it the
|
||||
// comment arrives without the context it was written against.
|
||||
if (comment.diffHunk) {
|
||||
const diffHunk = sanitizeContent(comment.diffHunk);
|
||||
formatted += `\n Diff context:\n\`\`\`diff\n${diffHunk}\n\`\`\``;
|
||||
}
|
||||
|
||||
return formatted;
|
||||
})
|
||||
.join("\n");
|
||||
if (comments) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Octokits } from "../api/client";
|
||||
import { GITHUB_SERVER_URL } from "../api/config";
|
||||
import { encodeBranchNameForUrl } from "./comments/common";
|
||||
import { $ } from "bun";
|
||||
|
||||
export async function checkAndCommitOrDeleteBranch(
|
||||
@@ -9,10 +10,32 @@ export async function checkAndCommitOrDeleteBranch(
|
||||
claudeBranch: string | undefined,
|
||||
baseBranch: string,
|
||||
useCommitSigning: boolean,
|
||||
restoredConfigPaths: string[] = [],
|
||||
): Promise<{ shouldDeleteBranch: boolean; branchLink: string }> {
|
||||
let branchLink = "";
|
||||
let shouldDeleteBranch = false;
|
||||
|
||||
// On pull requests, restoreConfigFromBase replaces .claude/, CLAUDE.md and
|
||||
// friends with the base branch's versions and leaves them unstaged so the
|
||||
// revert does not reach a commit. Auto-committing with a bare `git add -A`
|
||||
// would stage them anyway and push a silent revert of the PR author's own
|
||||
// config onto their branch.
|
||||
//
|
||||
// The exclusion is driven by what was actually restored rather than applied
|
||||
// unconditionally: this path also runs for issues, where no restore happens
|
||||
// and Claude may legitimately have been asked to edit CLAUDE.md or
|
||||
// .claude/settings.json. Excluding those there would silently drop the work.
|
||||
const pathspecArgs =
|
||||
restoredConfigPaths.length > 0
|
||||
? ["--", ".", ...restoredConfigPaths.map((p) => `:(exclude)${p}`)]
|
||||
: [];
|
||||
|
||||
if (pathspecArgs.length > 0) {
|
||||
console.log(
|
||||
`Excluding base-restored config from auto-commit: ${restoredConfigPaths.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (claudeBranch) {
|
||||
// First check if the branch exists remotely
|
||||
let branchExistsRemotely = false;
|
||||
@@ -57,15 +80,19 @@ export async function checkAndCommitOrDeleteBranch(
|
||||
|
||||
// Check for uncommitted changes using git status
|
||||
try {
|
||||
const gitStatus = await $`git status --porcelain`.quiet();
|
||||
// Scoped the same way as the staging below: if the restored config
|
||||
// is the only dirty entry there is no real work, and the branch
|
||||
// should be treated as empty rather than receiving a pure revert.
|
||||
const gitStatus =
|
||||
await $`git status --porcelain ${pathspecArgs}`.quiet();
|
||||
const hasUncommittedChanges =
|
||||
gitStatus.stdout.toString().trim().length > 0;
|
||||
|
||||
if (hasUncommittedChanges) {
|
||||
console.log("Found uncommitted changes, committing them...");
|
||||
|
||||
// Add all changes
|
||||
await $`git add -A`;
|
||||
// Add all changes, minus anything restored from the base branch
|
||||
await $`git add -A ${pathspecArgs}`;
|
||||
|
||||
// Commit with a descriptive message
|
||||
const runId = process.env.GITHUB_RUN_ID || "unknown";
|
||||
@@ -80,7 +107,7 @@ export async function checkAndCommitOrDeleteBranch(
|
||||
);
|
||||
|
||||
// Set branch link since we now have commits
|
||||
const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${claudeBranch}`;
|
||||
const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${encodeBranchNameForUrl(claudeBranch)}`;
|
||||
branchLink = `\n[View branch](${branchUrl})`;
|
||||
} else {
|
||||
console.log(
|
||||
@@ -91,7 +118,7 @@ export async function checkAndCommitOrDeleteBranch(
|
||||
} catch (gitError) {
|
||||
console.error("Error checking/committing changes:", gitError);
|
||||
// If we can't check git status, assume the branch might have changes
|
||||
const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${claudeBranch}`;
|
||||
const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${encodeBranchNameForUrl(claudeBranch)}`;
|
||||
branchLink = `\n[View branch](${branchUrl})`;
|
||||
}
|
||||
} else {
|
||||
@@ -102,13 +129,13 @@ export async function checkAndCommitOrDeleteBranch(
|
||||
}
|
||||
} else {
|
||||
// Only add branch link if there are commits
|
||||
const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${claudeBranch}`;
|
||||
const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${encodeBranchNameForUrl(claudeBranch)}`;
|
||||
branchLink = `\n[View branch](${branchUrl})`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error comparing commits on Claude branch:", error);
|
||||
// If we can't compare but the branch exists remotely, include the branch link
|
||||
const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${claudeBranch}`;
|
||||
const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${encodeBranchNameForUrl(claudeBranch)}`;
|
||||
branchLink = `\n[View branch](${branchUrl})`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { GitHubPullRequest } from "../types";
|
||||
import type { Octokits } from "../api/client";
|
||||
import type { FetchDataResult } from "../data/fetcher";
|
||||
import { generateBranchName } from "../../utils/branch-template";
|
||||
import { fetchDepthArgs } from "./fetch-depth";
|
||||
|
||||
/**
|
||||
* Extracts the first label from GitHub data, or returns undefined if no labels exist
|
||||
@@ -28,7 +29,7 @@ function extractFirstLabel(githubData: FetchDataResult): string | undefined {
|
||||
*
|
||||
* Valid branch names:
|
||||
* - Start with alphanumeric character, underscore, or @ (not dash, to prevent option injection)
|
||||
* - Contain only alphanumeric, forward slash, hyphen, underscore, period, hash (#), plus (+), comma (,), or at sign (@)
|
||||
* - Contain only alphanumeric, forward slash, hyphen, underscore, period, hash (#), plus (+), comma (,), at sign (@), or parentheses
|
||||
* - Do not start or end with a period
|
||||
* - Do not end with a slash
|
||||
* - Do not contain '..' (path traversal)
|
||||
@@ -59,7 +60,7 @@ export function validateBranchName(branchName: string): void {
|
||||
);
|
||||
}
|
||||
|
||||
// Strict whitelist pattern: alphanumeric or @ start, then alphanumeric/slash/hyphen/underscore/period/hash/plus/comma/at-sign.
|
||||
// Strict whitelist pattern: alphanumeric or @ start, then alphanumeric/slash/hyphen/underscore/period/hash/plus/comma/at-sign/parentheses.
|
||||
// # is valid per git-check-ref-format and commonly used in branch names like "fix/#123-description".
|
||||
// + is valid per git-check-ref-format and generated by Claude Code's EnterWorktree tool when
|
||||
// converting worktree names containing "/" (e.g. "feat/foo" becomes "worktree-feat+foo").
|
||||
@@ -71,12 +72,14 @@ export function validateBranchName(branchName: string): void {
|
||||
// _ is valid per git-check-ref-format anywhere in a ref name, including the first character;
|
||||
// leading underscores are a common convention for release/internal branches (e.g.
|
||||
// "_release/v1.2.3"), which previously failed validation as a PR's base branch.
|
||||
// Parentheses are valid per git-check-ref-format and commonly appear in branch names that
|
||||
// use Conventional Commit-style scopes (e.g. "feat(parser)-handle-empty-input").
|
||||
// All git calls use execFileSync (not shell interpolation), so none of these characters carry injection risk.
|
||||
const validPattern = /^[a-zA-Z0-9@_][a-zA-Z0-9/_.#+,@-]*$/;
|
||||
const validPattern = /^[a-zA-Z0-9@_][a-zA-Z0-9/_.#+,@()-]*$/;
|
||||
|
||||
if (!validPattern.test(branchName)) {
|
||||
throw new Error(
|
||||
`Invalid branch name: "${branchName}". Branch names must start with an alphanumeric character, underscore, or '@' and contain only alphanumeric characters, forward slashes, hyphens, underscores, periods, hashes (#), plus signs (+), commas (,), or at signs (@).`,
|
||||
`Invalid branch name: "${branchName}". Branch names must start with an alphanumeric character, underscore, or '@' and contain only alphanumeric characters, forward slashes, hyphens, underscores, periods, hashes (#), plus signs (+), commas (,), at signs (@), or parentheses.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -175,12 +178,19 @@ export async function setupBranch(
|
||||
|
||||
const branchName = prData.headRefName;
|
||||
|
||||
// Determine optimal fetch depth based on PR commit count, with a minimum of 20
|
||||
// Determine optimal fetch depth based on PR commit count, with a minimum
|
||||
// of 20. Only applied to a checkout that is already shallow — see
|
||||
// fetchDepthArgs.
|
||||
const commitCount = prData.commits.totalCount;
|
||||
const fetchDepth = Math.max(commitCount, 20);
|
||||
const depthArgs = fetchDepthArgs(fetchDepth);
|
||||
|
||||
console.log(
|
||||
`PR #${entityNumber}: ${commitCount} commits, using fetch depth ${fetchDepth}`,
|
||||
`PR #${entityNumber}: ${commitCount} commits, ${
|
||||
depthArgs.length > 0
|
||||
? `using fetch depth ${fetchDepth}`
|
||||
: "fetching without a depth limit (checkout has full history)"
|
||||
}`,
|
||||
);
|
||||
|
||||
// Validate branch names before use to prevent command injection
|
||||
@@ -195,13 +205,13 @@ export async function setupBranch(
|
||||
execGit([
|
||||
"fetch",
|
||||
"origin",
|
||||
`--depth=${fetchDepth}`,
|
||||
...depthArgs,
|
||||
`pull/${entityNumber}/head:${branchName}`,
|
||||
]);
|
||||
} else {
|
||||
// Execute git commands to checkout PR branch (dynamic depth based on PR size)
|
||||
// Using execFileSync instead of shell template literals for security
|
||||
execGit(["fetch", "origin", `--depth=${fetchDepth}`, branchName]);
|
||||
execGit(["fetch", "origin", ...depthArgs, branchName]);
|
||||
}
|
||||
execGit(["checkout", branchName, "--"]);
|
||||
|
||||
@@ -288,6 +298,11 @@ export async function setupBranch(
|
||||
// Branch doesn't exist (non-zero exit code), continue with generated name
|
||||
}
|
||||
|
||||
// Validate before either path uses the name. The signing path hands it to
|
||||
// the file ops server rather than to git, so without this an invalid
|
||||
// template only surfaces as a 422 on the first commit.
|
||||
validateBranchName(newBranch);
|
||||
|
||||
// For commit signing, defer branch creation to the file ops server
|
||||
if (context.inputs.useCommitSigning) {
|
||||
console.log(
|
||||
@@ -297,7 +312,7 @@ export async function setupBranch(
|
||||
// Ensure we're on the source branch
|
||||
console.log(`Fetching and checking out source branch: ${sourceBranch}`);
|
||||
validateBranchName(sourceBranch);
|
||||
execGit(["fetch", "origin", sourceBranch, "--depth=1"]);
|
||||
execGit(["fetch", "origin", sourceBranch, ...fetchDepthArgs(1)]);
|
||||
execGit(["checkout", sourceBranch, "--"]);
|
||||
|
||||
return {
|
||||
@@ -315,8 +330,7 @@ export async function setupBranch(
|
||||
// Fetch and checkout the source branch first to ensure we branch from the correct base
|
||||
console.log(`Fetching and checking out source branch: ${sourceBranch}`);
|
||||
validateBranchName(sourceBranch);
|
||||
validateBranchName(newBranch);
|
||||
execGit(["fetch", "origin", sourceBranch, "--depth=1"]);
|
||||
execGit(["fetch", "origin", sourceBranch, ...fetchDepthArgs(1)]);
|
||||
execGit(["checkout", sourceBranch, "--"]);
|
||||
|
||||
// Create and checkout the new branch from the source branch
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { GITHUB_SERVER_URL } from "../api/config";
|
||||
import { redactSecrets } from "../utils/sanitizer";
|
||||
import { encodeBranchNameForUrl } from "./comments/common";
|
||||
|
||||
export type ExecutionDetails = {
|
||||
total_cost_usd?: number;
|
||||
@@ -160,7 +162,7 @@ export function updateCommentBody(input: CommentUpdateInput): string {
|
||||
// Extract owner/repo from jobUrl
|
||||
const repoMatch = jobUrl.match(/github\.com\/([^\/]+)\/([^\/]+)\//);
|
||||
if (repoMatch) {
|
||||
branchUrl = `${GITHUB_SERVER_URL}/${repoMatch[1]}/${repoMatch[2]}/tree/${finalBranchName}`;
|
||||
branchUrl = `${GITHUB_SERVER_URL}/${repoMatch[1]}/${repoMatch[2]}/tree/${encodeBranchNameForUrl(finalBranchName)}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,9 +183,11 @@ export function updateCommentBody(input: CommentUpdateInput): string {
|
||||
// Build the new body with blank line between header and separator
|
||||
let newBody = `${header}${links}`;
|
||||
|
||||
// Add error details if available
|
||||
// Add error details if available. The message may embed runtime credentials
|
||||
// (e.g. a token in a git remote URL) that are not registered as workflow
|
||||
// secrets, so redact known formats before posting.
|
||||
if (actionFailed && errorDetails) {
|
||||
newBody += `\n\n\`\`\`\n${errorDetails}\n\`\`\``;
|
||||
newBody += `\n\n\`\`\`\n${redactSecrets(errorDetails)}\n\`\`\``;
|
||||
}
|
||||
|
||||
newBody += `\n\n---\n`;
|
||||
|
||||
@@ -12,12 +12,17 @@ export function createJobRunLink(
|
||||
return `[View job run](${jobRunUrl})`;
|
||||
}
|
||||
|
||||
/** Encode Git-ref path segments without turning `/` into `%2F`. */
|
||||
export function encodeBranchNameForUrl(branchName: string): string {
|
||||
return branchName.split("/").map(encodeURIComponent).join("/");
|
||||
}
|
||||
|
||||
export function createBranchLink(
|
||||
owner: string,
|
||||
repo: string,
|
||||
branchName: string,
|
||||
): string {
|
||||
const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${branchName}`;
|
||||
const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${encodeBranchNameForUrl(branchName)}`;
|
||||
return `\n[View branch](${branchUrl})`;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { execFileSync } from "child_process";
|
||||
|
||||
/**
|
||||
* Builds the `--depth` argument for a `git fetch`, unless the checkout still
|
||||
* has its full history.
|
||||
*
|
||||
* `--depth` does not only cap what gets downloaded. Against a complete checkout
|
||||
* (`actions/checkout` with `fetch-depth: 0`) it also truncates the history that
|
||||
* is already there and marks the repository shallow, which drops the merge base
|
||||
* with the base branch: `git log origin/<base>..HEAD` then quietly lists
|
||||
* commits that are already merged, and `git diff origin/<base>...HEAD` fails
|
||||
* with "no merge base". Those are the commands the prompt tells Claude to run
|
||||
* to scope its work to the PR.
|
||||
*
|
||||
* A shallow checkout (the `fetch-depth: 1` default) has no history left to
|
||||
* lose, so the limit still applies there and large repositories keep the fetch
|
||||
* savings it was added for.
|
||||
*/
|
||||
export function fetchDepthArgs(depth: number): string[] {
|
||||
return isShallowRepository() ? [`--depth=${depth}`] : [];
|
||||
}
|
||||
|
||||
function isShallowRepository(): boolean {
|
||||
try {
|
||||
const output = execFileSync(
|
||||
"git",
|
||||
["rev-parse", "--is-shallow-repository"],
|
||||
{
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
return output.trim() === "true";
|
||||
} catch {
|
||||
// No repository yet, or a git old enough not to know the flag. Treat the
|
||||
// checkout as complete: fetching more than necessary is recoverable,
|
||||
// truncating history is not.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -42,14 +42,68 @@ export async function configureGitAuth(
|
||||
await $`git config user.email "${botId}+${botName}@${noreplyDomain}"`;
|
||||
console.log(`✓ Set git user as ${botName}`);
|
||||
|
||||
await replaceCheckoutCredentials(githubToken, context);
|
||||
|
||||
console.log("Git authentication configured successfully");
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the credential that actions/checkout persisted in the working tree.
|
||||
*
|
||||
* actions/checkout stores its token as an `http.<server>/.extraheader` entry
|
||||
* in .git/config for the duration of the job. Claude and the tools it invokes
|
||||
* run inside this working tree, so remove that entry and back git with the
|
||||
* action's own token instead (a credential helper when non-write users are
|
||||
* allowed, otherwise the origin URL). This applies to every mode, including API
|
||||
* commit signing where no other git configuration is needed.
|
||||
*
|
||||
* actions/checkout < v6 stored the header directly in the repo-local config,
|
||||
* where `git config --unset-all` removes it. Since v6.0.0 (backported to
|
||||
* v5.0.1 and v4.3.1) the header is written to a separate file under
|
||||
* RUNNER_TEMP that the repo config pulls in via `include.path`; `--unset-all`
|
||||
* on the local config cannot touch an include-provided value, so the removal
|
||||
* was a silent no-op and the checkout credential (typically the workflow
|
||||
* GITHUB_TOKEN) stayed usable by git for the rest of the job. Clear the
|
||||
* header from the local config AND from every included file so it can no
|
||||
* longer authenticate while Claude runs.
|
||||
*/
|
||||
export async function replaceCheckoutCredentials(
|
||||
githubToken: string,
|
||||
context: GitHubContext,
|
||||
) {
|
||||
const serverUrl = new URL(GITHUB_SERVER_URL);
|
||||
|
||||
// Remove the authorization header that actions/checkout sets
|
||||
console.log("Removing existing git authentication headers...");
|
||||
const extraheaderKey = `http.${GITHUB_SERVER_URL}/.extraheader`;
|
||||
let removedHeader = false;
|
||||
try {
|
||||
await $`git config --unset-all http.${GITHUB_SERVER_URL}/.extraheader`;
|
||||
console.log("✓ Removed existing authentication headers");
|
||||
} catch (e) {
|
||||
console.log("No existing authentication headers to remove");
|
||||
await $`git config --unset-all ${extraheaderKey}`;
|
||||
removedHeader = true;
|
||||
} catch {
|
||||
// No extraheader in the local config (expected on the v6+ include layout).
|
||||
}
|
||||
try {
|
||||
const includePaths =
|
||||
await $`git config --local --get-all include.path`.text();
|
||||
for (const includePath of includePaths.split("\n")) {
|
||||
const path = includePath.trim();
|
||||
if (!path) continue;
|
||||
try {
|
||||
await $`git config --file ${path} --unset-all ${extraheaderKey}`;
|
||||
removedHeader = true;
|
||||
} catch {
|
||||
// This include does not define the header; leave it untouched.
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// No include.path entries in the local config.
|
||||
}
|
||||
console.log(
|
||||
removedHeader
|
||||
? "✓ Removed existing authentication headers"
|
||||
: "No existing authentication headers to remove",
|
||||
);
|
||||
|
||||
if (process.env.ALLOWED_NON_WRITE_USERS) {
|
||||
// When processing content from non-write users, use a credential helper
|
||||
@@ -79,8 +133,6 @@ export async function configureGitAuth(
|
||||
await $`git remote set-url origin ${remoteUrl}`;
|
||||
console.log("✓ Updated remote URL with authentication token");
|
||||
}
|
||||
|
||||
console.log("Git authentication configured successfully");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,11 +3,17 @@ import {
|
||||
appendFileSync,
|
||||
cpSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readlinkSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "fs";
|
||||
import { dirname } from "path";
|
||||
import { dirname, join, posix, relative, sep } from "path";
|
||||
import { fetchDepthArgs } from "./fetch-depth";
|
||||
|
||||
// Paths that are both PR-controllable and read from cwd at CLI startup.
|
||||
//
|
||||
@@ -17,7 +23,7 @@ import { dirname } from "path";
|
||||
// .gitconfig — git reads ~/.gitconfig and .git/config, never cwd/.gitconfig.
|
||||
// .bashrc etc. — shells source these from $HOME; checkout cannot reach $HOME.
|
||||
// .vscode/.idea— IDE config; nothing in the CLI's startup path reads them.
|
||||
const SENSITIVE_PATHS = [
|
||||
export const SENSITIVE_PATHS = [
|
||||
".claude",
|
||||
".mcp.json",
|
||||
".claude.json",
|
||||
@@ -30,18 +36,173 @@ const SENSITIVE_PATHS = [
|
||||
|
||||
const CLAUDE_PR_EXCLUDE_PATTERN = "/.claude-pr/";
|
||||
|
||||
function snapshotSensitivePath(src: string, dest: string): void {
|
||||
try {
|
||||
cpSync(src, dest, { recursive: true, dereference: true });
|
||||
} catch (error) {
|
||||
// Symlinks whose targets are absent on the PR head (e.g. `.claude/CLAUDE.md`
|
||||
// -> `../AGENTS.md` when the PR deleted the target) make dereferenced
|
||||
// copies throw ENOENT. Preserve the symlink for the review snapshot instead.
|
||||
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
||||
cpSync(src, dest, { recursive: true });
|
||||
return;
|
||||
function isSameOrInside(child: string, parent: string): boolean {
|
||||
return child === parent || child.startsWith(`${parent}${sep}`);
|
||||
}
|
||||
|
||||
// Repository paths (relative to cwd, `/`-separated) that a link may resolve
|
||||
// to: `files` are tracked files whose working-tree content is unchanged from
|
||||
// HEAD, `dirs` are directories that contain at least one tracked file.
|
||||
type TrackedPaths = { files: Set<string>; dirs: Set<string> };
|
||||
|
||||
// Built from the superproject only: `git ls-files` reports a submodule as a
|
||||
// single entry, so paths inside a checked-out submodule are in neither set and
|
||||
// links into one are recorded as placeholders.
|
||||
function listTrackedPaths(): TrackedPaths {
|
||||
const gitPathList = (args: string[]) =>
|
||||
execFileSync("git", args, { encoding: "utf8", maxBuffer: Infinity })
|
||||
.split("\0")
|
||||
.filter(Boolean);
|
||||
const modified = new Set(
|
||||
gitPathList([
|
||||
"diff",
|
||||
"--name-only",
|
||||
"-z",
|
||||
"--relative",
|
||||
"--ignore-submodules",
|
||||
"HEAD",
|
||||
"--",
|
||||
]),
|
||||
);
|
||||
const tracked: TrackedPaths = { files: new Set(), dirs: new Set() };
|
||||
for (const file of gitPathList(["ls-files", "-z"])) {
|
||||
if (!modified.has(file)) {
|
||||
tracked.files.add(file);
|
||||
}
|
||||
throw error;
|
||||
for (
|
||||
let dir = posix.dirname(file);
|
||||
dir !== "." && !tracked.dirs.has(dir);
|
||||
dir = posix.dirname(dir)
|
||||
) {
|
||||
tracked.dirs.add(dir);
|
||||
}
|
||||
}
|
||||
return tracked;
|
||||
}
|
||||
|
||||
// The snapshot is scoped to tracked repository content and never contains
|
||||
// links. An entry is copied with its content only when all of these hold:
|
||||
// 1. its real target (through any links) lies inside the working tree;
|
||||
// 2. no component of the target's path inside the tree is `.git`, and the
|
||||
// target is not inside the snapshot directory itself;
|
||||
// 3. the target does not contain a directory already on the entry's own
|
||||
// path (which would recurse);
|
||||
// 4. if the entry is reached through a link (it is one, or a directory above
|
||||
// it inside the sensitive path is), a file target must be tracked in the
|
||||
// checkout with its content unchanged from HEAD, and a directory target
|
||||
// must contain at least one tracked file (see listTrackedPaths). Directory
|
||||
// targets that pass are descended into and their children are checked
|
||||
// individually.
|
||||
// Files and directories at their literal, non-linked location are unaffected
|
||||
// by rule 4 and are copied as-is. Every other entry — targets outside the
|
||||
// tree, dangling or looping links, git metadata, submodule contents, untracked
|
||||
// or locally modified files reached through a link — is recorded as a
|
||||
// placeholder file (see recordPlaceholder), so nothing in the snapshot
|
||||
// resolves anywhere else.
|
||||
function shouldSnapshotContent(
|
||||
entryPath: string,
|
||||
workTreeRealPath: string,
|
||||
tracked: TrackedPaths,
|
||||
): boolean {
|
||||
try {
|
||||
const targetRealPath = realpathSync(entryPath);
|
||||
if (!isSameOrInside(targetRealPath, workTreeRealPath)) {
|
||||
return false;
|
||||
}
|
||||
const targetParts = relative(workTreeRealPath, targetRealPath).split(sep);
|
||||
if (targetParts.includes(".git") || targetParts[0] === ".claude-pr") {
|
||||
return false;
|
||||
}
|
||||
for (let dir = dirname(entryPath); ; dir = dirname(dir)) {
|
||||
if (isSameOrInside(realpathSync(dir), targetRealPath)) {
|
||||
return false;
|
||||
}
|
||||
if (dir === dirname(dir)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const literalPath = join(
|
||||
workTreeRealPath,
|
||||
relative(process.cwd(), entryPath),
|
||||
);
|
||||
if (targetRealPath === literalPath) {
|
||||
return true;
|
||||
}
|
||||
const targetRepoPath = targetParts.join("/");
|
||||
return statSync(targetRealPath).isDirectory()
|
||||
? tracked.dirs.has(targetRepoPath)
|
||||
: tracked.files.has(targetRepoPath);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Writes a short regular file at `dest` describing the entry that was left
|
||||
// out, so the snapshot records that something was there without linking to it.
|
||||
function recordPlaceholder(src: string, dest: string): void {
|
||||
console.warn(
|
||||
`Snapshot: ${src} not included in snapshot; recording a placeholder`,
|
||||
);
|
||||
let description = "is not included in this snapshot";
|
||||
try {
|
||||
description = `was a symbolic link to ${JSON.stringify(readlinkSync(src))}; the link target is not included in this snapshot`;
|
||||
} catch {
|
||||
// Not a link (or no longer present).
|
||||
}
|
||||
mkdirSync(dirname(dest), { recursive: true });
|
||||
writeFileSync(dest, `Snapshot placeholder: ${src} ${description}.\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies a sensitive path into the review snapshot. Entries that pass the
|
||||
* check above are copied dereferenced (reviewers see the effective content);
|
||||
* every other entry is recorded as a placeholder file, never as a link.
|
||||
* Applies per entry, including links nested inside a real directory.
|
||||
*/
|
||||
function snapshotSensitivePath(
|
||||
src: string,
|
||||
dest: string,
|
||||
workTreeRealPath: string,
|
||||
tracked: TrackedPaths,
|
||||
): void {
|
||||
const excluded: Array<{ src: string; dest: string }> = [];
|
||||
const keepOrExclude =
|
||||
(keep: (entry: string) => boolean) =>
|
||||
(entrySrc: string, entryDest: string) => {
|
||||
if (keep(entrySrc)) {
|
||||
return true;
|
||||
}
|
||||
excluded.push({ src: entrySrc, dest: entryDest });
|
||||
return false;
|
||||
};
|
||||
try {
|
||||
cpSync(src, dest, {
|
||||
recursive: true,
|
||||
dereference: true,
|
||||
filter: keepOrExclude((entry) =>
|
||||
shouldSnapshotContent(entry, workTreeRealPath, tracked),
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
// Dangling links are normally caught by the filter above. If a target
|
||||
// disappears between that check and the copy, the dereferencing copy
|
||||
// throws ENOENT; start over without following links, recording every link
|
||||
// as a placeholder, instead of failing the restore.
|
||||
if (
|
||||
!(error instanceof Error && "code" in error && error.code === "ENOENT")
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
rmSync(dest, { recursive: true, force: true });
|
||||
excluded.length = 0;
|
||||
cpSync(src, dest, {
|
||||
recursive: true,
|
||||
filter: keepOrExclude((entry) => !lstatSync(entry).isSymbolicLink()),
|
||||
});
|
||||
}
|
||||
|
||||
for (const entry of excluded) {
|
||||
recordPlaceholder(entry.src, entry.dest);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,8 +262,11 @@ function ensureClaudePrExcludedFromGit(): void {
|
||||
*
|
||||
* @param baseBranch - PR base branch name. Must be pre-validated (branch.ts
|
||||
* calls validateBranchName on it before returning).
|
||||
* @returns The paths whose working-tree state now comes from the base branch
|
||||
* rather than the PR. Callers that stage files must exclude these, or they
|
||||
* will commit the revert back onto the PR author's branch.
|
||||
*/
|
||||
export function restoreConfigFromBase(baseBranch: string): void {
|
||||
export function restoreConfigFromBase(baseBranch: string): string[] {
|
||||
console.log(
|
||||
`Restoring ${SENSITIVE_PATHS.join(", ")} from origin/${baseBranch} (PR head is untrusted)`,
|
||||
);
|
||||
@@ -110,11 +274,15 @@ export function restoreConfigFromBase(baseBranch: string): void {
|
||||
// Snapshot every PR-authored sensitive path into .claude-pr/ before deletion
|
||||
// so review agents can inspect what the PR changes without those files ever
|
||||
// being executed. Captured before the security delete so it reflects the
|
||||
// PR-authored version.
|
||||
// PR-authored version. Links are followed only to tracked, unmodified content
|
||||
// inside the working tree; anything else is recorded as a placeholder file,
|
||||
// so the snapshot itself never contains links.
|
||||
rmSync(".claude-pr", { recursive: true, force: true });
|
||||
const workTreeRealPath = realpathSync(process.cwd());
|
||||
const tracked = listTrackedPaths();
|
||||
for (const p of SENSITIVE_PATHS) {
|
||||
if (existsSync(p)) {
|
||||
snapshotSensitivePath(p, `.claude-pr/${p}`);
|
||||
if (lstatSync(p, { throwIfNoEntry: false })) {
|
||||
snapshotSensitivePath(p, `.claude-pr/${p}`, workTreeRealPath, tracked);
|
||||
}
|
||||
}
|
||||
if (existsSync(".claude-pr")) {
|
||||
@@ -141,7 +309,13 @@ export function restoreConfigFromBase(baseBranch: string): void {
|
||||
// fetch.recurseSubmodules config. Defense-in-depth alongside the delete above.
|
||||
execFileSync(
|
||||
"git",
|
||||
["fetch", "origin", baseBranch, "--depth=1", "--no-recurse-submodules"],
|
||||
[
|
||||
"fetch",
|
||||
"origin",
|
||||
baseBranch,
|
||||
...fetchDepthArgs(1),
|
||||
"--no-recurse-submodules",
|
||||
],
|
||||
{
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
@@ -167,4 +341,9 @@ export function restoreConfigFromBase(baseBranch: string): void {
|
||||
} catch {
|
||||
// Nothing was staged, or paths don't exist on HEAD — either is fine.
|
||||
}
|
||||
|
||||
// Every sensitive path is reported, not just the ones that changed: the
|
||||
// restore also deletes paths the PR added that are absent on base, and those
|
||||
// deletions are stageable too.
|
||||
return [...SENSITIVE_PATHS];
|
||||
}
|
||||
|
||||
+11
-1
@@ -3,9 +3,14 @@
|
||||
// GitHub's GraphQL `author`/`actor` fields resolve to null when the underlying
|
||||
// account has been deleted (the "ghost" user). Any field typed as
|
||||
// `GitHubAuthor | null` can therefore be null at runtime and must be guarded.
|
||||
// `__typename` distinguishes an App/bot actor from a human. GraphQL's
|
||||
// `Actor.login` returns the bare name for bots ("dependabot"), unlike REST which
|
||||
// appends a suffix ("dependabot[bot]"), so the typename is the only reliable bot
|
||||
// signal on this data. See `resolveActorName` in `utils/actor-filter.ts`.
|
||||
export type GitHubAuthor = {
|
||||
login: string;
|
||||
name?: string;
|
||||
__typename?: string;
|
||||
};
|
||||
|
||||
export type GitHubComment = {
|
||||
@@ -22,6 +27,7 @@ export type GitHubComment = {
|
||||
export type GitHubReviewComment = GitHubComment & {
|
||||
path: string;
|
||||
line: number | null;
|
||||
diffHunk?: string | null;
|
||||
};
|
||||
|
||||
export type GitHubCommit = {
|
||||
@@ -85,9 +91,13 @@ export type GitHubPullRequest = {
|
||||
commit: GitHubCommit;
|
||||
}>;
|
||||
};
|
||||
// GitHub's GraphQL `files` field resolves to null when the PR's diff is too
|
||||
// large for GitHub to compute (very large PRs). `changedFiles` is also
|
||||
// misreported as 0 in that case, so the null must be guarded and treated as
|
||||
// "file list unavailable" rather than "no files changed".
|
||||
files: {
|
||||
nodes: GitHubFile[];
|
||||
};
|
||||
} | null;
|
||||
comments: {
|
||||
nodes: GitHubComment[];
|
||||
};
|
||||
|
||||
@@ -11,6 +11,31 @@ export function parseActorFilter(filterString: string): string[] {
|
||||
.filter((actor) => actor.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the name to match actor filter patterns against.
|
||||
*
|
||||
* GitHub's GraphQL API returns the bare login for App actors ("dependabot"),
|
||||
* whereas REST and the GitHub UI use a "[bot]" suffix ("dependabot[bot]"). Users
|
||||
* write filter patterns in the suffixed form, both the documented "*[bot]"
|
||||
* wildcard and exact entries like "renovate[bot]", so GraphQL bot logins are
|
||||
* normalized to that form before matching. Without this no "[bot]" pattern can
|
||||
* ever match, because the suffix is simply absent from the data.
|
||||
*
|
||||
* @param author - Comment author; null for deleted ("ghost") accounts
|
||||
* @returns Actor name, "[bot]"-suffixed for App actors
|
||||
*/
|
||||
export function resolveActorName(
|
||||
author: { login: string; __typename?: string } | null | undefined,
|
||||
): string {
|
||||
if (!author) return "ghost";
|
||||
|
||||
if (author.__typename === "Bot" && !author.login.endsWith("[bot]")) {
|
||||
return `${author.login}[bot]`;
|
||||
}
|
||||
|
||||
return author.login;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an actor matches a pattern
|
||||
* Supports wildcards: "*[bot]" matches all bots, "dependabot[bot]" matches specific
|
||||
|
||||
@@ -34,6 +34,8 @@ const SIGNED_URL_HOST = "private-user-images.githubusercontent.com";
|
||||
const SIGNED_URL_PATH_REGEX =
|
||||
/^\/[^/]+\/[^/]*-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:\.[a-z0-9]+)?$/i;
|
||||
|
||||
const DEFAULT_IMAGE_DOWNLOAD_TIMEOUT_MS = 30_000;
|
||||
|
||||
function extractSignedUrlAssetGuid(signedUrl: string): string | undefined {
|
||||
let parsed: URL;
|
||||
try {
|
||||
@@ -85,13 +87,19 @@ export type CommentWithImages =
|
||||
| IssueBody
|
||||
| PullRequestBody;
|
||||
|
||||
type ImageDownloadOptions = {
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export async function downloadCommentImages(
|
||||
octokits: Octokits,
|
||||
owner: string,
|
||||
repo: string,
|
||||
comments: CommentWithImages[],
|
||||
options: ImageDownloadOptions = {},
|
||||
): Promise<Map<string, string>> {
|
||||
const urlToPathMap = new Map<string, string>();
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_IMAGE_DOWNLOAD_TIMEOUT_MS;
|
||||
const downloadsDir = "/tmp/github-images";
|
||||
|
||||
await fs.mkdir(downloadsDir, { recursive: true });
|
||||
@@ -241,15 +249,7 @@ export async function downloadCommentImages(
|
||||
try {
|
||||
console.log(`Downloading ${originalUrl}...`);
|
||||
|
||||
const imageResponse = await fetch(signedUrl);
|
||||
if (!imageResponse.ok) {
|
||||
throw new Error(
|
||||
`HTTP ${imageResponse.status}: ${imageResponse.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const arrayBuffer = await imageResponse.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
const buffer = await fetchImage(signedUrl, timeoutMs);
|
||||
|
||||
// GitHub user-attachment URLs (/user-attachments/assets/<uuid>) carry
|
||||
// no file extension, so the URL-based guess silently falls back to
|
||||
@@ -289,6 +289,37 @@ export async function downloadCommentImages(
|
||||
return urlToPathMap;
|
||||
}
|
||||
|
||||
async function fetchImage(url: string, timeoutMs: number): Promise<Buffer> {
|
||||
const controller = new AbortController();
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
controller.abort();
|
||||
reject(new Error(`Image download timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await Promise.race([
|
||||
fetch(url, { signal: controller.signal }),
|
||||
timeoutPromise,
|
||||
]);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const arrayBuffer = await Promise.race([
|
||||
response.arrayBuffer(),
|
||||
timeoutPromise,
|
||||
]);
|
||||
return Buffer.from(arrayBuffer);
|
||||
} finally {
|
||||
if (timeoutHandle !== undefined) {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getImageExtension(url: string): string {
|
||||
const urlParts = url.split("/");
|
||||
const filename = urlParts[urlParts.length - 1];
|
||||
|
||||
@@ -76,40 +76,82 @@ export function sanitizeContent(content: string): string {
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redact well-known credential formats (GitHub, Anthropic, AWS, Slack, JWTs)
|
||||
* from arbitrary text. Callers don't need to know which vendor a value belongs to.
|
||||
*
|
||||
* Vendor-prefixed formats are matched without a leading word boundary: the
|
||||
* prefix already anchors them, and runtime output frequently puts a word
|
||||
* character directly against the value (e.g. an ANSI color code ending in `m`,
|
||||
* or a serialized JSON escape such as `\n`).
|
||||
*/
|
||||
export function redactSecrets(content: string): string {
|
||||
content = redactGitHubTokens(content);
|
||||
|
||||
// Anthropic API keys: sk-ant-...
|
||||
content = content.replace(
|
||||
/sk-ant-[A-Za-z0-9_-]{20,}/g,
|
||||
"[REDACTED_ANTHROPIC_KEY]",
|
||||
);
|
||||
|
||||
// AWS access key ids: AKIA/ASIA followed by 16 uppercase alphanumerics. All
|
||||
// uppercase alphanumeric, so keep a leading boundary to avoid matching inside
|
||||
// larger blobs; also treat a JSON escape or ANSI color code as a boundary.
|
||||
content = content.replace(
|
||||
/(?:\b|(?<=\\(?:[nrtbf"\\/]|u[0-9a-fA-F]{4}))|(?<=\[[0-9;]*m))(?:AKIA|ASIA)[A-Z0-9]{16}\b/g,
|
||||
"[REDACTED_AWS_KEY_ID]",
|
||||
);
|
||||
|
||||
// Slack tokens: xoxb-, xoxp-, xoxa-, xoxs-, xoxr-
|
||||
content = content.replace(
|
||||
/xox[abpsr]-[A-Za-z0-9-]{10,}/g,
|
||||
"[REDACTED_SLACK_TOKEN]",
|
||||
);
|
||||
|
||||
// JWT-shaped strings: three base64url segments, the first two starting
|
||||
// with eyJ (base64 of `{"`).
|
||||
content = content.replace(
|
||||
/eyJ[A-Za-z0-9_-]{10,2000}\.eyJ[A-Za-z0-9_-]{10,4000}\.[A-Za-z0-9_-]{10,2000}\b/g,
|
||||
"[REDACTED_JWT]",
|
||||
);
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
export function redactGitHubTokens(content: string): string {
|
||||
// GitHub Personal Access Tokens (classic): ghp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars)
|
||||
content = content.replace(
|
||||
/\bghp_[A-Za-z0-9]{36}\b/g,
|
||||
/ghp_[A-Za-z0-9]{36}\b/g,
|
||||
"[REDACTED_GITHUB_TOKEN]",
|
||||
);
|
||||
|
||||
// GitHub OAuth tokens: gho_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars)
|
||||
content = content.replace(
|
||||
/\bgho_[A-Za-z0-9]{36}\b/g,
|
||||
/gho_[A-Za-z0-9]{36}\b/g,
|
||||
"[REDACTED_GITHUB_TOKEN]",
|
||||
);
|
||||
|
||||
// GitHub user-to-server tokens: ghu_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars)
|
||||
content = content.replace(
|
||||
/\bghu_[A-Za-z0-9]{36}\b/g,
|
||||
/ghu_[A-Za-z0-9]{36}\b/g,
|
||||
"[REDACTED_GITHUB_TOKEN]",
|
||||
);
|
||||
|
||||
// GitHub installation tokens: ghs_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars)
|
||||
content = content.replace(
|
||||
/\bghs_[A-Za-z0-9]{36}\b/g,
|
||||
/ghs_[A-Za-z0-9]{36}\b/g,
|
||||
"[REDACTED_GITHUB_TOKEN]",
|
||||
);
|
||||
|
||||
// GitHub refresh tokens: ghr_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars)
|
||||
content = content.replace(
|
||||
/\bghr_[A-Za-z0-9]{36}\b/g,
|
||||
/ghr_[A-Za-z0-9]{36}\b/g,
|
||||
"[REDACTED_GITHUB_TOKEN]",
|
||||
);
|
||||
|
||||
// GitHub fine-grained personal access tokens: github_pat_XXXXXXXXXX (up to 255 chars)
|
||||
content = content.replace(
|
||||
/\bgithub_pat_[A-Za-z0-9_]{11,221}\b/g,
|
||||
/github_pat_[A-Za-z0-9_]{11,221}\b/g,
|
||||
"[REDACTED_GITHUB_TOKEN]",
|
||||
);
|
||||
|
||||
|
||||
@@ -38,7 +38,10 @@ export function checkContainsTrigger(context: ParsedGitHubContext): boolean {
|
||||
if (isIssuesEvent(context) && context.eventAction === "labeled") {
|
||||
const labelName = (context.payload as any).label?.name || "";
|
||||
|
||||
if (labelTrigger && labelName === labelTrigger) {
|
||||
if (
|
||||
labelTrigger &&
|
||||
labelName.toLowerCase() === labelTrigger.toLowerCase()
|
||||
) {
|
||||
console.log(`Issue labeled with trigger label '${labelTrigger}'`);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Decides whether a file has to be committed as a base64 blob instead of being
|
||||
* inlined in the Git tree as UTF-8 text.
|
||||
*
|
||||
* Inlining is only safe for content that survives a UTF-8 decode untouched;
|
||||
* anything else gets its invalid bytes replaced during the decode, which
|
||||
* silently corrupts the committed file. A NUL byte is treated as binary for the
|
||||
* same reason Git does it: no text file carries one, and it is the cheapest
|
||||
* signal available.
|
||||
*/
|
||||
export function isBinaryContent(content: Buffer): boolean {
|
||||
if (content.includes(0)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(content);
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
|
||||
type ActionsClient = Octokit["actions"];
|
||||
|
||||
export type WorkflowRunsParams = Parameters<
|
||||
ActionsClient["listWorkflowRunsForRepo"]
|
||||
>[0];
|
||||
|
||||
export type WorkflowJobsParams = Parameters<
|
||||
ActionsClient["listJobsForWorkflowRun"]
|
||||
>[0];
|
||||
|
||||
export function listWorkflowRuns(client: Octokit, params: WorkflowRunsParams) {
|
||||
return client.paginate(client.actions.listWorkflowRunsForRepo, params);
|
||||
}
|
||||
|
||||
export function listWorkflowJobs(client: Octokit, params: WorkflowJobsParams) {
|
||||
return client.paginate(client.actions.listJobsForWorkflowRun, params);
|
||||
}
|
||||
@@ -6,6 +6,10 @@ import { z } from "zod";
|
||||
import { GITHUB_API_URL } from "../github/api/config";
|
||||
import { mkdir, writeFile } from "fs/promises";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import {
|
||||
listWorkflowJobs,
|
||||
listWorkflowRuns,
|
||||
} from "./github-actions-pagination";
|
||||
|
||||
const REPO_OWNER = process.env.REPO_OWNER;
|
||||
const REPO_NAME = process.env.REPO_NAME;
|
||||
@@ -13,11 +17,18 @@ const PR_NUMBER = process.env.PR_NUMBER;
|
||||
const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
|
||||
const RUNNER_TEMP = process.env.RUNNER_TEMP || "/tmp";
|
||||
|
||||
if (!REPO_OWNER || !REPO_NAME || !PR_NUMBER || !GITHUB_TOKEN) {
|
||||
console.error(
|
||||
"[GitHub CI Server] Error: REPO_OWNER, REPO_NAME, PR_NUMBER, and GITHUB_TOKEN environment variables are required",
|
||||
);
|
||||
process.exit(1);
|
||||
// Job logs are fetched by ID from GitHub-hosted storage; bound the request so a
|
||||
// stalled fetch can't hang this MCP call forever. Mirrors the timeout added to
|
||||
// fetchImage() in src/github/utils/image-downloader.ts (#1625).
|
||||
const DOWNLOAD_JOB_LOG_TIMEOUT_MS = 30_000;
|
||||
|
||||
if (import.meta.main) {
|
||||
if (!REPO_OWNER || !REPO_NAME || !PR_NUMBER || !GITHUB_TOKEN) {
|
||||
console.error(
|
||||
"[GitHub CI Server] Error: REPO_OWNER, REPO_NAME, PR_NUMBER, and GITHUB_TOKEN environment variables are required",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const server = new McpServer({
|
||||
@@ -66,7 +77,7 @@ server.tool(
|
||||
});
|
||||
const headSha = prData.head.sha;
|
||||
|
||||
const { data: runsData } = await client.actions.listWorkflowRunsForRepo({
|
||||
const runs = await listWorkflowRuns(client, {
|
||||
owner: REPO_OWNER!,
|
||||
repo: REPO_NAME!,
|
||||
head_sha: headSha,
|
||||
@@ -74,7 +85,6 @@ server.tool(
|
||||
});
|
||||
|
||||
// Process runs to create summary
|
||||
const runs = runsData.workflow_runs || [];
|
||||
const summary = {
|
||||
total_runs: runs.length,
|
||||
failed: 0,
|
||||
@@ -148,13 +158,13 @@ server.tool(
|
||||
});
|
||||
|
||||
// Get jobs for this workflow run
|
||||
const { data: jobsData } = await client.actions.listJobsForWorkflowRun({
|
||||
const jobs = await listWorkflowJobs(client, {
|
||||
owner: REPO_OWNER!,
|
||||
repo: REPO_NAME!,
|
||||
run_id,
|
||||
});
|
||||
|
||||
const processedJobs = jobsData.jobs.map((job: any) => {
|
||||
const processedJobs = jobs.map((job: any) => {
|
||||
// Extract failed steps
|
||||
const failedSteps = (job.steps || [])
|
||||
.filter((step: any) => step.conclusion === "failure")
|
||||
@@ -202,6 +212,40 @@ server.tool(
|
||||
},
|
||||
);
|
||||
|
||||
export async function downloadJobLog(
|
||||
client: Octokit,
|
||||
params: { owner: string; repo: string; job_id: number },
|
||||
runnerTemp: string,
|
||||
timeoutMs: number = DOWNLOAD_JOB_LOG_TIMEOUT_MS,
|
||||
): Promise<{ path: string; size_bytes: number }> {
|
||||
const controller = new AbortController();
|
||||
const timeoutHandle = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await client.actions.downloadJobLogsForWorkflowRun({
|
||||
owner: params.owner,
|
||||
repo: params.repo,
|
||||
job_id: params.job_id,
|
||||
request: { signal: controller.signal },
|
||||
});
|
||||
|
||||
const logsText = response.data as unknown as string;
|
||||
|
||||
const logsDir = `${runnerTemp}/github-ci-logs`;
|
||||
await mkdir(logsDir, { recursive: true });
|
||||
|
||||
const logPath = `${logsDir}/job-${params.job_id}.log`;
|
||||
await writeFile(logPath, logsText, "utf-8");
|
||||
|
||||
return {
|
||||
path: logPath,
|
||||
size_bytes: Buffer.byteLength(logsText, "utf-8"),
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
}
|
||||
|
||||
server.tool(
|
||||
"download_job_log",
|
||||
"Download job logs to disk",
|
||||
@@ -215,24 +259,11 @@ server.tool(
|
||||
baseUrl: GITHUB_API_URL,
|
||||
});
|
||||
|
||||
const response = await client.actions.downloadJobLogsForWorkflowRun({
|
||||
owner: REPO_OWNER!,
|
||||
repo: REPO_NAME!,
|
||||
job_id,
|
||||
});
|
||||
|
||||
const logsText = response.data as unknown as string;
|
||||
|
||||
const logsDir = `${RUNNER_TEMP}/github-ci-logs`;
|
||||
await mkdir(logsDir, { recursive: true });
|
||||
|
||||
const logPath = `${logsDir}/job-${job_id}.log`;
|
||||
await writeFile(logPath, logsText, "utf-8");
|
||||
|
||||
const result = {
|
||||
path: logPath,
|
||||
size_bytes: Buffer.byteLength(logsText, "utf-8"),
|
||||
};
|
||||
const result = await downloadJobLog(
|
||||
client,
|
||||
{ owner: REPO_OWNER!, repo: REPO_NAME!, job_id },
|
||||
RUNNER_TEMP,
|
||||
);
|
||||
|
||||
return {
|
||||
content: [
|
||||
@@ -274,6 +305,8 @@ async function runServer() {
|
||||
}
|
||||
}
|
||||
|
||||
runServer().catch(() => {
|
||||
process.exit(1);
|
||||
});
|
||||
if (import.meta.main) {
|
||||
runServer().catch(() => {
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { z } from "zod";
|
||||
import { GITHUB_API_URL } from "../github/api/config";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import { updateClaudeComment } from "../github/operations/comments/update-claude-comment";
|
||||
import { sanitizeContent } from "../github/utils/sanitizer";
|
||||
import { redactSecrets, sanitizeContent } from "../github/utils/sanitizer";
|
||||
|
||||
// Get repository information from environment variables
|
||||
const REPO_OWNER = process.env.REPO_OWNER;
|
||||
@@ -55,7 +55,7 @@ server.tool(
|
||||
const isPullRequestReviewComment =
|
||||
eventName === "pull_request_review_comment";
|
||||
|
||||
const sanitizedBody = sanitizeContent(body);
|
||||
const sanitizedBody = redactSecrets(sanitizeContent(body));
|
||||
|
||||
const result = await updateClaudeComment(octokit, {
|
||||
owner,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/** Raw shape passed to `server.tool` for commit_files. */
|
||||
export const commitFilesInputSchema = {
|
||||
files: z
|
||||
.array(z.string())
|
||||
.describe(
|
||||
'Array of file paths relative to repository root (e.g. ["src/main.js", "README.md"]). All files must exist locally.',
|
||||
),
|
||||
message: z.string().describe("Commit message"),
|
||||
};
|
||||
|
||||
/** Raw shape passed to `server.tool` for delete_files. */
|
||||
export const deleteFilesInputSchema = {
|
||||
paths: z
|
||||
.array(z.string())
|
||||
.describe(
|
||||
'Array of file paths to delete relative to repository root (e.g. ["src/old-file.js", "docs/deprecated.md"])',
|
||||
),
|
||||
message: z.string().describe("Commit message"),
|
||||
};
|
||||
|
||||
export const commitFilesPayloadSchema = z.object(commitFilesInputSchema);
|
||||
export const deleteFilesPayloadSchema = z.object(deleteFilesInputSchema);
|
||||
@@ -2,14 +2,18 @@
|
||||
// GitHub File Operations MCP Server
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { z } from "zod";
|
||||
import { readFile, stat } from "fs/promises";
|
||||
import { resolve } from "path";
|
||||
import { constants } from "fs";
|
||||
import fetch from "node-fetch";
|
||||
import { GITHUB_API_URL } from "../github/api/config";
|
||||
import { retryWithBackoff } from "../utils/retry";
|
||||
import { isBinaryContent } from "./binary-detection";
|
||||
import { validatePathWithinRepo } from "./path-validation";
|
||||
import { updateGitReference } from "./update-git-reference";
|
||||
import {
|
||||
commitFilesInputSchema,
|
||||
deleteFilesInputSchema,
|
||||
} from "./github-file-ops-schemas";
|
||||
|
||||
type GitHubRef = {
|
||||
object: {
|
||||
@@ -196,14 +200,7 @@ async function getFileMode(filePath: string): Promise<string> {
|
||||
server.tool(
|
||||
"commit_files",
|
||||
"Commit one or more files to a repository in a single commit (this will commit them atomically in the remote repository)",
|
||||
{
|
||||
files: z
|
||||
.array(z.string())
|
||||
.describe(
|
||||
'Array of file paths relative to repository root (e.g. ["src/main.js", "README.md"]). All files must exist locally.',
|
||||
),
|
||||
message: z.string().describe("Commit message"),
|
||||
},
|
||||
commitFilesInputSchema,
|
||||
async ({ files, message }) => {
|
||||
const owner = REPO_OWNER;
|
||||
const repo = REPO_NAME;
|
||||
@@ -258,17 +255,14 @@ server.tool(
|
||||
// Get the proper file mode based on file permissions
|
||||
const fileMode = await getFileMode(fullPath);
|
||||
|
||||
// Check if file is binary (images, etc.)
|
||||
const isBinaryFile =
|
||||
/\.(png|jpg|jpeg|gif|webp|ico|pdf|zip|tar|gz|exe|bin|woff|woff2|ttf|eot)$/i.test(
|
||||
relativePath,
|
||||
);
|
||||
// Check if the file is binary by inspecting its contents. An
|
||||
// extension allowlist used to decide this, which corrupted every
|
||||
// binary type that wasn't on the list.
|
||||
const fileContent = await readFile(fullPath);
|
||||
|
||||
if (isBinaryFile) {
|
||||
if (isBinaryContent(fileContent)) {
|
||||
// For binary files, create a blob first using the Blobs API
|
||||
const binaryContent = await readFile(fullPath);
|
||||
|
||||
// Create blob using Blobs API (supports encoding parameter)
|
||||
// (supports the encoding parameter)
|
||||
const blobUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/blobs`;
|
||||
const blobResponse = await fetch(blobUrl, {
|
||||
method: "POST",
|
||||
@@ -279,7 +273,7 @@ server.tool(
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: binaryContent.toString("base64"),
|
||||
content: fileContent.toString("base64"),
|
||||
encoding: "base64",
|
||||
}),
|
||||
});
|
||||
@@ -302,12 +296,11 @@ server.tool(
|
||||
};
|
||||
} else {
|
||||
// For text files, include content directly in tree
|
||||
const content = await readFile(fullPath, "utf-8");
|
||||
return {
|
||||
path: relativePath,
|
||||
mode: fileMode,
|
||||
type: "blob",
|
||||
content: content,
|
||||
content: fileContent.toString("utf-8"),
|
||||
};
|
||||
}
|
||||
}),
|
||||
@@ -365,57 +358,13 @@ server.tool(
|
||||
const newCommitData = (await newCommitResponse.json()) as GitHubNewCommit;
|
||||
|
||||
// 6. Update the reference to point to the new commit
|
||||
const updateRefUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs/heads/${branch}`;
|
||||
|
||||
// We're seeing intermittent 403 "Resource not accessible by integration" errors
|
||||
// on certain repos when updating git references. These appear to be transient
|
||||
// GitHub API issues that succeed on retry.
|
||||
await retryWithBackoff(
|
||||
async () => {
|
||||
const updateRefResponse = await fetch(updateRefUrl, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${githubToken}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sha: newCommitData.sha,
|
||||
force: false,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!updateRefResponse.ok) {
|
||||
const errorText = await updateRefResponse.text();
|
||||
|
||||
// Provide a more helpful error message for 403 permission errors
|
||||
if (updateRefResponse.status === 403) {
|
||||
const permissionError = new Error(
|
||||
`Permission denied: Unable to push commits to branch '${branch}'. ` +
|
||||
`Please rebase your branch from the main/master branch to allow Claude to commit.\n\n` +
|
||||
`Original error: ${errorText}`,
|
||||
);
|
||||
throw permissionError;
|
||||
}
|
||||
|
||||
// For other errors, use the original message
|
||||
const error = new Error(
|
||||
`Failed to update reference: ${updateRefResponse.status} - ${errorText}`,
|
||||
);
|
||||
|
||||
// For non-403 errors, fail immediately without retry
|
||||
console.error("Non-retryable error:", updateRefResponse.status);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{
|
||||
maxAttempts: 3,
|
||||
initialDelayMs: 1000, // Start with 1 second delay
|
||||
maxDelayMs: 5000, // Max 5 seconds delay
|
||||
backoffFactor: 2, // Double the delay each time
|
||||
},
|
||||
);
|
||||
await updateGitReference({
|
||||
owner,
|
||||
repo,
|
||||
branch,
|
||||
sha: newCommitData.sha,
|
||||
githubToken,
|
||||
});
|
||||
|
||||
const simplifiedResult = {
|
||||
commit: {
|
||||
@@ -461,14 +410,7 @@ server.tool(
|
||||
server.tool(
|
||||
"delete_files",
|
||||
"Delete one or more files from a repository in a single commit",
|
||||
{
|
||||
paths: z
|
||||
.array(z.string())
|
||||
.describe(
|
||||
'Array of file paths to delete relative to repository root (e.g. ["src/old-file.js", "docs/deprecated.md"])',
|
||||
),
|
||||
message: z.string().describe("Commit message"),
|
||||
},
|
||||
deleteFilesInputSchema,
|
||||
async ({ paths, message }) => {
|
||||
const owner = REPO_OWNER;
|
||||
const repo = REPO_NAME;
|
||||
@@ -479,21 +421,18 @@ server.tool(
|
||||
throw new Error("GITHUB_TOKEN environment variable is required");
|
||||
}
|
||||
|
||||
// Convert absolute paths to relative if they match CWD
|
||||
const cwd = process.cwd();
|
||||
const processedPaths = paths.map((filePath) => {
|
||||
if (filePath.startsWith("/")) {
|
||||
if (filePath.startsWith(cwd)) {
|
||||
// Strip CWD from absolute path
|
||||
return filePath.slice(cwd.length + 1);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Path '${filePath}' must be relative to repository root or within current working directory`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return filePath;
|
||||
});
|
||||
// Validate all paths are within the repository root and normalize them to
|
||||
// repo-relative paths for the git tree entries. This mirrors the validation
|
||||
// already performed by the commit_files tool and rejects path traversal
|
||||
// ("../") and symlinked escapes as defense-in-depth.
|
||||
const resolvedRepoDir = resolve(REPO_DIR);
|
||||
const processedPaths = await Promise.all(
|
||||
paths.map(async (filePath) => {
|
||||
await validatePathWithinRepo(filePath, REPO_DIR);
|
||||
const normalizedPath = resolve(resolvedRepoDir, filePath);
|
||||
return normalizedPath.slice(resolvedRepoDir.length + 1);
|
||||
}),
|
||||
);
|
||||
|
||||
// 1. Get the branch reference (create if doesn't exist)
|
||||
const baseSha = await getOrCreateBranchRef(
|
||||
@@ -580,58 +519,13 @@ server.tool(
|
||||
const newCommitData = (await newCommitResponse.json()) as GitHubNewCommit;
|
||||
|
||||
// 6. Update the reference to point to the new commit
|
||||
const updateRefUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs/heads/${branch}`;
|
||||
|
||||
// We're seeing intermittent 403 "Resource not accessible by integration" errors
|
||||
// on certain repos when updating git references. These appear to be transient
|
||||
// GitHub API issues that succeed on retry.
|
||||
await retryWithBackoff(
|
||||
async () => {
|
||||
const updateRefResponse = await fetch(updateRefUrl, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${githubToken}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sha: newCommitData.sha,
|
||||
force: false,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!updateRefResponse.ok) {
|
||||
const errorText = await updateRefResponse.text();
|
||||
|
||||
// Provide a more helpful error message for 403 permission errors
|
||||
if (updateRefResponse.status === 403) {
|
||||
console.log("Received 403 error, will retry...");
|
||||
const permissionError = new Error(
|
||||
`Permission denied: Unable to push commits to branch '${branch}'. ` +
|
||||
`Please rebase your branch from the main/master branch to allow Claude to commit.\n\n` +
|
||||
`Original error: ${errorText}`,
|
||||
);
|
||||
throw permissionError;
|
||||
}
|
||||
|
||||
// For other errors, use the original message
|
||||
const error = new Error(
|
||||
`Failed to update reference: ${updateRefResponse.status} - ${errorText}`,
|
||||
);
|
||||
|
||||
// For non-403 errors, fail immediately without retry
|
||||
console.error("Non-retryable error:", updateRefResponse.status);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{
|
||||
maxAttempts: 3,
|
||||
initialDelayMs: 1000, // Start with 1 second delay
|
||||
maxDelayMs: 5000, // Max 5 seconds delay
|
||||
backoffFactor: 2, // Double the delay each time
|
||||
},
|
||||
);
|
||||
await updateGitReference({
|
||||
owner,
|
||||
repo,
|
||||
branch,
|
||||
sha: newCommitData.sha,
|
||||
githubToken,
|
||||
});
|
||||
|
||||
const simplifiedResult = {
|
||||
commit: {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
||||
import { appendFileSync } from "fs";
|
||||
import { z } from "zod";
|
||||
import { createOctokit } from "../github/api/client";
|
||||
import { sanitizeContent } from "../github/utils/sanitizer";
|
||||
import { redactSecrets, sanitizeContent } from "../github/utils/sanitizer";
|
||||
import { removeBufferedComment } from "./inline-comment-buffer";
|
||||
|
||||
// Get repository and PR information from environment variables
|
||||
@@ -98,8 +98,8 @@ server.tool(
|
||||
const repo = REPO_NAME;
|
||||
const pull_number = parseInt(PR_NUMBER, 10);
|
||||
|
||||
// Sanitize the comment body to remove any potential GitHub tokens
|
||||
const sanitizedBody = sanitizeContent(body);
|
||||
// Sanitize the comment body to remove potential prompt injections and redact secrets
|
||||
const sanitizedBody = redactSecrets(sanitizeContent(body));
|
||||
|
||||
// Validate that either line or both startLine and line are provided
|
||||
if (!line && !startLine) {
|
||||
|
||||
@@ -83,20 +83,25 @@ export async function prepareMcpConfig(
|
||||
// Detect if we're in agent mode (explicit prompt provided)
|
||||
const isAgentMode = mode === "agent";
|
||||
|
||||
const hasGitHubCommentTools = allowedToolsList.some((tool) =>
|
||||
tool.startsWith("mcp__github_comment__"),
|
||||
const hasGitHubCommentTools = allowedToolsList.some(
|
||||
(tool) =>
|
||||
tool === "mcp__github_comment" ||
|
||||
tool.startsWith("mcp__github_comment__"),
|
||||
);
|
||||
|
||||
const hasGitHubMcpTools = allowedToolsList.some((tool) =>
|
||||
tool.startsWith("mcp__github__"),
|
||||
const hasGitHubMcpTools = allowedToolsList.some(
|
||||
(tool) => tool === "mcp__github" || tool.startsWith("mcp__github__"),
|
||||
);
|
||||
|
||||
const hasInlineCommentTools = allowedToolsList.some((tool) =>
|
||||
tool.startsWith("mcp__github_inline_comment__"),
|
||||
const hasInlineCommentTools = allowedToolsList.some(
|
||||
(tool) =>
|
||||
tool === "mcp__github_inline_comment" ||
|
||||
tool.startsWith("mcp__github_inline_comment__"),
|
||||
);
|
||||
|
||||
const hasGitHubCITools = allowedToolsList.some((tool) =>
|
||||
tool.startsWith("mcp__github_ci__"),
|
||||
const hasGitHubCITools = allowedToolsList.some(
|
||||
(tool) =>
|
||||
tool === "mcp__github_ci" || tool.startsWith("mcp__github_ci__"),
|
||||
);
|
||||
|
||||
const baseMcpConfig: { mcpServers: Record<string, unknown> } = {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import fetch, { type RequestInit, type Response } from "node-fetch";
|
||||
import { GITHUB_API_URL } from "../github/api/config";
|
||||
import { retryWithBackoff, type RetryOptions } from "../utils/retry";
|
||||
|
||||
type GitHubFetch = (
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
) => Promise<Pick<Response, "ok" | "status" | "text">>;
|
||||
|
||||
type UpdateGitReferenceOptions = {
|
||||
owner: string;
|
||||
repo: string;
|
||||
branch: string;
|
||||
sha: string;
|
||||
githubToken: string;
|
||||
fetchFn?: GitHubFetch;
|
||||
retryOptions?: Omit<RetryOptions, "shouldRetry">;
|
||||
};
|
||||
|
||||
class GitReferenceUpdateError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "GitReferenceUpdateError";
|
||||
}
|
||||
}
|
||||
|
||||
function shouldRetryGitReferenceUpdate(error: Error): boolean {
|
||||
if (!(error instanceof GitReferenceUpdateError)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return error.status === 403 || error.status === 429 || error.status >= 500;
|
||||
}
|
||||
|
||||
export async function updateGitReference({
|
||||
owner,
|
||||
repo,
|
||||
branch,
|
||||
sha,
|
||||
githubToken,
|
||||
fetchFn = fetch,
|
||||
retryOptions,
|
||||
}: UpdateGitReferenceOptions): Promise<void> {
|
||||
const updateRefUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs/heads/${branch}`;
|
||||
|
||||
await retryWithBackoff(
|
||||
async () => {
|
||||
const response = await fetchFn(updateRefUrl, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${githubToken}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ sha, force: false }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const errorText = await response.text();
|
||||
if (response.status === 403) {
|
||||
throw new GitReferenceUpdateError(
|
||||
response.status,
|
||||
`Permission denied: Unable to push commits to branch '${branch}'. ` +
|
||||
`Please rebase your branch from the main/master branch to allow Claude to commit.\n\n` +
|
||||
`Original error: ${errorText}`,
|
||||
);
|
||||
}
|
||||
|
||||
throw new GitReferenceUpdateError(
|
||||
response.status,
|
||||
`Failed to update reference: ${response.status} - ${errorText}`,
|
||||
);
|
||||
},
|
||||
{
|
||||
maxAttempts: 3,
|
||||
initialDelayMs: 1000,
|
||||
maxDelayMs: 5000,
|
||||
backoffFactor: 2,
|
||||
...retryOptions,
|
||||
shouldRetry: shouldRetryGitReferenceUpdate,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { prepareMcpConfig } from "../../mcp/install-mcp-server";
|
||||
import { parseAllowedTools } from "./parse-tools";
|
||||
import {
|
||||
configureGitAuth,
|
||||
replaceCheckoutCredentials,
|
||||
setupSshSigning,
|
||||
} from "../../github/operations/git-config";
|
||||
import { checkHumanActor } from "../../github/validation/actor";
|
||||
@@ -62,6 +63,16 @@ export async function prepareAgentMode({
|
||||
console.error("Failed to configure git authentication:", error);
|
||||
// Continue anyway - git operations may still work with default config
|
||||
}
|
||||
} else {
|
||||
// Commits go through the GitHub API, so no git user setup is needed, but
|
||||
// the credential actions/checkout left in git config should still be
|
||||
// replaced with the action's own.
|
||||
try {
|
||||
await replaceCheckoutCredentials(githubToken, context);
|
||||
} catch (error) {
|
||||
console.error("Failed to configure git credentials:", error);
|
||||
// Continue anyway - git operations may still work with default config
|
||||
}
|
||||
}
|
||||
|
||||
// Create prompt directory. Clear any stale files from a prior invocation first —
|
||||
|
||||
@@ -103,6 +103,7 @@ function validateTrackProgressEvent(context: GitHubContext): void {
|
||||
"synchronize",
|
||||
"ready_for_review",
|
||||
"reopened",
|
||||
"labeled",
|
||||
];
|
||||
if (!validActions.includes(context.eventAction)) {
|
||||
throw new Error(
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createInitialComment } from "../../github/operations/comments/create-in
|
||||
import { setupBranch } from "../../github/operations/branch";
|
||||
import {
|
||||
configureGitAuth,
|
||||
replaceCheckoutCredentials,
|
||||
setupSshSigning,
|
||||
} from "../../github/operations/git-config";
|
||||
import { prepareMcpConfig } from "../../mcp/install-mcp-server";
|
||||
@@ -98,6 +99,16 @@ export async function prepareTagMode({
|
||||
console.error("Failed to configure git authentication:", error);
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
// Commits go through the GitHub API, so no git user setup is needed, but
|
||||
// the credential actions/checkout left in git config should still be
|
||||
// replaced with the action's own.
|
||||
try {
|
||||
await replaceCheckoutCredentials(githubToken, context);
|
||||
} catch (error) {
|
||||
console.error("Failed to configure git credentials:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Create prompt file
|
||||
|
||||
@@ -72,6 +72,21 @@ export function applyBranchTemplate(
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapses empty path segments produced when a template variable resolves to
|
||||
* an empty string. For example, an issue title with no alphanumeric characters
|
||||
* (emoji-only, CJK-only, or punctuation-only) makes `{{description}}` empty, so
|
||||
* a template like `{{prefix}}{{description}}/{{entityNumber}}` yields
|
||||
* `claude//123`. Consecutive slashes — and a leading or trailing slash — are
|
||||
* rejected by `validateBranchName`, which aborts the whole run, so normalize
|
||||
* them into a valid branch name instead of crashing.
|
||||
*/
|
||||
function collapseEmptyPathSegments(branchName: string): string {
|
||||
return branchName
|
||||
.replace(/\/{2,}/g, "/") // collapse runs of slashes left by empty segments
|
||||
.replace(/^\/+|\/+$/g, ""); // drop leading/trailing slashes
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@@ -97,7 +112,9 @@ export function generateBranchName(
|
||||
};
|
||||
|
||||
if (template?.trim()) {
|
||||
const branchName = applyBranchTemplate(template, variables);
|
||||
const branchName = collapseEmptyPathSegments(
|
||||
applyBranchTemplate(template, variables),
|
||||
);
|
||||
|
||||
// Some templates could produce empty results- validate
|
||||
if (branchName.trim().length > 0) return branchName;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
describe("action metadata", () => {
|
||||
test("should expose the conclusion output from the run step", () => {
|
||||
const metadata = readFileSync(
|
||||
new URL("../action.yml", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(metadata).toMatch(
|
||||
/^ conclusion:\n description: .+\n value: \$\{\{ steps\.run\.outputs\.conclusion \}\}$/m,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
parseActorFilter,
|
||||
actorMatchesPattern,
|
||||
resolveActorName,
|
||||
shouldIncludeCommentByActor,
|
||||
} from "../src/github/utils/actor-filter";
|
||||
|
||||
@@ -170,3 +171,49 @@ describe("shouldIncludeCommentByActor", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveActorName", () => {
|
||||
test("appends the [bot] suffix to GraphQL App actors", () => {
|
||||
// GraphQL returns the bare login for bots; REST would say "dependabot[bot]".
|
||||
expect(resolveActorName({ __typename: "Bot", login: "dependabot" })).toBe(
|
||||
"dependabot[bot]",
|
||||
);
|
||||
});
|
||||
|
||||
test("leaves human logins untouched", () => {
|
||||
expect(resolveActorName({ __typename: "User", login: "octocat" })).toBe(
|
||||
"octocat",
|
||||
);
|
||||
});
|
||||
|
||||
test("does not double-suffix a login that already ends with [bot]", () => {
|
||||
expect(
|
||||
resolveActorName({ __typename: "Bot", login: "dependabot[bot]" }),
|
||||
).toBe("dependabot[bot]");
|
||||
});
|
||||
|
||||
test("maps deleted accounts to ghost", () => {
|
||||
expect(resolveActorName(null)).toBe("ghost");
|
||||
expect(resolveActorName(undefined)).toBe("ghost");
|
||||
});
|
||||
|
||||
test("falls back to the login when __typename is absent", () => {
|
||||
expect(resolveActorName({ login: "octocat" })).toBe("octocat");
|
||||
});
|
||||
|
||||
test("a bot actor matches the *[bot] wildcard once resolved", () => {
|
||||
const actor = resolveActorName({ __typename: "Bot", login: "renovate" });
|
||||
|
||||
expect(actorMatchesPattern(actor, "*[bot]")).toBe(true);
|
||||
// The raw GraphQL login never matches, which is the bug being fixed.
|
||||
expect(actorMatchesPattern("renovate", "*[bot]")).toBe(false);
|
||||
});
|
||||
|
||||
test("a bot actor matches an exact [bot] pattern once resolved", () => {
|
||||
const actor = resolveActorName({ __typename: "Bot", login: "dependabot" });
|
||||
|
||||
expect(shouldIncludeCommentByActor(actor, [], ["dependabot[bot]"])).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { isBinaryContent } from "../src/mcp/binary-detection";
|
||||
|
||||
describe("isBinaryContent", () => {
|
||||
describe("text content", () => {
|
||||
it("treats ASCII as text", () => {
|
||||
expect(isBinaryContent(Buffer.from("hello world\n"))).toBe(false);
|
||||
});
|
||||
|
||||
it("treats multibyte UTF-8 as text", () => {
|
||||
expect(isBinaryContent(Buffer.from("café — 日本語 🎉\n"))).toBe(false);
|
||||
});
|
||||
|
||||
it("treats an empty file as text", () => {
|
||||
expect(isBinaryContent(Buffer.from(""))).toBe(false);
|
||||
});
|
||||
|
||||
it("treats CRLF and tabs as text", () => {
|
||||
expect(isBinaryContent(Buffer.from("a\tb\r\nc\r\n"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("binary content", () => {
|
||||
// The extensions below are the ones the previous allowlist covered, so
|
||||
// these files were already committed correctly.
|
||||
it("detects PNG", () => {
|
||||
expect(
|
||||
isBinaryContent(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a])),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// These are the regression cases: binary formats that were not on the
|
||||
// allowlist and got decoded as UTF-8, corrupting the committed bytes.
|
||||
it("detects BMP", () => {
|
||||
expect(
|
||||
isBinaryContent(Buffer.from([0x42, 0x4d, 0x36, 0x00, 0x00, 0x00])),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("detects SQLite databases", () => {
|
||||
expect(isBinaryContent(Buffer.from("SQLite format 3\0", "binary"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("detects WebAssembly modules", () => {
|
||||
expect(
|
||||
isBinaryContent(Buffer.from([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00])),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("detects arbitrary invalid UTF-8 without NUL bytes", () => {
|
||||
// Lone continuation bytes: no NUL, but not decodable as UTF-8 either.
|
||||
expect(isBinaryContent(Buffer.from([0xc3, 0x28, 0xa0, 0xa1]))).toBe(true);
|
||||
});
|
||||
|
||||
it("detects a truncated multibyte sequence", () => {
|
||||
// First two bytes of a 3-byte character, cut short.
|
||||
expect(isBinaryContent(Buffer.from([0xe6, 0x97]))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("round-trips text through UTF-8 without loss", () => {
|
||||
const original = "acentuação, emoji 🚀, símbolos ±≠";
|
||||
const buffer = Buffer.from(original);
|
||||
|
||||
expect(isBinaryContent(buffer)).toBe(false);
|
||||
expect(buffer.toString("utf-8")).toBe(original);
|
||||
});
|
||||
|
||||
it("preserves bytes that a UTF-8 decode would have replaced", () => {
|
||||
const bytes = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x10, 0x4a]);
|
||||
|
||||
expect(isBinaryContent(bytes)).toBe(true);
|
||||
// What the old text path would have produced, versus base64.
|
||||
expect(Buffer.from(bytes.toString("utf-8"), "utf-8")).not.toEqual(bytes);
|
||||
expect(Buffer.from(bytes.toString("base64"), "base64")).toEqual(bytes);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
/**
|
||||
* Tests the interaction between restoreConfigFromBase and the auto-commit in
|
||||
* checkAndCommitOrDeleteBranch.
|
||||
*
|
||||
* On pull requests the restore replaces .claude/, CLAUDE.md and friends with
|
||||
* the base branch's versions and leaves them unstaged, so the revert does not
|
||||
* reach a commit. A bare `git add -A` re-staged them anyway and pushed a silent
|
||||
* revert of the PR author's own config onto their branch.
|
||||
*
|
||||
* These run against real git — the fix is a pathspec, so a mock would only
|
||||
* assert that the arguments were passed, not that git honours them.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { checkAndCommitOrDeleteBranch } from "../src/github/operations/branch-cleanup";
|
||||
import { SENSITIVE_PATHS } from "../src/github/operations/restore-config";
|
||||
import type { Octokits } from "../src/github/api/client";
|
||||
|
||||
const BRANCH = "claude/issue-1-20260101-0000";
|
||||
|
||||
let workDir: string;
|
||||
let remoteDir: string;
|
||||
let originalCwd: string;
|
||||
let logSpy: ReturnType<typeof spyOn>;
|
||||
let errorSpy: ReturnType<typeof spyOn>;
|
||||
|
||||
function git(...args: string[]): string {
|
||||
return execFileSync("git", args, { cwd: workDir, encoding: "utf-8" }).trim();
|
||||
}
|
||||
|
||||
function write(relative: string, contents: string) {
|
||||
const full = join(workDir, relative);
|
||||
mkdirSync(join(full, ".."), { recursive: true });
|
||||
writeFileSync(full, contents);
|
||||
}
|
||||
|
||||
/** Branch exists, and has no commits ahead of base, so cleanup inspects git. */
|
||||
const mockOctokit = {
|
||||
rest: {
|
||||
repos: {
|
||||
getBranch: async () => ({ data: {} }),
|
||||
compareCommitsWithBasehead: async () => ({
|
||||
data: { total_commits: 0 },
|
||||
}),
|
||||
},
|
||||
git: { deleteRef: async () => ({ data: {} }) },
|
||||
},
|
||||
} as unknown as Octokits;
|
||||
|
||||
beforeEach(() => {
|
||||
originalCwd = process.cwd();
|
||||
const root = mkdtempSync(join(tmpdir(), "branch-cleanup-"));
|
||||
remoteDir = join(root, "remote.git");
|
||||
workDir = join(root, "work");
|
||||
|
||||
execFileSync("git", ["init", "-q", "--bare", remoteDir]);
|
||||
execFileSync("git", ["init", "-q", "-b", "main", workDir]);
|
||||
git("config", "user.email", "test@example.com");
|
||||
git("config", "user.name", "Test");
|
||||
git("remote", "add", "origin", remoteDir);
|
||||
|
||||
write(".claude/settings.json", '{"from":"base"}\n');
|
||||
write("CLAUDE.md", "base docs\n");
|
||||
write("src/app.ts", "base code\n");
|
||||
git("add", "-A");
|
||||
git("commit", "-qm", "base");
|
||||
git("push", "-q", "origin", "main");
|
||||
git("checkout", "-qb", BRANCH);
|
||||
git("push", "-q", "origin", BRANCH);
|
||||
|
||||
process.chdir(workDir);
|
||||
logSpy = spyOn(console, "log").mockImplementation(() => {});
|
||||
errorSpy = spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd);
|
||||
logSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
rmSync(join(workDir, ".."), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** Files touched by the most recent commit. */
|
||||
function filesInHeadCommit(): string[] {
|
||||
return git("show", "--name-only", "--format=", "HEAD")
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reproduce the working-tree state restoreConfigFromBase leaves behind: the
|
||||
* PR-authored config overwritten with the base branch's content, unstaged, so
|
||||
* git reports it as a plain modification.
|
||||
*/
|
||||
function simulateRestoredConfig() {
|
||||
write(".claude/settings.json", '{"from":"base"}\n');
|
||||
write("CLAUDE.md", "base docs\n");
|
||||
}
|
||||
|
||||
function authorPrConfigEdits() {
|
||||
write(".claude/settings.json", '{"from":"pr-author"}\n');
|
||||
write("CLAUDE.md", "pr author docs\n");
|
||||
git("commit", "-qam", "PR author edits config");
|
||||
}
|
||||
|
||||
describe("auto-commit with restored config paths", () => {
|
||||
test("does not commit the base-branch revert onto the PR branch", async () => {
|
||||
authorPrConfigEdits();
|
||||
simulateRestoredConfig(); // config now reverted + unstaged
|
||||
write("src/app.ts", "claude's real change\n");
|
||||
|
||||
const result = await checkAndCommitOrDeleteBranch(
|
||||
mockOctokit,
|
||||
"owner",
|
||||
"repo",
|
||||
BRANCH,
|
||||
"main",
|
||||
false,
|
||||
[...SENSITIVE_PATHS],
|
||||
);
|
||||
|
||||
expect(filesInHeadCommit()).toEqual(["src/app.ts"]);
|
||||
expect(result.shouldDeleteBranch).toBe(false);
|
||||
});
|
||||
|
||||
test("leaves the reverted config dirty in the working tree", async () => {
|
||||
authorPrConfigEdits();
|
||||
simulateRestoredConfig();
|
||||
write("src/app.ts", "claude's real change\n");
|
||||
|
||||
await checkAndCommitOrDeleteBranch(
|
||||
mockOctokit,
|
||||
"owner",
|
||||
"repo",
|
||||
BRANCH,
|
||||
"main",
|
||||
false,
|
||||
[...SENSITIVE_PATHS],
|
||||
);
|
||||
|
||||
// --name-only gives bare paths, avoiding porcelain's status-column prefix.
|
||||
const stillDirty = git("diff", "--name-only")
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.sort();
|
||||
expect(stillDirty).toEqual([".claude/settings.json", "CLAUDE.md"]);
|
||||
});
|
||||
|
||||
test("treats a branch whose only change is the revert as empty", async () => {
|
||||
// No real work — just the reverted config. Committing here would push a
|
||||
// pure revert and keep an otherwise-empty branch alive.
|
||||
authorPrConfigEdits();
|
||||
simulateRestoredConfig();
|
||||
|
||||
const before = git("rev-parse", "HEAD");
|
||||
const result = await checkAndCommitOrDeleteBranch(
|
||||
mockOctokit,
|
||||
"owner",
|
||||
"repo",
|
||||
BRANCH,
|
||||
"main",
|
||||
false,
|
||||
[...SENSITIVE_PATHS],
|
||||
);
|
||||
|
||||
expect(git("rev-parse", "HEAD")).toBe(before);
|
||||
expect(result.shouldDeleteBranch).toBe(true);
|
||||
expect(result.branchLink).toBe("");
|
||||
});
|
||||
|
||||
test("still commits Claude's own changes to non-config files", async () => {
|
||||
authorPrConfigEdits();
|
||||
simulateRestoredConfig();
|
||||
write("src/app.ts", "changed\n");
|
||||
write("src/new-file.ts", "added\n");
|
||||
|
||||
await checkAndCommitOrDeleteBranch(
|
||||
mockOctokit,
|
||||
"owner",
|
||||
"repo",
|
||||
BRANCH,
|
||||
"main",
|
||||
false,
|
||||
[...SENSITIVE_PATHS],
|
||||
);
|
||||
|
||||
expect(filesInHeadCommit()).toEqual(["src/app.ts", "src/new-file.ts"]);
|
||||
});
|
||||
|
||||
test("pushes the commit to the branch", async () => {
|
||||
authorPrConfigEdits();
|
||||
simulateRestoredConfig();
|
||||
write("src/app.ts", "claude's real change\n");
|
||||
|
||||
const result = await checkAndCommitOrDeleteBranch(
|
||||
mockOctokit,
|
||||
"owner",
|
||||
"repo",
|
||||
BRANCH,
|
||||
"main",
|
||||
false,
|
||||
[...SENSITIVE_PATHS],
|
||||
);
|
||||
|
||||
const remoteHead = execFileSync(
|
||||
"git",
|
||||
["--git-dir", remoteDir, "rev-parse", BRANCH],
|
||||
{ encoding: "utf-8" },
|
||||
).trim();
|
||||
expect(remoteHead).toBe(git("rev-parse", "HEAD"));
|
||||
expect(result.branchLink).toContain(BRANCH);
|
||||
});
|
||||
});
|
||||
|
||||
describe("without restored config paths (the issue path)", () => {
|
||||
test("commits config changes normally, since no revert happened", async () => {
|
||||
// Reached for issues, where restoreConfigFromBase never runs and Claude may
|
||||
// have been asked to edit CLAUDE.md. Excluding it here would drop the work.
|
||||
write("CLAUDE.md", "claude wrote these docs\n");
|
||||
write(".claude/settings.json", '{"written":"by claude"}\n');
|
||||
write("src/app.ts", "and some code\n");
|
||||
|
||||
await checkAndCommitOrDeleteBranch(
|
||||
mockOctokit,
|
||||
"owner",
|
||||
"repo",
|
||||
BRANCH,
|
||||
"main",
|
||||
false,
|
||||
[],
|
||||
);
|
||||
|
||||
expect(filesInHeadCommit()).toEqual([
|
||||
".claude/settings.json",
|
||||
"CLAUDE.md",
|
||||
"src/app.ts",
|
||||
]);
|
||||
});
|
||||
|
||||
test("defaults to committing everything when the argument is omitted", async () => {
|
||||
// Backwards compatibility: the parameter is optional.
|
||||
write("CLAUDE.md", "claude wrote these docs\n");
|
||||
|
||||
await checkAndCommitOrDeleteBranch(
|
||||
mockOctokit,
|
||||
"owner",
|
||||
"repo",
|
||||
BRANCH,
|
||||
"main",
|
||||
false,
|
||||
);
|
||||
|
||||
expect(filesInHeadCommit()).toEqual(["CLAUDE.md"]);
|
||||
});
|
||||
});
|
||||
@@ -291,5 +291,68 @@ describe("branch template utilities", () => {
|
||||
expect(result).toMatch(/^fix\/pr-456-\d{8}-\d{4}$/);
|
||||
expect(result.length).toBeLessThanOrEqual(50);
|
||||
});
|
||||
|
||||
// Regression: a title with no ASCII-alphanumeric content makes
|
||||
// {{description}} sanitize to an empty string. Around a slash separator this
|
||||
// previously produced "claude//123", which validateBranchName rejects
|
||||
// ("cannot contain consecutive slashes"), aborting the entire run.
|
||||
it("should collapse the double slash from an empty description (emoji-only title)", () => {
|
||||
const template = "{{prefix}}{{description}}/{{entityNumber}}";
|
||||
const result = generateBranchName(
|
||||
template,
|
||||
"claude/",
|
||||
"issue",
|
||||
123,
|
||||
undefined,
|
||||
undefined,
|
||||
"🎉🎉🎉",
|
||||
);
|
||||
|
||||
expect(result).toBe("claude/123");
|
||||
});
|
||||
|
||||
it("should collapse the double slash for a CJK-only title", () => {
|
||||
const template = "{{prefix}}{{description}}/{{entityNumber}}";
|
||||
const result = generateBranchName(
|
||||
template,
|
||||
"claude/",
|
||||
"issue",
|
||||
123,
|
||||
undefined,
|
||||
undefined,
|
||||
"日本語のタイトル",
|
||||
);
|
||||
|
||||
expect(result).toBe("claude/123");
|
||||
});
|
||||
|
||||
it("should drop a trailing slash left by an empty trailing segment", () => {
|
||||
const template = "{{prefix}}{{entityNumber}}/{{description}}";
|
||||
const result = generateBranchName(
|
||||
template,
|
||||
"claude/",
|
||||
"issue",
|
||||
123,
|
||||
undefined,
|
||||
undefined,
|
||||
"!!! ???",
|
||||
);
|
||||
|
||||
expect(result).toBe("claude/123");
|
||||
});
|
||||
|
||||
it("should produce a name that passes validateBranchName when a segment is empty", () => {
|
||||
const result = generateBranchName(
|
||||
"{{prefix}}{{description}}/{{entityNumber}}",
|
||||
"claude/",
|
||||
"issue",
|
||||
123,
|
||||
undefined,
|
||||
undefined,
|
||||
"🎉",
|
||||
);
|
||||
|
||||
expect(() => validateBranchName(result)).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,6 +36,12 @@ describe("comments/common", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("encodes URL-significant characters in a branch name", () => {
|
||||
expect(createBranchLink("o", "r", "claude/fix#123")).toBe(
|
||||
`\n[View branch](${GITHUB_SERVER_URL}/o/r/tree/claude/fix%23123)`,
|
||||
);
|
||||
});
|
||||
|
||||
test("prefixes the link with a newline so it renders on its own line", () => {
|
||||
expect(createBranchLink("o", "r", "main").startsWith("\n")).toBe(true);
|
||||
});
|
||||
|
||||
@@ -819,11 +819,46 @@ describe("generatePrompt", () => {
|
||||
// Should have commit signing tool instructions
|
||||
expect(prompt).toContain("mcp__github_file_ops__commit_files");
|
||||
expect(prompt).toContain("mcp__github_file_ops__delete_files");
|
||||
expect(prompt).toContain(
|
||||
'mcp__github_file_ops__delete_files: {"paths": ["path/to/old.js"]',
|
||||
);
|
||||
expect(prompt).not.toContain(
|
||||
'mcp__github_file_ops__delete_files: {"files":',
|
||||
);
|
||||
// Comment tool should always be from comment server, not file ops
|
||||
expect(prompt).toContain("mcp__github_comment__update_claude_comment");
|
||||
|
||||
// Should not have git command instructions
|
||||
expect(prompt).not.toContain("Use git commands via the Bash tool");
|
||||
|
||||
// Bash is off unless the user passes --allowedTools through claude_args.
|
||||
// allowed_tools was removed in v1.0 and must not appear as live guidance.
|
||||
expect(prompt).toContain(
|
||||
"Run arbitrary Bash commands (unless explicitly allowed via claude_args with --allowedTools)",
|
||||
);
|
||||
expect(prompt).not.toContain("allowed_tools configuration");
|
||||
});
|
||||
|
||||
test("does not mention allowed_tools when commit signing is off", async () => {
|
||||
const envVars: PreparedContext = {
|
||||
repository: "owner/repo",
|
||||
claudeCommentId: "12345",
|
||||
triggerPhrase: "@claude",
|
||||
eventData: {
|
||||
eventName: "issue_comment",
|
||||
commentId: "67890",
|
||||
isPR: true,
|
||||
prNumber: "123",
|
||||
commentBody: "@claude fix the bug",
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = await generatePrompt(envVars, mockGitHubData, false, "tag");
|
||||
|
||||
expect(prompt).not.toContain("allowed_tools");
|
||||
expect(prompt).not.toContain(
|
||||
"Run arbitrary Bash commands (unless explicitly allowed",
|
||||
);
|
||||
});
|
||||
|
||||
describe("simplified prompt (USE_SIMPLE_PROMPT)", () => {
|
||||
|
||||
@@ -1215,7 +1215,10 @@ describe("fetchGitHubData integration with time filtering", () => {
|
||||
{
|
||||
id: "2",
|
||||
databaseId: "2",
|
||||
author: { login: "scanner[bot]" },
|
||||
// GraphQL returns the bare login for App actors plus
|
||||
// __typename: "Bot". It does NOT append a "[bot]" suffix the
|
||||
// way REST does, so this mirrors a real payload.
|
||||
author: { __typename: "Bot", login: "scanner" },
|
||||
body: "Pre-trigger bot review",
|
||||
state: "COMMENTED",
|
||||
submittedAt: "2024-01-15T11:00:00Z",
|
||||
@@ -1775,6 +1778,49 @@ describe("fetchGitHubData integration with time filtering", () => {
|
||||
// Webhook says no body at trigger time — attacker-added GraphQL body must not be used
|
||||
expect(result.contextData.body).toBe("");
|
||||
});
|
||||
|
||||
it("should not crash when GraphQL returns null files for a very large PR", async () => {
|
||||
// GitHub declines to compute the diff for very large PRs: `files` comes
|
||||
// back as null (with no errors entry) and `changedFiles` is misreported
|
||||
// as 0. The fetch must degrade gracefully instead of throwing.
|
||||
const mockOctokits = {
|
||||
graphql: jest.fn().mockResolvedValue({
|
||||
repository: {
|
||||
pullRequest: {
|
||||
number: 7912,
|
||||
title: "Very large PR",
|
||||
body: "PR body",
|
||||
author: { login: "author" },
|
||||
createdAt: "2024-01-15T10:00:00Z",
|
||||
state: "OPEN",
|
||||
labels: { nodes: [] },
|
||||
comments: { nodes: [] },
|
||||
files: null,
|
||||
reviews: { nodes: [] },
|
||||
},
|
||||
},
|
||||
user: { login: "trigger-user" },
|
||||
}),
|
||||
rest: {
|
||||
pulls: {
|
||||
listFiles: jest.fn().mockResolvedValue({ data: [] }),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await fetchGitHubData({
|
||||
octokits: mockOctokits as any,
|
||||
repository: "test-owner/test-repo",
|
||||
prNumber: "7912",
|
||||
isPR: true,
|
||||
triggerUsername: "trigger-user",
|
||||
triggerTime: "2024-01-15T12:00:00Z",
|
||||
});
|
||||
|
||||
// No file list is available, so the PR is processed without file-level context.
|
||||
expect(result.changedFiles).toEqual([]);
|
||||
expect(result.changedFilesWithSHA).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterCommentsByActor", () => {
|
||||
|
||||
@@ -108,6 +108,43 @@ Changed Files: 2 files`,
|
||||
);
|
||||
});
|
||||
|
||||
test("renders an unknown file count when GraphQL returns null files (very large PR)", () => {
|
||||
// GitHub declines to compute the diff for very large PRs and returns
|
||||
// `files: null`. `changedFiles` is misreported as 0 in that case, so the
|
||||
// count must render as unavailable rather than "0 files".
|
||||
const prData: GitHubPullRequest = {
|
||||
title: "Very large PR",
|
||||
body: "PR body",
|
||||
author: { login: "test-user" },
|
||||
baseRefName: "main",
|
||||
headRefName: "feature/test",
|
||||
headRefOid: "abc123",
|
||||
isCrossRepository: false,
|
||||
headRepository: { owner: { login: "testowner" }, name: "testrepo" },
|
||||
createdAt: "2023-01-01T00:00:00Z",
|
||||
additions: 50,
|
||||
deletions: 30,
|
||||
state: "OPEN",
|
||||
labels: {
|
||||
nodes: [],
|
||||
},
|
||||
commits: {
|
||||
totalCount: 3,
|
||||
nodes: [],
|
||||
},
|
||||
files: null,
|
||||
comments: {
|
||||
nodes: [],
|
||||
},
|
||||
reviews: {
|
||||
nodes: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = formatContext(prData, true);
|
||||
expect(result).toContain("Changed Files: unknown (file list unavailable)");
|
||||
});
|
||||
|
||||
test("formats Issue context correctly", () => {
|
||||
const issueData: GitHubIssue = {
|
||||
title: "Test Issue",
|
||||
@@ -471,6 +508,112 @@ describe("formatReviewComments", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("includes the diff hunk as context when present", () => {
|
||||
const reviewData = {
|
||||
nodes: [
|
||||
{
|
||||
id: "review1",
|
||||
databaseId: "300001",
|
||||
author: { login: "reviewer1" },
|
||||
body: "",
|
||||
state: "COMMENTED",
|
||||
submittedAt: "2023-01-01T00:00:00Z",
|
||||
comments: {
|
||||
nodes: [
|
||||
{
|
||||
id: "comment1",
|
||||
databaseId: "200001",
|
||||
body: "This can overflow",
|
||||
author: { login: "reviewer1" },
|
||||
createdAt: "2023-01-01T00:00:00Z",
|
||||
path: "src/index.ts",
|
||||
line: 42,
|
||||
diffHunk: "@@ -40,3 +40,3 @@\n-const a = 1;\n+const a = 2;",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = formatReviewComments(reviewData);
|
||||
|
||||
expect(result).toContain("[Comment on src/index.ts:42]: This can overflow");
|
||||
expect(result).toContain("Diff context:");
|
||||
expect(result).toContain("@@ -40,3 +40,3 @@");
|
||||
expect(result).toContain("+const a = 2;");
|
||||
});
|
||||
|
||||
test("omits the diff context when the comment has no diff hunk", () => {
|
||||
const reviewData = {
|
||||
nodes: [
|
||||
{
|
||||
id: "review1",
|
||||
databaseId: "300001",
|
||||
author: { login: "reviewer1" },
|
||||
body: "",
|
||||
state: "COMMENTED",
|
||||
submittedAt: "2023-01-01T00:00:00Z",
|
||||
comments: {
|
||||
nodes: [
|
||||
{
|
||||
id: "comment1",
|
||||
databaseId: "200001",
|
||||
body: "No hunk here",
|
||||
author: { login: "reviewer1" },
|
||||
createdAt: "2023-01-01T00:00:00Z",
|
||||
path: "src/index.ts",
|
||||
line: 42,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = formatReviewComments(reviewData);
|
||||
|
||||
expect(result).toContain("[Comment on src/index.ts:42]: No hunk here");
|
||||
expect(result).not.toContain("Diff context:");
|
||||
});
|
||||
|
||||
// GitHub returns line: null and diffHunk: "" for outdated comments whose
|
||||
// line no longer exists in the diff (observed on anthropics/claude-code-action#1025).
|
||||
test("omits the diff context for an outdated comment with an empty diff hunk", () => {
|
||||
const reviewData = {
|
||||
nodes: [
|
||||
{
|
||||
id: "review1",
|
||||
databaseId: "300001",
|
||||
author: { login: "reviewer1" },
|
||||
body: "",
|
||||
state: "COMMENTED",
|
||||
submittedAt: "2023-01-01T00:00:00Z",
|
||||
comments: {
|
||||
nodes: [
|
||||
{
|
||||
id: "comment1",
|
||||
databaseId: "200001",
|
||||
body: "Outdated comment",
|
||||
author: { login: "reviewer1" },
|
||||
createdAt: "2023-01-01T00:00:00Z",
|
||||
path: "src/index.ts",
|
||||
line: null,
|
||||
diffHunk: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = formatReviewComments(reviewData);
|
||||
|
||||
expect(result).toContain("[Comment on src/index.ts:?]: Outdated comment");
|
||||
expect(result).not.toContain("Diff context:");
|
||||
expect(result).not.toContain("```diff");
|
||||
});
|
||||
|
||||
test("formats review with only body (no comments) correctly", () => {
|
||||
const reviewData = {
|
||||
nodes: [
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { describe, expect, test, beforeAll } from "bun:test";
|
||||
import { generatePrompt } from "../src/create-prompt";
|
||||
import type { PreparedContext } from "../src/create-prompt";
|
||||
import {
|
||||
commitFilesPayloadSchema,
|
||||
deleteFilesPayloadSchema,
|
||||
} from "../src/mcp/github-file-ops-schemas";
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.GITHUB_ACTION_PATH = "/test/action/path";
|
||||
});
|
||||
|
||||
const mockGitHubData = {
|
||||
contextData: {
|
||||
title: "Test PR",
|
||||
body: "This is a test PR",
|
||||
author: { login: "testuser" },
|
||||
state: "OPEN",
|
||||
labels: { nodes: [] },
|
||||
createdAt: "2023-01-01T00:00:00Z",
|
||||
additions: 15,
|
||||
deletions: 5,
|
||||
baseRefName: "main",
|
||||
headRefName: "feature-branch",
|
||||
headRefOid: "abc123",
|
||||
isCrossRepository: false,
|
||||
headRepository: { owner: { login: "testowner" }, name: "testrepo" },
|
||||
commits: { totalCount: 0, nodes: [] },
|
||||
files: { nodes: [] },
|
||||
comments: { nodes: [] },
|
||||
reviews: { nodes: [] },
|
||||
},
|
||||
comments: [],
|
||||
changedFiles: [],
|
||||
changedFilesWithSHA: [],
|
||||
reviewData: null,
|
||||
imageUrlMap: new Map<string, string>(),
|
||||
};
|
||||
|
||||
const signingContext: PreparedContext = {
|
||||
repository: "owner/repo",
|
||||
claudeCommentId: "12345",
|
||||
triggerPhrase: "@claude",
|
||||
eventData: {
|
||||
eventName: "issue_comment",
|
||||
commentId: "67890",
|
||||
isPR: true,
|
||||
prNumber: "123",
|
||||
commentBody: "@claude delete the old file",
|
||||
},
|
||||
};
|
||||
|
||||
function extractToolExample(
|
||||
prompt: string,
|
||||
tool: string,
|
||||
): Record<string, unknown> {
|
||||
const match = prompt.match(
|
||||
new RegExp(
|
||||
`${tool.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}:\\s*(\\{[^}]+\\})`,
|
||||
),
|
||||
);
|
||||
if (!match) {
|
||||
throw new Error(`No JSON example for ${tool} in prompt`);
|
||||
}
|
||||
return JSON.parse(match[1] as string);
|
||||
}
|
||||
|
||||
describe("delete_files prompt vs live MCP schema (#1665)", () => {
|
||||
test("the payload the old prompt taught is rejected by the tool schema", () => {
|
||||
const taughtByOldPrompt = {
|
||||
files: ["path/to/old.js"],
|
||||
message: "chore: remove deprecated file",
|
||||
};
|
||||
const result = deleteFilesPayloadSchema.safeParse(taughtByOldPrompt);
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
const fields = result.error.issues.map((issue) => issue.path.join("."));
|
||||
expect(fields).toContain("paths");
|
||||
}
|
||||
});
|
||||
|
||||
test("the payload the new prompt teaches is accepted by the tool schema", () => {
|
||||
const taughtByNewPrompt = {
|
||||
paths: ["path/to/old.js"],
|
||||
message: "chore: remove deprecated file",
|
||||
};
|
||||
const result = deleteFilesPayloadSchema.safeParse(taughtByNewPrompt);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("generated tag-mode prompt example parses against the live schema", async () => {
|
||||
const prompt = await generatePrompt(
|
||||
signingContext,
|
||||
mockGitHubData,
|
||||
true,
|
||||
"tag",
|
||||
);
|
||||
const example = extractToolExample(
|
||||
prompt,
|
||||
"mcp__github_file_ops__delete_files",
|
||||
);
|
||||
expect(example).toHaveProperty("paths");
|
||||
expect(example).not.toHaveProperty("files");
|
||||
const result = deleteFilesPayloadSchema.safeParse(example);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects paths when the value is a string instead of an array", () => {
|
||||
const result = deleteFilesPayloadSchema.safeParse({
|
||||
paths: "path/to/old.js",
|
||||
message: "chore: remove deprecated file",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects a payload that has paths but omits message", () => {
|
||||
const result = deleteFilesPayloadSchema.safeParse({
|
||||
paths: ["path/to/old.js"],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(
|
||||
result.error.issues.map((issue) => issue.path.join(".")),
|
||||
).toContain("message");
|
||||
}
|
||||
});
|
||||
|
||||
test("accepts a payload that still includes the old files key beside paths", () => {
|
||||
const result = deleteFilesPayloadSchema.safeParse({
|
||||
files: ["path/to/old.js"],
|
||||
paths: ["path/to/old.js"],
|
||||
message: "chore: remove deprecated file",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("does not change commit_files — that sibling tool still requires files", async () => {
|
||||
const prompt = await generatePrompt(
|
||||
signingContext,
|
||||
mockGitHubData,
|
||||
true,
|
||||
"tag",
|
||||
);
|
||||
const example = extractToolExample(
|
||||
prompt,
|
||||
"mcp__github_file_ops__commit_files",
|
||||
);
|
||||
expect(example).toHaveProperty("files");
|
||||
expect(example).not.toHaveProperty("paths");
|
||||
expect(commitFilesPayloadSchema.safeParse(example).success).toBe(true);
|
||||
expect(deleteFilesPayloadSchema.safeParse(example).success).toBe(false);
|
||||
});
|
||||
|
||||
test("generated delete_files example keys are exactly paths and message", async () => {
|
||||
const prompt = await generatePrompt(
|
||||
signingContext,
|
||||
mockGitHubData,
|
||||
true,
|
||||
"tag",
|
||||
);
|
||||
const example = extractToolExample(
|
||||
prompt,
|
||||
"mcp__github_file_ops__delete_files",
|
||||
);
|
||||
expect(Object.keys(example).sort()).toEqual(["message", "paths"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { execFileSync } from "child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { setupBranch } from "../src/github/operations/branch";
|
||||
import { fetchDepthArgs } from "../src/github/operations/fetch-depth";
|
||||
import { createMockContext } from "./mockContext";
|
||||
|
||||
const octokits = {
|
||||
rest: {
|
||||
repos: { get: async () => ({ data: { default_branch: "main" } }) },
|
||||
git: { getRef: async () => ({ data: { object: { sha: "abc1234" } } }) },
|
||||
},
|
||||
} as any;
|
||||
|
||||
const githubData = {
|
||||
contextData: { title: "Add feature", labels: { nodes: [] } },
|
||||
} as any;
|
||||
|
||||
describe("setupBranch fetch depth", () => {
|
||||
let originalCwd: string;
|
||||
let tempDir = "";
|
||||
let repoDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
originalCwd = process.cwd();
|
||||
tempDir = mkdtempSync(join("/tmp", "fetch-depth-"));
|
||||
repoDir = join(tempDir, "repo");
|
||||
const remoteDir = join(tempDir, "origin.git");
|
||||
|
||||
// Pin the remote's HEAD to main: with the default init.defaultBranch of
|
||||
// master it would dangle, and `git clone --depth=1` (which implies
|
||||
// --single-branch) then produces an empty, non-shallow clone.
|
||||
execFileSync(
|
||||
"git",
|
||||
["init", "--bare", "--initial-branch=main", remoteDir],
|
||||
{
|
||||
stdio: "pipe",
|
||||
},
|
||||
);
|
||||
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
|
||||
git(["checkout", "-b", "main"]);
|
||||
git(["config", "user.email", "test@example.com"]);
|
||||
git(["config", "user.name", "Test User"]);
|
||||
|
||||
for (const message of ["first", "second", "third"]) {
|
||||
writeFileSync(join(repoDir, `${message}.txt`), `${message}\n`);
|
||||
git(["add", "."]);
|
||||
git(["commit", "-m", message]);
|
||||
}
|
||||
|
||||
git(["remote", "add", "origin", remoteDir]);
|
||||
git(["push", "-u", "origin", "main"]);
|
||||
|
||||
process.chdir(repoDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd);
|
||||
if (tempDir) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
for (const useCommitSigning of [false, true]) {
|
||||
test(`keeps the full history of a complete checkout with use_commit_signing: ${useCommitSigning}`, async () => {
|
||||
const context = createMockContext({
|
||||
isPR: false,
|
||||
entityNumber: 7,
|
||||
inputs: { useCommitSigning, branchPrefix: "claude/" },
|
||||
});
|
||||
|
||||
await setupBranch(octokits, githubData, context);
|
||||
|
||||
expect(git(["rev-parse", "--is-shallow-repository"]).trim()).toBe(
|
||||
"false",
|
||||
);
|
||||
expect(git(["rev-list", "--count", "HEAD"]).trim()).toBe("3");
|
||||
});
|
||||
}
|
||||
|
||||
test("still limits the depth on an already shallow checkout", () => {
|
||||
const shallowDir = join(tempDir, "shallow");
|
||||
execFileSync(
|
||||
"git",
|
||||
[
|
||||
"clone",
|
||||
"--depth=1",
|
||||
`file://${join(tempDir, "origin.git")}`,
|
||||
shallowDir,
|
||||
],
|
||||
{ stdio: "pipe" },
|
||||
);
|
||||
|
||||
process.chdir(shallowDir);
|
||||
expect(
|
||||
execFileSync("git", ["rev-parse", "--is-shallow-repository"], {
|
||||
cwd: shallowDir,
|
||||
encoding: "utf8",
|
||||
}).trim(),
|
||||
).toBe("true");
|
||||
expect(fetchDepthArgs(20)).toEqual(["--depth=20"]);
|
||||
});
|
||||
|
||||
test("drops the depth limit on a complete checkout", () => {
|
||||
expect(fetchDepthArgs(20)).toEqual([]);
|
||||
});
|
||||
|
||||
function git(args: string[]): string {
|
||||
return execFileSync("git", args, {
|
||||
cwd: repoDir,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
}
|
||||
});
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
// Wire-level probe for the GitHub API client's endpoint routing.
|
||||
//
|
||||
// `src/github/api/config.ts` reads GITHUB_API_URL / GITHUB_GRAPHQL_URL at module
|
||||
// load time, so each endpoint configuration has to be exercised in its own fresh
|
||||
// process (the companion test spawns this file once per case with the relevant
|
||||
// env vars set). We stub global fetch to capture the FINAL request URL and
|
||||
// Authorization header — asserting constructor options is not enough because
|
||||
// @octokit/graphql rewrites/append the path (".../api/v3" -> ".../api/graphql",
|
||||
// otherwise it appends "/graphql") after the client is constructed.
|
||||
import { createOctokit } from "../../src/github/api/client";
|
||||
|
||||
type Captured = { url: string; auth: string | null };
|
||||
const captured: Captured[] = [];
|
||||
|
||||
globalThis.fetch = (async (input: any, init?: any) => {
|
||||
const url: string =
|
||||
typeof input === "string" ? input : (input?.url ?? String(input));
|
||||
const headers = new Headers(init?.headers ?? input?.headers);
|
||||
captured.push({ url, auth: headers.get("authorization") });
|
||||
return new Response(JSON.stringify({ data: {} }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
const octokits = createOctokit("test-token");
|
||||
|
||||
await octokits.graphql(`query { viewer { login } }`);
|
||||
const graphql = captured[captured.length - 1]!;
|
||||
|
||||
await octokits.rest.request("GET /meta");
|
||||
const rest = captured[captured.length - 1]!;
|
||||
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
graphqlUrl: graphql.url,
|
||||
graphqlAuth: graphql.auth,
|
||||
restUrl: rest.url,
|
||||
restAuth: rest.auth,
|
||||
}),
|
||||
);
|
||||
@@ -111,6 +111,42 @@ describe("formatResultContent", () => {
|
||||
const result = formatResultContent(JSON.stringify(structuredContent));
|
||||
expect(result).toBe("**→** Hello world\n\n");
|
||||
});
|
||||
|
||||
test("keeps every text block, not just the first", () => {
|
||||
const structuredContent = [
|
||||
{ type: "text", text: "first line" },
|
||||
{ type: "text", text: "second line" },
|
||||
{ type: "text", text: "third line" },
|
||||
];
|
||||
const result = formatResultContent(JSON.stringify(structuredContent));
|
||||
|
||||
expect(result).toContain("first line");
|
||||
expect(result).toContain("second line");
|
||||
expect(result).toContain("third line");
|
||||
});
|
||||
|
||||
test("keeps every text block when given an array directly", () => {
|
||||
const result = formatResultContent([
|
||||
{ type: "text", text: "alpha" },
|
||||
{ type: "text", text: "beta" },
|
||||
]);
|
||||
|
||||
expect(result).toContain("alpha");
|
||||
expect(result).toContain("beta");
|
||||
});
|
||||
|
||||
test("skips non-text blocks while keeping the text ones", () => {
|
||||
const structuredContent = [
|
||||
{ type: "text", text: "visible" },
|
||||
{ type: "image", source: { data: "ignored-binary" } },
|
||||
{ type: "text", text: "also visible" },
|
||||
];
|
||||
const result = formatResultContent(JSON.stringify(structuredContent));
|
||||
|
||||
expect(result).toContain("visible");
|
||||
expect(result).toContain("also visible");
|
||||
expect(result).not.toContain("ignored-binary");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatToolWithResult", () => {
|
||||
@@ -467,6 +503,15 @@ describe("formatResultContent non-string input", () => {
|
||||
expect(typeof result).toBe("string");
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("handles a text content block whose text field is not a string", () => {
|
||||
expect(() =>
|
||||
formatResultContent('[{"type":"text","text":{"foo":"bar"}}]'),
|
||||
).not.toThrow();
|
||||
expect(formatResultContent('[{"type":"text","text":123}]')).toContain(
|
||||
"123",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("system_other handling", () => {
|
||||
@@ -515,3 +560,106 @@ describe("system_other handling", () => {
|
||||
expect(result).toContain("## 🚀 System Initialization");
|
||||
});
|
||||
});
|
||||
|
||||
describe("credential redaction", () => {
|
||||
test("redacts credentials embedded in tool results", () => {
|
||||
const data: Turn[] = [
|
||||
{
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_1",
|
||||
name: "Bash",
|
||||
input: { command: "cat .env" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "user",
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_1",
|
||||
content:
|
||||
"GITHUB_TOKEN=ghs_xz7yzju2SZjGPa0dUNMAx0SH4xDOCS31LXQW\nAWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const result = formatTurnsFromData(data);
|
||||
|
||||
expect(result).toContain("[REDACTED_GITHUB_TOKEN]");
|
||||
expect(result).toContain("[REDACTED_AWS_KEY_ID]");
|
||||
expect(result).not.toContain("ghs_xz7yzju2SZjGPa0dUNMAx0SH4xDOCS31LXQW");
|
||||
expect(result).not.toContain("AKIAIOSFODNN7EXAMPLE");
|
||||
});
|
||||
|
||||
test("redacts credentials embedded in multi-line tool inputs", () => {
|
||||
const data: Turn[] = [
|
||||
{
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_2",
|
||||
name: "Write",
|
||||
input: {
|
||||
file_path: ".env",
|
||||
content:
|
||||
"AWS_ACCESS_KEY_ID=x\nGITHUB_TOKEN=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\n",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const result = formatTurnsFromData(data);
|
||||
|
||||
expect(result).toContain("[REDACTED_GITHUB_TOKEN]");
|
||||
expect(result).not.toContain("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");
|
||||
});
|
||||
|
||||
test("redacts credentials wrapped in ANSI color codes", () => {
|
||||
const key = "sk-ant-api03-AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-abcdefgh";
|
||||
const data: Turn[] = [
|
||||
{
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_3",
|
||||
name: "Bash",
|
||||
input: { command: "node print-config.js" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "user",
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_3",
|
||||
content: `apiKey: \x1b[32m${key}\x1b[39m\nregion: us-east-1`,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const result = formatTurnsFromData(data);
|
||||
|
||||
expect(result).toContain("[REDACTED_ANTHROPIC_KEY]");
|
||||
expect(result).not.toContain(key);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
||||
import { execFileSync } from "child_process";
|
||||
import { mkdtempSync, rmSync, statSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import {
|
||||
configureGitAuth,
|
||||
replaceCheckoutCredentials,
|
||||
} from "../src/github/operations/git-config";
|
||||
import { GITHUB_SERVER_URL } from "../src/github/api/config";
|
||||
import { createMockAutomationContext } from "./mockContext";
|
||||
|
||||
// Derive host-specific expectations from GITHUB_SERVER_URL so the suite passes
|
||||
// on GHES runners (where Actions exports that variable) as well as github.com.
|
||||
const SERVER = new URL(GITHUB_SERVER_URL);
|
||||
const NOREPLY_DOMAIN =
|
||||
SERVER.hostname === "github.com"
|
||||
? "users.noreply.github.com"
|
||||
: `users.noreply.${SERVER.hostname}`;
|
||||
const EXTRAHEADER_KEY = `http.${GITHUB_SERVER_URL}/.extraheader`;
|
||||
|
||||
// git exports these into hooks (e.g. a pre-commit hook running the test
|
||||
// suite); if inherited they would point every git command below at the
|
||||
// enclosing repository instead of the temp repo.
|
||||
const GIT_ENV_OVERRIDES = [
|
||||
"GIT_DIR",
|
||||
"GIT_WORK_TREE",
|
||||
"GIT_INDEX_FILE",
|
||||
"GIT_COMMON_DIR",
|
||||
"GIT_PREFIX",
|
||||
] as const;
|
||||
|
||||
// Pass an explicit env copy: unlike bun's `$`, execFileSync does not pick up
|
||||
// deletions from process.env, so the GIT_* overrides removed in beforeEach
|
||||
// would otherwise still reach the child process.
|
||||
function runGit(args: string[], cwd?: string): string {
|
||||
return execFileSync("git", args, {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
stdio: "pipe",
|
||||
env: { ...process.env },
|
||||
}).trim();
|
||||
}
|
||||
|
||||
function gitConfigGetAll(key: string): string {
|
||||
try {
|
||||
return runGit(["config", "--local", "--get-all", key]);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function remoteUrl(): string {
|
||||
return runGit(["remote", "get-url", "origin"]);
|
||||
}
|
||||
|
||||
describe("git-config", () => {
|
||||
let originalCwd: string;
|
||||
let tempDir: string;
|
||||
let repoDir: string;
|
||||
let originalActionPath: string | undefined;
|
||||
let originalNonWriteUsers: string | undefined;
|
||||
let originalGhToken: string | undefined;
|
||||
let originalGitEnv: Record<string, string | undefined>;
|
||||
let consoleLogSpy: any;
|
||||
|
||||
beforeEach(() => {
|
||||
originalCwd = process.cwd();
|
||||
originalActionPath = process.env.GITHUB_ACTION_PATH;
|
||||
originalNonWriteUsers = process.env.ALLOWED_NON_WRITE_USERS;
|
||||
originalGhToken = process.env.GH_TOKEN;
|
||||
delete process.env.ALLOWED_NON_WRITE_USERS;
|
||||
originalGitEnv = {};
|
||||
for (const name of GIT_ENV_OVERRIDES) {
|
||||
originalGitEnv[name] = process.env[name];
|
||||
delete process.env[name];
|
||||
}
|
||||
|
||||
tempDir = mkdtempSync(join(tmpdir(), "git-config-test-"));
|
||||
repoDir = join(tempDir, "repo");
|
||||
runGit(["init", repoDir]);
|
||||
process.env.GITHUB_ACTION_PATH = tempDir;
|
||||
process.chdir(repoDir);
|
||||
|
||||
git(["remote", "add", "origin", `https://${SERVER.host}/test/repo.git`]);
|
||||
// Mimic the credential actions/checkout persists in the local config
|
||||
git([
|
||||
"config",
|
||||
"--local",
|
||||
"--add",
|
||||
EXTRAHEADER_KEY,
|
||||
"AUTHORIZATION: basic one",
|
||||
]);
|
||||
git([
|
||||
"config",
|
||||
"--local",
|
||||
"--add",
|
||||
EXTRAHEADER_KEY,
|
||||
"AUTHORIZATION: basic two",
|
||||
]);
|
||||
git(["config", "--local", "user.name", "pre-existing"]);
|
||||
|
||||
consoleLogSpy = spyOn(console, "log").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd);
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
consoleLogSpy?.mockRestore();
|
||||
restoreEnv("GITHUB_ACTION_PATH", originalActionPath);
|
||||
restoreEnv("ALLOWED_NON_WRITE_USERS", originalNonWriteUsers);
|
||||
restoreEnv("GH_TOKEN", originalGhToken);
|
||||
for (const name of GIT_ENV_OVERRIDES) {
|
||||
restoreEnv(name, originalGitEnv[name]);
|
||||
}
|
||||
});
|
||||
|
||||
describe("replaceCheckoutCredentials", () => {
|
||||
test("removes the checkout extraheader and sets a token remote URL", async () => {
|
||||
expect(gitConfigGetAll(EXTRAHEADER_KEY)).toContain("AUTHORIZATION");
|
||||
|
||||
await replaceCheckoutCredentials(
|
||||
"test-token",
|
||||
createMockAutomationContext(),
|
||||
);
|
||||
|
||||
expect(gitConfigGetAll(EXTRAHEADER_KEY)).toBe("");
|
||||
expect(remoteUrl()).toBe(
|
||||
`https://x-access-token:test-token@${SERVER.host}/test-owner/test-repo.git`,
|
||||
);
|
||||
// Only the credential is touched — the git identity is left alone
|
||||
expect(gitConfigGetAll("user.name")).toBe("pre-existing");
|
||||
});
|
||||
|
||||
test("uses a credential helper when non-write users are allowed", async () => {
|
||||
process.env.ALLOWED_NON_WRITE_USERS = "someone";
|
||||
|
||||
await replaceCheckoutCredentials(
|
||||
"helper-token",
|
||||
createMockAutomationContext(),
|
||||
);
|
||||
|
||||
expect(gitConfigGetAll(EXTRAHEADER_KEY)).toBe("");
|
||||
expect(remoteUrl()).toBe(
|
||||
`https://${SERVER.host}/test-owner/test-repo.git`,
|
||||
);
|
||||
const helperPath = join(tempDir, ".git-credential-gh-token");
|
||||
expect(gitConfigGetAll("credential.helper")).toBe(helperPath);
|
||||
expect(statSync(helperPath).mode & 0o777).toBe(0o700);
|
||||
expect(process.env.GH_TOKEN).toBe("helper-token");
|
||||
});
|
||||
|
||||
test("succeeds when there is no checkout extraheader to remove", async () => {
|
||||
git(["config", "--local", "--unset-all", EXTRAHEADER_KEY]);
|
||||
|
||||
await expect(
|
||||
replaceCheckoutCredentials("test-token", createMockAutomationContext()),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(remoteUrl()).toContain("x-access-token:test-token@");
|
||||
});
|
||||
});
|
||||
|
||||
describe("configureGitAuth", () => {
|
||||
test("configures the git user and replaces the checkout credential", async () => {
|
||||
await configureGitAuth("test-token", createMockAutomationContext(), {
|
||||
login: "claude[bot]",
|
||||
id: 42,
|
||||
});
|
||||
|
||||
expect(gitConfigGetAll("user.name")).toBe("claude[bot]");
|
||||
expect(gitConfigGetAll("user.email")).toBe(
|
||||
`42+claude[bot]@${NOREPLY_DOMAIN}`,
|
||||
);
|
||||
expect(gitConfigGetAll(EXTRAHEADER_KEY)).toBe("");
|
||||
expect(remoteUrl()).toBe(
|
||||
`https://x-access-token:test-token@${SERVER.host}/test-owner/test-repo.git`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function git(args: string[]): void {
|
||||
runGit(args, repoDir);
|
||||
}
|
||||
});
|
||||
|
||||
function restoreEnv(name: string, value: string | undefined): void {
|
||||
if (value === undefined) {
|
||||
delete process.env[name];
|
||||
} else {
|
||||
process.env[name] = value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
import {
|
||||
listWorkflowJobs,
|
||||
listWorkflowRuns,
|
||||
} from "../src/mcp/github-actions-pagination";
|
||||
|
||||
function createPaginatedClient<T>(pages: T[][]) {
|
||||
const request = async () => ({ data: pages[0] });
|
||||
const client = {
|
||||
actions: {
|
||||
listWorkflowRunsForRepo: request,
|
||||
listJobsForWorkflowRun: request,
|
||||
},
|
||||
paginate: async () => pages.flat(),
|
||||
} as unknown as Octokit;
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
describe("GitHub Actions pagination", () => {
|
||||
test("returns workflow runs from every page", async () => {
|
||||
const firstPage = [{ id: 1 }, { id: 2 }];
|
||||
const secondPage = [{ id: 3 }];
|
||||
const client = createPaginatedClient([firstPage, secondPage]);
|
||||
|
||||
const runs = await listWorkflowRuns(client, {
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
head_sha: "sha",
|
||||
});
|
||||
|
||||
expect(runs.map((run) => run.id)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
test("returns workflow jobs from every page", async () => {
|
||||
const firstPage = [{ id: 1 }, { id: 2 }];
|
||||
const secondPage = [{ id: 3 }];
|
||||
const client = createPaginatedClient([firstPage, secondPage]);
|
||||
|
||||
const jobs = await listWorkflowJobs(client, {
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
run_id: 123,
|
||||
});
|
||||
|
||||
expect(jobs.map((job) => job.id)).toEqual([1, 2, 3]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, test, expect, afterEach } from "bun:test";
|
||||
import { readFile, rm } from "fs/promises";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { downloadJobLog } from "../src/mcp/github-actions-server";
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
|
||||
describe("downloadJobLog", () => {
|
||||
const tmpDirs: string[] = [];
|
||||
|
||||
const makeRunnerTemp = () => {
|
||||
const dir = path.join(
|
||||
os.tmpdir(),
|
||||
`download-job-log-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
tmpDirs.push(dir);
|
||||
return dir;
|
||||
};
|
||||
|
||||
afterEach(async () => {
|
||||
while (tmpDirs.length) {
|
||||
const dir = tmpDirs.pop()!;
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
const createStallingClient = (): {
|
||||
client: Octokit;
|
||||
getSignal: () => AbortSignal | undefined;
|
||||
} => {
|
||||
let signal: AbortSignal | undefined;
|
||||
const client = {
|
||||
actions: {
|
||||
downloadJobLogsForWorkflowRun: (params: {
|
||||
request?: { signal?: AbortSignal };
|
||||
}) => {
|
||||
signal = params.request?.signal;
|
||||
return new Promise((_resolve, reject) => {
|
||||
signal?.addEventListener("abort", () => {
|
||||
reject(new Error("This operation was aborted"));
|
||||
});
|
||||
// Otherwise never settles, simulating a stalled fetch.
|
||||
});
|
||||
},
|
||||
},
|
||||
} as unknown as Octokit;
|
||||
return { client, getSignal: () => signal };
|
||||
};
|
||||
|
||||
test("rejects with a timeout instead of hanging when the download stalls", async () => {
|
||||
const { client, getSignal } = createStallingClient();
|
||||
const runnerTemp = makeRunnerTemp();
|
||||
|
||||
await expect(
|
||||
downloadJobLog(
|
||||
client,
|
||||
{ owner: "owner", repo: "repo", job_id: 123 },
|
||||
runnerTemp,
|
||||
5,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(getSignal()?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
test("writes the log to disk and clears the timeout when the download succeeds", async () => {
|
||||
const runnerTemp = makeRunnerTemp();
|
||||
const client = {
|
||||
actions: {
|
||||
downloadJobLogsForWorkflowRun: async (params: {
|
||||
request?: { signal?: AbortSignal };
|
||||
}) => {
|
||||
expect(params.request?.signal?.aborted).toBe(false);
|
||||
return { data: "log line 1\nlog line 2\n" };
|
||||
},
|
||||
},
|
||||
} as unknown as Octokit;
|
||||
|
||||
const result = await downloadJobLog(
|
||||
client,
|
||||
{ owner: "owner", repo: "repo", job_id: 456 },
|
||||
runnerTemp,
|
||||
30_000,
|
||||
);
|
||||
|
||||
expect(result.path).toBe(`${runnerTemp}/github-ci-logs/job-456.log`);
|
||||
expect(result.size_bytes).toBe(
|
||||
Buffer.byteLength("log line 1\nlog line 2\n", "utf-8"),
|
||||
);
|
||||
|
||||
const written = await readFile(result.path, "utf-8");
|
||||
expect(written).toBe("log line 1\nlog line 2\n");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { join } from "node:path";
|
||||
|
||||
// The GitHub client reads GITHUB_API_URL / GITHUB_GRAPHQL_URL when
|
||||
// `src/github/api/config.ts` is first imported, so we cannot flip env vars
|
||||
// between cases inside a single process. Instead each case runs the real
|
||||
// `createOctokit` factory in a fresh Bun process (test/fixtures/graphql-endpoint-probe.ts)
|
||||
// with a stubbed fetch that reports the FINAL wire URL and Authorization header.
|
||||
//
|
||||
// This is the level that matters: @octokit/graphql derives the GraphQL endpoint
|
||||
// from its baseUrl AFTER construction (rewriting a REST ".../api/v3" base to
|
||||
// ".../api/graphql", and otherwise appending "/graphql"), so a constructor-option
|
||||
// assertion would not catch a regression.
|
||||
|
||||
const PROBE = join(import.meta.dir, "fixtures", "graphql-endpoint-probe.ts");
|
||||
|
||||
type ProbeResult = {
|
||||
graphqlUrl: string;
|
||||
graphqlAuth: string | null;
|
||||
restUrl: string;
|
||||
restAuth: string | null;
|
||||
};
|
||||
|
||||
function probe(env: Record<string, string>): ProbeResult {
|
||||
const result = Bun.spawnSync({
|
||||
cmd: ["bun", "run", PROBE],
|
||||
env: {
|
||||
...process.env,
|
||||
// Start from a clean slate so the host's own env cannot leak in.
|
||||
GITHUB_API_URL: "",
|
||||
GITHUB_GRAPHQL_URL: "",
|
||||
...env,
|
||||
},
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`probe failed (exit ${result.exitCode}): ${result.stderr.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
return JSON.parse(result.stdout.toString().trim()) as ProbeResult;
|
||||
}
|
||||
|
||||
describe("GitHub API client endpoint routing", () => {
|
||||
test("both env vars unset: REST and GraphQL use github.com", () => {
|
||||
const r = probe({});
|
||||
expect(r.restUrl).toBe("https://api.github.com/meta");
|
||||
expect(r.graphqlUrl).toBe("https://api.github.com/graphql");
|
||||
});
|
||||
|
||||
test("GITHUB_API_URL alone (GHES): GraphQL still resolves to /api/graphql", () => {
|
||||
// Regression guard: @octokit/graphql rewrites a ".../api/v3" REST base to
|
||||
// ".../api/graphql", so GraphQL must keep working when only GITHUB_API_URL
|
||||
// is provided (e.g. under `act` or partial configs).
|
||||
const r = probe({ GITHUB_API_URL: "https://ghe.example.test/api/v3" });
|
||||
expect(r.restUrl).toBe("https://ghe.example.test/api/v3/meta");
|
||||
expect(r.graphqlUrl).toBe("https://ghe.example.test/api/graphql");
|
||||
});
|
||||
|
||||
test("GITHUB_GRAPHQL_URL alone: GraphQL honors it exactly, REST stays public", () => {
|
||||
const r = probe({
|
||||
GITHUB_GRAPHQL_URL: "https://ghe.example.test/api/graphql",
|
||||
});
|
||||
expect(r.graphqlUrl).toBe("https://ghe.example.test/api/graphql");
|
||||
expect(r.restUrl).toBe("https://api.github.com/meta");
|
||||
});
|
||||
|
||||
test("both set to standard GHES values: REST and GraphQL route independently", () => {
|
||||
const r = probe({
|
||||
GITHUB_API_URL: "https://ghe.example.test/api/v3",
|
||||
GITHUB_GRAPHQL_URL: "https://ghe.example.test/api/graphql",
|
||||
});
|
||||
expect(r.restUrl).toBe("https://ghe.example.test/api/v3/meta");
|
||||
expect(r.graphqlUrl).toBe("https://ghe.example.test/api/graphql");
|
||||
});
|
||||
|
||||
test("GITHUB_GRAPHQL_URL wins over a GITHUB_API_URL-derived endpoint", () => {
|
||||
// Distinguishing case: without honoring GITHUB_GRAPHQL_URL, GraphQL would be
|
||||
// derived from GITHUB_API_URL and hit the wrong host.
|
||||
const r = probe({
|
||||
GITHUB_API_URL: "https://ghe.example.test/api/v3",
|
||||
GITHUB_GRAPHQL_URL: "https://gql.example.test/api/graphql",
|
||||
});
|
||||
expect(r.graphqlUrl).toBe("https://gql.example.test/api/graphql");
|
||||
expect(r.restUrl).toBe("https://ghe.example.test/api/v3/meta");
|
||||
});
|
||||
|
||||
test("trailing slash on GITHUB_GRAPHQL_URL is normalized", () => {
|
||||
const r = probe({
|
||||
GITHUB_GRAPHQL_URL: "https://ghe.example.test/api/graphql/",
|
||||
});
|
||||
expect(r.graphqlUrl).toBe("https://ghe.example.test/api/graphql");
|
||||
});
|
||||
|
||||
test("GITHUB_GRAPHQL_URL without a /graphql suffix is preserved before the client appends one", () => {
|
||||
const r = probe({
|
||||
GITHUB_GRAPHQL_URL: "https://gql.example.test/custom",
|
||||
});
|
||||
expect(r.graphqlUrl).toBe("https://gql.example.test/custom/graphql");
|
||||
});
|
||||
|
||||
test("a base already ending in /graphql is not doubled", () => {
|
||||
const r = probe({
|
||||
GITHUB_GRAPHQL_URL: "https://gql.example.test/api/graphql",
|
||||
});
|
||||
expect(r.graphqlUrl).not.toContain("/graphql/graphql");
|
||||
expect(r.graphqlUrl).toBe("https://gql.example.test/api/graphql");
|
||||
});
|
||||
|
||||
test("the token authorization header is preserved on both clients", () => {
|
||||
const r = probe({
|
||||
GITHUB_API_URL: "https://ghe.example.test/api/v3",
|
||||
GITHUB_GRAPHQL_URL: "https://ghe.example.test/api/graphql",
|
||||
});
|
||||
expect(r.graphqlAuth).toBe("token test-token");
|
||||
expect(r.restAuth).toBe("token test-token");
|
||||
});
|
||||
});
|
||||
@@ -148,7 +148,9 @@ describe("downloadCommentImages", () => {
|
||||
mediaType: { format: "full+json" },
|
||||
});
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(signedUrl);
|
||||
expect(fetchSpy).toHaveBeenCalledWith(signedUrl, {
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(fsWriteFileSpy).toHaveBeenCalledWith(
|
||||
"/tmp/github-images/image-1704067200000-0.png",
|
||||
Buffer.from(mockArrayBuffer),
|
||||
@@ -481,8 +483,12 @@ describe("downloadCommentImages", () => {
|
||||
);
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
expect(fetchSpy).toHaveBeenNthCalledWith(1, signedUrl1);
|
||||
expect(fetchSpy).toHaveBeenNthCalledWith(2, signedUrl2);
|
||||
expect(fetchSpy).toHaveBeenNthCalledWith(1, signedUrl1, {
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(fetchSpy).toHaveBeenNthCalledWith(2, signedUrl2, {
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(result.get(imageUrl1)).toBe(
|
||||
"/tmp/github-images/image-1704067200000-0.png",
|
||||
);
|
||||
@@ -523,7 +529,9 @@ describe("downloadCommentImages", () => {
|
||||
comments,
|
||||
);
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(signedUrl);
|
||||
expect(fetchSpy).toHaveBeenCalledWith(signedUrl, {
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(result.get(imageUrl)).toBe(
|
||||
"/tmp/github-images/image-1704067200000-0.png",
|
||||
);
|
||||
@@ -766,6 +774,95 @@ describe("downloadCommentImages", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("should skip an image when the fetch times out", async () => {
|
||||
const mockOctokit = createMockOctokit();
|
||||
const imageUrl = assetUrl(GUID_1);
|
||||
const signedUrl = signedUrlFor(GUID_1, ".png");
|
||||
let signal: AbortSignal | null | undefined;
|
||||
|
||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
||||
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
body_html: `<img src="${signedUrl}">`,
|
||||
},
|
||||
});
|
||||
|
||||
fetchSpy = spyOn(global, "fetch");
|
||||
fetchSpy.mockImplementation((_input: unknown, init?: RequestInit) => {
|
||||
signal = init?.signal;
|
||||
return new Promise<Response>(() => {});
|
||||
});
|
||||
|
||||
const result = await downloadCommentImages(
|
||||
mockOctokit,
|
||||
"owner",
|
||||
"repo",
|
||||
[
|
||||
{
|
||||
type: "issue_comment",
|
||||
id: "445",
|
||||
body: `Stalled image: `,
|
||||
},
|
||||
],
|
||||
{ timeoutMs: 5 },
|
||||
);
|
||||
|
||||
expect(result.size).toBe(0);
|
||||
expect(signal?.aborted).toBe(true);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to download"),
|
||||
expect.objectContaining({
|
||||
message: "Image download timed out after 5ms",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("should time out while reading a response body", async () => {
|
||||
const mockOctokit = createMockOctokit();
|
||||
const imageUrl = assetUrl(GUID_1);
|
||||
const signedUrl = signedUrlFor(GUID_1, ".png");
|
||||
let signal: AbortSignal | null | undefined;
|
||||
|
||||
// @ts-expect-error Mock implementation doesn't match full type signature
|
||||
mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
body_html: `<img src="${signedUrl}">`,
|
||||
},
|
||||
});
|
||||
|
||||
fetchSpy = spyOn(global, "fetch");
|
||||
fetchSpy.mockImplementation((_input: unknown, init?: RequestInit) => {
|
||||
signal = init?.signal;
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
arrayBuffer: () => new Promise<ArrayBuffer>(() => {}),
|
||||
} as Response);
|
||||
});
|
||||
|
||||
const result = await downloadCommentImages(
|
||||
mockOctokit,
|
||||
"owner",
|
||||
"repo",
|
||||
[
|
||||
{
|
||||
type: "issue_comment",
|
||||
id: "446",
|
||||
body: `Stalled body: `,
|
||||
},
|
||||
],
|
||||
{ timeoutMs: 5 },
|
||||
);
|
||||
|
||||
expect(result.size).toBe(0);
|
||||
expect(signal?.aborted).toBe(true);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to download"),
|
||||
expect.objectContaining({
|
||||
message: "Image download timed out after 5ms",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle API errors gracefully", async () => {
|
||||
const mockOctokit = createMockOctokit();
|
||||
const imageUrl = assetUrl(GUID_1);
|
||||
@@ -936,7 +1033,9 @@ describe("downloadCommentImages", () => {
|
||||
mediaType: { format: "full+json" },
|
||||
});
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(signedUrl);
|
||||
expect(fetchSpy).toHaveBeenCalledWith(signedUrl, {
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(fsWriteFileSpy).toHaveBeenCalledWith(
|
||||
"/tmp/github-images/image-1704067200000-0.png",
|
||||
Buffer.from(mockArrayBuffer),
|
||||
|
||||
@@ -354,4 +354,102 @@ describe("prepareMcpConfig", () => {
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers.github_ci).not.toBeDefined();
|
||||
});
|
||||
|
||||
test("should include github MCP server when mcp__github shorthand is used", async () => {
|
||||
const result = await prepareMcpConfig({
|
||||
githubToken: "test-token",
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
allowedTools: ["mcp__github"],
|
||||
mode: "agent",
|
||||
context: mockContext,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers.github).toBeDefined();
|
||||
expect(parsed.mcpServers.github.command).toBe("docker");
|
||||
expect(parsed.mcpServers.github.env.GITHUB_PERSONAL_ACCESS_TOKEN).toBe(
|
||||
"test-token",
|
||||
);
|
||||
});
|
||||
|
||||
test("should include inline comment server when mcp__github_inline_comment shorthand is used", async () => {
|
||||
const result = await prepareMcpConfig({
|
||||
githubToken: "test-token",
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
allowedTools: ["mcp__github_inline_comment"],
|
||||
mode: "agent",
|
||||
context: mockPRContext,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers.github_inline_comment).toBeDefined();
|
||||
expect(parsed.mcpServers.github_inline_comment.env.GITHUB_TOKEN).toBe(
|
||||
"test-token",
|
||||
);
|
||||
expect(parsed.mcpServers.github_inline_comment.env.PR_NUMBER).toBe("456");
|
||||
});
|
||||
|
||||
test("should include comment server in agent mode when mcp__github_comment shorthand is used", async () => {
|
||||
const result = await prepareMcpConfig({
|
||||
githubToken: "test-token",
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
allowedTools: ["mcp__github_comment"],
|
||||
mode: "agent",
|
||||
context: mockContext,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers.github_comment).toBeDefined();
|
||||
expect(parsed.mcpServers.github_comment.env.GITHUB_TOKEN).toBe(
|
||||
"test-token",
|
||||
);
|
||||
});
|
||||
|
||||
test("should include CI server in agent mode when mcp__github_ci shorthand is used", async () => {
|
||||
process.env.DEFAULT_WORKFLOW_TOKEN = "workflow-token";
|
||||
|
||||
const result = await prepareMcpConfig({
|
||||
githubToken: "test-token",
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
allowedTools: ["mcp__github_ci"],
|
||||
mode: "agent",
|
||||
context: mockPRContext,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers.github_ci).toBeDefined();
|
||||
expect(parsed.mcpServers.github_ci.env.GITHUB_TOKEN).toBe("workflow-token");
|
||||
expect(parsed.mcpServers.github_ci.env.PR_NUMBER).toBe("456");
|
||||
|
||||
delete process.env.DEFAULT_WORKFLOW_TOKEN;
|
||||
});
|
||||
|
||||
test("should not include github MCP server when unrelated tool is specified", async () => {
|
||||
const result = await prepareMcpConfig({
|
||||
githubToken: "test-token",
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
allowedTools: ["Bash", "Read", "Grep"],
|
||||
mode: "agent",
|
||||
context: mockContext,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers.github).not.toBeDefined();
|
||||
expect(parsed.mcpServers.github_inline_comment).not.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,28 +16,35 @@ describe("Agent Mode", () => {
|
||||
let exportVariableSpy: any;
|
||||
let setOutputSpy: any;
|
||||
let configureGitAuthSpy: any;
|
||||
let replaceCheckoutCredentialsSpy: any;
|
||||
|
||||
beforeEach(() => {
|
||||
exportVariableSpy = spyOn(core, "exportVariable").mockImplementation(
|
||||
() => {},
|
||||
);
|
||||
setOutputSpy = spyOn(core, "setOutput").mockImplementation(() => {});
|
||||
// Mock configureGitAuth to prevent actual git commands from running
|
||||
// Mock git configuration to prevent actual git commands from running
|
||||
configureGitAuthSpy = spyOn(
|
||||
gitConfig,
|
||||
"configureGitAuth",
|
||||
).mockImplementation(async () => {
|
||||
// Do nothing - prevent actual git config modifications
|
||||
});
|
||||
replaceCheckoutCredentialsSpy = spyOn(
|
||||
gitConfig,
|
||||
"replaceCheckoutCredentials",
|
||||
).mockImplementation(async () => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
exportVariableSpy?.mockClear();
|
||||
setOutputSpy?.mockClear();
|
||||
configureGitAuthSpy?.mockClear();
|
||||
replaceCheckoutCredentialsSpy?.mockClear();
|
||||
exportVariableSpy?.mockRestore();
|
||||
setOutputSpy?.mockRestore();
|
||||
configureGitAuthSpy?.mockRestore();
|
||||
replaceCheckoutCredentialsSpy?.mockRestore();
|
||||
});
|
||||
|
||||
test("prepareAgentMode is exported as a function", () => {
|
||||
@@ -257,4 +264,59 @@ describe("Agent Mode", () => {
|
||||
// Should be empty or just whitespace when no MCP servers are included
|
||||
expect(result.claudeArgs).not.toContain("--mcp-config");
|
||||
});
|
||||
|
||||
describe("git credential configuration", () => {
|
||||
const mockOctokit = {
|
||||
rest: {
|
||||
users: {
|
||||
getByUsername: mock(() =>
|
||||
Promise.resolve({
|
||||
data: { login: "test-user", id: 12345, type: "User" },
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
test("uses full git auth on the non-signing path", async () => {
|
||||
const context = createMockAutomationContext({
|
||||
eventName: "workflow_dispatch",
|
||||
});
|
||||
|
||||
await prepareAgentMode({
|
||||
context,
|
||||
octokit: mockOctokit,
|
||||
githubToken: "test-token",
|
||||
});
|
||||
|
||||
expect(configureGitAuthSpy).toHaveBeenCalledTimes(1);
|
||||
expect(configureGitAuthSpy).toHaveBeenCalledWith("test-token", context, {
|
||||
login: context.inputs.botName,
|
||||
id: parseInt(context.inputs.botId),
|
||||
});
|
||||
// configureGitAuth performs the credential replacement itself; the mock
|
||||
// stands in for it here, so the standalone helper is not invoked.
|
||||
expect(replaceCheckoutCredentialsSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("still replaces the checkout credential when API commit signing is enabled", async () => {
|
||||
const context = createMockAutomationContext({
|
||||
eventName: "workflow_dispatch",
|
||||
inputs: { useCommitSigning: true },
|
||||
});
|
||||
|
||||
await prepareAgentMode({
|
||||
context,
|
||||
octokit: mockOctokit,
|
||||
githubToken: "test-token",
|
||||
});
|
||||
|
||||
expect(configureGitAuthSpy).not.toHaveBeenCalled();
|
||||
expect(replaceCheckoutCredentialsSpy).toHaveBeenCalledTimes(1);
|
||||
expect(replaceCheckoutCredentialsSpy).toHaveBeenCalledWith(
|
||||
"test-token",
|
||||
context,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -76,6 +76,20 @@ describe("detectMode with enhanced routing", () => {
|
||||
expect(detectMode(context)).toBe("agent");
|
||||
});
|
||||
|
||||
it("should use tag mode when track_progress is true for pull_request.labeled", () => {
|
||||
const context: GitHubContext = {
|
||||
...baseContext,
|
||||
eventName: "pull_request",
|
||||
eventAction: "labeled",
|
||||
payload: { pull_request: { number: 1 } } as any,
|
||||
entityNumber: 1,
|
||||
isPR: true,
|
||||
inputs: { ...baseContext.inputs, trackProgress: true },
|
||||
};
|
||||
|
||||
expect(detectMode(context)).toBe("tag");
|
||||
});
|
||||
|
||||
it("should throw error when track_progress is used with unsupported PR action", () => {
|
||||
const context: GitHubContext = {
|
||||
...baseContext,
|
||||
|
||||
+92
-1
@@ -1,8 +1,99 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test";
|
||||
import { prepareTagMode } from "../../src/modes/tag";
|
||||
import { mockIssueCommentContext } from "../mockContext";
|
||||
import * as actor from "../../src/github/validation/actor";
|
||||
import * as createInitial from "../../src/github/operations/comments/create-initial";
|
||||
import * as fetcher from "../../src/github/data/fetcher";
|
||||
import * as branch from "../../src/github/operations/branch";
|
||||
import * as createPrompt from "../../src/create-prompt";
|
||||
import * as mcp from "../../src/mcp/install-mcp-server";
|
||||
import * as gitConfig from "../../src/github/operations/git-config";
|
||||
|
||||
describe("Tag Mode", () => {
|
||||
test("prepareTagMode is exported as a function", () => {
|
||||
expect(typeof prepareTagMode).toBe("function");
|
||||
});
|
||||
|
||||
describe("git credential configuration", () => {
|
||||
let spies: Array<{ mockRestore: () => void }>;
|
||||
let configureGitAuthSpy: any;
|
||||
let replaceCheckoutCredentialsSpy: any;
|
||||
|
||||
beforeEach(() => {
|
||||
configureGitAuthSpy = spyOn(
|
||||
gitConfig,
|
||||
"configureGitAuth",
|
||||
).mockImplementation(async () => {});
|
||||
replaceCheckoutCredentialsSpy = spyOn(
|
||||
gitConfig,
|
||||
"replaceCheckoutCredentials",
|
||||
).mockImplementation(async () => {});
|
||||
spies = [
|
||||
configureGitAuthSpy,
|
||||
replaceCheckoutCredentialsSpy,
|
||||
spyOn(actor, "checkHumanActor").mockImplementation(async () => {}),
|
||||
spyOn(createInitial, "createInitialComment").mockImplementation(
|
||||
async () => ({ id: 42 }) as any,
|
||||
),
|
||||
spyOn(fetcher, "fetchGitHubData").mockImplementation(
|
||||
async () => ({}) as any,
|
||||
),
|
||||
spyOn(branch, "setupBranch").mockImplementation(
|
||||
async () =>
|
||||
({
|
||||
baseBranch: "main",
|
||||
claudeBranch: "claude/test",
|
||||
currentBranch: "claude/test",
|
||||
}) as any,
|
||||
),
|
||||
spyOn(createPrompt, "createPrompt").mockImplementation(async () => {}),
|
||||
spyOn(mcp, "prepareMcpConfig").mockImplementation(async () => "{}"),
|
||||
];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const spy of spies) {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("uses full git auth on the non-signing path", async () => {
|
||||
const context = { ...mockIssueCommentContext };
|
||||
|
||||
await prepareTagMode({
|
||||
context,
|
||||
octokit: {} as any,
|
||||
githubToken: "test-token",
|
||||
});
|
||||
|
||||
expect(configureGitAuthSpy).toHaveBeenCalledTimes(1);
|
||||
expect(configureGitAuthSpy).toHaveBeenCalledWith("test-token", context, {
|
||||
login: context.inputs.botName,
|
||||
id: parseInt(context.inputs.botId),
|
||||
});
|
||||
// configureGitAuth performs the credential replacement itself; the mock
|
||||
// stands in for it here, so the standalone helper is not invoked.
|
||||
expect(replaceCheckoutCredentialsSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("still replaces the checkout credential when API commit signing is enabled", async () => {
|
||||
const context = {
|
||||
...mockIssueCommentContext,
|
||||
inputs: { ...mockIssueCommentContext.inputs, useCommitSigning: true },
|
||||
};
|
||||
|
||||
await prepareTagMode({
|
||||
context,
|
||||
octokit: {} as any,
|
||||
githubToken: "test-token",
|
||||
});
|
||||
|
||||
expect(configureGitAuthSpy).not.toHaveBeenCalled();
|
||||
expect(replaceCheckoutCredentialsSpy).toHaveBeenCalledTimes(1);
|
||||
expect(replaceCheckoutCredentialsSpy).toHaveBeenCalledWith(
|
||||
"test-token",
|
||||
context,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { redactSecrets, sanitizeContent } from "../src/github/utils/sanitizer";
|
||||
|
||||
describe("Public Comment Output Sanitization & Redaction", () => {
|
||||
it("redacts all credential types from public comment output", () => {
|
||||
const rawComment = [
|
||||
"Here is the summary of the work done:",
|
||||
"- GitHub Token: ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890",
|
||||
"- Anthropic Key: sk-ant-api03-abcdefghijklmnopqrstuvwxyz1234567890",
|
||||
"- AWS Access Key: AKIAIOSFODNN7EXAMPLE",
|
||||
"- Slack Bot Token: xoxb-1234567890-abcdefghijkl-mnopqrstuvwx",
|
||||
"- JWT Bearer: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U",
|
||||
"<!-- Hidden instruction injection -->",
|
||||
"Invisible\u200Bzero-width chars",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
const sanitizedOutput = redactSecrets(sanitizeContent(rawComment));
|
||||
|
||||
// Ensure all secret types are redacted
|
||||
expect(sanitizedOutput).not.toContain(
|
||||
"ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890",
|
||||
);
|
||||
expect(sanitizedOutput).not.toContain(
|
||||
"sk-ant-api03-abcdefghijklmnopqrstuvwxyz1234567890",
|
||||
);
|
||||
expect(sanitizedOutput).not.toContain("AKIAIOSFODNN7EXAMPLE");
|
||||
expect(sanitizedOutput).not.toContain(
|
||||
"xoxb-1234567890-abcdefghijkl-mnopqrstuvwx",
|
||||
);
|
||||
expect(sanitizedOutput).not.toContain(
|
||||
"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U",
|
||||
);
|
||||
|
||||
expect(sanitizedOutput).toContain("[REDACTED_GITHUB_TOKEN]");
|
||||
expect(sanitizedOutput).toContain("[REDACTED_ANTHROPIC_KEY]");
|
||||
expect(sanitizedOutput).toContain("[REDACTED_AWS_KEY_ID]");
|
||||
expect(sanitizedOutput).toContain("[REDACTED_SLACK_TOKEN]");
|
||||
expect(sanitizedOutput).toContain("[REDACTED_JWT]");
|
||||
|
||||
// Ensure prompt injection / invisible chars / hidden tags are also sanitized
|
||||
expect(sanitizedOutput).not.toContain(
|
||||
"<!-- Hidden instruction injection -->",
|
||||
);
|
||||
expect(sanitizedOutput).not.toContain("\u200B");
|
||||
expect(sanitizedOutput).not.toContain("Image Alt Injection");
|
||||
expect(sanitizedOutput).toContain("");
|
||||
});
|
||||
|
||||
it("ensures public comments have the same secret redaction coverage as logs/errors", () => {
|
||||
const errorDetails =
|
||||
"Error: failed to connect with sk-ant-abcdefghijklmnopqrstuvwxyz123456 and AKIAIOSFODNN7EXAMPLE";
|
||||
const commentBody =
|
||||
"Report: encountered sk-ant-abcdefghijklmnopqrstuvwxyz123456 and AKIAIOSFODNN7EXAMPLE";
|
||||
|
||||
const redactedError = redactSecrets(errorDetails);
|
||||
const redactedComment = redactSecrets(sanitizeContent(commentBody));
|
||||
|
||||
expect(redactedError).toContain("[REDACTED_ANTHROPIC_KEY]");
|
||||
expect(redactedError).toContain("[REDACTED_AWS_KEY_ID]");
|
||||
expect(redactedComment).toContain("[REDACTED_ANTHROPIC_KEY]");
|
||||
expect(redactedComment).toContain("[REDACTED_AWS_KEY_ID]");
|
||||
});
|
||||
});
|
||||
+300
-4
@@ -5,6 +5,7 @@ import {
|
||||
lstatSync,
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
@@ -147,7 +148,7 @@ describe("restoreConfigFromBase", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("snapshots symlinked sensitive paths even when the PR head target is missing", () => {
|
||||
test("records dangling links as placeholders, including top-level ones", () => {
|
||||
setupSymlinkedMainBranch();
|
||||
|
||||
git(["checkout", "pr"]);
|
||||
@@ -157,14 +158,229 @@ describe("restoreConfigFromBase", () => {
|
||||
|
||||
restoreConfigFromBase("main");
|
||||
|
||||
expect(lstatRepoFile(".claude-pr/.claude/CLAUDE.md").isSymbolicLink()).toBe(
|
||||
true,
|
||||
);
|
||||
expectPlaceholder(".claude-pr/CLAUDE.md");
|
||||
expectPlaceholder(".claude-pr/.claude/CLAUDE.md");
|
||||
expectNoLinksInSnapshot();
|
||||
expect(readRepoFile(".claude/settings.json")).toBe(
|
||||
`${JSON.stringify({ source: "base" })}\n`,
|
||||
);
|
||||
});
|
||||
|
||||
test("snapshots links to tracked in-tree files as dereferenced content", () => {
|
||||
setupSymlinkedMainBranch();
|
||||
|
||||
git(["checkout", "pr"]);
|
||||
|
||||
restoreConfigFromBase("main");
|
||||
|
||||
expect(lstatRepoFile(".claude-pr/CLAUDE.md").isFile()).toBe(true);
|
||||
expect(lstatRepoFile(".claude-pr/.claude/CLAUDE.md").isFile()).toBe(true);
|
||||
expect(readRepoFile(".claude-pr/CLAUDE.md")).toBe(
|
||||
"shared agent instructions\n",
|
||||
);
|
||||
expect(readRepoFile(".claude-pr/.claude/CLAUDE.md")).toBe(
|
||||
"shared agent instructions\n",
|
||||
);
|
||||
expectNoLinksInSnapshot();
|
||||
});
|
||||
|
||||
test("records CLAUDE.md links to targets outside the working tree as placeholders", () => {
|
||||
const outsideFile = writeOutsideFile("notes.md", "outside notes\n");
|
||||
|
||||
rmSync(join(repoDir, "CLAUDE.md"), { force: true });
|
||||
symlinkRepoFile("CLAUDE.md", outsideFile);
|
||||
git(["add", "-A"]);
|
||||
git(["commit", "-m", "pr links CLAUDE.md outside the repo"]);
|
||||
|
||||
restoreConfigFromBase("main");
|
||||
|
||||
expectPlaceholder(".claude-pr/CLAUDE.md");
|
||||
expect(readRepoFile(".claude-pr/CLAUDE.md")).not.toBe("outside notes\n");
|
||||
expect(snapshotRegularFileContents()).not.toContain("outside notes\n");
|
||||
expectNoLinksInSnapshot();
|
||||
expect(readRepoFile("CLAUDE.md")).toBe("base claude instructions\n");
|
||||
});
|
||||
|
||||
test("records nested links to targets outside the working tree as placeholders", () => {
|
||||
const outsideFile = writeOutsideFile(
|
||||
"secret.txt",
|
||||
"outside file content\n",
|
||||
);
|
||||
writeOutsideFile("dir/inner.txt", "outside dir content\n");
|
||||
const outsideDir = join(tempDir, "outside", "dir");
|
||||
|
||||
symlinkRepoFile(".claude/linked-file.md", outsideFile);
|
||||
symlinkRepoFile(".claude/linked-dir", outsideDir);
|
||||
git(["add", "-A"]);
|
||||
git(["commit", "-m", "pr adds nested links outside the repo"]);
|
||||
|
||||
restoreConfigFromBase("main");
|
||||
|
||||
expect(readRepoFile(".claude-pr/.claude/settings.json")).toBe(
|
||||
`${JSON.stringify({ source: "pr" })}\n`,
|
||||
);
|
||||
expectPlaceholder(".claude-pr/.claude/linked-file.md");
|
||||
expectPlaceholder(".claude-pr/.claude/linked-dir");
|
||||
const contents = snapshotRegularFileContents();
|
||||
expect(contents).not.toContain("outside file content\n");
|
||||
expect(contents).not.toContain("outside dir content\n");
|
||||
expectNoLinksInSnapshot();
|
||||
expect(readRepoFile(".claude/settings.json")).toBe(
|
||||
`${JSON.stringify({ source: "base" })}\n`,
|
||||
);
|
||||
});
|
||||
|
||||
test("records links into git metadata as placeholders", () => {
|
||||
symlinkRepoFile(".claude/git-config", "../.git/config");
|
||||
git(["add", "-A"]);
|
||||
git(["commit", "-m", "pr links into git metadata"]);
|
||||
|
||||
restoreConfigFromBase("main");
|
||||
|
||||
expectPlaceholder(".claude-pr/.claude/git-config");
|
||||
expect(snapshotRegularFileContents()).not.toContain(
|
||||
readRepoFile(".git/config"),
|
||||
);
|
||||
expectNoLinksInSnapshot();
|
||||
});
|
||||
|
||||
test("records relative links that only resolve from inside the snapshot as placeholders", () => {
|
||||
// Both targets dangle at their source location but would resolve to the
|
||||
// repository's .git/config if re-created one directory deeper.
|
||||
symlinkRepoFile(".claude/x", "../../.git/config");
|
||||
rmSync(join(repoDir, "CLAUDE.md"), { force: true });
|
||||
symlinkRepoFile("CLAUDE.md", "../.git/config");
|
||||
git(["add", "-A"]);
|
||||
git(["commit", "-m", "pr adds relative links"]);
|
||||
|
||||
restoreConfigFromBase("main");
|
||||
|
||||
const gitConfig = readRepoFile(".git/config");
|
||||
for (const path of [".claude-pr/.claude/x", ".claude-pr/CLAUDE.md"]) {
|
||||
expectPlaceholder(path);
|
||||
expect(readRepoFile(path)).not.toBe(gitConfig);
|
||||
}
|
||||
expect(snapshotRegularFileContents()).not.toContain(gitConfig);
|
||||
expectNoLinksInSnapshot();
|
||||
});
|
||||
|
||||
test("records links into nested git metadata inside the working tree as placeholders", () => {
|
||||
writeRepoFile("other/.git/config", "nested checkout config\n");
|
||||
symlinkRepoFile(".claude/x", "../other/.git/config");
|
||||
|
||||
restoreConfigFromBase("main");
|
||||
|
||||
expectPlaceholder(".claude-pr/.claude/x");
|
||||
expect(snapshotRegularFileContents()).not.toContain(
|
||||
"nested checkout config\n",
|
||||
);
|
||||
expectNoLinksInSnapshot();
|
||||
});
|
||||
|
||||
test("records links to untracked in-tree files as placeholders", () => {
|
||||
writeRepoFile(".env", "untracked env contents\n");
|
||||
symlinkRepoFile(".claude/env", "../.env");
|
||||
git(["add", ".claude/env"]);
|
||||
git(["commit", "-m", "pr links to an untracked file"]);
|
||||
|
||||
restoreConfigFromBase("main");
|
||||
|
||||
expectPlaceholder(".claude-pr/.claude/env");
|
||||
expect(snapshotRegularFileContents()).not.toContain(
|
||||
"untracked env contents\n",
|
||||
);
|
||||
expect(readRepoFile(".claude-pr/.claude/settings.json")).toBe(
|
||||
`${JSON.stringify({ source: "pr" })}\n`,
|
||||
);
|
||||
expectNoLinksInSnapshot();
|
||||
});
|
||||
|
||||
test("records links to tracked files modified after checkout as placeholders", () => {
|
||||
writeRepoFile(".env", "PLACEHOLDER=1\n");
|
||||
symlinkRepoFile(".claude/env", "../.env");
|
||||
git(["add", ".env", ".claude/env"]);
|
||||
git(["commit", "-m", "pr links to a tracked file"]);
|
||||
writeRepoFile(".env", "written after checkout\n");
|
||||
|
||||
restoreConfigFromBase("main");
|
||||
|
||||
expectPlaceholder(".claude-pr/.claude/env");
|
||||
expect(snapshotRegularFileContents()).not.toContain(
|
||||
"written after checkout\n",
|
||||
);
|
||||
expectNoLinksInSnapshot();
|
||||
});
|
||||
|
||||
test("snapshots a sensitive path that links to a tracked in-tree directory", () => {
|
||||
rmSync(join(repoDir, ".claude"), { recursive: true, force: true });
|
||||
writeRepoFile(
|
||||
"config/claude/settings.json",
|
||||
`${JSON.stringify({ source: "linked-dir" })}\n`,
|
||||
);
|
||||
writeRepoFile("config/claude/agents/reviewer.md", "reviewer agent\n");
|
||||
writeRepoFile("docs/agents/writer.md", "writer agent\n");
|
||||
symlinkRepoFile("config/claude/more-agents", "../../docs/agents");
|
||||
symlinkRepoFile(".claude", "config/claude");
|
||||
git(["add", "-A"]);
|
||||
git(["commit", "-m", "pr links .claude to a tracked directory"]);
|
||||
writeRepoFile("config/claude/local.txt", "untracked file\n");
|
||||
writeRepoFile("config/claude/cache/entry.txt", "untracked dir entry\n");
|
||||
|
||||
restoreConfigFromBase("main");
|
||||
|
||||
expect(lstatRepoFile(".claude-pr/.claude").isDirectory()).toBe(true);
|
||||
expect(readRepoFile(".claude-pr/.claude/settings.json")).toBe(
|
||||
`${JSON.stringify({ source: "linked-dir" })}\n`,
|
||||
);
|
||||
expect(readRepoFile(".claude-pr/.claude/agents/reviewer.md")).toBe(
|
||||
"reviewer agent\n",
|
||||
);
|
||||
expect(readRepoFile(".claude-pr/.claude/more-agents/writer.md")).toBe(
|
||||
"writer agent\n",
|
||||
);
|
||||
expectPlaceholder(".claude-pr/.claude/local.txt");
|
||||
expectPlaceholder(".claude-pr/.claude/cache");
|
||||
const contents = snapshotRegularFileContents();
|
||||
expect(contents).not.toContain("untracked file\n");
|
||||
expect(contents).not.toContain("untracked dir entry\n");
|
||||
expectNoLinksInSnapshot();
|
||||
expect(lstatRepoFile(".claude").isDirectory()).toBe(true);
|
||||
expect(readRepoFile(".claude/settings.json")).toBe(
|
||||
`${JSON.stringify({ source: "base" })}\n`,
|
||||
);
|
||||
});
|
||||
|
||||
test("records links to untracked in-tree directories as a single placeholder", () => {
|
||||
writeRepoFile("build/out/a.js", "generated a\n");
|
||||
writeRepoFile("build/out/b.js", "generated b\n");
|
||||
symlinkRepoFile(".claude/build", "../build");
|
||||
git(["add", ".claude/build"]);
|
||||
git(["commit", "-m", "pr links to an untracked directory"]);
|
||||
|
||||
restoreConfigFromBase("main");
|
||||
|
||||
expectPlaceholder(".claude-pr/.claude/build");
|
||||
const contents = snapshotRegularFileContents();
|
||||
expect(contents).not.toContain("generated a\n");
|
||||
expect(contents).not.toContain("generated b\n");
|
||||
expectNoLinksInSnapshot();
|
||||
});
|
||||
|
||||
test("records links back into a parent directory as placeholders", () => {
|
||||
symlinkRepoFile(".claude/parent-dir", "..");
|
||||
git(["add", "-A"]);
|
||||
git(["commit", "-m", "pr adds a link back to the repo root"]);
|
||||
|
||||
restoreConfigFromBase("main");
|
||||
|
||||
expectPlaceholder(".claude-pr/.claude/parent-dir");
|
||||
expect(existsRepoFile(".claude-pr/.claude/parent-dir/src")).toBe(false);
|
||||
expect(readRepoFile(".claude-pr/.claude/settings.json")).toBe(
|
||||
`${JSON.stringify({ source: "pr" })}\n`,
|
||||
);
|
||||
expectNoLinksInSnapshot();
|
||||
});
|
||||
|
||||
test("does not modify an existing .gitignore", () => {
|
||||
writeRepoFile(".gitignore", "node_modules\n");
|
||||
git(["add", ".gitignore"]);
|
||||
@@ -178,6 +394,37 @@ describe("restoreConfigFromBase", () => {
|
||||
expect(countClaudePrExcludeEntries()).toBe(1);
|
||||
});
|
||||
|
||||
test("leaves a full checkout unshallow so base..HEAD stays scoped to the PR", () => {
|
||||
// The damage only shows up once base has moved on since the PR branched:
|
||||
// the merge base is then an older commit that a depth-limited fetch of base
|
||||
// truncates away, and every base..HEAD comparison silently changes meaning.
|
||||
git(["checkout", "main"]);
|
||||
writeRepoFile("src/other.ts", "export const advanced = true;\n");
|
||||
git(["add", "src/other.ts"]);
|
||||
git(["commit", "-m", "base advance"]);
|
||||
git(["push", "origin", "main"]);
|
||||
git(["checkout", "pr"]);
|
||||
|
||||
expect(git(["rev-parse", "--is-shallow-repository"]).trim()).toBe("false");
|
||||
const mergeBaseBefore = git(["merge-base", "origin/main", "HEAD"]).trim();
|
||||
|
||||
restoreConfigFromBase("main");
|
||||
|
||||
expect(git(["rev-parse", "--is-shallow-repository"]).trim()).toBe("false");
|
||||
expect(git(["merge-base", "origin/main", "HEAD"]).trim()).toBe(
|
||||
mergeBaseBefore,
|
||||
);
|
||||
// These are the two commands the prompt tells Claude to run to scope its
|
||||
// work to the PR: the log range must not pick up already-merged commits,
|
||||
// and the three-dot diff must still resolve a merge base at all.
|
||||
expect(git(["log", "--format=%s", "origin/main..HEAD"]).trim()).toBe(
|
||||
"pr config",
|
||||
);
|
||||
expect(
|
||||
git(["diff", "--name-only", "origin/main...HEAD"]).trim().split("\n"),
|
||||
).toEqual([".claude/settings.json", "CLAUDE.md"]);
|
||||
});
|
||||
|
||||
function git(args: string[]): string {
|
||||
return execFileSync("git", args, {
|
||||
cwd: repoDir,
|
||||
@@ -196,6 +443,55 @@ describe("restoreConfigFromBase", () => {
|
||||
return readFileSync(join(repoDir, path), "utf8");
|
||||
}
|
||||
|
||||
function writeOutsideFile(path: string, contents: string): string {
|
||||
const fullPath = join(tempDir, "outside", path);
|
||||
mkdirSync(dirname(fullPath), { recursive: true });
|
||||
writeFileSync(fullPath, contents);
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
// Contents of every regular file recorded in the snapshot, without following
|
||||
// links, so tests can assert what actually got copied into the repository.
|
||||
function snapshotRegularFileContents(): string[] {
|
||||
const contents: string[] = [];
|
||||
const visit = (dir: string) => {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const entryPath = join(dir, entry);
|
||||
const stats = lstatSync(entryPath);
|
||||
if (stats.isDirectory()) {
|
||||
visit(entryPath);
|
||||
} else if (stats.isFile()) {
|
||||
contents.push(readFileSync(entryPath, "utf8"));
|
||||
}
|
||||
}
|
||||
};
|
||||
visit(join(repoDir, ".claude-pr"));
|
||||
return contents;
|
||||
}
|
||||
|
||||
// The snapshot must never contain links: every entry is a regular file or a
|
||||
// real directory.
|
||||
function expectNoLinksInSnapshot(): void {
|
||||
const visit = (dir: string) => {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const entryPath = join(dir, entry);
|
||||
const stats = lstatSync(entryPath);
|
||||
expect(stats.isSymbolicLink()).toBe(false);
|
||||
if (stats.isDirectory()) {
|
||||
visit(entryPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
visit(join(repoDir, ".claude-pr"));
|
||||
}
|
||||
|
||||
function expectPlaceholder(path: string): void {
|
||||
const stats = lstatRepoFile(path);
|
||||
expect(stats.isSymbolicLink()).toBe(false);
|
||||
expect(stats.isFile()).toBe(true);
|
||||
expect(readRepoFile(path)).toStartWith("Snapshot placeholder: ");
|
||||
}
|
||||
|
||||
function existsRepoFile(path: string): boolean {
|
||||
return existsSync(join(repoDir, path));
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
sanitizeContent,
|
||||
stripHtmlComments,
|
||||
redactGitHubTokens,
|
||||
redactSecrets,
|
||||
} from "../src/github/utils/sanitizer";
|
||||
|
||||
describe("stripInvisibleCharacters", () => {
|
||||
@@ -368,7 +369,122 @@ export GITHUB_TOKEN=[REDACTED_GITHUB_TOKEN]
|
||||
});
|
||||
});
|
||||
|
||||
describe("redactSecrets", () => {
|
||||
it("should still redact GitHub tokens", () => {
|
||||
expect(
|
||||
redactSecrets("Token: ghs_xz7yzju2SZjGPa0dUNMAx0SH4xDOCS31LXQW"),
|
||||
).toBe("Token: [REDACTED_GITHUB_TOKEN]");
|
||||
});
|
||||
|
||||
it("should redact Anthropic API keys (sk-ant-)", () => {
|
||||
const key = "sk-ant-api03-AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-abcdefgh";
|
||||
expect(redactSecrets(`ANTHROPIC_API_KEY=${key}`)).toBe(
|
||||
"ANTHROPIC_API_KEY=[REDACTED_ANTHROPIC_KEY]",
|
||||
);
|
||||
});
|
||||
|
||||
it("should not redact sk- strings that are not sk-ant-", () => {
|
||||
const content =
|
||||
"sk-proj-abcdefghijklmnopqrstuvwxyz0123456789 and sk-ant-short";
|
||||
expect(redactSecrets(content)).toBe(content);
|
||||
});
|
||||
|
||||
it("should redact AWS access key ids", () => {
|
||||
expect(redactSecrets("aws_access_key_id = AKIAIOSFODNN7EXAMPLE")).toBe(
|
||||
"aws_access_key_id = [REDACTED_AWS_KEY_ID]",
|
||||
);
|
||||
expect(redactSecrets("temp creds ASIAIOSFODNN7EXAMPLE end")).toBe(
|
||||
"temp creds [REDACTED_AWS_KEY_ID] end",
|
||||
);
|
||||
});
|
||||
|
||||
it("should not redact AWS-like strings that do not fit the format", () => {
|
||||
const content =
|
||||
"AKIAtest AKIA123 AKIAIOSFODNN7EXAMPLEXYZ akiaiosfodnn7example";
|
||||
expect(redactSecrets(content)).toBe(content);
|
||||
});
|
||||
|
||||
it("should redact Slack tokens", () => {
|
||||
expect(redactSecrets("token=xoxb-1234567890-abcdefghijkl")).toBe(
|
||||
"token=[REDACTED_SLACK_TOKEN]",
|
||||
);
|
||||
expect(redactSecrets("xoxp-1234567890-1234567890-abc")).toBe(
|
||||
"[REDACTED_SLACK_TOKEN]",
|
||||
);
|
||||
});
|
||||
|
||||
it("should not redact xox strings that do not fit the format", () => {
|
||||
const content = "xoxo-1234567890abc xoxz-1234567890abc xoxb-short";
|
||||
expect(redactSecrets(content)).toBe(content);
|
||||
});
|
||||
|
||||
it("should redact JWT-shaped strings", () => {
|
||||
const jwt =
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
|
||||
expect(redactSecrets(`Authorization: Bearer ${jwt}`)).toBe(
|
||||
"Authorization: Bearer [REDACTED_JWT]",
|
||||
);
|
||||
});
|
||||
|
||||
it("should redact tokens that follow a JSON escape sequence", () => {
|
||||
const serialized = JSON.stringify({
|
||||
content:
|
||||
"line one\nGITHUB_TOKEN=ghs_xz7yzju2SZjGPa0dUNMAx0SH4xDOCS31LXQW\tsk-ant-api03-AbCdEfGhIjKlMnOpQrStUvWx",
|
||||
});
|
||||
const redacted = redactSecrets(serialized);
|
||||
expect(redacted).toContain("[REDACTED_GITHUB_TOKEN]");
|
||||
expect(redacted).toContain("[REDACTED_ANTHROPIC_KEY]");
|
||||
expect(redacted).not.toContain("ghs_xz7yzju2SZjGPa0dUNMAx0SH4xDOCS31LXQW");
|
||||
});
|
||||
|
||||
it("should redact tokens preceded by ANSI color codes", () => {
|
||||
const ghp = "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";
|
||||
const anthropic =
|
||||
"sk-ant-api03-AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-abcdefgh";
|
||||
expect(redactSecrets(`\x1b[31m${ghp}\x1b[0m`)).toBe(
|
||||
"\x1b[31m[REDACTED_GITHUB_TOKEN]\x1b[0m",
|
||||
);
|
||||
expect(redactSecrets(`key=\x1b[32m${anthropic}\x1b[39m`)).toBe(
|
||||
"key=\x1b[32m[REDACTED_ANTHROPIC_KEY]\x1b[39m",
|
||||
);
|
||||
expect(redactSecrets(`\x1b[1mAKIAIOSFODNN7EXAMPLE\x1b[0m`)).toBe(
|
||||
"\x1b[1m[REDACTED_AWS_KEY_ID]\x1b[0m",
|
||||
);
|
||||
});
|
||||
|
||||
it("should redact tokens that follow other JSON escapes", () => {
|
||||
const ghp = "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";
|
||||
const serialized = JSON.stringify({
|
||||
colored: `\x1b[31m${ghp}`,
|
||||
formfeed: `\f${ghp}`,
|
||||
quoted: `"AKIAIOSFODNN7EXAMPLE"`,
|
||||
});
|
||||
const redacted = redactSecrets(serialized);
|
||||
expect(redacted).not.toContain(ghp);
|
||||
expect(redacted).not.toContain("AKIAIOSFODNN7EXAMPLE");
|
||||
expect(redacted).toContain("[REDACTED_GITHUB_TOKEN]");
|
||||
expect(redacted).toContain("[REDACTED_AWS_KEY_ID]");
|
||||
});
|
||||
|
||||
it("should not redact base64 blobs that are not JWTs", () => {
|
||||
// Long base64 without dots, and two-segment strings, are left alone
|
||||
const content =
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9eyJzdWIiOiIxMjM0NTY3ODkw " +
|
||||
"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0 " +
|
||||
"aGVsbG8gd29ybGQgdGhpcyBpcyBub3QgYSBqd3Q=";
|
||||
expect(redactSecrets(content)).toBe(content);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeContent with token redaction", () => {
|
||||
it("should only redact GitHub tokens from inbound content", () => {
|
||||
const content =
|
||||
"docs example key AKIAIOSFODNN7EXAMPLE and token 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";
|
||||
expect(sanitizeContent(content)).toBe(
|
||||
"docs example key AKIAIOSFODNN7EXAMPLE and token [REDACTED_GITHUB_TOKEN]",
|
||||
);
|
||||
});
|
||||
|
||||
it("should redact tokens as part of full sanitization", () => {
|
||||
const content = `
|
||||
<!-- Hidden comment with token: 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 -->
|
||||
@@ -402,3 +518,28 @@ describe("stripHtmlComments (legacy)", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("outbound comment sanitization and redaction", () => {
|
||||
it("should sanitize content and redact all credential types for public comments", () => {
|
||||
const rawComment =
|
||||
"Done! Configured AWS AKIAIOSFODNN7EXAMPLE, Anthropic sk-ant-api03-abcdefghijklmnopqrstuvwxyz1234567890, Slack xoxb-1234567890-abcdefghijkl-mnopqrstuvwx, and GitHub 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 <!-- secret note -->";
|
||||
const sanitizedAndRedacted = redactSecrets(sanitizeContent(rawComment));
|
||||
|
||||
expect(sanitizedAndRedacted).not.toContain("AKIAIOSFODNN7EXAMPLE");
|
||||
expect(sanitizedAndRedacted).not.toContain(
|
||||
"sk-ant-api03-abcdefghijklmnopqrstuvwxyz1234567890",
|
||||
);
|
||||
expect(sanitizedAndRedacted).not.toContain(
|
||||
"xoxb-1234567890-abcdefghijkl-mnopqrstuvwx",
|
||||
);
|
||||
expect(sanitizedAndRedacted).not.toContain(
|
||||
"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",
|
||||
);
|
||||
expect(sanitizedAndRedacted).not.toContain("secret note");
|
||||
|
||||
expect(sanitizedAndRedacted).toContain("[REDACTED_AWS_KEY_ID]");
|
||||
expect(sanitizedAndRedacted).toContain("[REDACTED_ANTHROPIC_KEY]");
|
||||
expect(sanitizedAndRedacted).toContain("[REDACTED_SLACK_TOKEN]");
|
||||
expect(sanitizedAndRedacted).toContain("[REDACTED_GITHUB_TOKEN]");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
|
||||
import { mkdtempSync, rmSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { setupBranch } from "../src/github/operations/branch";
|
||||
import { createMockContext } from "./mockContext";
|
||||
|
||||
const octokits = {
|
||||
rest: {
|
||||
repos: { get: async () => ({ data: { default_branch: "main" } }) },
|
||||
git: { getRef: async () => ({ data: { object: { sha: "abc1234" } } }) },
|
||||
},
|
||||
} as any;
|
||||
|
||||
const githubData = {
|
||||
contextData: { title: "Add feature", labels: { nodes: [] } },
|
||||
} as any;
|
||||
|
||||
// ':' is rejected by validateBranchName. The signing path used to skip that
|
||||
// check and only fail on the file ops server's first commit (a 422).
|
||||
const INVALID_TEMPLATE = "{{prefix}}release:{{entityNumber}}";
|
||||
|
||||
const loggedErrors: string[] = [];
|
||||
|
||||
describe("setupBranch generated branch name validation", () => {
|
||||
let originalCwd: string;
|
||||
let tempDir: string;
|
||||
let exitCode: number | undefined;
|
||||
let originalExit: typeof process.exit;
|
||||
let originalError: typeof console.error;
|
||||
|
||||
beforeEach(() => {
|
||||
originalCwd = process.cwd();
|
||||
// Not a git repo, so the remote existence probe fails and setupBranch
|
||||
// continues with the generated name.
|
||||
tempDir = mkdtempSync(join("/tmp", "setup-branch-"));
|
||||
process.chdir(tempDir);
|
||||
|
||||
exitCode = undefined;
|
||||
loggedErrors.length = 0;
|
||||
originalExit = process.exit;
|
||||
originalError = console.error;
|
||||
console.error = (...args: unknown[]) => {
|
||||
loggedErrors.push(args.map(String).join(" "));
|
||||
};
|
||||
process.exit = ((code?: number) => {
|
||||
exitCode = code;
|
||||
throw new Error("process.exit called");
|
||||
}) as typeof process.exit;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.exit = originalExit;
|
||||
console.error = originalError;
|
||||
process.chdir(originalCwd);
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
for (const useCommitSigning of [true, false]) {
|
||||
test(`rejects an invalid generated branch name with use_commit_signing: ${useCommitSigning}`, async () => {
|
||||
const context = createMockContext({
|
||||
isPR: false,
|
||||
entityNumber: 42,
|
||||
inputs: {
|
||||
useCommitSigning,
|
||||
branchPrefix: "claude/",
|
||||
branchNameTemplate: INVALID_TEMPLATE,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(setupBranch(octokits, githubData, context)).rejects.toThrow(
|
||||
"process.exit called",
|
||||
);
|
||||
expect(exitCode).toBe(1);
|
||||
// Must fail on the name itself, not on a later git or API call.
|
||||
expect(loggedErrors.join("\n")).toContain(
|
||||
'Invalid branch name: "claude/release:42"',
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -134,6 +134,20 @@ describe("checkContainsTrigger", () => {
|
||||
expect(checkContainsTrigger(context)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true when the labeled name differs only in case from the trigger", () => {
|
||||
const context = {
|
||||
...mockIssueLabeledContext,
|
||||
payload: {
|
||||
...mockIssueLabeledContext.payload,
|
||||
label: {
|
||||
...(mockIssueLabeledContext.payload as any).label,
|
||||
name: "Claude-Task",
|
||||
},
|
||||
},
|
||||
} as ParsedGitHubContext;
|
||||
expect(checkContainsTrigger(context)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for non-labeled events", () => {
|
||||
const context = {
|
||||
...mockIssueLabeledContext,
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { GITHUB_API_URL } from "../src/github/api/config";
|
||||
import { updateGitReference } from "../src/mcp/update-git-reference";
|
||||
|
||||
const reference = {
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
branch: "feature",
|
||||
sha: "abc123",
|
||||
githubToken: "token",
|
||||
};
|
||||
|
||||
function response(status: number) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
text: async () => "response body",
|
||||
};
|
||||
}
|
||||
|
||||
describe("updateGitReference", () => {
|
||||
it("should patch the branch reference", async () => {
|
||||
await updateGitReference({
|
||||
...reference,
|
||||
fetchFn: async (url, init) => {
|
||||
expect(url).toBe(
|
||||
`${GITHUB_API_URL}/repos/owner/repo/git/refs/heads/feature`,
|
||||
);
|
||||
expect(init.method).toBe("PATCH");
|
||||
expect(init.headers).toMatchObject({ Authorization: "Bearer token" });
|
||||
expect(JSON.parse(String(init.body))).toEqual({
|
||||
sha: "abc123",
|
||||
force: false,
|
||||
});
|
||||
return response(200);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should not retry deterministic client errors", async () => {
|
||||
for (const status of [400, 404, 409, 422]) {
|
||||
let attempts = 0;
|
||||
|
||||
await expect(
|
||||
updateGitReference({
|
||||
...reference,
|
||||
fetchFn: async () => {
|
||||
attempts++;
|
||||
return response(status);
|
||||
},
|
||||
retryOptions: { initialDelayMs: 1 },
|
||||
}),
|
||||
).rejects.toThrow(`Failed to update reference: ${status}`);
|
||||
|
||||
expect(attempts).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("should retry transient HTTP errors", async () => {
|
||||
for (const status of [403, 429, 500]) {
|
||||
let attempts = 0;
|
||||
|
||||
await updateGitReference({
|
||||
...reference,
|
||||
fetchFn: async () => response(attempts++ === 0 ? status : 200),
|
||||
retryOptions: { initialDelayMs: 1 },
|
||||
});
|
||||
|
||||
expect(attempts).toBe(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("should retry network errors", async () => {
|
||||
let attempts = 0;
|
||||
|
||||
await updateGitReference({
|
||||
...reference,
|
||||
fetchFn: async () => {
|
||||
attempts++;
|
||||
if (attempts === 1) {
|
||||
throw new Error("network error");
|
||||
}
|
||||
return response(200);
|
||||
},
|
||||
retryOptions: { initialDelayMs: 1 },
|
||||
});
|
||||
|
||||
expect(attempts).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,15 @@ describe("validateBranchName", () => {
|
||||
expect(() => validateBranchName("release.1.2.3")).not.toThrow();
|
||||
});
|
||||
|
||||
it("should accept branch names containing parentheses", () => {
|
||||
expect(() =>
|
||||
validateBranchName("feat(example)-valid-branch"),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
validateBranchName("fix(parser)-handle-empty-input"),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("should accept typical branch name formats", () => {
|
||||
expect(() =>
|
||||
validateBranchName("claude/issue-123-20250101-1234"),
|
||||
|
||||
Reference in New Issue
Block a user