mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-09-17 15:00:46 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -147,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.228"
|
||||
CLAUDE_CODE_VERSION="2.1.274"
|
||||
echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..."
|
||||
for attempt in 1 2 3; do
|
||||
echo "Installation attempt $attempt..."
|
||||
@@ -165,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.228",
|
||||
"shell-quote": "^1.8.3",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.274",
|
||||
"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.228", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.228", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.228", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.228", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.228", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.228", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.228", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.228", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.228" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-OOaME54VCoBLjKMqWqFmHkZGyL/x/FHUA0snhyolmyEhVoeBM0Ub5mrnV2Gx3d5/RcVlk2BnEVvPqu0SpZ9VFw=="],
|
||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.274", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.274", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.274", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.274", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.274", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.274", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.274", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.274", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.274" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-kFmWMsh/BEd4jKkOxUeihr0xMkaMdOxONhNNkZ2pGmP0ElHkArvFvuZxqJTPvREnhCsPZByv5f0EzyAb/hkRZw=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.228", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HuCsV3/5XuYYaWuCbksX+e0JkDDUG/AlFJ8wKhDL3PBW/3hHNd6xBYx88kEWk1Z6B1GLxwHht9624lcmscpsyw=="],
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.274", "", { "os": "darwin", "cpu": "arm64" }, "sha512-B0uAdUIbUhWuybOT5FkhbdsALGNduyaHYo+bEggKBSQS3RFRjgNbX/ah1+IwkmTn4UKDN7AX86DkTEjpj6TSWQ=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.228", "", { "os": "darwin", "cpu": "x64" }, "sha512-jSUYY5Nd3efvbLZPU+i0tRBaFXskHu8M+4LMGBEw6A0PaklZ3YfGvKlTOWtJGRw6vMc6LzfOFts024xPNm6OrQ=="],
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.274", "", { "os": "darwin", "cpu": "x64" }, "sha512-l5pp3Z+z2mti0H3y1O0MRbTg/r+MDm0hly4WpoKmZyIZSOrDQR7jhCqS6Ihj3Vvd7CMP9NZ8t6F0oXVBDCM6YQ=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.228", "", { "os": "linux", "cpu": "arm64" }, "sha512-0Wjv6TiWwGlBZINAmNJX07jN359jKwB/4Sr/uWgQkdjuVIOhe/M8ydk7JL2EPqCsbiW1lc15NjE5MWpZiYqooA=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.274", "", { "os": "linux", "cpu": "arm64" }, "sha512-kYsmSN6zieDwVS3QZBBay9fxNp+h4t4OXleuk6xWhrUDEQRfRQEFlD7JVWWWjJIiHILnniycOxarwo4VpqTf1Q=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.228", "", { "os": "linux", "cpu": "arm64" }, "sha512-4PgfisC3kHKlzJvy3rrm4Oh26g+D78h4ahHjni9fvSKHuJgrvHu9Qgo6aaYmzWdc7v9drL+pgiCk5Ge4Y2ANPA=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.274", "", { "os": "linux", "cpu": "arm64" }, "sha512-pNjZQRF3f1O5JwnoifuAD7tSRfk9LSwwBzEPiUHB4sJgatF6hzqyDgTN05hcDx28xg44j0jpKpyVwsjveI/s4g=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.228", "", { "os": "linux", "cpu": "x64" }, "sha512-LmGplObceqMOu5mlrlhTZL/VSrEWdZagF0Bl8awglMu6WeQcNe7StORYkCznZ0BuzV4CwuC3ipV4q8Jrs66wSg=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.274", "", { "os": "linux", "cpu": "x64" }, "sha512-QLeK9LzTtTdCjCDRYUVRHj1I3U6AfMvpXYI4Ur1s+vrUcad/C20kShS5/kgug+rERU5CNxNA9v0A052rt47ZXQ=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.228", "", { "os": "linux", "cpu": "x64" }, "sha512-dnXxyiwGCZj27HVk6clYRqGMgrs3KVLVp0vvWYLjkPGBiKbI83qJiDpOfaekEXG2I4elX0M4XikggV1LGWjimg=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.274", "", { "os": "linux", "cpu": "x64" }, "sha512-a9eIkmgJ5sSywHwWJcComogjIMsfHsurckQ85ljPNTUSFguzl/ZvbTXGiWNRK5rHsoNd3amwQf2fVC/vFyv3eg=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.228", "", { "os": "win32", "cpu": "arm64" }, "sha512-mNS5yIMz/OXSQiDErb84jA8AKBFSlS9RSZ0qn2qyGkxplUx7kVmIDg/KnwOwHmygpzmH4UmR6OCaLXGohupqNA=="],
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.274", "", { "os": "win32", "cpu": "arm64" }, "sha512-A5wvZAXL3nhuabIXUcRuoYhuPPdEIt3LoA3jUkVLT3c0H+7SH0rv5bylpM9n+apf7lwLCm2TdMZ6x1Ze1Vzekg=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.228", "", { "os": "win32", "cpu": "x64" }, "sha512-DYT3HvdS64Pq0IRvgW3RDO31yjYp5yiUKoKaZolTpLKfALpG5LI/osfnKlya68PZ/bSST1FNAfW9I0EtCnaQ4w=="],
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.274", "", { "os": "win32", "cpu": "x64" }, "sha512-dGcaLfEpV4kwcSijPAIJru+iEiVhHvbBdkyixPyhhf8pniV9B62nUMFFsjNtYL37xIPHxixh2Gsx9/A+Yk4oww=="],
|
||||
|
||||
"@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.228",
|
||||
"shell-quote": "^1.8.3"
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.274",
|
||||
"shell-quote": "^1.8.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.2.12",
|
||||
|
||||
@@ -289,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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -620,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;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
() => {},
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.10.1",
|
||||
"@actions/github": "^6.0.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.228",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.274",
|
||||
"@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.228", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.228", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.228", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.228", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.228", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.228", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.228", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.228", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.228" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-OOaME54VCoBLjKMqWqFmHkZGyL/x/FHUA0snhyolmyEhVoeBM0Ub5mrnV2Gx3d5/RcVlk2BnEVvPqu0SpZ9VFw=="],
|
||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.274", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.274", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.274", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.274", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.274", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.274", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.274", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.274", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.274" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-kFmWMsh/BEd4jKkOxUeihr0xMkaMdOxONhNNkZ2pGmP0ElHkArvFvuZxqJTPvREnhCsPZByv5f0EzyAb/hkRZw=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.228", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HuCsV3/5XuYYaWuCbksX+e0JkDDUG/AlFJ8wKhDL3PBW/3hHNd6xBYx88kEWk1Z6B1GLxwHht9624lcmscpsyw=="],
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.274", "", { "os": "darwin", "cpu": "arm64" }, "sha512-B0uAdUIbUhWuybOT5FkhbdsALGNduyaHYo+bEggKBSQS3RFRjgNbX/ah1+IwkmTn4UKDN7AX86DkTEjpj6TSWQ=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.228", "", { "os": "darwin", "cpu": "x64" }, "sha512-jSUYY5Nd3efvbLZPU+i0tRBaFXskHu8M+4LMGBEw6A0PaklZ3YfGvKlTOWtJGRw6vMc6LzfOFts024xPNm6OrQ=="],
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.274", "", { "os": "darwin", "cpu": "x64" }, "sha512-l5pp3Z+z2mti0H3y1O0MRbTg/r+MDm0hly4WpoKmZyIZSOrDQR7jhCqS6Ihj3Vvd7CMP9NZ8t6F0oXVBDCM6YQ=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.228", "", { "os": "linux", "cpu": "arm64" }, "sha512-0Wjv6TiWwGlBZINAmNJX07jN359jKwB/4Sr/uWgQkdjuVIOhe/M8ydk7JL2EPqCsbiW1lc15NjE5MWpZiYqooA=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.274", "", { "os": "linux", "cpu": "arm64" }, "sha512-kYsmSN6zieDwVS3QZBBay9fxNp+h4t4OXleuk6xWhrUDEQRfRQEFlD7JVWWWjJIiHILnniycOxarwo4VpqTf1Q=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.228", "", { "os": "linux", "cpu": "arm64" }, "sha512-4PgfisC3kHKlzJvy3rrm4Oh26g+D78h4ahHjni9fvSKHuJgrvHu9Qgo6aaYmzWdc7v9drL+pgiCk5Ge4Y2ANPA=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.274", "", { "os": "linux", "cpu": "arm64" }, "sha512-pNjZQRF3f1O5JwnoifuAD7tSRfk9LSwwBzEPiUHB4sJgatF6hzqyDgTN05hcDx28xg44j0jpKpyVwsjveI/s4g=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.228", "", { "os": "linux", "cpu": "x64" }, "sha512-LmGplObceqMOu5mlrlhTZL/VSrEWdZagF0Bl8awglMu6WeQcNe7StORYkCznZ0BuzV4CwuC3ipV4q8Jrs66wSg=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.274", "", { "os": "linux", "cpu": "x64" }, "sha512-QLeK9LzTtTdCjCDRYUVRHj1I3U6AfMvpXYI4Ur1s+vrUcad/C20kShS5/kgug+rERU5CNxNA9v0A052rt47ZXQ=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.228", "", { "os": "linux", "cpu": "x64" }, "sha512-dnXxyiwGCZj27HVk6clYRqGMgrs3KVLVp0vvWYLjkPGBiKbI83qJiDpOfaekEXG2I4elX0M4XikggV1LGWjimg=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.274", "", { "os": "linux", "cpu": "x64" }, "sha512-a9eIkmgJ5sSywHwWJcComogjIMsfHsurckQ85ljPNTUSFguzl/ZvbTXGiWNRK5rHsoNd3amwQf2fVC/vFyv3eg=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.228", "", { "os": "win32", "cpu": "arm64" }, "sha512-mNS5yIMz/OXSQiDErb84jA8AKBFSlS9RSZ0qn2qyGkxplUx7kVmIDg/KnwOwHmygpzmH4UmR6OCaLXGohupqNA=="],
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.274", "", { "os": "win32", "cpu": "arm64" }, "sha512-A5wvZAXL3nhuabIXUcRuoYhuPPdEIt3LoA3jUkVLT3c0H+7SH0rv5bylpM9n+apf7lwLCm2TdMZ6x1Ze1Vzekg=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.228", "", { "os": "win32", "cpu": "x64" }, "sha512-DYT3HvdS64Pq0IRvgW3RDO31yjYp5yiUKoKaZolTpLKfALpG5LI/osfnKlya68PZ/bSST1FNAfW9I0EtCnaQ4w=="],
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.274", "", { "os": "win32", "cpu": "x64" }, "sha512-dGcaLfEpV4kwcSijPAIJru+iEiVhHvbBdkyixPyhhf8pniV9B62nUMFFsjNtYL37xIPHxixh2Gsx9/A+Yk4oww=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
|
||||
@@ -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.228",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.274",
|
||||
"@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)
|
||||
|
||||
|
||||
@@ -164,9 +164,15 @@ export function formatResultContent(content: any): string {
|
||||
typeof parsedContent[0] === "object" &&
|
||||
parsedContent[0]?.type === "text"
|
||||
) {
|
||||
// Extract the text field from the first item. Tool output is arbitrary,
|
||||
// so `text` is not guaranteed to be a string.
|
||||
contentStr = String(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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -77,7 +77,7 @@ async function installClaudeCode(): Promise<string> {
|
||||
return customExecutable;
|
||||
}
|
||||
|
||||
const claudeCodeVersion = "2.1.228";
|
||||
const claudeCodeVersion = "2.1.274";
|
||||
console.log(`Installing Claude Code v${claudeCodeVersion}...`);
|
||||
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
@@ -159,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 {
|
||||
@@ -268,7 +271,7 @@ async function run() {
|
||||
validateBranchName(restoreBase);
|
||||
}
|
||||
if (restoreBase) {
|
||||
restoreConfigFromBase(restoreBase);
|
||||
restoredConfigPaths = restoreConfigFromBase(restoreBase);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,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,
|
||||
),
|
||||
|
||||
@@ -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, "--"]);
|
||||
|
||||
@@ -302,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 {
|
||||
@@ -320,7 +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);
|
||||
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,5 +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;
|
||||
@@ -161,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)}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,16 @@ export async function configureGitAuth(
|
||||
* 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,
|
||||
@@ -65,12 +75,35 @@ export async function replaceCheckoutCredentials(
|
||||
|
||||
// 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
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
writeFileSync,
|
||||
} from "fs";
|
||||
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.
|
||||
//
|
||||
@@ -22,7 +23,7 @@ import { dirname, join, posix, relative, sep } 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",
|
||||
@@ -261,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)`,
|
||||
);
|
||||
@@ -305,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,
|
||||
@@ -331,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];
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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 { 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"),
|
||||
};
|
||||
}
|
||||
}),
|
||||
@@ -417,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;
|
||||
@@ -435,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(
|
||||
|
||||
@@ -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> } = {
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -508,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", () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]");
|
||||
});
|
||||
});
|
||||
@@ -394,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,
|
||||
|
||||
@@ -518,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]");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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