Compare commits

...
62 Commits
Author SHA1 Message Date
GitHub Actions 3b8197d3d4 chore: bump Claude Code to 2.1.274 and Agent SDK to 0.3.274 2026-09-17 00:12:40 +00:00
GitHub Actions 7b0b255830 chore: bump Claude Code to 2.1.273 and Agent SDK to 0.3.273 2026-09-15 20:23:44 +00:00
GitHub Actions bf38e86e58 chore: bump Claude Code to 2.1.272 and Agent SDK to 0.3.272 2026-09-15 00:43:27 +00:00
GitHub Actions 51db78a4b8 chore: bump Claude Code to 2.1.271 and Agent SDK to 0.3.271 2026-09-14 22:13:27 +00:00
GitHub Actions 9cdae7f0d9 chore: bump Claude Code to 2.1.270 and Agent SDK to 0.3.270 2026-09-12 19:46:49 +00:00
GitHub Actions 56cf60fde4 chore: bump Claude Code to 2.1.269 and Agent SDK to 0.3.269 2026-09-11 19:18:51 +00:00
GitHub Actions 0a8d3c9443 chore: bump Claude Code to 2.1.268 and Agent SDK to 0.3.268 2026-09-10 20:39:16 +00:00
GitHub Actions 19dda84776 chore: bump Claude Code to 2.1.267 and Agent SDK to 0.3.267 2026-09-09 20:11:01 +00:00
GitHub Actions 5ccc3a35a6 chore: bump Claude Code to 2.1.266 and Agent SDK to 0.3.266 2026-09-08 23:56:12 +00:00
GitHub Actions 0d0e0876d3 chore: bump Claude Code to 2.1.265 and Agent SDK to 0.3.265 2026-09-08 20:38:21 +00:00
GitHub Actions 9c5ddab2e6 chore: bump Claude Code to 2.1.263 and Agent SDK to 0.3.263 2026-09-06 02:55:18 +00:00
GitHub Actions d75b94d5ad chore: bump Claude Code to 2.1.261 and Agent SDK to 0.3.261 2026-09-04 19:58:53 +00:00
GitHub Actions ef8bb1e43b chore: bump Claude Code to 2.1.260 and Agent SDK to 0.3.260 2026-09-03 23:48:49 +00:00
GitHub Actions fa2b2666b7 chore: bump Claude Code to 2.1.259 and Agent SDK to 0.3.259 2026-09-02 22:34:41 +00:00
GitHub Actions 8251c103ac chore: bump Claude Code to 2.1.258 and Agent SDK to 0.3.258 2026-09-01 22:33:40 +00:00
GitHub Actions 781d62e9d5 chore: bump Claude Code to 2.1.257 and Agent SDK to 0.3.257 2026-09-01 17:54:28 +00:00
GitHub Actions 833fb0f8c9 chore: bump Claude Code to 2.1.252 and Agent SDK to 0.3.252 2026-08-31 19:47:59 +00:00
GitHub Actions a874e9ecd7 chore: bump Claude Code to 2.1.251 and Agent SDK to 0.3.251 2026-08-28 18:20:42 +00:00
GitHub Actions a60f3e1db3 chore: bump Claude Code to 2.1.250 and Agent SDK to 0.3.250 2026-08-28 00:50:11 +00:00
GitHub Actions e8c2d7c16c chore: bump Claude Code to 2.1.248 and Agent SDK to 0.3.248 2026-08-27 22:12:44 +00:00
GitHub Actions 70fec18385 chore: bump Claude Code to 2.1.247 and Agent SDK to 0.3.247 2026-08-27 00:09:24 +00:00
GitHub Actions 1f291e1cfe chore: bump Claude Code to 2.1.246 and Agent SDK to 0.3.246 2026-08-25 22:32:51 +00:00
Muhammad Abdullah khanandGitHub 76ac41a83e fix: encode branch names in GitHub links (#1713)
* fix: encode branch names in GitHub links

* style: format branch URL helper
2026-08-25 11:02:49 -07:00
Jeremy SchoemakerandGitHub 8ef9699156 fix: bound download_job_log against a stalled log fetch (#1719)
The download_job_log MCP tool called
client.actions.downloadJobLogsForWorkflowRun() with no timeout and no
AbortController. @octokit/rest@21 runs on Node's native fetch, which has
no default timeout, and Octokit only cancels a request when the caller
passes request.signal. If the log blob fetch stalls, that await never
resolves and never rejects.

This tool is always enabled in tag mode (src/modes/tag/index.ts), so a
"fix the failing CI" run that calls get_ci_status -> get_workflow_run_details
-> download_job_log can hang on this one await with nothing to recover it.
It's headless, so the run only ends when the Actions job-level
timeout-minutes kills it, burning the whole job budget with the tracking
comment stuck at "Claude Code is working...".

Sibling fetch in src/github/utils/image-downloader.ts (fetchImage) already
got this treatment in #1625 via a timeout-driven AbortController. Same
shape of call: fetch a GitHub-hosted resource by ID from untrusted PR/CI
content. This mirrors that fix for github-actions-server.ts.

Extracted the download+write logic into an exported downloadJobLog()
function (with an injectable timeoutMs) so the timeout path is directly
testable, and guarded the module's entrypoint side effects with
import.meta.main, matching the pattern already used by the other
entrypoints in src/entrypoints/.
2026-08-25 08:46:02 -07:00
Yauheni PapovichandGitHub 791545dab1 fix: allow parentheses in valid branch names (#1710)
Accept parentheses while preserving existing branch-name security checks.

Add regression coverage for scoped branch names.

Refs anthropics/claude-code-action#1709
2026-08-25 08:45:50 -07:00
2d7a787fbd fix: use paths in delete_files prompt example (#1702)
* fix: use paths param in delete_files prompt example

The tag-mode prompt told the model to call delete_files with
"files", but the MCP tool schema and handler expect "paths".

Co-authored-by: Cursor <cursoragent@cursor.com>

* test: prove delete_files prompt against the live MCP schema

The old {files} payload is rejected by the same Zod shape the
tool registers; the generated prompt example now parses cleanly.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test: cover delete_files schema edges and the sibling commit_files tool

Confirm the old files-only payload still fails, types and required
fields are enforced, and commit_files was not inverted by the fix.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: RESILIENCE Agentic Solutions <286555414+WeAreResilience@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 08:45:33 -07:00
Mohammed AlkindiandGitHub b58c16b325 chore: add .gitattributes to normalize line endings (#1708)
On Windows, git's default core.autocrlf=true checks out CRLF, and the contributor commands in CONTRIBUTING.md then fail: bun run format:check reports 191 files because Prettier defaults to endOfLine "lf", and three tests that match LF-terminated content fail.

All tracked blobs are already LF, so this changes checkout behavior only and produces no renormalization diff. Every CI job runs on ubuntu-latest, where it is a no-op.
2026-08-25 08:45:13 -07:00
GitHub Actions 16b3b310c3 chore: bump Claude Code to 2.1.245 and Agent SDK to 0.3.245 2026-08-25 05:14:05 +00:00
GitHub Actions 6bcfb8263a chore: bump Claude Code to 2.1.241 and Agent SDK to 0.3.241 2026-08-25 03:43:35 +00:00
GitHub Actions b62c7454dc chore: bump Claude Code to 2.1.241 and Agent SDK to 0.3.243 2026-08-25 03:38:24 +00:00
GitHub Actions e5ad3c7725 chore: bump Claude Code to 2.1.243 and Agent SDK to 0.3.243 2026-08-24 23:41:35 +00:00
GitHub Actions c81e3bc69d chore: bump Claude Code to 2.1.241 and Agent SDK to 0.3.241 2026-08-23 00:53:06 +00:00
GitHub Actions 24dcd50c05 chore: bump Claude Code to 2.1.240 and Agent SDK to 0.3.240 2026-08-22 14:45:56 +00:00
GitHub Actions dcb57747bf chore: bump Claude Code to 2.1.239 and Agent SDK to 0.3.239 2026-08-21 19:55:45 +00:00
492d2d78ee fix: teach claude_args --allowedTools in the signed prompt (#1704)
allowed_tools was removed in v1.0. The tag-mode prompt still named it
as the way to enable Bash under commit signing.

Co-authored-by: RESILIENCE Agentic Solutions <286555414+WeAreResilience@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 16:17:53 -07:00
2ca5fb4027 fix: surface resolved model limits (#1608)
Signed-off-by: ulofiai <monsterking@tutamail.com>
Co-authored-by: ulofiai <monsterking@tutamail.com>
2026-08-20 16:17:36 -07:00
f3f2789f0a fix(mcp): recognize mcp__github aggregate selector for GitHub MCP server initialization (#1657)
* fix(mcp): accept shorthand selectors for GitHub MCP server initialization

## Problem

Signed-off-by: anish <anishesg@users.noreply.github.com>

* address review feedback: fix prettier formatting

Signed-off-by: anish <anishesg@users.noreply.github.com>

---------

Signed-off-by: anish <anishesg@users.noreply.github.com>
Co-authored-by: anish <anishesg@users.noreply.github.com>
2026-08-20 16:17:25 -07:00
Gautam SharmaandGitHub 6a5f1d8e0a fix(cleanup): keep the base-branch config revert out of the auto-commit (#1677)
restoreConfigFromBase replaces .claude/, CLAUDE.md and the other sensitive
paths with the PR base branch's versions, then deliberately unstages them so
the revert does not reach a commit. checkAndCommitOrDeleteBranch then ran a
bare `git add -A`, which staged them again and pushed a silent revert of the
PR author's own config onto their branch, under a commit message that says
only "Auto-commit: Save uncommitted changes from Claude".

restoreConfigFromBase now returns the paths it restored. run.ts threads them
through updateCommentLink into checkAndCommitOrDeleteBranch, which excludes
them via pathspec from both the staging and the git status check.

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 —
trading one silent-data-loss bug for another. Reverting the fix, dropping the
status scoping, and switching to an unconditional exclusion each fail the new
tests.

The status check is scoped the same way as the staging: when the reverted
config is the only dirty entry there is no real work, so the branch is now
correctly treated as empty and deleted instead of receiving a pure revert.

Reachable on a closed or merged PR where Claude left uncommitted changes with
use_commit_signing false — the only combination where a restore has run and
claudeBranch is set.

Fixes #1669
2026-08-20 16:17:03 -07:00
HyunSooandGitHub 39ad3c8977 fix(github): honor GITHUB_GRAPHQL_URL for the GraphQL client (#1575)
The REST client honors GITHUB_API_URL, but the GraphQL client derived its
base URL from GITHUB_API_URL as well and ignored the standard
GITHUB_GRAPHQL_URL variable that GitHub Actions provides. On standard GitHub
Enterprise Server this still worked because @octokit/graphql rewrites a
".../api/v3" REST base to ".../api/graphql", but any deployment whose GraphQL
endpoint is not derivable from the REST base (custom proxy, separate host)
sent GraphQL requests to the wrong URL.

Honor GITHUB_GRAPHQL_URL independently and fall back to GITHUB_API_URL when it
is unset, so behavior is unchanged for github.com and standard GHES. A single
trailing "/graphql" is stripped because @octokit/graphql appends its own.

Add wire-level regression tests that run the real client factory in a fresh
process and assert the final request URLs and Authorization headers;
constructor-option assertions are insufficient because @octokit/graphql
rewrites the path after the client is constructed.
2026-08-20 15:45:21 -07:00
GitHub Actions 3f854a8fb5 chore: bump Claude Code to 2.1.238 and Agent SDK to 0.3.238 2026-08-20 20:33:55 +00:00
GitHub Actions 5ee796a55f chore: bump Claude Code to 2.1.237 and Agent SDK to 0.3.237 2026-08-20 00:54:23 +00:00
Tem RevilandGitHub cff8d3c8f0 fix(git-config): neutralize checkout credential in include-based config (#1526)
configureGitAuth() removed the actions/checkout auth header with
`git config --unset-all http.<server>/.extraheader`, which only edits the
repo-local config. Since actions/checkout v6.0.0 (backported to v5.0.1 and
v4.3.1) the header is written to a separate file under RUNNER_TEMP and
pulled in via include.path, so --unset-all on the local config is a no-op:
the code logged "No existing authentication headers to remove" while the
checkout credential (usually the workflow GITHUB_TOKEN) stayed usable by
git for the rest of the job.

Also clear the header from every included file, so it is neutralized under
both the pre-v6 (local) and v6+ (include) layouts. Includes that do not
define the header are left untouched.

Fixes #1510
2026-08-19 17:23:23 -07:00
GitHub Actions e2a4b761cd chore: bump Claude Code to 2.1.236 and Agent SDK to 0.3.236 2026-08-19 20:05:23 +00:00
65b50df083 fix(github): match bot actors in comment filters using GraphQL __typename (#1616)
`exclude_comments_by_actor` and `include_comments_by_actor` never matched
any bot. Both the documented `*[bot]` wildcard and exact entries such as
`dependabot[bot]` silently did nothing.

GitHub's GraphQL API returns the bare login for App actors ("dependabot"),
while REST and the GitHub UI append a suffix ("dependabot[bot]"). Filter
patterns are written in the suffixed form, so matching a GraphQL login
against them could never succeed and `actor.endsWith("[bot]")` was dead
code.

Request `__typename` on the Actor-typed author selections and normalize
App actors to their suffixed name via `resolveActorName()` before matching.
Normalizing at the filter boundary fixes the wildcard and exact-match cases
together, and leaves the author names shown in the prompt unchanged.

The existing test mocked `login: "scanner[bot]"`, a payload GraphQL never
produces, which is why the gap was invisible. It now mocks the real shape
(`__typename: "Bot", login: "scanner"`) and fails without this fix.

The commit author selection is left alone: it is a GitCommit, not an Actor.

Fixes #1514

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 17:24:26 -07:00
JuwanandGitHub 0a80d21df7 fix: strip unused ALL_INPUTS environment variable from Claude subprocess env (#1692) 2026-08-18 17:23:38 -07:00
JuwanandGitHub 54eadc2f72 fix(security): unify secret redaction in public comment outputs (#1693)
Ensure all public issue, PR, and inline comments apply redactSecrets() in addition to sanitizeContent() before submitting payloads to the GitHub API. This aligns public comment output with error log and step-summary redaction policies, preventing potential leakage of Anthropic API keys, AWS credentials, Slack tokens, JWTs, and GitHub tokens.
2026-08-18 17:23:11 -07:00
GitHub Actions d40ddef4c0 chore: bump Claude Code to 2.1.235 and Agent SDK to 0.3.235 2026-08-18 20:39:22 +00:00
GitHub Actions 459ad358ae chore: bump Claude Code to 2.1.234 and Agent SDK to 0.3.234 2026-08-17 20:23:54 +00:00
Abhinav Kumar SinghandGitHub d721746d68 fix: bound image attachment downloads (#1625) 2026-08-14 16:47:46 -07:00
5da4c76dde fix: bump shell-quote to 1.8.4 to remediate CVE-2026-9277 (#1557)
shell-quote 1.8.3 (root and base-action dependency) is affected by
CVE-2026-9277, a CRITICAL severity vulnerability. 1.8.4 contains the fix.

Co-authored-by: Ashwin Bhat <ashwin@anthropic.com>
2026-08-14 16:46:34 -07:00
Ashwin BhatandGitHub a2cac87e27 ci: skip Claude-backed test jobs on fork PRs (#1655)
* ci: skip Claude-backed test jobs on fork PRs

Jobs that run the action against the Claude API authenticate via workload
identity federation, which fork PRs cannot mint an OIDC token for, so they
always failed on external contributions. Gate each such job on the PR head
repo matching the base repo; push and workflow_dispatch runs are unaffected.

No-Verification-Needed: CI workflow config only, exercised by Actions on the PR

* test: pin the bare remote's initial branch in fetch-depth test

The shallow-clone case created its bare remote with a plain git init, so
HEAD pointed at whatever init.defaultBranch resolves to (master on CI)
while the test only pushed main. git clone --depth=1 implies
--single-branch, and with a dangling remote HEAD it produces an empty,
non-shallow clone, so the is-shallow assertion failed on runners whose
default branch is not main.

No-Verification-Needed: test-only change
2026-08-14 16:40:34 -07:00
Rishav NaskarandGitHub b49813d0e7 feat(context): include diffHunk in PR review comment context (#1584)
* feat(context): include diffHunk in PR review comment context

Review comments arrived with only path and line, so the code they were
written against was missing from the prompt. Fetch diffHunk in the PR
GraphQL query and render it under the comment as a diff block.

The hunk is PR-authored content, so it goes through sanitizeContent like
the comment body. Comments without a hunk are unchanged.

Fixes #855

* test(formatter): cover outdated review comments with an empty diff hunk

GitHub returns diffHunk: "" (not null) for comments whose line no longer
exists in the diff, so the render guard has to reject empty strings too.
Found running the real query against anthropics/claude-code-action#1025.
2026-08-14 16:32:44 -07:00
9678fce999 fix(base-action): add ~/.local/bin to $GITHUB_PATH after auto-install (#1643)
## Problem

Signed-off-by: anish <anishesg@users.noreply.github.com>
Co-authored-by: anish <anishesg@users.noreply.github.com>
2026-08-14 16:31:36 -07:00
Madan kumarandGitHub ed186becce fix: only limit fetch depth when the checkout is already shallow (#1647)
restoreConfigFromBase and setupBranch pass --depth to every git fetch. On a
checkout made with fetch-depth: 0 that does not just cap the download: it
truncates the history already present and marks the repository shallow, which
drops the merge base with the base branch. `git log origin/<base>..HEAD` then
silently includes commits that are already merged, and
`git diff origin/<base>...HEAD` fails with "no merge base" — the two commands
the prompt tells Claude to run to scope its work to the PR.

Gate the flag on `git rev-parse --is-shallow-repository`, so a checkout that is
already shallow (the fetch-depth: 1 default) keeps the same depth behaviour and
the fetch savings it was added for, while a full checkout stays full.

Fixes #1642
2026-08-14 16:31:26 -07:00
05ee4b30d7 Harden delete_files MCP tool: validate paths within repo root (#1636)
Mirror the path validation already performed by the commit_files tool.
delete_files previously only normalized absolute paths against CWD and
passed relative paths through unchecked; it now runs each path through
validatePathWithinRepo, rejecting "../" traversal and symlinked escapes
for consistency and defense-in-depth.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-14 16:31:15 -07:00
GitHub Actions 9d7150bc8a chore: bump Claude Code to 2.1.233 and Agent SDK to 0.3.233 2026-08-14 22:21:44 +00:00
GitHub Actions e63208cb98 chore: bump Claude Code to 2.1.232 and Agent SDK to 0.3.232 2026-08-13 23:30:40 +00:00
GitHub Actions dc33e8a15b chore: bump Claude Code to 2.1.231 and Agent SDK to 0.3.231 2026-08-13 08:39:40 +00:00
GitHub Actions c58ad32088 chore: bump Claude Code to 2.1.229 and Agent SDK to 0.3.229 2026-08-12 20:57:42 +00:00
Henrique PiresandGitHub dfb8fc798e fix(mcp): detect binary files by content instead of extension allowlist (#1633) 2026-08-11 16:35:46 -07:00
a2489efcb9 fix(summary): keep every text block in structured tool results (#1619)
formatResultContent recognized structured tool output shaped like
`[{ type: "text", text: "..." }]` but read only `parsedContent[0].text`.
When a tool result split its output across several text blocks, the step
summary showed the first and silently dropped the rest, so extra findings,
file paths and follow-up instructions vanished from the rendered
Claude Code Report while remaining in the execution transcript.

Collect the text from every block instead of just the first. Blocks of other
types, such as images, are skipped rather than stringified into the summary.

Fixes #1572

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 16:35:38 -07:00
Abhinav Kumar SinghandGitHub 8b8745859f fix: paginate GitHub Actions MCP responses (#1629) 2026-08-11 16:35:34 -07:00
66 changed files with 2196 additions and 178 deletions
+3
View File
@@ -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
+2
View File
@@ -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
+4
View File
@@ -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
+4
View File
@@ -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
+8
View File
@@ -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
+11 -1
View File
@@ -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: |
+3 -1
View File
@@ -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
View File
@@ -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=="],
+4 -4
View File
@@ -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"
+2 -2
View File
@@ -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",
+4
View File
@@ -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) {
+30
View File
@@ -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;
}
});
});
});
+89
View File
@@ -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(
() => {},
+12 -12
View File
@@ -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=="],
+23
View File
@@ -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
View File
@@ -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": {
+2 -2
View File
@@ -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)
+9 -3
View File
@@ -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,
+6 -2
View File
@@ -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);
+10 -1
View File
@@ -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) {
+2 -2
View File
@@ -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}`,
},
+13
View File
@@ -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
View File
@@ -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
+6 -4
View File
@@ -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,
),
+10 -1
View File
@@ -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) {
+34 -7
View File
@@ -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})`;
}
}
+20 -10
View File
@@ -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
+2 -1
View File
@@ -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)}`;
}
}
+6 -1
View File
@@ -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})`;
}
+40
View File
@@ -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;
}
}
+37 -4
View File
@@ -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
+18 -3
View File
@@ -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];
}
+6
View File
@@ -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 = {
+25
View File
@@ -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
+40 -9
View File
@@ -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];
+22
View File
@@ -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;
}
}
+19
View File
@@ -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);
}
+63 -30
View File
@@ -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);
});
}
+2 -2
View File
@@ -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,
+24
View File
@@ -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);
+27 -44
View File
@@ -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(
+3 -3
View File
@@ -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) {
+13 -8
View File
@@ -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> } = {
+47
View File
@@ -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,
);
});
});
+79
View File
@@ -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);
});
});
+262
View File
@@ -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"]);
});
});
+6
View File
@@ -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);
});
+35
View File
@@ -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)", () => {
+4 -1
View File
@@ -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",
+106
View File
@@ -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: [
+169
View File
@@ -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"]);
});
});
+116
View File
@@ -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
View File
@@ -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,
}),
);
+36
View File
@@ -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", () => {
+49
View File
@@ -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]);
});
});
+94
View File
@@ -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");
});
});
+121
View File
@@ -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");
});
});
+104 -5
View File
@@ -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: ![stalled](${imageUrl})`,
},
],
{ 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: ![stalled](${imageUrl})`,
},
],
{ 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),
+98
View File
@@ -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();
});
});
+64
View File
@@ -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",
"![Image Alt Injection](https://example.com/pic.png)",
].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("![](https://example.com/pic.png)");
});
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]");
});
});
+31
View File
@@ -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,
+25
View File
@@ -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]");
});
});
+9
View File
@@ -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"),