mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-08-04 02:08:30 +08:00
Compare commits
No commits in common. "main" and "v1.0.152" have entirely different histories.
@ -439,10 +439,8 @@ runs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
curl -L \
|
curl -L \
|
||||||
--connect-timeout 5 \
|
|
||||||
--max-time 10 \
|
|
||||||
-X DELETE \
|
-X DELETE \
|
||||||
-H "Accept: application/vnd.github+json" \
|
-H "Accept: application/vnd.github+json" \
|
||||||
-H "Authorization: Bearer ${{ steps.run.outputs.github_token }}" \
|
-H "Authorization: Bearer ${{ steps.run.outputs.github_token }}" \
|
||||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||||
${GITHUB_API_URL:-https://api.github.com}/installation/token || true
|
${GITHUB_API_URL:-https://api.github.com}/installation/token
|
||||||
|
|||||||
@ -1,123 +0,0 @@
|
|||||||
# Agent Approval Check
|
|
||||||
|
|
||||||
Require **N human approvals** on any pull request that contains commits
|
|
||||||
authored by an AI agent (Claude, Claude Code, or any bot identity you
|
|
||||||
configure). PRs without agent activity are unaffected.
|
|
||||||
|
|
||||||
This is the same gate Anthropic runs internally on every agent-authored PR.
|
|
||||||
|
|
||||||
## What it does
|
|
||||||
|
|
||||||
When a PR is opened, pushed to, or commented on, this action:
|
|
||||||
|
|
||||||
1. Scans the PR's commits, author, and reviews for the configured agent
|
|
||||||
identities (committer email, bot login, or an `APPROVED` review from a
|
|
||||||
bot). If none are found it posts `success: No agent activity` and stops.
|
|
||||||
2. Counts distinct human approvals: the latest `APPROVED` review per login,
|
|
||||||
plus any `/approve <head-sha>` comment whose SHA matches the current
|
|
||||||
head. Only users with write access to the repo count (verified per-user
|
|
||||||
via the collaborators permission API); agent and excluded-bot logins
|
|
||||||
never count.
|
|
||||||
3. Posts an `agent-approval-check` commit status (`success` once the count
|
|
||||||
reaches `required_approvals`, otherwise `pending`) and a sticky PR
|
|
||||||
comment explaining what's still needed.
|
|
||||||
4. Re-evaluates on every new push or comment. A push moves the head SHA,
|
|
||||||
so earlier `/approve <old-sha>` comments are flagged stale. Approving
|
|
||||||
reviews still count toward the threshold — they're picked up the next
|
|
||||||
time the workflow runs (on push or `/approve`); they just don't trigger
|
|
||||||
a run on their own.
|
|
||||||
|
|
||||||
Mark `agent-approval-check` as a **required status check** on your protected
|
|
||||||
branches and GitHub will refuse to merge until it's green.
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
Copy [`examples/agent-approval-check.yml`](../examples/agent-approval-check.yml)
|
|
||||||
into `.github/workflows/` in your repo, then add `agent-approval-check` to the
|
|
||||||
required status checks on your protected branch.
|
|
||||||
|
|
||||||
This action is designed to run **alongside** GitHub's native branch
|
|
||||||
protection, not replace it. On the same protected branch you should also:
|
|
||||||
|
|
||||||
1. Require at least 1 approving review from someone with write access.
|
|
||||||
2. Enable **Dismiss stale pull request approvals when new commits are pushed**.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
name: agent-approval-check
|
|
||||||
on:
|
|
||||||
pull_request_target:
|
|
||||||
types: [opened, synchronize, reopened, ready_for_review]
|
|
||||||
issue_comment:
|
|
||||||
types: [created]
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: write
|
|
||||||
statuses: write
|
|
||||||
jobs:
|
|
||||||
check:
|
|
||||||
if: github.event_name != 'issue_comment' || github.event.issue.pull_request
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: anthropics/claude-code-action/agent-approval-check@main
|
|
||||||
with:
|
|
||||||
required_approvals: 2
|
|
||||||
agent_emails: noreply@anthropic.com
|
|
||||||
agent_logins: claude[bot],claude-code[bot]
|
|
||||||
```
|
|
||||||
|
|
||||||
## Inputs
|
|
||||||
|
|
||||||
| Input | Default | Meaning |
|
|
||||||
| ---------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `required_approvals` | `2` | Distinct human approvals needed. |
|
|
||||||
| `agent_emails` | `noreply@anthropic.com` | Committer emails that mark a commit agent-authored. |
|
|
||||||
| `agent_logins` | `claude[bot],claude-code[bot]` | Logins treated as agents (PR author or approving reviewer). |
|
|
||||||
| `excluded_approvers` | _(empty)_ | Logins whose approvals never count. |
|
|
||||||
| `exempt_head_branches` | _(empty)_ | Head-branch globs that auto-pass. ⚠️ Leave empty — branch names are attacker-controlled, so this is not a safe place to encode trust. |
|
|
||||||
| `exempt_path_prefixes` | _(empty)_ | PRs touching only these prefixes auto-pass. |
|
|
||||||
| `protected_bases` | _(default branch)_ | Base branches this check gates (see threat model). |
|
|
||||||
| `config_file` | _(empty)_ | Path to an [agent-identities YAML](./agent-identities.example.yaml) replacing the inline inputs. See the warning below. |
|
|
||||||
| `docs_url` | this README | Link in the PR comment footer. |
|
|
||||||
| `github_token` | `${{ github.token }}` | Needs `statuses:write` + `pull-requests:write`. |
|
|
||||||
|
|
||||||
> ⚠️ **`config_file` and checkout:** if you set `config_file`, your workflow
|
|
||||||
> must check out the **base** branch to read it (the default behaviour of
|
|
||||||
> `actions/checkout` under `pull_request_target`). Never check out the PR
|
|
||||||
> head ref — doing so would let the PR author control the config and bypass
|
|
||||||
> this check.
|
|
||||||
|
|
||||||
## Approving
|
|
||||||
|
|
||||||
A human counts as an approver by either:
|
|
||||||
|
|
||||||
- submitting a normal GitHub **Approve** review, or
|
|
||||||
- commenting `/approve <sha>` where `<sha>` is the current head commit
|
|
||||||
(12–40 hex chars). This path lets the PR author — who can't approve their
|
|
||||||
own PR in GitHub's UI — vouch for commits an agent pushed on their behalf.
|
|
||||||
The author's `/approve` is subject to the same write-access verification
|
|
||||||
as any other approver, so a fork-PR author without write access on the
|
|
||||||
base repository cannot self-count. The author counts as **one** approval;
|
|
||||||
the remaining approvals must come from other reviewers with write access.
|
|
||||||
|
|
||||||
## Threat model
|
|
||||||
|
|
||||||
- **Tamper-proof triggers.** `pull_request_target` and `issue_comment` run
|
|
||||||
the workflow file from the base/default branch, so the PR under review
|
|
||||||
cannot edit this check. `pull_request_review` does **not** share this
|
|
||||||
property — it runs from the merge ref — so the example workflow omits it;
|
|
||||||
native Approve reviews are picked up on the next synchronize or
|
|
||||||
`/approve` comment. This tamper-resistance assumes the workflow file
|
|
||||||
itself is protected: an actor who can push workflow changes to the
|
|
||||||
default branch can spoof any required status check, including this one,
|
|
||||||
so protect `.github/workflows/` via branch protection or CODEOWNERS.
|
|
||||||
- **Fail-closed.** Any unhandled error exits non-zero; the required status
|
|
||||||
stays non-success and the PR stays blocked. PRs with >100 commits are
|
|
||||||
treated as agent-authored because the full commit list can't be verified.
|
|
||||||
- **Sibling-PR guard.** Commit statuses attach to a SHA, not a PR. The
|
|
||||||
action refuses to post a status on a PR whose base isn't in
|
|
||||||
`protected_bases`, and withholds `success` while another open PR to a
|
|
||||||
protected base shares the same head commit — otherwise a green status on
|
|
||||||
one PR would also unblock the other.
|
|
||||||
- **No checkout of PR code.** The action never checks out the PR's branch;
|
|
||||||
it reads PR metadata via the GitHub API, so the usual
|
|
||||||
`pull_request_target` code-execution risk does not apply.
|
|
||||||
@ -1,72 +0,0 @@
|
|||||||
name: Agent Approval Check
|
|
||||||
description: |
|
|
||||||
Require N human approvals on PRs that contain agent-authored commits
|
|
||||||
(Claude, Claude Code, or any configured bot identity). Posts an
|
|
||||||
`agent-approval-check` commit status — mark it as a required check on
|
|
||||||
protected branches to gate merges.
|
|
||||||
|
|
||||||
inputs:
|
|
||||||
github_token:
|
|
||||||
description: Token with statuses:write and pull-requests:write on this repo.
|
|
||||||
default: ${{ github.token }}
|
|
||||||
required_approvals:
|
|
||||||
description: Number of distinct human approvals required. Must be >= 1.
|
|
||||||
default: "2"
|
|
||||||
agent_emails:
|
|
||||||
description: Comma-separated committer emails treated as agent-authored.
|
|
||||||
default: noreply@anthropic.com
|
|
||||||
agent_logins:
|
|
||||||
description: |
|
|
||||||
Comma-separated GitHub logins treated as agents — a PR opened by, or an
|
|
||||||
APPROVED review from, one of these triggers the check.
|
|
||||||
default: claude[bot],claude-code[bot]
|
|
||||||
excluded_approvers:
|
|
||||||
description: Comma-separated logins whose approvals never count (e.g. rubber-stamp bots).
|
|
||||||
default: ""
|
|
||||||
exempt_head_branches:
|
|
||||||
description: |
|
|
||||||
Comma-separated glob patterns; PRs from matching head branches auto-pass.
|
|
||||||
WARNING: leave empty — branch names are attacker-controlled, so this is
|
|
||||||
not a safe place to encode trust.
|
|
||||||
default: ""
|
|
||||||
exempt_path_prefixes:
|
|
||||||
description: Comma-separated path prefixes; PRs touching only these auto-pass.
|
|
||||||
default: ""
|
|
||||||
protected_bases:
|
|
||||||
description: |
|
|
||||||
Comma-separated base branches this check gates. Empty = the repo's
|
|
||||||
default branch only. PRs targeting any other base are refused (no
|
|
||||||
status posted) so a sibling PR sharing the head SHA can't get the
|
|
||||||
shared commit stamped green.
|
|
||||||
default: ""
|
|
||||||
config_file:
|
|
||||||
description: Optional path to an agent-identities YAML file (overrides the inline inputs).
|
|
||||||
default: ""
|
|
||||||
docs_url:
|
|
||||||
description: Link shown in the PR comment footer.
|
|
||||||
default: "https://github.com/anthropics/claude-code-action/tree/main/agent-approval-check"
|
|
||||||
|
|
||||||
runs:
|
|
||||||
using: composite
|
|
||||||
steps:
|
|
||||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
- run: pip install 'httpx==0.28.1' 'pyyaml==6.0.3' 'tenacity==9.1.4'
|
|
||||||
shell: bash
|
|
||||||
- run: python "${{ github.action_path }}/agent_approval_check.py"
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ inputs.github_token }}
|
|
||||||
GH_REPOSITORY: ${{ github.repository }}
|
|
||||||
GH_EVENT_NAME: ${{ github.event_name }}
|
|
||||||
GH_EVENT_PATH: ${{ github.event_path }}
|
|
||||||
REQUIRED_APPROVALS: ${{ inputs.required_approvals }}
|
|
||||||
AGENT_EMAILS: ${{ inputs.agent_emails }}
|
|
||||||
AGENT_LOGINS: ${{ inputs.agent_logins }}
|
|
||||||
EXCLUDED_APPROVERS: ${{ inputs.excluded_approvers }}
|
|
||||||
EXEMPT_HEAD_BRANCHES: ${{ inputs.exempt_head_branches }}
|
|
||||||
EXEMPT_PATH_PREFIXES: ${{ inputs.exempt_path_prefixes }}
|
|
||||||
PROTECTED_BASES: ${{ inputs.protected_bases }}
|
|
||||||
CONFIG_FILE: ${{ inputs.config_file }}
|
|
||||||
DOCS_URL: ${{ inputs.docs_url }}
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
---
|
|
||||||
# Optional config-file form of the agent-approval-check inputs.
|
|
||||||
# Pass via `with: { config_file: .github/agent-identities.yaml }` instead of
|
|
||||||
# the inline `agent_emails` / `agent_logins` / … inputs.
|
|
||||||
|
|
||||||
# Committer emails that mark a commit as agent-authored.
|
|
||||||
agent_emails:
|
|
||||||
- noreply@anthropic.com
|
|
||||||
|
|
||||||
# GitHub logins treated as agents — a PR opened by, or an APPROVED review
|
|
||||||
# from, one of these triggers the check.
|
|
||||||
agent_app_logins:
|
|
||||||
- claude[bot]
|
|
||||||
- claude-code[bot]
|
|
||||||
|
|
||||||
# Logins whose approvals never count toward the required total.
|
|
||||||
excluded_approver_logins: []
|
|
||||||
|
|
||||||
# Head-branch glob patterns that auto-pass. Leave empty: branch names are
|
|
||||||
# attacker-controlled, so this is not a safe place to encode trust.
|
|
||||||
exempt_head_branches: []
|
|
||||||
|
|
||||||
# Per-repo path prefixes whose PRs auto-pass when ONLY those paths change.
|
|
||||||
exempt_path_prefixes:
|
|
||||||
owner/repo:
|
|
||||||
- docs/
|
|
||||||
|
|
||||||
# Per-repo base branches this check gates. A repo with no entry defaults to
|
|
||||||
# its default branch only. Listing a repo here REPLACES that default.
|
|
||||||
protected_bases:
|
|
||||||
owner/repo:
|
|
||||||
exact: [main]
|
|
||||||
prefixes: [release/]
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -145,7 +145,7 @@ runs:
|
|||||||
PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
|
PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
|
||||||
run: |
|
run: |
|
||||||
if [ -z "$PATH_TO_CLAUDE_CODE_EXECUTABLE" ]; then
|
if [ -z "$PATH_TO_CLAUDE_CODE_EXECUTABLE" ]; then
|
||||||
CLAUDE_CODE_VERSION="2.1.220"
|
CLAUDE_CODE_VERSION="2.1.183"
|
||||||
echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..."
|
echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..."
|
||||||
for attempt in 1 2 3; do
|
for attempt in 1 2 3; do
|
||||||
echo "Installation attempt $attempt..."
|
echo "Installation attempt $attempt..."
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
"name": "@anthropic-ai/claude-code-base-action",
|
"name": "@anthropic-ai/claude-code-base-action",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "^1.10.1",
|
"@actions/core": "^1.10.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
|
"@anthropic-ai/claude-agent-sdk": "^0.3.183",
|
||||||
"shell-quote": "^1.8.3",
|
"shell-quote": "^1.8.3",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -27,23 +27,23 @@
|
|||||||
|
|
||||||
"@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="],
|
"@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="],
|
||||||
|
|
||||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.220", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.220", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.220", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.220", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.220", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.220", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.220", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.220", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.220" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-glc7SdwPkOkLw8oxwLo9PKTdLJGqW/PIR4urWXFoRtX9YllwozsEVc5Tc1+EvLSkfrsxPJqQWqOgpjUOQXf1oA=="],
|
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.183", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.183", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.183", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.183", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.183", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.183", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.183", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.183", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.183" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-rAKws76RKdFu+DK5q9wYN0AGLRdPrw0vGd+TMzSFynFdxwUAehYc9vUKJoK27j2xRoHFdNt+JDsih5BzsUzi+Q=="],
|
||||||
|
|
||||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.220", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7VxlbEosK7DODiOnsjoVd0DSJzbnaPrM2jelMHI0y8zx1UnLS3WC6EFUXbvy74F2sXqEznh2tzn7EKWInaRN6Q=="],
|
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.183", "", { "os": "darwin", "cpu": "arm64" }, "sha512-o/+sxwKgXuw6RG5cERWjvcvL1CDSPe/TaXMhax+dq+V4lDOI5iTqg3y5Wfb6dL3xlWoTA2OhWowDQllKbE04LQ=="],
|
||||||
|
|
||||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.220", "", { "os": "darwin", "cpu": "x64" }, "sha512-X9RwDsSmbF6ultKZroaip+DL8WRgC64gHbrAwrRlAFSPNZV7zmJyP2ur8rW7KrxqmtuehdMMkw8+SAC/6hD2PA=="],
|
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.183", "", { "os": "darwin", "cpu": "x64" }, "sha512-V7Cf8JeD5EPf4MPomFUlEblCIQI0wg+aWdOSqvfMsDmCBEHljd52CQ3a7W263oVt6I7QUfRTpX2KNvdma56rDA=="],
|
||||||
|
|
||||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.220", "", { "os": "linux", "cpu": "arm64" }, "sha512-WkROPwWskqhKR9XgnmseHQ6rLi9zM9qt57IWoToIjL/eXOqDWipp7JXZ1L5ud+LrA42dunHPZfBwD/vXZ+A7LA=="],
|
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.183", "", { "os": "linux", "cpu": "arm64" }, "sha512-qqnSf85D3uBH7kRHLGOMhr7aQrREUZ4hUlCztrkJe/twUT4bmJsO86zamnAf7vOYeCul/sHYwBMmXtOfI+cHZg=="],
|
||||||
|
|
||||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.220", "", { "os": "linux", "cpu": "arm64" }, "sha512-OHoZOZ8Cf2TBr6oXIXPwyvUxj9jrq2w8E4poA8dMpacXszcPSPiCQCMuuOh4aWJzfeJE1+TtWxhKMVb2csXyZQ=="],
|
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.183", "", { "os": "linux", "cpu": "arm64" }, "sha512-ua6XaiCcYYKfd0CY9cjoyYnTQWI2GocpbP7Gwvmr+9taW/lTpQGVQH3FrF2VFKXVSt7AaVE6Z7WN9oPxcxl3VA=="],
|
||||||
|
|
||||||
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.220", "", { "os": "linux", "cpu": "x64" }, "sha512-tkTJFnpR9VifvWX2fmkCAPkT6+8Wk/gVu8B5jsVekKZPiZoWRHmMXO30BnZn+f0TZhgYP+82PSX3S8crH1kn+w=="],
|
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.183", "", { "os": "linux", "cpu": "x64" }, "sha512-GvfNp9XsKWFvr0EH3NgX2pqzHPYhFeqhOmVc4A+2e+GdxBMYUEa0ylfPLJItahQU9FkTJAw2oQNnzTHPPm1GxA=="],
|
||||||
|
|
||||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.220", "", { "os": "linux", "cpu": "x64" }, "sha512-K+FWj+LcGhC1Z7wqeWoLxm1iemcba5xKpLLFVwYm4V6HyMx3ruYd/2r2TiQtjT+JWeNFWIys0ScHiItR6vWAiA=="],
|
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.183", "", { "os": "linux", "cpu": "x64" }, "sha512-wp+p+4xeZOW+h4InMKR7nIdGlgYftXyR+ErOVz7i2tMRcKuYMoc5jRIYjdJkuETMSr5nsRj0rDeO5gG7M6MGZQ=="],
|
||||||
|
|
||||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.220", "", { "os": "win32", "cpu": "arm64" }, "sha512-rIwgq0UwQExWl6KrHUyC4w5KwpL9l6nd95aUTx6RitexaAuEw//xtfTVLnuE4hDDQZFkzEwpdKc3nxDWoGcUbA=="],
|
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.183", "", { "os": "win32", "cpu": "arm64" }, "sha512-cGyMC9YDsrCQ4av74dMVa2liOb5oLFX4+1SxwJGRCW7mBVBazcM6rg7ifWbd9XfkDhNPKOIfyiloipyMfoaE4g=="],
|
||||||
|
|
||||||
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.220", "", { "os": "win32", "cpu": "x64" }, "sha512-MuOuXhbr66HlGaWXD2f3w0k2PsvmnbkwcUZ0dAe2poFLdl72GC2dapwwOBefxm9QmoNqk9+jmv/dSKGOVWyvLw=="],
|
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.183", "", { "os": "win32", "cpu": "x64" }, "sha512-h/XzbrSmXGroTk/FYKR6J4/8G9vDb1HUUUeNXeBGqGW1kppIiWPJKLRzjtSe0brVjADOKOT6tE5IHK0mV/1gBw=="],
|
||||||
|
|
||||||
"@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=="],
|
"@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=="],
|
||||||
|
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "^1.10.1",
|
"@actions/core": "^1.10.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
|
"@anthropic-ai/claude-agent-sdk": "^0.3.183",
|
||||||
"shell-quote": "^1.8.3"
|
"shell-quote": "^1.8.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@ -75,8 +75,7 @@ async function run() {
|
|||||||
core.setOutput("conclusion", "failure");
|
core.setOutput("conclusion", "failure");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
} finally {
|
} finally {
|
||||||
// Stop refreshing the workload identity token file (so the process can
|
// Stop refreshing the workload identity token file so the process can exit
|
||||||
// exit) and delete the token material so it doesn't outlive this step
|
|
||||||
workloadIdentity?.stop();
|
workloadIdentity?.stop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,7 +19,6 @@ const ACCUMULATING_FLAGS = new Set([
|
|||||||
"disallowedTools",
|
"disallowedTools",
|
||||||
"disallowed-tools",
|
"disallowed-tools",
|
||||||
"mcp-config",
|
"mcp-config",
|
||||||
"add-dir",
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Delimiter used to join accumulated flag values
|
// Delimiter used to join accumulated flag values
|
||||||
@ -201,17 +200,6 @@ export function parseSdkOptions(options: ClaudeOptions): ParsedSdkOptions {
|
|||||||
// Detect if --json-schema is present (for hasJsonSchema flag)
|
// Detect if --json-schema is present (for hasJsonSchema flag)
|
||||||
const hasJsonSchema = "json-schema" in extraArgs;
|
const hasJsonSchema = "json-schema" in extraArgs;
|
||||||
|
|
||||||
const modelFromClaudeArgs = extraArgs["model"] || undefined;
|
|
||||||
delete extraArgs["model"];
|
|
||||||
|
|
||||||
const additionalDirectories = extraArgs["add-dir"]
|
|
||||||
? extraArgs["add-dir"]
|
|
||||||
.split(ACCUMULATE_DELIMITER)
|
|
||||||
.map((dir) => dir.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
: [];
|
|
||||||
delete extraArgs["add-dir"];
|
|
||||||
|
|
||||||
// Extract and merge allowedTools from all sources:
|
// Extract and merge allowedTools from all sources:
|
||||||
// 1. From extraArgs (parsed from claudeArgs - contains tag mode's tools)
|
// 1. From extraArgs (parsed from claudeArgs - contains tag mode's tools)
|
||||||
// - Check both camelCase (--allowedTools) and hyphenated (--allowed-tools) variants
|
// - Check both camelCase (--allowedTools) and hyphenated (--allowed-tools) variants
|
||||||
@ -307,7 +295,7 @@ export function parseSdkOptions(options: ClaudeOptions): ParsedSdkOptions {
|
|||||||
// Build SDK options - use merged tools from both direct options and claudeArgs
|
// Build SDK options - use merged tools from both direct options and claudeArgs
|
||||||
const sdkOptions: SdkOptions = {
|
const sdkOptions: SdkOptions = {
|
||||||
// Direct options from ClaudeOptions inputs
|
// Direct options from ClaudeOptions inputs
|
||||||
model: options.model || modelFromClaudeArgs,
|
model: options.model,
|
||||||
maxTurns: options.maxTurns ? parseInt(options.maxTurns, 10) : undefined,
|
maxTurns: options.maxTurns ? parseInt(options.maxTurns, 10) : undefined,
|
||||||
allowedTools:
|
allowedTools:
|
||||||
mergedAllowedTools.length > 0 ? mergedAllowedTools : undefined,
|
mergedAllowedTools.length > 0 ? mergedAllowedTools : undefined,
|
||||||
@ -316,8 +304,6 @@ export function parseSdkOptions(options: ClaudeOptions): ParsedSdkOptions {
|
|||||||
systemPrompt,
|
systemPrompt,
|
||||||
fallbackModel: options.fallbackModel,
|
fallbackModel: options.fallbackModel,
|
||||||
pathToClaudeCodeExecutable: options.pathToClaudeCodeExecutable,
|
pathToClaudeCodeExecutable: options.pathToClaudeCodeExecutable,
|
||||||
additionalDirectories:
|
|
||||||
additionalDirectories.length > 0 ? additionalDirectories : undefined,
|
|
||||||
|
|
||||||
// Pass through claudeArgs as extraArgs - CLI handles --mcp-config, --json-schema, etc.
|
// Pass through claudeArgs as extraArgs - CLI handles --mcp-config, --json-schema, etc.
|
||||||
// Note: allowedTools and disallowedTools have been removed from extraArgs to prevent duplicates
|
// Note: allowedTools and disallowedTools have been removed from extraArgs to prevent duplicates
|
||||||
|
|||||||
@ -208,10 +208,7 @@ export async function runClaudeWithSdk(
|
|||||||
throw new Error("No result message received from Claude");
|
throw new Error("No result message received from Claude");
|
||||||
}
|
}
|
||||||
|
|
||||||
// subtype "success" with is_error:true means the run errored without producing
|
const isSuccess = resultMessage.subtype === "success";
|
||||||
// a real result — treat it as failure so CI does not show a misleading green check.
|
|
||||||
const isSuccess =
|
|
||||||
resultMessage.subtype === "success" && !resultMessage.is_error;
|
|
||||||
result.conclusion = isSuccess ? "success" : "failure";
|
result.conclusion = isSuccess ? "success" : "failure";
|
||||||
|
|
||||||
// Handle structured output
|
// Handle structured output
|
||||||
@ -237,21 +234,14 @@ export async function runClaudeWithSdk(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!isSuccess) {
|
if (!isSuccess) {
|
||||||
if (resultMessage.subtype === "success" && resultMessage.is_error) {
|
|
||||||
core.error(
|
|
||||||
"Claude result reported subtype success with is_error:true (run did not complete successfully)",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if ("errors" in resultMessage && resultMessage.errors) {
|
if ("errors" in resultMessage && resultMessage.errors) {
|
||||||
core.error(`Execution failed: ${resultMessage.errors.join(", ")}`);
|
core.error(`Execution failed: ${resultMessage.errors.join(", ")}`);
|
||||||
}
|
}
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Claude execution failed: ${
|
`Claude execution failed: ${
|
||||||
resultMessage.subtype === "success" && resultMessage.is_error
|
"errors" in resultMessage && resultMessage.errors
|
||||||
? "result is_error:true"
|
? resultMessage.errors.join(", ")
|
||||||
: "errors" in resultMessage && resultMessage.errors
|
: "unknown error"
|
||||||
? resultMessage.errors.join(", ")
|
|
||||||
: "unknown error"
|
|
||||||
}`,
|
}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,8 +15,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as core from "@actions/core";
|
import * as core from "@actions/core";
|
||||||
import { createHash } from "crypto";
|
import { mkdirSync, writeFileSync } from "fs";
|
||||||
import { mkdirSync, rmSync, writeFileSync } from "fs";
|
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import { retryWithBackoff } from "./retry";
|
import { retryWithBackoff } from "./retry";
|
||||||
|
|
||||||
@ -51,63 +50,6 @@ async function fetchIdentityToken(audience: string) {
|
|||||||
return retryWithBackoff(() => core.getIDToken(audience));
|
return retryWithBackoff(() => core.getIDToken(audience));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Writes a profile config that switches federation resolution to the
|
|
||||||
* file-backed path. Resolving federation through a profile (rather than bare
|
|
||||||
* env vars) enables the SDK's on-disk credentials cache, so the several
|
|
||||||
* `claude` processes the action spawns (plugin installs, main query) share
|
|
||||||
* one exchanged access token instead of each re-exchanging the single-use
|
|
||||||
* GitHub OIDC token, which fails with 401 (`jti_reused`).
|
|
||||||
*
|
|
||||||
* The profile is intentionally minimal: the SDK gap-fills the federation
|
|
||||||
* fields (rule, organization, identity-token file, service account, base URL)
|
|
||||||
* from the ANTHROPIC_* env vars the action already exports, so the file only
|
|
||||||
* needs to exist to turn the cache on.
|
|
||||||
*
|
|
||||||
* The config dir name embeds a fingerprint of the federation inputs. The
|
|
||||||
* SDK's cache reuses a token on `expires_at` alone, with no record of the
|
|
||||||
* config that minted it, and the token's scope is bound at mint time — so a
|
|
||||||
* later action step in the same job (RUNNER_TEMP is per-job) with different
|
|
||||||
* federation inputs must land in a different dir or it would silently reuse
|
|
||||||
* the first step's token.
|
|
||||||
*
|
|
||||||
* Sharing the cache is only safe while the action spawns its `claude`
|
|
||||||
* subprocesses sequentially: the SDK cache is not cross-process serialized,
|
|
||||||
* and concurrent cache misses would each re-exchange the same single-use
|
|
||||||
* identity token. Parallelizing the plugin installs would reintroduce the
|
|
||||||
* `jti_reused` failures.
|
|
||||||
*/
|
|
||||||
function writeFederationProfile(baseDir: string): string {
|
|
||||||
// Every input that changes which credential the exchange mints must be in
|
|
||||||
// here; service_account_id and scope are sent in the exchange request body.
|
|
||||||
const fingerprint = createHash("sha256")
|
|
||||||
.update(
|
|
||||||
JSON.stringify([
|
|
||||||
process.env.ANTHROPIC_FEDERATION_RULE_ID?.trim() ?? "",
|
|
||||||
process.env.ANTHROPIC_ORGANIZATION_ID?.trim() ?? "",
|
|
||||||
process.env.ANTHROPIC_SERVICE_ACCOUNT_ID?.trim() ?? "",
|
|
||||||
process.env.ANTHROPIC_WORKSPACE_ID?.trim() ?? "",
|
|
||||||
process.env.ANTHROPIC_BASE_URL?.trim() ?? "",
|
|
||||||
process.env.ANTHROPIC_SCOPE?.trim() ?? "",
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
.digest("hex")
|
|
||||||
.slice(0, 16);
|
|
||||||
const configDir = join(baseDir, `config-${fingerprint}`);
|
|
||||||
|
|
||||||
mkdirSync(join(configDir, "configs"), { recursive: true, mode: 0o700 });
|
|
||||||
writeFileSync(
|
|
||||||
join(configDir, "configs", "default.json"),
|
|
||||||
JSON.stringify(
|
|
||||||
{ version: "1.0", authentication: { type: "oidc_federation" } },
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
{ mode: 0o600 },
|
|
||||||
);
|
|
||||||
return configDir;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches a GitHub Actions OIDC token, writes it to a file in RUNNER_TEMP,
|
* Fetches a GitHub Actions OIDC token, writes it to a file in RUNNER_TEMP,
|
||||||
* exports ANTHROPIC_IDENTITY_TOKEN_FILE, and starts a background refresh so
|
* exports ANTHROPIC_IDENTITY_TOKEN_FILE, and starts a background refresh so
|
||||||
@ -115,8 +57,7 @@ function writeFederationProfile(baseDir: string): string {
|
|||||||
*
|
*
|
||||||
* Returns undefined when federation is not configured or is shadowed by a
|
* Returns undefined when federation is not configured or is shadowed by a
|
||||||
* higher-precedence credential. Callers must invoke stop() when execution
|
* higher-precedence credential. Callers must invoke stop() when execution
|
||||||
* finishes; it also deletes the identity token and any cached exchanged
|
* finishes.
|
||||||
* credential.
|
|
||||||
*/
|
*/
|
||||||
export async function setupWorkloadIdentity(): Promise<
|
export async function setupWorkloadIdentity(): Promise<
|
||||||
WorkloadIdentityHandle | undefined
|
WorkloadIdentityHandle | undefined
|
||||||
@ -160,17 +101,6 @@ export async function setupWorkloadIdentity(): Promise<
|
|||||||
}
|
}
|
||||||
|
|
||||||
process.env.ANTHROPIC_IDENTITY_TOKEN_FILE = tokenFile;
|
process.env.ANTHROPIC_IDENTITY_TOKEN_FILE = tokenFile;
|
||||||
if (
|
|
||||||
process.env.ANTHROPIC_CONFIG_DIR?.trim() ||
|
|
||||||
process.env.ANTHROPIC_PROFILE?.trim()
|
|
||||||
) {
|
|
||||||
core.warning(
|
|
||||||
"ANTHROPIC_CONFIG_DIR or ANTHROPIC_PROFILE is already set, so the action will not write its own federation profile. Credential caching across the spawned Claude processes follows the existing profile configuration.",
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
process.env.ANTHROPIC_CONFIG_DIR = writeFederationProfile(tokenDir);
|
|
||||||
process.env.ANTHROPIC_PROFILE = "default";
|
|
||||||
}
|
|
||||||
console.log(
|
console.log(
|
||||||
`Workload identity federation configured (rule: ${process.env.ANTHROPIC_FEDERATION_RULE_ID}, identity token file: ${tokenFile})`,
|
`Workload identity federation configured (rule: ${process.env.ANTHROPIC_FEDERATION_RULE_ID}, identity token file: ${tokenFile})`,
|
||||||
);
|
);
|
||||||
@ -185,12 +115,6 @@ export async function setupWorkloadIdentity(): Promise<
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
tokenFile,
|
tokenFile,
|
||||||
stop: () => {
|
stop: () => clearInterval(refreshInterval),
|
||||||
clearInterval(refreshInterval);
|
|
||||||
// RUNNER_TEMP is per-job, not per-step: remove the identity token, the
|
|
||||||
// profile, and the cached exchanged credential so they don't outlive
|
|
||||||
// this step.
|
|
||||||
rmSync(tokenDir, { recursive: true, force: true });
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -106,8 +106,7 @@ describe("parseSdkOptions", () => {
|
|||||||
const result = parseSdkOptions(options);
|
const result = parseSdkOptions(options);
|
||||||
|
|
||||||
expect(result.sdkOptions.extraArgs?.["allowedTools"]).toBeUndefined();
|
expect(result.sdkOptions.extraArgs?.["allowedTools"]).toBeUndefined();
|
||||||
expect(result.sdkOptions.extraArgs?.["model"]).toBeUndefined();
|
expect(result.sdkOptions.extraArgs?.["model"]).toBe("claude-3-5-sonnet");
|
||||||
expect(result.sdkOptions.model).toBe("claude-3-5-sonnet");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should handle hyphenated --allowed-tools flag", () => {
|
test("should handle hyphenated --allowed-tools flag", () => {
|
||||||
@ -367,8 +366,7 @@ describe("parseSdkOptions", () => {
|
|||||||
);
|
);
|
||||||
expect(mcpConfig.mcpServers).toHaveProperty("server1");
|
expect(mcpConfig.mcpServers).toHaveProperty("server1");
|
||||||
expect(mcpConfig.mcpServers).toHaveProperty("server2");
|
expect(mcpConfig.mcpServers).toHaveProperty("server2");
|
||||||
expect(result.sdkOptions.extraArgs?.["model"]).toBeUndefined();
|
expect(result.sdkOptions.extraArgs?.["model"]).toBe("claude-3-5-sonnet");
|
||||||
expect(result.sdkOptions.model).toBe("claude-3-5-sonnet");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should handle real-world scenario: action config + user config", () => {
|
test("should handle real-world scenario: action config + user config", () => {
|
||||||
@ -404,46 +402,6 @@ describe("parseSdkOptions", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("add-dir handling", () => {
|
|
||||||
test("should accumulate multiple add-dir flags into additionalDirectories", () => {
|
|
||||||
const options: ClaudeOptions = {
|
|
||||||
claudeArgs: '--add-dir "/path/to/dir-a"\n--add-dir "/path/to/dir-b"',
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = parseSdkOptions(options);
|
|
||||||
|
|
||||||
expect(result.sdkOptions.additionalDirectories).toEqual([
|
|
||||||
"/path/to/dir-a",
|
|
||||||
"/path/to/dir-b",
|
|
||||||
]);
|
|
||||||
expect(result.sdkOptions.extraArgs?.["add-dir"]).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should map a single add-dir flag to additionalDirectories", () => {
|
|
||||||
const options: ClaudeOptions = {
|
|
||||||
claudeArgs: '--add-dir "/path/to/dir"',
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = parseSdkOptions(options);
|
|
||||||
|
|
||||||
expect(result.sdkOptions.additionalDirectories).toEqual(["/path/to/dir"]);
|
|
||||||
expect(result.sdkOptions.extraArgs?.["add-dir"]).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should preserve other extraArgs when extracting add-dir", () => {
|
|
||||||
const options: ClaudeOptions = {
|
|
||||||
claudeArgs: '--model "claude-3-5-sonnet" --add-dir "/path/to/dir"',
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = parseSdkOptions(options);
|
|
||||||
|
|
||||||
expect(result.sdkOptions.additionalDirectories).toEqual(["/path/to/dir"]);
|
|
||||||
expect(result.sdkOptions.extraArgs?.["model"]).toBeUndefined();
|
|
||||||
expect(result.sdkOptions.model).toBe("claude-3-5-sonnet");
|
|
||||||
expect(result.sdkOptions.extraArgs?.["add-dir"]).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("other extraArgs passthrough", () => {
|
describe("other extraArgs passthrough", () => {
|
||||||
test("should pass through json-schema in extraArgs", () => {
|
test("should pass through json-schema in extraArgs", () => {
|
||||||
const options: ClaudeOptions = {
|
const options: ClaudeOptions = {
|
||||||
@ -467,8 +425,7 @@ describe("parseSdkOptions", () => {
|
|||||||
|
|
||||||
const result = parseSdkOptions(options);
|
const result = parseSdkOptions(options);
|
||||||
|
|
||||||
expect(result.sdkOptions.extraArgs?.["model"]).toBeUndefined();
|
expect(result.sdkOptions.extraArgs?.["model"]).toBe("claude-haiku");
|
||||||
expect(result.sdkOptions.model).toBe("claude-haiku");
|
|
||||||
expect(result.sdkOptions.allowedTools).toEqual(["Edit"]);
|
expect(result.sdkOptions.allowedTools).toEqual(["Edit"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -479,8 +436,7 @@ describe("parseSdkOptions", () => {
|
|||||||
|
|
||||||
const result = parseSdkOptions(options);
|
const result = parseSdkOptions(options);
|
||||||
|
|
||||||
expect(result.sdkOptions.extraArgs?.["model"]).toBeUndefined();
|
expect(result.sdkOptions.extraArgs?.["model"]).toBe("claude-haiku");
|
||||||
expect(result.sdkOptions.model).toBe("claude-haiku");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should not strip inline # that appears inside a quoted value", () => {
|
test("should not strip inline # that appears inside a quoted value", () => {
|
||||||
@ -490,37 +446,11 @@ describe("parseSdkOptions", () => {
|
|||||||
|
|
||||||
const result = parseSdkOptions(options);
|
const result = parseSdkOptions(options);
|
||||||
|
|
||||||
expect(result.sdkOptions.extraArgs?.["model"]).toBeUndefined();
|
expect(result.sdkOptions.extraArgs?.["model"]).toBe("claude-haiku");
|
||||||
expect(result.sdkOptions.model).toBe("claude-haiku");
|
|
||||||
expect(result.sdkOptions.extraArgs?.["prompt"]).toBe("use color #ff0000");
|
expect(result.sdkOptions.extraArgs?.["prompt"]).toBe("use color #ff0000");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("model handling", () => {
|
|
||||||
test("should map --model from claudeArgs to sdkOptions.model", () => {
|
|
||||||
const options: ClaudeOptions = {
|
|
||||||
claudeArgs: "--model claude-haiku-4-5-20251001",
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = parseSdkOptions(options);
|
|
||||||
|
|
||||||
expect(result.sdkOptions.model).toBe("claude-haiku-4-5-20251001");
|
|
||||||
expect(result.sdkOptions.extraArgs?.["model"]).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should prefer direct model option over --model from claudeArgs", () => {
|
|
||||||
const options: ClaudeOptions = {
|
|
||||||
model: "claude-sonnet-4-6",
|
|
||||||
claudeArgs: "--model claude-haiku-4-5-20251001",
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = parseSdkOptions(options);
|
|
||||||
|
|
||||||
expect(result.sdkOptions.model).toBe("claude-sonnet-4-6");
|
|
||||||
expect(result.sdkOptions.extraArgs?.["model"]).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("environment variables passthrough", () => {
|
describe("environment variables passthrough", () => {
|
||||||
test("should include OTEL environment variables in sdkOptions.env", () => {
|
test("should include OTEL environment variables in sdkOptions.env", () => {
|
||||||
// Set up test environment variables
|
// Set up test environment variables
|
||||||
|
|||||||
@ -63,69 +63,4 @@ describe("runClaudeWithSdk", () => {
|
|||||||
consoleLogSpy.mockRestore();
|
consoleLogSpy.mockRestore();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("fails when result subtype is success but is_error is true", async () => {
|
|
||||||
const consoleErrorSpy = spyOn(console, "error").mockImplementation(
|
|
||||||
() => {},
|
|
||||||
);
|
|
||||||
const consoleLogSpy = spyOn(console, "log").mockImplementation(() => {});
|
|
||||||
const coreErrorSpy = spyOn(
|
|
||||||
await import("@actions/core"),
|
|
||||||
"error",
|
|
||||||
).mockImplementation(() => {});
|
|
||||||
|
|
||||||
tempDir = await mkdtemp(join(tmpdir(), "claude-sdk-"));
|
|
||||||
process.env.RUNNER_TEMP = tempDir;
|
|
||||||
|
|
||||||
const promptPath = join(tempDir, "prompt.txt");
|
|
||||||
await writeFile(promptPath, "test prompt");
|
|
||||||
|
|
||||||
const initMessage = {
|
|
||||||
type: "system",
|
|
||||||
subtype: "init",
|
|
||||||
session_id: "session-123",
|
|
||||||
model: "claude-sonnet-5",
|
|
||||||
};
|
|
||||||
|
|
||||||
const errorResultMessage = {
|
|
||||||
type: "result",
|
|
||||||
subtype: "success",
|
|
||||||
is_error: true,
|
|
||||||
duration_ms: 434,
|
|
||||||
num_turns: 1,
|
|
||||||
total_cost_usd: 0,
|
|
||||||
permission_denials: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
mock.module("@anthropic-ai/claude-agent-sdk", () => ({
|
|
||||||
query: async function* () {
|
|
||||||
yield initMessage;
|
|
||||||
yield errorResultMessage;
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { runClaudeWithSdk } = await import("../src/run-claude-sdk");
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
runClaudeWithSdk(promptPath, {
|
|
||||||
sdkOptions: {},
|
|
||||||
showFullOutput: false,
|
|
||||||
hasJsonSchema: false,
|
|
||||||
}),
|
|
||||||
).rejects.toThrow("result is_error:true");
|
|
||||||
|
|
||||||
const executionFile = join(tempDir, "claude-execution-output.json");
|
|
||||||
await expect(readFile(executionFile, "utf-8")).resolves.toBe(
|
|
||||||
JSON.stringify([initMessage, errorResultMessage], null, 2),
|
|
||||||
);
|
|
||||||
expect(coreErrorSpy).toHaveBeenCalledWith(
|
|
||||||
"Claude result reported subtype success with is_error:true (run did not complete successfully)",
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
consoleErrorSpy.mockRestore();
|
|
||||||
consoleLogSpy.mockRestore();
|
|
||||||
coreErrorSpy.mockRestore();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -2,14 +2,7 @@
|
|||||||
|
|
||||||
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test";
|
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test";
|
||||||
import * as core from "@actions/core";
|
import * as core from "@actions/core";
|
||||||
import {
|
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from "fs";
|
||||||
existsSync,
|
|
||||||
mkdtempSync,
|
|
||||||
readdirSync,
|
|
||||||
readFileSync,
|
|
||||||
rmSync,
|
|
||||||
statSync,
|
|
||||||
} from "fs";
|
|
||||||
import { tmpdir } from "os";
|
import { tmpdir } from "os";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import {
|
import {
|
||||||
@ -34,12 +27,6 @@ describe("workload identity federation", () => {
|
|||||||
delete process.env.ANTHROPIC_ORGANIZATION_ID;
|
delete process.env.ANTHROPIC_ORGANIZATION_ID;
|
||||||
delete process.env.ANTHROPIC_OIDC_AUDIENCE;
|
delete process.env.ANTHROPIC_OIDC_AUDIENCE;
|
||||||
delete process.env.ANTHROPIC_IDENTITY_TOKEN_FILE;
|
delete process.env.ANTHROPIC_IDENTITY_TOKEN_FILE;
|
||||||
delete process.env.ANTHROPIC_SERVICE_ACCOUNT_ID;
|
|
||||||
delete process.env.ANTHROPIC_WORKSPACE_ID;
|
|
||||||
delete process.env.ANTHROPIC_BASE_URL;
|
|
||||||
delete process.env.ANTHROPIC_SCOPE;
|
|
||||||
delete process.env.ANTHROPIC_CONFIG_DIR;
|
|
||||||
delete process.env.ANTHROPIC_PROFILE;
|
|
||||||
|
|
||||||
getIDTokenSpy = spyOn(core, "getIDToken").mockResolvedValue(
|
getIDTokenSpy = spyOn(core, "getIDToken").mockResolvedValue(
|
||||||
"test-identity-token",
|
"test-identity-token",
|
||||||
@ -136,123 +123,5 @@ describe("workload identity federation", () => {
|
|||||||
handle?.stop();
|
handle?.stop();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("writes a minimal federation profile and selects it", async () => {
|
|
||||||
process.env.ANTHROPIC_FEDERATION_RULE_ID = "fdrl_test";
|
|
||||||
process.env.ANTHROPIC_ORGANIZATION_ID =
|
|
||||||
"00000000-0000-0000-0000-000000000000";
|
|
||||||
process.env.ANTHROPIC_SERVICE_ACCOUNT_ID = "svac_test";
|
|
||||||
process.env.ANTHROPIC_WORKSPACE_ID = "wrkspc_test";
|
|
||||||
|
|
||||||
const handle = await setupWorkloadIdentity();
|
|
||||||
try {
|
|
||||||
const configDir = process.env.ANTHROPIC_CONFIG_DIR;
|
|
||||||
expect(configDir).toBeDefined();
|
|
||||||
expect(
|
|
||||||
configDir!.startsWith(
|
|
||||||
join(tempDir, "claude-workload-identity", "config-"),
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
expect(process.env.ANTHROPIC_PROFILE).toBe("default");
|
|
||||||
|
|
||||||
const profilePath = join(configDir!, "configs", "default.json");
|
|
||||||
expect(statSync(profilePath).mode & 0o777).toBe(0o600);
|
|
||||||
// Minimal on purpose: the SDK gap-fills the federation fields from
|
|
||||||
// the ANTHROPIC_* env vars the action exports.
|
|
||||||
expect(JSON.parse(readFileSync(profilePath, "utf-8"))).toEqual({
|
|
||||||
version: "1.0",
|
|
||||||
authentication: { type: "oidc_federation" },
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
handle?.stop();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("derives the config dir from the federation inputs", async () => {
|
|
||||||
process.env.ANTHROPIC_FEDERATION_RULE_ID = "fdrl_test";
|
|
||||||
process.env.ANTHROPIC_ORGANIZATION_ID =
|
|
||||||
"00000000-0000-0000-0000-000000000000";
|
|
||||||
process.env.ANTHROPIC_WORKSPACE_ID = "wrkspc_a";
|
|
||||||
|
|
||||||
(await setupWorkloadIdentity())?.stop();
|
|
||||||
const firstConfigDir = process.env.ANTHROPIC_CONFIG_DIR;
|
|
||||||
expect(firstConfigDir).toBeDefined();
|
|
||||||
|
|
||||||
// A later step in the same job with a different workspace must not
|
|
||||||
// share the first step's credentials cache.
|
|
||||||
delete process.env.ANTHROPIC_CONFIG_DIR;
|
|
||||||
delete process.env.ANTHROPIC_PROFILE;
|
|
||||||
process.env.ANTHROPIC_WORKSPACE_ID = "wrkspc_b";
|
|
||||||
|
|
||||||
(await setupWorkloadIdentity())?.stop();
|
|
||||||
const secondConfigDir = process.env.ANTHROPIC_CONFIG_DIR;
|
|
||||||
expect(secondConfigDir).toBeDefined();
|
|
||||||
expect(secondConfigDir).not.toBe(firstConfigDir);
|
|
||||||
|
|
||||||
// Same inputs land in the same dir, so an unchanged config can still
|
|
||||||
// reuse a cached token.
|
|
||||||
delete process.env.ANTHROPIC_CONFIG_DIR;
|
|
||||||
delete process.env.ANTHROPIC_PROFILE;
|
|
||||||
|
|
||||||
(await setupWorkloadIdentity())?.stop();
|
|
||||||
expect(process.env.ANTHROPIC_CONFIG_DIR).toBe(secondConfigDir!);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not overwrite an operator-set ANTHROPIC_PROFILE", async () => {
|
|
||||||
process.env.ANTHROPIC_FEDERATION_RULE_ID = "fdrl_test";
|
|
||||||
process.env.ANTHROPIC_ORGANIZATION_ID =
|
|
||||||
"00000000-0000-0000-0000-000000000000";
|
|
||||||
process.env.ANTHROPIC_PROFILE = "operator";
|
|
||||||
|
|
||||||
const handle = await setupWorkloadIdentity();
|
|
||||||
try {
|
|
||||||
expect(process.env.ANTHROPIC_PROFILE).toBe("operator");
|
|
||||||
expect(process.env.ANTHROPIC_CONFIG_DIR).toBeUndefined();
|
|
||||||
expect(warningSpy).toHaveBeenCalled();
|
|
||||||
|
|
||||||
const entries = readdirSync(join(tempDir, "claude-workload-identity"));
|
|
||||||
expect(entries.filter((e) => e.startsWith("config-"))).toEqual([]);
|
|
||||||
|
|
||||||
// The identity token file is still provisioned for the operator's
|
|
||||||
// profile (or the env-var fallback) to consume.
|
|
||||||
expect(process.env.ANTHROPIC_IDENTITY_TOKEN_FILE).toBe(
|
|
||||||
handle!.tokenFile,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
handle?.stop();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not overwrite an operator-set ANTHROPIC_CONFIG_DIR", async () => {
|
|
||||||
process.env.ANTHROPIC_FEDERATION_RULE_ID = "fdrl_test";
|
|
||||||
process.env.ANTHROPIC_ORGANIZATION_ID =
|
|
||||||
"00000000-0000-0000-0000-000000000000";
|
|
||||||
const operatorConfigDir = join(tempDir, "operator-config");
|
|
||||||
process.env.ANTHROPIC_CONFIG_DIR = operatorConfigDir;
|
|
||||||
|
|
||||||
const handle = await setupWorkloadIdentity();
|
|
||||||
try {
|
|
||||||
expect(process.env.ANTHROPIC_CONFIG_DIR).toBe(operatorConfigDir);
|
|
||||||
expect(process.env.ANTHROPIC_PROFILE).toBeUndefined();
|
|
||||||
expect(warningSpy).toHaveBeenCalled();
|
|
||||||
} finally {
|
|
||||||
handle?.stop();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("stop removes the identity token and credential cache", async () => {
|
|
||||||
process.env.ANTHROPIC_FEDERATION_RULE_ID = "fdrl_test";
|
|
||||||
process.env.ANTHROPIC_ORGANIZATION_ID =
|
|
||||||
"00000000-0000-0000-0000-000000000000";
|
|
||||||
|
|
||||||
const handle = await setupWorkloadIdentity();
|
|
||||||
const tokenDir = join(tempDir, "claude-workload-identity");
|
|
||||||
expect(existsSync(handle!.tokenFile)).toBe(true);
|
|
||||||
expect(existsSync(process.env.ANTHROPIC_CONFIG_DIR!)).toBe(true);
|
|
||||||
|
|
||||||
handle!.stop();
|
|
||||||
|
|
||||||
expect(existsSync(tokenDir)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
20
bun.lock
20
bun.lock
@ -7,7 +7,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "^1.10.1",
|
"@actions/core": "^1.10.1",
|
||||||
"@actions/github": "^6.0.1",
|
"@actions/github": "^6.0.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
|
"@anthropic-ai/claude-agent-sdk": "^0.3.183",
|
||||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||||
"@octokit/graphql": "^8.2.2",
|
"@octokit/graphql": "^8.2.2",
|
||||||
"@octokit/rest": "^21.1.1",
|
"@octokit/rest": "^21.1.1",
|
||||||
@ -37,23 +37,23 @@
|
|||||||
|
|
||||||
"@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="],
|
"@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="],
|
||||||
|
|
||||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.220", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.220", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.220", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.220", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.220", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.220", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.220", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.220", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.220" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-glc7SdwPkOkLw8oxwLo9PKTdLJGqW/PIR4urWXFoRtX9YllwozsEVc5Tc1+EvLSkfrsxPJqQWqOgpjUOQXf1oA=="],
|
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.183", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.183", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.183", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.183", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.183", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.183", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.183", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.183", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.183" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-rAKws76RKdFu+DK5q9wYN0AGLRdPrw0vGd+TMzSFynFdxwUAehYc9vUKJoK27j2xRoHFdNt+JDsih5BzsUzi+Q=="],
|
||||||
|
|
||||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.220", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7VxlbEosK7DODiOnsjoVd0DSJzbnaPrM2jelMHI0y8zx1UnLS3WC6EFUXbvy74F2sXqEznh2tzn7EKWInaRN6Q=="],
|
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.183", "", { "os": "darwin", "cpu": "arm64" }, "sha512-o/+sxwKgXuw6RG5cERWjvcvL1CDSPe/TaXMhax+dq+V4lDOI5iTqg3y5Wfb6dL3xlWoTA2OhWowDQllKbE04LQ=="],
|
||||||
|
|
||||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.220", "", { "os": "darwin", "cpu": "x64" }, "sha512-X9RwDsSmbF6ultKZroaip+DL8WRgC64gHbrAwrRlAFSPNZV7zmJyP2ur8rW7KrxqmtuehdMMkw8+SAC/6hD2PA=="],
|
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.183", "", { "os": "darwin", "cpu": "x64" }, "sha512-V7Cf8JeD5EPf4MPomFUlEblCIQI0wg+aWdOSqvfMsDmCBEHljd52CQ3a7W263oVt6I7QUfRTpX2KNvdma56rDA=="],
|
||||||
|
|
||||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.220", "", { "os": "linux", "cpu": "arm64" }, "sha512-WkROPwWskqhKR9XgnmseHQ6rLi9zM9qt57IWoToIjL/eXOqDWipp7JXZ1L5ud+LrA42dunHPZfBwD/vXZ+A7LA=="],
|
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.183", "", { "os": "linux", "cpu": "arm64" }, "sha512-qqnSf85D3uBH7kRHLGOMhr7aQrREUZ4hUlCztrkJe/twUT4bmJsO86zamnAf7vOYeCul/sHYwBMmXtOfI+cHZg=="],
|
||||||
|
|
||||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.220", "", { "os": "linux", "cpu": "arm64" }, "sha512-OHoZOZ8Cf2TBr6oXIXPwyvUxj9jrq2w8E4poA8dMpacXszcPSPiCQCMuuOh4aWJzfeJE1+TtWxhKMVb2csXyZQ=="],
|
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.183", "", { "os": "linux", "cpu": "arm64" }, "sha512-ua6XaiCcYYKfd0CY9cjoyYnTQWI2GocpbP7Gwvmr+9taW/lTpQGVQH3FrF2VFKXVSt7AaVE6Z7WN9oPxcxl3VA=="],
|
||||||
|
|
||||||
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.220", "", { "os": "linux", "cpu": "x64" }, "sha512-tkTJFnpR9VifvWX2fmkCAPkT6+8Wk/gVu8B5jsVekKZPiZoWRHmMXO30BnZn+f0TZhgYP+82PSX3S8crH1kn+w=="],
|
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.183", "", { "os": "linux", "cpu": "x64" }, "sha512-GvfNp9XsKWFvr0EH3NgX2pqzHPYhFeqhOmVc4A+2e+GdxBMYUEa0ylfPLJItahQU9FkTJAw2oQNnzTHPPm1GxA=="],
|
||||||
|
|
||||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.220", "", { "os": "linux", "cpu": "x64" }, "sha512-K+FWj+LcGhC1Z7wqeWoLxm1iemcba5xKpLLFVwYm4V6HyMx3ruYd/2r2TiQtjT+JWeNFWIys0ScHiItR6vWAiA=="],
|
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.183", "", { "os": "linux", "cpu": "x64" }, "sha512-wp+p+4xeZOW+h4InMKR7nIdGlgYftXyR+ErOVz7i2tMRcKuYMoc5jRIYjdJkuETMSr5nsRj0rDeO5gG7M6MGZQ=="],
|
||||||
|
|
||||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.220", "", { "os": "win32", "cpu": "arm64" }, "sha512-rIwgq0UwQExWl6KrHUyC4w5KwpL9l6nd95aUTx6RitexaAuEw//xtfTVLnuE4hDDQZFkzEwpdKc3nxDWoGcUbA=="],
|
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.183", "", { "os": "win32", "cpu": "arm64" }, "sha512-cGyMC9YDsrCQ4av74dMVa2liOb5oLFX4+1SxwJGRCW7mBVBazcM6rg7ifWbd9XfkDhNPKOIfyiloipyMfoaE4g=="],
|
||||||
|
|
||||||
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.220", "", { "os": "win32", "cpu": "x64" }, "sha512-MuOuXhbr66HlGaWXD2f3w0k2PsvmnbkwcUZ0dAe2poFLdl72GC2dapwwOBefxm9QmoNqk9+jmv/dSKGOVWyvLw=="],
|
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.183", "", { "os": "win32", "cpu": "x64" }, "sha512-h/XzbrSmXGroTk/FYKR6J4/8G9vDb1HUUUeNXeBGqGW1kppIiWPJKLRzjtSe0brVjADOKOT6tE5IHK0mV/1gBw=="],
|
||||||
|
|
||||||
"@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=="],
|
"@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=="],
|
||||||
|
|
||||||
|
|||||||
@ -337,17 +337,17 @@ For a complete list of available settings and their descriptions, see the [Claud
|
|||||||
|
|
||||||
Many individual input parameters have been consolidated into `claude_args` or `settings`. Here's how to migrate:
|
Many individual input parameters have been consolidated into `claude_args` or `settings`. Here's how to migrate:
|
||||||
|
|
||||||
| Old Input | New Approach |
|
| Old Input | New Approach |
|
||||||
| --------------------- | --------------------------------------------------------------- |
|
| --------------------- | -------------------------------------------------------- |
|
||||||
| `allowed_tools` | Use `claude_args: "--allowedTools Tool1,Tool2"` |
|
| `allowed_tools` | Use `claude_args: "--allowedTools Tool1,Tool2"` |
|
||||||
| `disallowed_tools` | Use `claude_args: "--disallowedTools Tool1,Tool2"` |
|
| `disallowed_tools` | Use `claude_args: "--disallowedTools Tool1,Tool2"` |
|
||||||
| `max_turns` | Use `claude_args: "--max-turns 10"` |
|
| `max_turns` | Use `claude_args: "--max-turns 10"` |
|
||||||
| `model` | Use `claude_args: "--model claude-4-0-sonnet-20250805"` |
|
| `model` | Use `claude_args: "--model claude-4-0-sonnet-20250805"` |
|
||||||
| `claude_env` | Use `settings` with `"env"` object |
|
| `claude_env` | Use `settings` with `"env"` object |
|
||||||
| `custom_instructions` | Use `claude_args: "--append-system-prompt 'Your instructions'"` |
|
| `custom_instructions` | Use `claude_args: "--system-prompt 'Your instructions'"` |
|
||||||
| `mcp_config` | Use `claude_args: "--mcp-config '{...}'"` |
|
| `mcp_config` | Use `claude_args: "--mcp-config '{...}'"` |
|
||||||
| `direct_prompt` | Use `prompt` input instead |
|
| `direct_prompt` | Use `prompt` input instead |
|
||||||
| `override_prompt` | Use `prompt` with GitHub context variables |
|
| `override_prompt` | Use `prompt` with GitHub context variables |
|
||||||
|
|
||||||
## Custom Executables for Specialized Environments
|
## Custom Executables for Specialized Environments
|
||||||
|
|
||||||
|
|||||||
@ -26,7 +26,7 @@ This action supports the following GitHub events ([learn more GitHub event trigg
|
|||||||
|
|
||||||
## Automated Documentation Updates
|
## Automated Documentation Updates
|
||||||
|
|
||||||
Automatically update documentation when specific files change (see [`examples/pr-review-filtered-paths.yml`](../examples/pr-review-filtered-paths.yml)):
|
Automatically update documentation when specific files change (see [`examples/claude-pr-path-specific.yml`](../examples/claude-pr-path-specific.yml)):
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
on:
|
on:
|
||||||
@ -47,7 +47,7 @@ When API files are modified, the action automatically detects that a `prompt` is
|
|||||||
|
|
||||||
## Author-Specific Code Reviews
|
## Author-Specific Code Reviews
|
||||||
|
|
||||||
Automatically review PRs from specific authors or external contributors (see [`examples/pr-review-filtered-authors.yml`](../examples/pr-review-filtered-authors.yml)):
|
Automatically review PRs from specific authors or external contributors (see [`examples/claude-review-from-author.yml`](../examples/claude-review-from-author.yml)):
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
on:
|
on:
|
||||||
|
|||||||
@ -153,7 +153,7 @@ prompt: "Review this PR for security vulnerabilities"
|
|||||||
**These inputs are deprecated in v1.0:**
|
**These inputs are deprecated in v1.0:**
|
||||||
|
|
||||||
- **`direct_prompt`** → Use `prompt` instead
|
- **`direct_prompt`** → Use `prompt` instead
|
||||||
- **`custom_instructions`** → Use `claude_args` with `--append-system-prompt` (appends to the default system prompt, matching v0 behavior; `--system-prompt` replaces it entirely)
|
- **`custom_instructions`** → Use `claude_args` with `--system-prompt`
|
||||||
|
|
||||||
Migration examples:
|
Migration examples:
|
||||||
|
|
||||||
@ -165,7 +165,7 @@ custom_instructions: "Focus on security"
|
|||||||
# New (v1.0)
|
# New (v1.0)
|
||||||
prompt: "Review this PR"
|
prompt: "Review this PR"
|
||||||
claude_args: |
|
claude_args: |
|
||||||
--append-system-prompt "Focus on security"
|
--system-prompt "Focus on security"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Why doesn't Claude execute my bash commands?
|
### Why doesn't Claude execute my bash commands?
|
||||||
|
|||||||
@ -14,19 +14,19 @@ This guide helps you migrate from Claude Code Action v0.x to v1.0. The new versi
|
|||||||
|
|
||||||
The following inputs have been deprecated and replaced:
|
The following inputs have been deprecated and replaced:
|
||||||
|
|
||||||
| Deprecated Input | Replacement | Notes |
|
| Deprecated Input | Replacement | Notes |
|
||||||
| --------------------- | ------------------------------------- | ----------------------------------------------------------------------------------- |
|
| --------------------- | ------------------------------------ | --------------------------------------------- |
|
||||||
| `mode` | Auto-detected | Action automatically chooses based on context |
|
| `mode` | Auto-detected | Action automatically chooses based on context |
|
||||||
| `direct_prompt` | `prompt` | Direct drop-in replacement |
|
| `direct_prompt` | `prompt` | Direct drop-in replacement |
|
||||||
| `override_prompt` | `prompt` | Use GitHub context variables instead |
|
| `override_prompt` | `prompt` | Use GitHub context variables instead |
|
||||||
| `custom_instructions` | `claude_args: --append-system-prompt` | Appends to the default prompt (v0 behavior); `--system-prompt` replaces it entirely |
|
| `custom_instructions` | `claude_args: --system-prompt` | Move to CLI arguments |
|
||||||
| `max_turns` | `claude_args: --max-turns` | Use CLI format |
|
| `max_turns` | `claude_args: --max-turns` | Use CLI format |
|
||||||
| `model` | `claude_args: --model` | Specify via CLI |
|
| `model` | `claude_args: --model` | Specify via CLI |
|
||||||
| `allowed_tools` | `claude_args: --allowedTools` | Use CLI format |
|
| `allowed_tools` | `claude_args: --allowedTools` | Use CLI format |
|
||||||
| `disallowed_tools` | `claude_args: --disallowedTools` | Use CLI format |
|
| `disallowed_tools` | `claude_args: --disallowedTools` | Use CLI format |
|
||||||
| `claude_env` | `settings` with env object | Use settings JSON |
|
| `claude_env` | `settings` with env object | Use settings JSON |
|
||||||
| `mcp_config` | `claude_args: --mcp-config` | Pass MCP config via CLI arguments |
|
| `mcp_config` | `claude_args: --mcp-config` | Pass MCP config via CLI arguments |
|
||||||
| `timeout_minutes` | Use GitHub Actions `timeout-minutes` | Configure at job level instead of input level |
|
| `timeout_minutes` | Use GitHub Actions `timeout-minutes` | Configure at job level instead of input level |
|
||||||
|
|
||||||
## Migration Examples
|
## Migration Examples
|
||||||
|
|
||||||
@ -52,7 +52,7 @@ The following inputs have been deprecated and replaced:
|
|||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
claude_args: |
|
claude_args: |
|
||||||
--max-turns 10
|
--max-turns 10
|
||||||
--append-system-prompt "Follow our coding standards"
|
--system-prompt "Follow our coding standards"
|
||||||
--allowedTools Edit,Read,Write
|
--allowedTools Edit,Read,Write
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -255,15 +255,14 @@ claude_args: |
|
|||||||
|
|
||||||
### Common claude_args Options
|
### Common claude_args Options
|
||||||
|
|
||||||
| Option | Description | Example |
|
| Option | Description | Example |
|
||||||
| ------------------------ | ------------------------------------------------------------------------- | ------------------------------------------------------ |
|
| ------------------- | ------------------------ | -------------------------------------- |
|
||||||
| `--max-turns` | Limit conversation turns | `--max-turns 10` |
|
| `--max-turns` | Limit conversation turns | `--max-turns 10` |
|
||||||
| `--model` | Specify Claude model | `--model claude-4-0-sonnet-20250805` |
|
| `--model` | Specify Claude model | `--model claude-4-0-sonnet-20250805` |
|
||||||
| `--allowedTools` | Enable specific tools | `--allowedTools Edit,Read,Write` |
|
| `--allowedTools` | Enable specific tools | `--allowedTools Edit,Read,Write` |
|
||||||
| `--disallowedTools` | Disable specific tools | `--disallowedTools WebSearch` |
|
| `--disallowedTools` | Disable specific tools | `--disallowedTools WebSearch` |
|
||||||
| `--system-prompt` | Replace the entire default system prompt | `--system-prompt "Focus on security"` |
|
| `--system-prompt` | Add system instructions | `--system-prompt "Focus on security"` |
|
||||||
| `--append-system-prompt` | Append to the default system prompt (keeps Claude Code's built-in prompt) | `--append-system-prompt "Follow our coding standards"` |
|
| `--mcp-config` | Add MCP server config | `--mcp-config '{"mcpServers": {...}}'` |
|
||||||
| `--mcp-config` | Add MCP server config | `--mcp-config '{"mcpServers": {...}}'` |
|
|
||||||
|
|
||||||
## Provider-Specific Updates
|
## Provider-Specific Updates
|
||||||
|
|
||||||
@ -331,7 +330,7 @@ You can also pass MCP configuration from a file:
|
|||||||
- [ ] Remove `mode` input (auto-detected now)
|
- [ ] Remove `mode` input (auto-detected now)
|
||||||
- [ ] Replace `direct_prompt` with `prompt`
|
- [ ] Replace `direct_prompt` with `prompt`
|
||||||
- [ ] Replace `override_prompt` with `prompt` using GitHub context
|
- [ ] Replace `override_prompt` with `prompt` using GitHub context
|
||||||
- [ ] Move `custom_instructions` to `claude_args` with `--append-system-prompt`
|
- [ ] Move `custom_instructions` to `claude_args` with `--system-prompt`
|
||||||
- [ ] Convert `max_turns` to `claude_args` with `--max-turns`
|
- [ ] Convert `max_turns` to `claude_args` with `--max-turns`
|
||||||
- [ ] Convert `model` to `claude_args` with `--model`
|
- [ ] Convert `model` to `claude_args` with `--model`
|
||||||
- [ ] Convert `allowed_tools` to `claude_args` with `--allowedTools`
|
- [ ] Convert `allowed_tools` to `claude_args` with `--allowedTools`
|
||||||
|
|||||||
@ -99,7 +99,7 @@ These inputs are deprecated and will be removed in a future version:
|
|||||||
| `mode` | **DEPRECATED**: Mode is now automatically detected based on workflow context | Remove this input; the action auto-detects the correct mode |
|
| `mode` | **DEPRECATED**: Mode is now automatically detected based on workflow context | Remove this input; the action auto-detects the correct mode |
|
||||||
| `direct_prompt` | **DEPRECATED**: Use `prompt` instead | Replace with `prompt` |
|
| `direct_prompt` | **DEPRECATED**: Use `prompt` instead | Replace with `prompt` |
|
||||||
| `override_prompt` | **DEPRECATED**: Use `prompt` with template variables or `claude_args` with `--system-prompt` | Use `prompt` for templates or `claude_args` for system prompts |
|
| `override_prompt` | **DEPRECATED**: Use `prompt` with template variables or `claude_args` with `--system-prompt` | Use `prompt` for templates or `claude_args` for system prompts |
|
||||||
| `custom_instructions` | **DEPRECATED**: Use `claude_args` with `--append-system-prompt` or include in `prompt` | Move instructions to `prompt` or use `claude_args` |
|
| `custom_instructions` | **DEPRECATED**: Use `claude_args` with `--system-prompt` or include in `prompt` | Move instructions to `prompt` or use `claude_args` |
|
||||||
| `max_turns` | **DEPRECATED**: Use `claude_args` with `--max-turns` instead | Use `claude_args: "--max-turns 5"` |
|
| `max_turns` | **DEPRECATED**: Use `claude_args` with `--max-turns` instead | Use `claude_args: "--max-turns 5"` |
|
||||||
| `model` | **DEPRECATED**: Use `claude_args` with `--model` instead | Use `claude_args: "--model claude-4-0-sonnet-20250805"` |
|
| `model` | **DEPRECATED**: Use `claude_args` with `--model` instead | Use `claude_args: "--model claude-4-0-sonnet-20250805"` |
|
||||||
| `fallback_model` | **DEPRECATED**: Use `claude_args` with fallback configuration | Configure fallback in `claude_args` or `settings` |
|
| `fallback_model` | **DEPRECATED**: Use `claude_args` with fallback configuration | Configure fallback in `claude_args` or `settings` |
|
||||||
@ -139,7 +139,7 @@ For a comprehensive guide on migrating from v0.x to v1.0, including step-by-step
|
|||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
claude_args: |
|
claude_args: |
|
||||||
--max-turns 10
|
--max-turns 10
|
||||||
--append-system-prompt "Focus on security"
|
--system-prompt "Focus on security"
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Automation Workflows
|
#### Automation Workflows
|
||||||
|
|||||||
@ -1,39 +0,0 @@
|
|||||||
# Require human approvals on PRs that contain agent-authored commits.
|
|
||||||
#
|
|
||||||
# Both triggers run the workflow file from the BASE/DEFAULT branch, so a PR
|
|
||||||
# cannot edit this check to approve itself. (`pull_request_review` is not
|
|
||||||
# used because it runs from the merge ref, not the default branch; native
|
|
||||||
# Approve reviews are picked up on the next synchronize or `/approve`
|
|
||||||
# comment.)
|
|
||||||
#
|
|
||||||
# After adding this workflow, mark `agent-approval-check` as a required
|
|
||||||
# status check on your protected branches.
|
|
||||||
|
|
||||||
name: agent-approval-check
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request_target:
|
|
||||||
types: [opened, synchronize, reopened, ready_for_review]
|
|
||||||
issue_comment:
|
|
||||||
types: [created]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: write
|
|
||||||
statuses: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
check:
|
|
||||||
# issue_comment also fires on plain issues; skip those early.
|
|
||||||
if: github.event_name != 'issue_comment' || github.event.issue.pull_request
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: anthropics/claude-code-action/agent-approval-check@main
|
|
||||||
with:
|
|
||||||
required_approvals: 2
|
|
||||||
agent_emails: noreply@anthropic.com
|
|
||||||
agent_logins: claude[bot],claude-code[bot]
|
|
||||||
# Uncomment to tune:
|
|
||||||
# excluded_approvers: dependabot[bot]
|
|
||||||
# exempt_path_prefixes: docs/
|
|
||||||
# protected_bases: main,release
|
|
||||||
@ -12,7 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "^1.10.1",
|
"@actions/core": "^1.10.1",
|
||||||
"@actions/github": "^6.0.1",
|
"@actions/github": "^6.0.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
|
"@anthropic-ai/claude-agent-sdk": "^0.3.183",
|
||||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||||
"@octokit/graphql": "^8.2.2",
|
"@octokit/graphql": "^8.2.2",
|
||||||
"@octokit/rest": "^21.1.1",
|
"@octokit/rest": "^21.1.1",
|
||||||
|
|||||||
@ -122,7 +122,6 @@ export function prepareContext(
|
|||||||
|
|
||||||
// Extract trigger username and comment data based on event type
|
// Extract trigger username and comment data based on event type
|
||||||
let triggerUsername: string | undefined;
|
let triggerUsername: string | undefined;
|
||||||
let triggerUserId: number | undefined;
|
|
||||||
let commentId: string | undefined;
|
let commentId: string | undefined;
|
||||||
let commentBody: string | undefined;
|
let commentBody: string | undefined;
|
||||||
|
|
||||||
@ -130,19 +129,15 @@ export function prepareContext(
|
|||||||
commentId = context.payload.comment.id.toString();
|
commentId = context.payload.comment.id.toString();
|
||||||
commentBody = context.payload.comment.body;
|
commentBody = context.payload.comment.body;
|
||||||
triggerUsername = context.payload.comment.user.login;
|
triggerUsername = context.payload.comment.user.login;
|
||||||
triggerUserId = context.payload.comment.user.id;
|
|
||||||
} else if (isPullRequestReviewEvent(context)) {
|
} else if (isPullRequestReviewEvent(context)) {
|
||||||
commentBody = context.payload.review.body ?? "";
|
commentBody = context.payload.review.body ?? "";
|
||||||
triggerUsername = context.payload.review.user.login;
|
triggerUsername = context.payload.review.user.login;
|
||||||
triggerUserId = context.payload.review.user.id;
|
|
||||||
} else if (isPullRequestReviewCommentEvent(context)) {
|
} else if (isPullRequestReviewCommentEvent(context)) {
|
||||||
commentId = context.payload.comment.id.toString();
|
commentId = context.payload.comment.id.toString();
|
||||||
commentBody = context.payload.comment.body;
|
commentBody = context.payload.comment.body;
|
||||||
triggerUsername = context.payload.comment.user.login;
|
triggerUsername = context.payload.comment.user.login;
|
||||||
triggerUserId = context.payload.comment.user.id;
|
|
||||||
} else if (isIssuesEvent(context)) {
|
} else if (isIssuesEvent(context)) {
|
||||||
triggerUsername = context.payload.issue.user.login;
|
triggerUsername = context.payload.issue.user.login;
|
||||||
triggerUserId = context.payload.issue.user.id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create infrastructure fields object
|
// Create infrastructure fields object
|
||||||
@ -151,7 +146,6 @@ export function prepareContext(
|
|||||||
claudeCommentId,
|
claudeCommentId,
|
||||||
triggerPhrase,
|
triggerPhrase,
|
||||||
...(triggerUsername && { triggerUsername }),
|
...(triggerUsername && { triggerUsername }),
|
||||||
...(triggerUserId && { triggerUserId }),
|
|
||||||
...(prompt && { prompt }),
|
...(prompt && { prompt }),
|
||||||
...(claudeBranch && { claudeBranch }),
|
...(claudeBranch && { claudeBranch }),
|
||||||
};
|
};
|
||||||
@ -400,16 +394,9 @@ function getCommitInstructions(
|
|||||||
context: PreparedContext,
|
context: PreparedContext,
|
||||||
useCommitSigning: boolean,
|
useCommitSigning: boolean,
|
||||||
): string {
|
): string {
|
||||||
const triggerName = githubData.triggerDisplayName ?? context.triggerUsername;
|
|
||||||
const triggerEmail =
|
|
||||||
context.triggerUserId && context.triggerUsername
|
|
||||||
? `${context.triggerUserId}+${context.triggerUsername}@users.noreply.github.com`
|
|
||||||
: context.triggerUsername
|
|
||||||
? `${context.triggerUsername}@users.noreply.github.com`
|
|
||||||
: undefined;
|
|
||||||
const coAuthorLine =
|
const coAuthorLine =
|
||||||
triggerName && triggerName !== "Unknown" && triggerEmail
|
(githubData.triggerDisplayName ?? context.triggerUsername) !== "Unknown"
|
||||||
? `Co-authored-by: ${triggerName} <${triggerEmail}>`
|
? `Co-authored-by: ${githubData.triggerDisplayName ?? context.triggerUsername} <${context.triggerUsername}@users.noreply.github.com>`
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
if (useCommitSigning) {
|
if (useCommitSigning) {
|
||||||
|
|||||||
@ -5,7 +5,6 @@ export type CommonFields = {
|
|||||||
claudeCommentId: string;
|
claudeCommentId: string;
|
||||||
triggerPhrase: string;
|
triggerPhrase: string;
|
||||||
triggerUsername?: string;
|
triggerUsername?: string;
|
||||||
triggerUserId?: number;
|
|
||||||
prompt?: string;
|
prompt?: string;
|
||||||
claudeBranch?: string;
|
claudeBranch?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
3
src/entrypoints/format-turns.ts
Normal file → Executable file
3
src/entrypoints/format-turns.ts
Normal file → Executable file
@ -268,8 +268,7 @@ export function groupTurnsNaturally(data: Turn[]): GroupedContent[] {
|
|||||||
type: "system_init",
|
type: "system_init",
|
||||||
tools_count: tools.length,
|
tools_count: tools.length,
|
||||||
});
|
});
|
||||||
} else if (subtype !== "thinking_tokens") {
|
} else {
|
||||||
// Skip thinking_tokens - internal progress events not meant for summary
|
|
||||||
groupedContent.push({
|
groupedContent.push({
|
||||||
type: "system_other",
|
type: "system_other",
|
||||||
data: turn,
|
data: turn,
|
||||||
|
|||||||
@ -44,13 +44,6 @@ import { runClaude } from "../../base-action/src/run-claude";
|
|||||||
import type { ClaudeRunResult } from "../../base-action/src/run-claude-sdk";
|
import type { ClaudeRunResult } from "../../base-action/src/run-claude-sdk";
|
||||||
import { setExecutionFileOutputIfPresent } from "../../base-action/src/execution-file";
|
import { setExecutionFileOutputIfPresent } from "../../base-action/src/execution-file";
|
||||||
|
|
||||||
// Exported for unit testing. `set -o pipefail` makes curl's non-zero exit
|
|
||||||
// propagate through the pipe so the install retry logic actually triggers
|
|
||||||
// on 429/403 instead of silently succeeding (see #1136).
|
|
||||||
export function buildInstallCommand(version: string): string {
|
|
||||||
return `set -o pipefail; curl -fsSL https://claude.ai/install.sh | bash -s -- ${version}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Install Claude Code CLI, handling retry logic and custom executable paths.
|
* Install Claude Code CLI, handling retry logic and custom executable paths.
|
||||||
* Returns the absolute path to the claude executable.
|
* Returns the absolute path to the claude executable.
|
||||||
@ -75,7 +68,7 @@ async function installClaudeCode(): Promise<string> {
|
|||||||
return customExecutable;
|
return customExecutable;
|
||||||
}
|
}
|
||||||
|
|
||||||
const claudeCodeVersion = "2.1.220";
|
const claudeCodeVersion = "2.1.183";
|
||||||
console.log(`Installing Claude Code v${claudeCodeVersion}...`);
|
console.log(`Installing Claude Code v${claudeCodeVersion}...`);
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||||
@ -84,7 +77,10 @@ async function installClaudeCode(): Promise<string> {
|
|||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const child = spawn(
|
const child = spawn(
|
||||||
"bash",
|
"bash",
|
||||||
["-c", buildInstallCommand(claudeCodeVersion)],
|
[
|
||||||
|
"-c",
|
||||||
|
`curl -fsSL https://claude.ai/install.sh | bash -s -- ${claudeCodeVersion}`,
|
||||||
|
],
|
||||||
{ stdio: "inherit" },
|
{ stdio: "inherit" },
|
||||||
);
|
);
|
||||||
child.on("close", (code) => {
|
child.on("close", (code) => {
|
||||||
@ -318,8 +314,7 @@ async function run() {
|
|||||||
} finally {
|
} finally {
|
||||||
// Phase 4: Cleanup (always runs)
|
// Phase 4: Cleanup (always runs)
|
||||||
|
|
||||||
// Stop refreshing the workload identity token file and delete the token
|
// Stop refreshing the workload identity token file
|
||||||
// material so it doesn't outlive this step
|
|
||||||
workloadIdentity?.stop();
|
workloadIdentity?.stop();
|
||||||
|
|
||||||
// Update tracking comment
|
// Update tracking comment
|
||||||
|
|||||||
@ -204,9 +204,11 @@ export function isBodySafeToUse(
|
|||||||
* @param excludeActors - Comma-separated actors to exclude
|
* @param excludeActors - Comma-separated actors to exclude
|
||||||
* @returns Filtered array of comments
|
* @returns Filtered array of comments
|
||||||
*/
|
*/
|
||||||
export function filterCommentsByActor<
|
export function filterCommentsByActor<T extends { author: { login: string } }>(
|
||||||
T extends { author: { login: string } | null },
|
comments: T[],
|
||||||
>(comments: T[], includeActors: string = "", excludeActors: string = ""): T[] {
|
includeActors: string = "",
|
||||||
|
excludeActors: string = "",
|
||||||
|
): T[] {
|
||||||
const includeParsed = parseActorFilter(includeActors);
|
const includeParsed = parseActorFilter(includeActors);
|
||||||
const excludeParsed = parseActorFilter(excludeActors);
|
const excludeParsed = parseActorFilter(excludeActors);
|
||||||
|
|
||||||
@ -217,9 +219,7 @@ export function filterCommentsByActor<
|
|||||||
|
|
||||||
return comments.filter((comment) =>
|
return comments.filter((comment) =>
|
||||||
shouldIncludeCommentByActor(
|
shouldIncludeCommentByActor(
|
||||||
// author is null for comments from deleted ("ghost") accounts; treat them
|
comment.author.login,
|
||||||
// as the "ghost" login so filtering never dereferences null and crashes.
|
|
||||||
comment.author?.login ?? "ghost",
|
|
||||||
includeParsed,
|
includeParsed,
|
||||||
excludeParsed,
|
excludeParsed,
|
||||||
),
|
),
|
||||||
@ -378,26 +378,34 @@ export async function fetchGitHubData({
|
|||||||
body: c.body,
|
body: c.body,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Filter reviews and inline review comments to trigger time and by actor
|
// Filter review bodies to trigger time
|
||||||
// before building anything from them. The trigger-time filter is the TOCTOU
|
const filteredReviewBodies = reviewData?.nodes
|
||||||
// protection applied to issue/PR comments and the body above: it drops
|
? filterReviewsToTriggerTime(reviewData.nodes, triggerTime).filter(
|
||||||
// anything submitted, created, or edited at/after the trigger so an attacker
|
(r) => r.body,
|
||||||
// cannot inject content into the prompt after an authorized trigger. Without
|
)
|
||||||
// it, review bodies and inline review comments would reach the prompt
|
: [];
|
||||||
// verbatim regardless of when they landed.
|
|
||||||
|
const reviewBodies: CommentWithImages[] = filteredReviewBodies.map((r) => ({
|
||||||
|
type: "review_body" as const,
|
||||||
|
id: r.databaseId,
|
||||||
|
pullNumber: prNumber,
|
||||||
|
body: r.body,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Filter review comments to trigger time and by actor
|
||||||
if (reviewData && reviewData.nodes) {
|
if (reviewData && reviewData.nodes) {
|
||||||
// Drop reviews submitted or edited after the trigger, then filter by actor.
|
// Filter reviews by actor
|
||||||
reviewData.nodes = filterCommentsByActor(
|
reviewData.nodes = filterCommentsByActor(
|
||||||
filterReviewsToTriggerTime(reviewData.nodes, triggerTime),
|
reviewData.nodes,
|
||||||
includeCommentsByActor,
|
includeCommentsByActor,
|
||||||
excludeCommentsByActor,
|
excludeCommentsByActor,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Apply the same trigger-time + actor filtering to inline review comments.
|
// Also filter inline review comments within each review
|
||||||
reviewData.nodes.forEach((review) => {
|
reviewData.nodes.forEach((review) => {
|
||||||
if (review.comments?.nodes) {
|
if (review.comments?.nodes) {
|
||||||
review.comments.nodes = filterCommentsByActor(
|
review.comments.nodes = filterCommentsByActor(
|
||||||
filterCommentsToTriggerTime(review.comments.nodes, triggerTime),
|
review.comments.nodes,
|
||||||
includeCommentsByActor,
|
includeCommentsByActor,
|
||||||
excludeCommentsByActor,
|
excludeCommentsByActor,
|
||||||
);
|
);
|
||||||
@ -405,19 +413,14 @@ export async function fetchGitHubData({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the image-processing lists from the already-filtered review nodes,
|
const allReviewComments =
|
||||||
// so reviews/comments excluded from the prompt are not processed for images.
|
reviewData?.nodes?.flatMap((r) => r.comments?.nodes ?? []) ?? [];
|
||||||
const reviewBodies: CommentWithImages[] = (reviewData?.nodes ?? [])
|
const filteredReviewComments = filterCommentsToTriggerTime(
|
||||||
.filter((r) => r.body)
|
allReviewComments,
|
||||||
.map((r) => ({
|
triggerTime,
|
||||||
type: "review_body" as const,
|
);
|
||||||
id: r.databaseId,
|
|
||||||
pullNumber: prNumber,
|
|
||||||
body: r.body,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const reviewComments: CommentWithImages[] = (reviewData?.nodes ?? [])
|
const reviewComments: CommentWithImages[] = filteredReviewComments
|
||||||
.flatMap((r) => r.comments?.nodes ?? [])
|
|
||||||
.filter((c) => c.body && !c.isMinimized)
|
.filter((c) => c.body && !c.isMinimized)
|
||||||
.map((c) => ({
|
.map((c) => ({
|
||||||
type: "review_comment" as const,
|
type: "review_comment" as const,
|
||||||
|
|||||||
@ -21,7 +21,7 @@ export function formatContext(
|
|||||||
const prData = contextData as GitHubPullRequest;
|
const prData = contextData as GitHubPullRequest;
|
||||||
const sanitizedTitle = sanitizeContent(prData.title);
|
const sanitizedTitle = sanitizeContent(prData.title);
|
||||||
return `PR Title: ${sanitizedTitle}
|
return `PR Title: ${sanitizedTitle}
|
||||||
PR Author: ${prData.author?.login ?? "ghost"}
|
PR Author: ${prData.author.login}
|
||||||
PR Branch: ${prData.headRefName} -> ${prData.baseRefName}
|
PR Branch: ${prData.headRefName} -> ${prData.baseRefName}
|
||||||
PR State: ${prData.state}
|
PR State: ${prData.state}
|
||||||
PR Labels: ${formatLabels(prData.labels.nodes)}
|
PR Labels: ${formatLabels(prData.labels.nodes)}
|
||||||
@ -33,7 +33,7 @@ Changed Files: ${prData.files.nodes.length} files`;
|
|||||||
const issueData = contextData as GitHubIssue;
|
const issueData = contextData as GitHubIssue;
|
||||||
const sanitizedTitle = sanitizeContent(issueData.title);
|
const sanitizedTitle = sanitizeContent(issueData.title);
|
||||||
return `Issue Title: ${sanitizedTitle}
|
return `Issue Title: ${sanitizedTitle}
|
||||||
Issue Author: ${issueData.author?.login ?? "ghost"}
|
Issue Author: ${issueData.author.login}
|
||||||
Issue State: ${issueData.state}
|
Issue State: ${issueData.state}
|
||||||
Issue Labels: ${formatLabels(issueData.labels.nodes)}`;
|
Issue Labels: ${formatLabels(issueData.labels.nodes)}`;
|
||||||
}
|
}
|
||||||
@ -71,7 +71,7 @@ export function formatComments(
|
|||||||
|
|
||||||
body = sanitizeContent(body);
|
body = sanitizeContent(body);
|
||||||
|
|
||||||
return `[${comment.author?.login ?? "ghost"} at ${comment.createdAt}]: ${body}`;
|
return `[${comment.author.login} at ${comment.createdAt}]: ${body}`;
|
||||||
})
|
})
|
||||||
.join("\n\n");
|
.join("\n\n");
|
||||||
}
|
}
|
||||||
@ -85,7 +85,7 @@ export function formatReviewComments(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const formattedReviews = reviewData.nodes.map((review) => {
|
const formattedReviews = reviewData.nodes.map((review) => {
|
||||||
let reviewOutput = `[Review by ${review.author?.login ?? "ghost"} at ${review.submittedAt}]: ${review.state}`;
|
let reviewOutput = `[Review by ${review.author.login} at ${review.submittedAt}]: ${review.state}`;
|
||||||
|
|
||||||
if (review.body && review.body.trim()) {
|
if (review.body && review.body.trim()) {
|
||||||
let body = review.body;
|
let body = review.body;
|
||||||
|
|||||||
@ -27,15 +27,14 @@ function extractFirstLabel(githubData: FetchDataResult): string | undefined {
|
|||||||
* This prevents command injection by ensuring only safe characters are used.
|
* This prevents command injection by ensuring only safe characters are used.
|
||||||
*
|
*
|
||||||
* Valid branch names:
|
* Valid branch names:
|
||||||
* - Start with alphanumeric character, underscore, or @ (not dash, to prevent option injection)
|
* - Start with alphanumeric character (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, or hash (#)
|
||||||
* - Do not start or end with a period
|
* - Do not start or end with a period
|
||||||
* - Do not end with a slash
|
* - Do not end with a slash
|
||||||
* - Do not contain '..' (path traversal)
|
* - Do not contain '..' (path traversal)
|
||||||
* - Do not contain '//' (consecutive slashes)
|
* - Do not contain '//' (consecutive slashes)
|
||||||
* - Do not end with '.lock'
|
* - Do not end with '.lock'
|
||||||
* - Do not contain '@{'
|
* - Do not contain '@{'
|
||||||
* - Are not the single character '@' (HEAD shorthand in git revision syntax)
|
|
||||||
* - Do not contain control characters or special git characters (~^:?*[\])
|
* - Do not contain control characters or special git characters (~^:?*[\])
|
||||||
*/
|
*/
|
||||||
export function validateBranchName(branchName: string): void {
|
export function validateBranchName(branchName: string): void {
|
||||||
@ -59,24 +58,18 @@ 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 start, then alphanumeric/slash/hyphen/underscore/period/hash/plus/comma.
|
||||||
// # 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 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
|
// + 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").
|
// converting worktree names containing "/" (e.g. "feat/foo" becomes "worktree-feat+foo").
|
||||||
// , is valid per git-check-ref-format and commonly appears in branch names derived from titles
|
// , is valid per git-check-ref-format and commonly appears in branch names derived from titles
|
||||||
// or external identifiers (e.g. place names like "feature/paris,france").
|
// or external identifiers (e.g. place names like "feature/paris,france").
|
||||||
// @ is valid per git-check-ref-format anywhere in a ref name, including the first character
|
|
||||||
// (e.g. ticket conventions like "TICKET-123@add-feature" or prefixes like "@hotfix/...");
|
|
||||||
// the bare name "@" (HEAD shorthand) and the "@{" sequence (reflog syntax) are rejected below.
|
|
||||||
// _ 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.
|
|
||||||
// All git calls use execFileSync (not shell interpolation), so none of these characters carry injection risk.
|
// 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)) {
|
if (!validPattern.test(branchName)) {
|
||||||
throw new Error(
|
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 and contain only alphanumeric characters, forward slashes, hyphens, underscores, periods, hashes (#), plus signs (+), or commas (,).`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -119,15 +112,6 @@ export function validateBranchName(branchName: string): void {
|
|||||||
`Invalid branch name: "${branchName}". Branch names cannot contain '@{'`,
|
`Invalid branch name: "${branchName}". Branch names cannot contain '@{'`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per git-check-ref-format, a refname cannot be the single character "@"; "@" also
|
|
||||||
// resolves to HEAD in git revision syntax, so a bare "@" must never reach git as a
|
|
||||||
// branch argument where it could be interpreted as a revision instead.
|
|
||||||
if (branchName === "@") {
|
|
||||||
throw new Error(
|
|
||||||
`Invalid branch name: "@". Branch names cannot be the single character '@'.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -30,21 +30,6 @@ const SENSITIVE_PATHS = [
|
|||||||
|
|
||||||
const CLAUDE_PR_EXCLUDE_PATTERN = "/.claude-pr/";
|
const CLAUDE_PR_EXCLUDE_PATTERN = "/.claude-pr/";
|
||||||
|
|
||||||
function snapshotSensitivePath(src: string, dest: string): void {
|
|
||||||
try {
|
|
||||||
cpSync(src, dest, { recursive: true, dereference: true });
|
|
||||||
} catch (error) {
|
|
||||||
// Symlinks whose targets are absent on the PR head (e.g. `.claude/CLAUDE.md`
|
|
||||||
// -> `../AGENTS.md` when the PR deleted the target) make dereferenced
|
|
||||||
// copies throw ENOENT. Preserve the symlink for the review snapshot instead.
|
|
||||||
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
||||||
cpSync(src, dest, { recursive: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureClaudePrExcludedFromGit(): void {
|
function ensureClaudePrExcludedFromGit(): void {
|
||||||
const excludePath = execFileSync(
|
const excludePath = execFileSync(
|
||||||
"git",
|
"git",
|
||||||
@ -101,7 +86,7 @@ export function restoreConfigFromBase(baseBranch: string): void {
|
|||||||
rmSync(".claude-pr", { recursive: true, force: true });
|
rmSync(".claude-pr", { recursive: true, force: true });
|
||||||
for (const p of SENSITIVE_PATHS) {
|
for (const p of SENSITIVE_PATHS) {
|
||||||
if (existsSync(p)) {
|
if (existsSync(p)) {
|
||||||
snapshotSensitivePath(p, `.claude-pr/${p}`);
|
cpSync(p, `.claude-pr/${p}`, { recursive: true, dereference: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (existsSync(".claude-pr")) {
|
if (existsSync(".claude-pr")) {
|
||||||
|
|||||||
@ -1,8 +1,4 @@
|
|||||||
// Types for GitHub GraphQL query responses
|
// Types for GitHub GraphQL query responses
|
||||||
|
|
||||||
// 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.
|
|
||||||
export type GitHubAuthor = {
|
export type GitHubAuthor = {
|
||||||
login: string;
|
login: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
@ -12,7 +8,7 @@ export type GitHubComment = {
|
|||||||
id: string;
|
id: string;
|
||||||
databaseId: string;
|
databaseId: string;
|
||||||
body: string;
|
body: string;
|
||||||
author: GitHubAuthor | null;
|
author: GitHubAuthor;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
lastEditedAt?: string;
|
lastEditedAt?: string;
|
||||||
@ -43,7 +39,7 @@ export type GitHubFile = {
|
|||||||
export type GitHubReview = {
|
export type GitHubReview = {
|
||||||
id: string;
|
id: string;
|
||||||
databaseId: string;
|
databaseId: string;
|
||||||
author: GitHubAuthor | null;
|
author: GitHubAuthor;
|
||||||
body: string;
|
body: string;
|
||||||
state: string;
|
state: string;
|
||||||
submittedAt: string;
|
submittedAt: string;
|
||||||
@ -57,7 +53,7 @@ export type GitHubReview = {
|
|||||||
export type GitHubPullRequest = {
|
export type GitHubPullRequest = {
|
||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
author: GitHubAuthor | null;
|
author: GitHubAuthor;
|
||||||
baseRefName: string;
|
baseRefName: string;
|
||||||
headRefName: string;
|
headRefName: string;
|
||||||
headRefOid: string;
|
headRefOid: string;
|
||||||
@ -99,7 +95,7 @@ export type GitHubPullRequest = {
|
|||||||
export type GitHubIssue = {
|
export type GitHubIssue = {
|
||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
author: GitHubAuthor | null;
|
author: GitHubAuthor;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
lastEditedAt?: string;
|
lastEditedAt?: string;
|
||||||
|
|||||||
@ -10,13 +10,7 @@ export function stripInvisibleCharacters(content: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function stripMarkdownImageAltText(content: string): string {
|
export function stripMarkdownImageAltText(content: string): string {
|
||||||
// Inline images:  -> 
|
return content.replace(/!\[[^\]]*\]\(/g, ";
|
||||||
content = content.replace(/!\[[^\]]*\]\(/g, ";
|
|
||||||
// Reference-style images: ![alt][ref] -> ![][ref] (keep the label, drop the
|
|
||||||
// alt text, which is otherwise a hidden-instruction channel just like the
|
|
||||||
// inline form above).
|
|
||||||
content = content.replace(/!\[[^\]]*\](\[[^\]]*\])/g, "![]$1");
|
|
||||||
return content;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stripMarkdownLinkTitles(content: string): string {
|
export function stripMarkdownLinkTitles(content: string): string {
|
||||||
@ -89,12 +83,6 @@ export function redactGitHubTokens(content: string): string {
|
|||||||
"[REDACTED_GITHUB_TOKEN]",
|
"[REDACTED_GITHUB_TOKEN]",
|
||||||
);
|
);
|
||||||
|
|
||||||
// GitHub user-to-server tokens: ghu_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars)
|
|
||||||
content = content.replace(
|
|
||||||
/\bghu_[A-Za-z0-9]{36}\b/g,
|
|
||||||
"[REDACTED_GITHUB_TOKEN]",
|
|
||||||
);
|
|
||||||
|
|
||||||
// GitHub installation tokens: ghs_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars)
|
// GitHub installation tokens: ghs_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars)
|
||||||
content = content.replace(
|
content = content.replace(
|
||||||
/\bghs_[A-Za-z0-9]{36}\b/g,
|
/\bghs_[A-Za-z0-9]{36}\b/g,
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import { appendFileSync } from "fs";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { createOctokit } from "../github/api/client";
|
import { createOctokit } from "../github/api/client";
|
||||||
import { sanitizeContent } from "../github/utils/sanitizer";
|
import { sanitizeContent } from "../github/utils/sanitizer";
|
||||||
import { removeBufferedComment } from "./inline-comment-buffer";
|
|
||||||
|
|
||||||
// Get repository and PR information from environment variables
|
// Get repository and PR information from environment variables
|
||||||
const REPO_OWNER = process.env.REPO_OWNER;
|
const REPO_OWNER = process.env.REPO_OWNER;
|
||||||
@ -181,16 +180,6 @@ server.tool(
|
|||||||
|
|
||||||
const result = await octokit.rest.pulls.createReviewComment(params);
|
const result = await octokit.rest.pulls.createReviewComment(params);
|
||||||
|
|
||||||
// The comment is now live. Drop any buffered copy of it so the
|
|
||||||
// post-session replay step cannot post it a second time (the model often
|
|
||||||
// re-issues a buffered call with confirmed=true after the buffer reply).
|
|
||||||
if (CLASSIFY_ENABLED) {
|
|
||||||
removeBufferedComment(
|
|
||||||
{ path, line, startLine, body: sanitizedBody },
|
|
||||||
BUFFER_PATH,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,54 +0,0 @@
|
|||||||
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
||||||
|
|
||||||
export type BufferedCommentMatch = {
|
|
||||||
path: string;
|
|
||||||
line?: number;
|
|
||||||
startLine?: number;
|
|
||||||
body: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove any buffered inline comment that matches an already-posted comment.
|
|
||||||
*
|
|
||||||
* When a comment is posted live (confirmed=true), an earlier buffered copy of
|
|
||||||
* the same comment must be dropped so the post-session replay step does not
|
|
||||||
* post it a second time. The model frequently re-issues a buffered call with
|
|
||||||
* confirmed=true after reading the "Set confirmed=true to post immediately"
|
|
||||||
* reply; previously the original buffered entry was left behind and replayed,
|
|
||||||
* producing duplicate inline comments.
|
|
||||||
*
|
|
||||||
* Entries are matched on path, line, startLine and body. Lines that cannot be
|
|
||||||
* parsed are kept untouched.
|
|
||||||
*/
|
|
||||||
export function removeBufferedComment(
|
|
||||||
match: BufferedCommentMatch,
|
|
||||||
bufferPath: string,
|
|
||||||
): void {
|
|
||||||
if (!existsSync(bufferPath)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const remaining = readFileSync(bufferPath, "utf8")
|
|
||||||
.split("\n")
|
|
||||||
.filter((line) => line.trim() !== "")
|
|
||||||
.filter((line) => {
|
|
||||||
let entry: BufferedCommentMatch;
|
|
||||||
try {
|
|
||||||
entry = JSON.parse(line);
|
|
||||||
} catch {
|
|
||||||
// Keep anything we cannot parse rather than silently dropping it.
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const isSameComment =
|
|
||||||
entry.path === match.path &&
|
|
||||||
entry.line === match.line &&
|
|
||||||
entry.startLine === match.startLine &&
|
|
||||||
entry.body === match.body;
|
|
||||||
return !isSameComment;
|
|
||||||
});
|
|
||||||
|
|
||||||
writeFileSync(
|
|
||||||
bufferPath,
|
|
||||||
remaining.length > 0 ? remaining.join("\n") + "\n" : "",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -28,20 +28,6 @@ function extractDescription(
|
|||||||
.replace(/^-|-$/g, ""); // Remove leading/trailing hyphens
|
.replace(/^-|-$/g, ""); // Remove leading/trailing hyphens
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitizes a label into a git-safe branch segment. Labels are free-form and
|
|
||||||
* often scoped (e.g. "area:permissions"), so characters that are invalid in a
|
|
||||||
* branch name (":", "/", spaces, ...) are replaced with a hyphen rather than
|
|
||||||
* dropped, keeping the label readable. Returns "" if nothing usable remains.
|
|
||||||
*/
|
|
||||||
function sanitizeLabel(label: string): string {
|
|
||||||
return label
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9-]+/g, "-") // Replace runs of invalid chars with a hyphen
|
|
||||||
.replace(/-+/g, "-") // Collapse multiple hyphens
|
|
||||||
.replace(/^-|-$/g, ""); // Remove leading/trailing hyphens
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BranchTemplateVariables {
|
export interface BranchTemplateVariables {
|
||||||
prefix: string;
|
prefix: string;
|
||||||
entityType: string;
|
entityType: string;
|
||||||
@ -92,7 +78,7 @@ export function generateBranchName(
|
|||||||
entityNumber,
|
entityNumber,
|
||||||
timestamp: `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}-${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}`,
|
timestamp: `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}-${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}`,
|
||||||
sha: sha?.substring(0, 8), // First 8 characters of SHA
|
sha: sha?.substring(0, 8), // First 8 characters of SHA
|
||||||
label: (label && sanitizeLabel(label)) || entityType, // Sanitize; fall back to entityType if empty/no label
|
label: label || entityType, // Fall back to entityType if no label
|
||||||
description: title ? extractDescription(title) : undefined,
|
description: title ? extractDescription(title) : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import {
|
|||||||
applyBranchTemplate,
|
applyBranchTemplate,
|
||||||
generateBranchName,
|
generateBranchName,
|
||||||
} from "../src/utils/branch-template";
|
} from "../src/utils/branch-template";
|
||||||
import { validateBranchName } from "../src/github/operations/branch";
|
|
||||||
|
|
||||||
describe("branch template utilities", () => {
|
describe("branch template utilities", () => {
|
||||||
describe("applyBranchTemplate", () => {
|
describe("applyBranchTemplate", () => {
|
||||||
@ -145,53 +144,6 @@ describe("branch template utilities", () => {
|
|||||||
expect(result).toBe("dev/enhancement-issue_789");
|
expect(result).toBe("dev/enhancement-issue_789");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should sanitize scoped labels that contain invalid git characters", () => {
|
|
||||||
const template = "{{prefix}}{{label}}/{{entityNumber}}";
|
|
||||||
const result = generateBranchName(
|
|
||||||
template,
|
|
||||||
"claude/",
|
|
||||||
"issue",
|
|
||||||
123,
|
|
||||||
undefined,
|
|
||||||
"area:permissions",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toBe("claude/area-permissions/123");
|
|
||||||
// Regression: an unsanitized ":" here previously failed validateBranchName
|
|
||||||
// and crashed the run via process.exit(1).
|
|
||||||
expect(() => validateBranchName(result)).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should replace spaces in labels with hyphens", () => {
|
|
||||||
const template = "{{prefix}}{{label}}-{{entityNumber}}";
|
|
||||||
const result = generateBranchName(
|
|
||||||
template,
|
|
||||||
"fix/",
|
|
||||||
"issue",
|
|
||||||
456,
|
|
||||||
undefined,
|
|
||||||
"needs review",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toBe("fix/needs-review-456");
|
|
||||||
expect(() => validateBranchName(result)).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should fall back to entityType when a label sanitizes to empty", () => {
|
|
||||||
const template = "{{prefix}}{{label}}-{{entityNumber}}";
|
|
||||||
const result = generateBranchName(
|
|
||||||
template,
|
|
||||||
"fix/",
|
|
||||||
"pr",
|
|
||||||
789,
|
|
||||||
undefined,
|
|
||||||
"🎉",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toBe("fix/pr-789");
|
|
||||||
expect(() => validateBranchName(result)).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should use description in template when provided", () => {
|
it("should use description in template when provided", () => {
|
||||||
const template = "{{prefix}}{{description}}/{{entityNumber}}";
|
const template = "{{prefix}}{{description}}/{{entityNumber}}";
|
||||||
const result = generateBranchName(
|
const result = generateBranchName(
|
||||||
|
|||||||
@ -1,74 +0,0 @@
|
|||||||
import { describe, test, expect } from "bun:test";
|
|
||||||
import {
|
|
||||||
SPINNER_HTML,
|
|
||||||
createJobRunLink,
|
|
||||||
createBranchLink,
|
|
||||||
createCommentBody,
|
|
||||||
} from "../src/github/operations/comments/common";
|
|
||||||
import { GITHUB_SERVER_URL } from "../src/github/api/config";
|
|
||||||
|
|
||||||
describe("comments/common", () => {
|
|
||||||
describe("createJobRunLink", () => {
|
|
||||||
test("builds a markdown link to the workflow run", () => {
|
|
||||||
const result = createJobRunLink("anthropics", "claude-code-action", "42");
|
|
||||||
expect(result).toBe(
|
|
||||||
`[View job run](${GITHUB_SERVER_URL}/anthropics/claude-code-action/actions/runs/42)`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("honors GITHUB_SERVER_URL (GHES) rather than hardcoding github.com", () => {
|
|
||||||
// The link is built from the configured server URL, so it must point at
|
|
||||||
// whatever GITHUB_SERVER_URL resolves to (github.com by default, a GHES
|
|
||||||
// host in enterprise setups).
|
|
||||||
expect(createJobRunLink("o", "r", "1")).toContain(GITHUB_SERVER_URL);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("createBranchLink", () => {
|
|
||||||
test("builds a leading-newline markdown link to the branch tree", () => {
|
|
||||||
const result = createBranchLink(
|
|
||||||
"anthropics",
|
|
||||||
"claude-code-action",
|
|
||||||
"feature/x",
|
|
||||||
);
|
|
||||||
expect(result).toBe(
|
|
||||||
`\n[View branch](${GITHUB_SERVER_URL}/anthropics/claude-code-action/tree/feature/x)`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("prefixes the link with a newline so it renders on its own line", () => {
|
|
||||||
expect(createBranchLink("o", "r", "main").startsWith("\n")).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("createCommentBody", () => {
|
|
||||||
test("includes the spinner, the working message, and the job run link", () => {
|
|
||||||
const jobRunLink = createJobRunLink("o", "r", "7");
|
|
||||||
const body = createCommentBody(jobRunLink);
|
|
||||||
|
|
||||||
expect(body).toContain(SPINNER_HTML);
|
|
||||||
expect(body).toContain("Claude Code is working…");
|
|
||||||
expect(body).toContain(jobRunLink);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("omits the branch link when none is provided (defaults to empty)", () => {
|
|
||||||
const body = createCommentBody(createJobRunLink("o", "r", "7"));
|
|
||||||
expect(body).not.toContain("View branch");
|
|
||||||
// No trailing branch content: body ends with the job run link.
|
|
||||||
expect(body.endsWith(")")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("appends the branch link when provided", () => {
|
|
||||||
const jobRunLink = createJobRunLink("o", "r", "7");
|
|
||||||
const branchLink = createBranchLink("o", "r", "feature/x");
|
|
||||||
const body = createCommentBody(jobRunLink, branchLink);
|
|
||||||
|
|
||||||
expect(body).toContain(jobRunLink);
|
|
||||||
expect(body).toContain(branchLink);
|
|
||||||
// The branch link (with its leading newline) comes after the job run link.
|
|
||||||
expect(body.indexOf(branchLink)).toBeGreaterThan(
|
|
||||||
body.indexOf(jobRunLink),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -6,10 +6,8 @@ import {
|
|||||||
getEventTypeAndContext,
|
getEventTypeAndContext,
|
||||||
buildAllowedToolsString,
|
buildAllowedToolsString,
|
||||||
buildDisallowedToolsString,
|
buildDisallowedToolsString,
|
||||||
prepareContext,
|
|
||||||
} from "../src/create-prompt";
|
} from "../src/create-prompt";
|
||||||
import type { PreparedContext } from "../src/create-prompt";
|
import type { PreparedContext } from "../src/create-prompt";
|
||||||
import { createMockContext } from "./mockContext";
|
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
process.env.GITHUB_ACTION_PATH = "/test/action/path";
|
process.env.GITHUB_ACTION_PATH = "/test/action/path";
|
||||||
@ -497,32 +495,6 @@ describe("generatePrompt", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should use numeric GitHub noreply address when trigger user id is provided", async () => {
|
|
||||||
const envVars: PreparedContext = {
|
|
||||||
repository: "owner/repo",
|
|
||||||
claudeCommentId: "12345",
|
|
||||||
triggerPhrase: "@claude",
|
|
||||||
triggerUsername: "johndoe",
|
|
||||||
triggerUserId: 123456,
|
|
||||||
eventData: {
|
|
||||||
eventName: "issue_comment",
|
|
||||||
commentId: "67890",
|
|
||||||
isPR: false,
|
|
||||||
issueNumber: "123",
|
|
||||||
baseBranch: "main",
|
|
||||||
claudeBranch: "claude/issue-67890-20240101-1200",
|
|
||||||
commentBody: "@claude please fix this",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const prompt = await generatePrompt(envVars, mockGitHubData, false, "tag");
|
|
||||||
|
|
||||||
expect(prompt).toContain(
|
|
||||||
"Co-authored-by: johndoe <123456+johndoe@users.noreply.github.com>",
|
|
||||||
);
|
|
||||||
expect(prompt).not.toContain("<johndoe@users.noreply.github.com>");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should include PR-specific instructions only for PR events", async () => {
|
test("should include PR-specific instructions only for PR events", async () => {
|
||||||
const envVars: PreparedContext = {
|
const envVars: PreparedContext = {
|
||||||
repository: "owner/repo",
|
repository: "owner/repo",
|
||||||
@ -1272,83 +1244,3 @@ describe("buildDisallowedToolsString", () => {
|
|||||||
expect(result).toBe("BadTool1,BadTool2");
|
expect(result).toBe("BadTool1,BadTool2");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("prepareContext validation errors", () => {
|
|
||||||
const commentId = "12345";
|
|
||||||
|
|
||||||
test("throws on an unsupported event type", () => {
|
|
||||||
const context = createMockContext({
|
|
||||||
eventName: "deployment_status" as any,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(() => prepareContext(context, commentId)).toThrow(
|
|
||||||
"Unsupported event type: deployment_status",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("pull_request event requires a PR number (isPR must be true)", () => {
|
|
||||||
const context = createMockContext({
|
|
||||||
eventName: "pull_request",
|
|
||||||
eventAction: "opened",
|
|
||||||
isPR: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(() => prepareContext(context, commentId)).toThrow(
|
|
||||||
"PR_NUMBER is required for pull_request event",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("pull_request_review event requires a PR number", () => {
|
|
||||||
const context = createMockContext({
|
|
||||||
eventName: "pull_request_review",
|
|
||||||
isPR: false,
|
|
||||||
payload: {
|
|
||||||
review: { body: "please fix", user: { login: "user1" } },
|
|
||||||
} as any,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(() => prepareContext(context, commentId)).toThrow(
|
|
||||||
"PR_NUMBER is required for pull_request_review event",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("issues event requires an event action", () => {
|
|
||||||
const context = createMockContext({
|
|
||||||
eventName: "issues",
|
|
||||||
eventAction: "",
|
|
||||||
isPR: false,
|
|
||||||
payload: { issue: { user: { login: "user1" } } } as any,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(() => prepareContext(context, commentId)).toThrow(
|
|
||||||
"GITHUB_EVENT_ACTION is required for issues event",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("issues event rejects an unsupported action", () => {
|
|
||||||
const context = createMockContext({
|
|
||||||
eventName: "issues",
|
|
||||||
eventAction: "deleted",
|
|
||||||
isPR: false,
|
|
||||||
payload: { issue: { user: { login: "user1" } } } as any,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(() =>
|
|
||||||
prepareContext(context, commentId, "main", "claude/issue-1"),
|
|
||||||
).toThrow("Unsupported issue action: deleted");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("issue_comment on an issue requires a claude branch", () => {
|
|
||||||
const context = createMockContext({
|
|
||||||
eventName: "issue_comment",
|
|
||||||
isPR: false,
|
|
||||||
payload: {
|
|
||||||
comment: { id: 999, body: "@claude help", user: { login: "user1" } },
|
|
||||||
} as any,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(() => prepareContext(context, commentId)).toThrow(
|
|
||||||
"CLAUDE_BRANCH is required for issue_comment event",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@ -723,17 +723,16 @@ describe("fetchGitHubData integration with time filtering", () => {
|
|||||||
triggerTime: "2024-01-15T12:00:00Z",
|
triggerTime: "2024-01-15T12:00:00Z",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Only the review submitted before the trigger and not edited afterward
|
// The reviewData field returns all reviews (not filtered), but the filtering
|
||||||
// reaches the prompt. The review submitted after the trigger and the one
|
// happens when processing review bodies for download
|
||||||
// edited after the trigger are dropped (TOCTOU protection), matching the
|
// We can check the image download map to verify filtering
|
||||||
// issue/PR comment and body handling.
|
expect(result.reviewData?.nodes?.length).toBe(3); // All reviews are returned
|
||||||
expect(result.reviewData?.nodes?.length).toBe(1);
|
|
||||||
expect(result.reviewData?.nodes?.[0]?.databaseId).toBe("1");
|
|
||||||
|
|
||||||
// Only that surviving review's body is queued for image download.
|
// Check that only the first review's body would be downloaded (filtered)
|
||||||
const reviewsInMap = Object.keys(result.imageUrlMap).filter((key) =>
|
const reviewsInMap = Object.keys(result.imageUrlMap).filter((key) =>
|
||||||
key.startsWith("review_body"),
|
key.startsWith("review_body"),
|
||||||
);
|
);
|
||||||
|
// Only review 1 should have its body processed (before trigger and not edited after)
|
||||||
expect(reviewsInMap.length).toBeLessThanOrEqual(1);
|
expect(reviewsInMap.length).toBeLessThanOrEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -806,83 +805,14 @@ describe("fetchGitHubData integration with time filtering", () => {
|
|||||||
triggerTime: "2024-01-15T12:00:00Z",
|
triggerTime: "2024-01-15T12:00:00Z",
|
||||||
});
|
});
|
||||||
|
|
||||||
// The review itself is pre-trigger and kept, but its inline comments are
|
// The imageUrlMap contains processed comments for image downloading
|
||||||
// filtered to trigger time: the comment created after the trigger (id 11)
|
// We should have processed review comments, but only those before trigger time
|
||||||
// and the one edited after the trigger (id 12) are dropped, leaving only
|
// The exact check depends on how imageUrlMap is structured, but we can verify
|
||||||
// the pre-trigger comment (id 10).
|
// that filtering occurred by checking the review data still has all nodes
|
||||||
expect(result.reviewData?.nodes?.length).toBe(1);
|
expect(result.reviewData?.nodes?.length).toBe(1); // Original review is kept
|
||||||
const reviewCommentIds =
|
|
||||||
result.reviewData?.nodes?.[0]?.comments?.nodes?.map((c) => c.databaseId);
|
|
||||||
expect(reviewCommentIds).toEqual(["10"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should filter reviews by both trigger time and actor", async () => {
|
// The actual filtering happens during processing for image download
|
||||||
const mockOctokits = {
|
// Since the mock doesn't actually download images, we verify the input was correct
|
||||||
graphql: jest.fn().mockResolvedValue({
|
|
||||||
repository: {
|
|
||||||
pullRequest: {
|
|
||||||
number: 321,
|
|
||||||
title: "Test PR",
|
|
||||||
body: "PR body",
|
|
||||||
author: { login: "author" },
|
|
||||||
comments: { nodes: [] },
|
|
||||||
files: { nodes: [] },
|
|
||||||
reviews: {
|
|
||||||
nodes: [
|
|
||||||
{
|
|
||||||
id: "1",
|
|
||||||
databaseId: "1",
|
|
||||||
author: { login: "reviewer1" },
|
|
||||||
body: "Pre-trigger human review",
|
|
||||||
state: "APPROVED",
|
|
||||||
submittedAt: "2024-01-15T11:00:00Z",
|
|
||||||
comments: { nodes: [] },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "2",
|
|
||||||
databaseId: "2",
|
|
||||||
author: { login: "scanner[bot]" },
|
|
||||||
body: "Pre-trigger bot review",
|
|
||||||
state: "COMMENTED",
|
|
||||||
submittedAt: "2024-01-15T11:00:00Z",
|
|
||||||
comments: { nodes: [] },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "3",
|
|
||||||
databaseId: "3",
|
|
||||||
author: { login: "reviewer3" },
|
|
||||||
body: "Post-trigger human review",
|
|
||||||
state: "CHANGES_REQUESTED",
|
|
||||||
submittedAt: "2024-01-15T13:00:00Z",
|
|
||||||
comments: { nodes: [] },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
user: { login: "trigger-user" },
|
|
||||||
}),
|
|
||||||
rest: {
|
|
||||||
pulls: {
|
|
||||||
listFiles: jest.fn().mockResolvedValue({ data: [] }),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await fetchGitHubData({
|
|
||||||
octokits: mockOctokits as any,
|
|
||||||
repository: "test-owner/test-repo",
|
|
||||||
prNumber: "321",
|
|
||||||
isPR: true,
|
|
||||||
triggerUsername: "trigger-user",
|
|
||||||
triggerTime: "2024-01-15T12:00:00Z",
|
|
||||||
excludeCommentsByActor: "*[bot]",
|
|
||||||
});
|
|
||||||
|
|
||||||
// The trigger-time and actor filters compose: the pre-trigger human review
|
|
||||||
// is kept, the pre-trigger bot review is dropped by actor, and the
|
|
||||||
// post-trigger human review is dropped by trigger time.
|
|
||||||
expect(result.reviewData?.nodes?.map((r) => r.databaseId)).toEqual(["1"]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle backward compatibility when no trigger time provided", async () => {
|
it("should handle backward compatibility when no trigger time provided", async () => {
|
||||||
@ -1499,42 +1429,4 @@ describe("filterCommentsByActor", () => {
|
|||||||
const filtered = filterCommentsByActor(comments, "user1", "");
|
const filtered = filterCommentsByActor(comments, "user1", "");
|
||||||
expect(filtered).toHaveLength(0);
|
expect(filtered).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("does not crash on comments from deleted (null-author) accounts", () => {
|
|
||||||
// GitHub's GraphQL returns author: null for comments whose account was
|
|
||||||
// deleted. With an exclude filter set (the exact `*[bot]` config we
|
|
||||||
// recommend), the null author must not throw when dereferenced.
|
|
||||||
const comments = [
|
|
||||||
{ author: { login: "user1" }, body: "comment1" },
|
|
||||||
{ author: null, body: "from a deleted account" },
|
|
||||||
{ author: { login: "bot[bot]" }, body: "comment3" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const { filterCommentsByActor } = require("../src/github/data/fetcher");
|
|
||||||
const filtered = filterCommentsByActor(comments, "", "*[bot]");
|
|
||||||
// ghost comment is retained (it matches no exclude pattern); the bot is dropped.
|
|
||||||
expect(filtered).toHaveLength(2);
|
|
||||||
expect(filtered.map((c: any) => c.body)).toEqual([
|
|
||||||
"comment1",
|
|
||||||
"from a deleted account",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("treats null author as the 'ghost' login for include/exclude", () => {
|
|
||||||
const comments = [
|
|
||||||
{ author: null, body: "from a deleted account" },
|
|
||||||
{ author: { login: "user1" }, body: "comment2" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const { filterCommentsByActor } = require("../src/github/data/fetcher");
|
|
||||||
// Excluding "ghost" removes the deleted-account comment.
|
|
||||||
expect(filterCommentsByActor(comments, "", "ghost")).toHaveLength(1);
|
|
||||||
expect(filterCommentsByActor(comments, "", "ghost")[0].body).toBe(
|
|
||||||
"comment2",
|
|
||||||
);
|
|
||||||
// Including only "ghost" keeps just the deleted-account comment.
|
|
||||||
const onlyGhost = filterCommentsByActor(comments, "ghost", "");
|
|
||||||
expect(onlyGhost).toHaveLength(1);
|
|
||||||
expect(onlyGhost[0].body).toBe("from a deleted account");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -159,21 +159,6 @@ Issue State: OPEN
|
|||||||
Issue Labels: architecture, agent-sdk, drift:functional`,
|
Issue Labels: architecture, agent-sdk, drift:functional`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders a deleted (null-author) issue author as 'ghost'", () => {
|
|
||||||
const issueData: GitHubIssue = {
|
|
||||||
title: "Test Issue",
|
|
||||||
body: "Issue body",
|
|
||||||
author: null,
|
|
||||||
createdAt: "2023-01-01T00:00:00Z",
|
|
||||||
state: "OPEN",
|
|
||||||
labels: { nodes: [] },
|
|
||||||
comments: { nodes: [] },
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = formatContext(issueData, false);
|
|
||||||
expect(result).toContain("Issue Author: ghost");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("formatBody", () => {
|
describe("formatBody", () => {
|
||||||
@ -267,24 +252,6 @@ describe("formatComments", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders deleted (null-author) comments as 'ghost'", () => {
|
|
||||||
// GitHub returns author: null for comments from deleted accounts.
|
|
||||||
const comments: GitHubComment[] = [
|
|
||||||
{
|
|
||||||
id: "1",
|
|
||||||
databaseId: "100001",
|
|
||||||
body: "From a deleted account",
|
|
||||||
author: null,
|
|
||||||
createdAt: "2023-01-01T00:00:00Z",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = formatComments(comments);
|
|
||||||
expect(result).toBe(
|
|
||||||
"[ghost at 2023-01-01T00:00:00Z]: From a deleted account",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns empty string for empty comments array", () => {
|
test("returns empty string for empty comments array", () => {
|
||||||
const result = formatComments([]);
|
const result = formatComments([]);
|
||||||
expect(result).toBe("");
|
expect(result).toBe("");
|
||||||
@ -527,29 +494,6 @@ describe("formatReviewComments", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders deleted (null-author) reviews as 'ghost'", () => {
|
|
||||||
const reviewData = {
|
|
||||||
nodes: [
|
|
||||||
{
|
|
||||||
id: "review1",
|
|
||||||
databaseId: "300099",
|
|
||||||
author: null,
|
|
||||||
body: "Left before deleting the account",
|
|
||||||
state: "COMMENTED",
|
|
||||||
submittedAt: "2023-01-01T00:00:00Z",
|
|
||||||
comments: {
|
|
||||||
nodes: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = formatReviewComments(reviewData);
|
|
||||||
expect(result).toBe(
|
|
||||||
`[Review by ghost at 2023-01-01T00:00:00Z]: COMMENTED\nLeft before deleting the account`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("formats multiple reviews correctly", () => {
|
test("formats multiple reviews correctly", () => {
|
||||||
const reviewData = {
|
const reviewData = {
|
||||||
nodes: [
|
nodes: [
|
||||||
|
|||||||
@ -437,81 +437,3 @@ describe("integration tests", () => {
|
|||||||
expect(actualOutput).toBe(expectedOutput);
|
expect(actualOutput).toBe(expectedOutput);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("detectContentType fallbacks", () => {
|
|
||||||
test("falls back to text for malformed JSON objects", () => {
|
|
||||||
// Looks like an object (starts with { ends with }) but does not parse.
|
|
||||||
expect(detectContentType("{not valid json}")).toBe("text");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("falls back to text for malformed JSON arrays", () => {
|
|
||||||
// Looks like an array (starts with [ ends with ]) but does not parse.
|
|
||||||
expect(detectContentType("[not, valid, json]")).toBe("text");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("classifies non-python, non-js code keywords as python by default", () => {
|
|
||||||
// Contains a code keyword ("class ") but matches neither the python-specific
|
|
||||||
// nor the javascript-specific checks, so it hits the default branch.
|
|
||||||
expect(detectContentType("class Foo {}")).toBe("python");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("formatResultContent non-string input", () => {
|
|
||||||
test("handles a numeric (non-string) result value", () => {
|
|
||||||
const result = formatResultContent(42);
|
|
||||||
expect(result).toContain("42");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles a plain object (non-string, non-text-array) result value", () => {
|
|
||||||
const result = formatResultContent({ status: "ok" });
|
|
||||||
expect(typeof result).toBe("string");
|
|
||||||
expect(result.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("system_other handling", () => {
|
|
||||||
test("groups a non-init system turn as system_other", () => {
|
|
||||||
const systemTurn: Turn = { type: "system", subtype: "some_other_subtype" };
|
|
||||||
const grouped = groupTurnsNaturally([systemTurn]);
|
|
||||||
expect(grouped).toHaveLength(1);
|
|
||||||
expect(grouped[0]?.type).toBe("system_other");
|
|
||||||
expect(grouped[0]?.data).toEqual(systemTurn);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders a system_other group as a System Message section", () => {
|
|
||||||
const markdown = formatGroupedContent([
|
|
||||||
{ type: "system_other", data: { type: "system" } as Turn },
|
|
||||||
]);
|
|
||||||
expect(markdown).toContain("## ⚙️ System Message");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("filters out thinking_tokens system messages", () => {
|
|
||||||
const data: Turn[] = [
|
|
||||||
{ type: "system", subtype: "init", tools: [{ name: "tool1" }] },
|
|
||||||
{ type: "system", subtype: "thinking_tokens" },
|
|
||||||
{ type: "system", subtype: "thinking_tokens" },
|
|
||||||
{ type: "system", subtype: "other_subtype" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const grouped = groupTurnsNaturally(data);
|
|
||||||
|
|
||||||
// Should have init and other_subtype, but not thinking_tokens
|
|
||||||
expect(grouped).toHaveLength(2);
|
|
||||||
expect(grouped[0]?.type).toBe("system_init");
|
|
||||||
expect(grouped[1]?.type).toBe("system_other");
|
|
||||||
expect(grouped[1]?.data?.subtype).toBe("other_subtype");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("thinking_tokens does not appear in formatted output", () => {
|
|
||||||
const data: Turn[] = [
|
|
||||||
{ type: "system", subtype: "init", tools: [] },
|
|
||||||
{ type: "system", subtype: "thinking_tokens" },
|
|
||||||
{ type: "system", subtype: "thinking_tokens" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = formatTurnsFromData(data);
|
|
||||||
|
|
||||||
expect(result).not.toContain("thinking_tokens");
|
|
||||||
expect(result).toContain("## 🚀 System Initialization");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@ -1,139 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
||||||
import {
|
|
||||||
existsSync,
|
|
||||||
mkdtempSync,
|
|
||||||
readFileSync,
|
|
||||||
rmSync,
|
|
||||||
writeFileSync,
|
|
||||||
} from "fs";
|
|
||||||
import { tmpdir } from "os";
|
|
||||||
import { join } from "path";
|
|
||||||
import { removeBufferedComment } from "../src/mcp/inline-comment-buffer";
|
|
||||||
|
|
||||||
describe("removeBufferedComment", () => {
|
|
||||||
let dir: string;
|
|
||||||
let bufferPath: string;
|
|
||||||
|
|
||||||
const entryA = {
|
|
||||||
ts: "2026-06-13T00:00:00.000Z",
|
|
||||||
path: "src/index.ts",
|
|
||||||
line: 10,
|
|
||||||
startLine: undefined,
|
|
||||||
side: "RIGHT",
|
|
||||||
body: "Comment A",
|
|
||||||
};
|
|
||||||
const entryB = {
|
|
||||||
ts: "2026-06-13T00:00:01.000Z",
|
|
||||||
path: "src/other.ts",
|
|
||||||
line: 20,
|
|
||||||
startLine: undefined,
|
|
||||||
side: "RIGHT",
|
|
||||||
body: "Comment B",
|
|
||||||
};
|
|
||||||
|
|
||||||
const writeBuffer = (entries: object[]): void => {
|
|
||||||
writeFileSync(
|
|
||||||
bufferPath,
|
|
||||||
entries.map((e) => JSON.stringify(e)).join("\n") + "\n",
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const readBuffer = (): Array<{ body: string }> => {
|
|
||||||
if (!existsSync(bufferPath)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return readFileSync(bufferPath, "utf8")
|
|
||||||
.split("\n")
|
|
||||||
.filter((line) => line.trim() !== "")
|
|
||||||
.map((line) => JSON.parse(line));
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
dir = mkdtempSync(join(tmpdir(), "inline-buffer-"));
|
|
||||||
bufferPath = join(dir, "buffer.jsonl");
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
rmSync(dir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("removes the matching buffered entry and keeps the others", () => {
|
|
||||||
writeBuffer([entryA, entryB]);
|
|
||||||
|
|
||||||
removeBufferedComment(
|
|
||||||
{
|
|
||||||
path: "src/index.ts",
|
|
||||||
line: 10,
|
|
||||||
startLine: undefined,
|
|
||||||
body: "Comment A",
|
|
||||||
},
|
|
||||||
bufferPath,
|
|
||||||
);
|
|
||||||
|
|
||||||
const remaining = readBuffer();
|
|
||||||
expect(remaining.map((e) => e.body)).toEqual(["Comment B"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("removes every copy when the same comment was buffered more than once", () => {
|
|
||||||
writeBuffer([entryA, entryA, entryB]);
|
|
||||||
|
|
||||||
removeBufferedComment(
|
|
||||||
{
|
|
||||||
path: "src/index.ts",
|
|
||||||
line: 10,
|
|
||||||
startLine: undefined,
|
|
||||||
body: "Comment A",
|
|
||||||
},
|
|
||||||
bufferPath,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(readBuffer().map((e) => e.body)).toEqual(["Comment B"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves the buffer untouched when nothing matches", () => {
|
|
||||||
writeBuffer([entryA, entryB]);
|
|
||||||
|
|
||||||
removeBufferedComment(
|
|
||||||
{
|
|
||||||
path: "src/index.ts",
|
|
||||||
line: 999,
|
|
||||||
startLine: undefined,
|
|
||||||
body: "Comment A",
|
|
||||||
},
|
|
||||||
bufferPath,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(readBuffer().map((e) => e.body)).toEqual(["Comment A", "Comment B"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does nothing when the buffer file does not exist", () => {
|
|
||||||
expect(() =>
|
|
||||||
removeBufferedComment(
|
|
||||||
{ path: "src/index.ts", line: 10, body: "Comment A" },
|
|
||||||
bufferPath,
|
|
||||||
),
|
|
||||||
).not.toThrow();
|
|
||||||
expect(existsSync(bufferPath)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps lines that cannot be parsed as JSON", () => {
|
|
||||||
writeFileSync(
|
|
||||||
bufferPath,
|
|
||||||
["not json", JSON.stringify(entryA)].join("\n") + "\n",
|
|
||||||
);
|
|
||||||
|
|
||||||
removeBufferedComment(
|
|
||||||
{
|
|
||||||
path: "src/index.ts",
|
|
||||||
line: 10,
|
|
||||||
startLine: undefined,
|
|
||||||
body: "Comment A",
|
|
||||||
},
|
|
||||||
bufferPath,
|
|
||||||
);
|
|
||||||
|
|
||||||
const raw = readFileSync(bufferPath, "utf8");
|
|
||||||
expect(raw).toContain("not json");
|
|
||||||
expect(raw).not.toContain("Comment A");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
import { describe, it, expect } from "bun:test";
|
|
||||||
import { spawnSync } from "child_process";
|
|
||||||
import { buildInstallCommand } from "../src/entrypoints/run";
|
|
||||||
|
|
||||||
describe("buildInstallCommand (regression for #1136)", () => {
|
|
||||||
it("includes the pinned claude version in the bash -s args", () => {
|
|
||||||
const cmd = buildInstallCommand("2.1.114");
|
|
||||||
expect(cmd).toContain("bash -s -- 2.1.114");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("prefixes the pipeline with `set -o pipefail`", () => {
|
|
||||||
const cmd = buildInstallCommand("2.1.114");
|
|
||||||
expect(cmd.startsWith("set -o pipefail;")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the curl -fsSL flags so the script is fetched, not inlined", () => {
|
|
||||||
const cmd = buildInstallCommand("2.1.114");
|
|
||||||
expect(cmd).toContain(
|
|
||||||
"curl -fsSL https://claude.ai/install.sh | bash -s --",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("pipefail semantics (proves the bug shape and the fix)", () => {
|
|
||||||
// Mirrors the real install invocation: a curl that returns non-zero
|
|
||||||
// feeding into `bash -s --`. Without pipefail, the pipeline exits 0
|
|
||||||
// because bash -s receives an empty stdin and does nothing. With
|
|
||||||
// pipefail, curl's exit code wins and the retry loop in run.ts triggers.
|
|
||||||
//
|
|
||||||
// Uses port 1 (reserved/unused) so curl fails deterministically with no
|
|
||||||
// network access. No shell-escaping traps here: the version argument is
|
|
||||||
// a numeric literal.
|
|
||||||
const unreachable = "http://127.0.0.1:1/nope";
|
|
||||||
const version = "2.1.114";
|
|
||||||
|
|
||||||
it("BEFORE FIX: pipeline without pipefail swallows curl failure (exit 0)", () => {
|
|
||||||
const buggy = `curl -fsSL ${unreachable} | bash -s -- ${version}`;
|
|
||||||
const result = spawnSync("bash", ["-c", buggy], { stdio: "pipe" });
|
|
||||||
expect(result.status).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("AFTER FIX: buildInstallCommand (against unreachable host) exits non-zero", () => {
|
|
||||||
const fixed = buildInstallCommand(version).replace(
|
|
||||||
"https://claude.ai/install.sh",
|
|
||||||
unreachable,
|
|
||||||
);
|
|
||||||
const result = spawnSync("bash", ["-c", fixed], { stdio: "pipe" });
|
|
||||||
expect(result.status).not.toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -2,12 +2,10 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|||||||
import { execFileSync } from "child_process";
|
import { execFileSync } from "child_process";
|
||||||
import {
|
import {
|
||||||
existsSync,
|
existsSync,
|
||||||
lstatSync,
|
|
||||||
mkdtempSync,
|
mkdtempSync,
|
||||||
mkdirSync,
|
mkdirSync,
|
||||||
readFileSync,
|
readFileSync,
|
||||||
rmSync,
|
rmSync,
|
||||||
symlinkSync,
|
|
||||||
writeFileSync,
|
writeFileSync,
|
||||||
} from "fs";
|
} from "fs";
|
||||||
import { dirname, isAbsolute, join } from "path";
|
import { dirname, isAbsolute, join } from "path";
|
||||||
@ -123,48 +121,6 @@ describe("restoreConfigFromBase", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("restores symlinked CLAUDE.md paths from the PR base branch", () => {
|
|
||||||
setupSymlinkedMainBranch();
|
|
||||||
|
|
||||||
git(["checkout", "pr"]);
|
|
||||||
writeRepoFile(
|
|
||||||
".claude/settings.json",
|
|
||||||
`${JSON.stringify({ source: "pr-with-symlinks" })}\n`,
|
|
||||||
);
|
|
||||||
git(["add", ".claude/settings.json"]);
|
|
||||||
git(["commit", "-m", "pr updates settings"]);
|
|
||||||
|
|
||||||
restoreConfigFromBase("main");
|
|
||||||
|
|
||||||
expect(lstatRepoFile("CLAUDE.md").isSymbolicLink()).toBe(true);
|
|
||||||
expect(lstatRepoFile(".claude/CLAUDE.md").isSymbolicLink()).toBe(true);
|
|
||||||
expect(readRepoFile("CLAUDE.md").trim()).toBe("shared agent instructions");
|
|
||||||
expect(readRepoFile(".claude/CLAUDE.md").trim()).toBe(
|
|
||||||
"shared agent instructions",
|
|
||||||
);
|
|
||||||
expect(readRepoFile(".claude/settings.json")).toBe(
|
|
||||||
`${JSON.stringify({ source: "base" })}\n`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("snapshots symlinked sensitive paths even when the PR head target is missing", () => {
|
|
||||||
setupSymlinkedMainBranch();
|
|
||||||
|
|
||||||
git(["checkout", "pr"]);
|
|
||||||
rmSync(join(repoDir, "AGENTS.md"), { force: true });
|
|
||||||
git(["add", "-A"]);
|
|
||||||
git(["commit", "-m", "pr deletes agents file"]);
|
|
||||||
|
|
||||||
restoreConfigFromBase("main");
|
|
||||||
|
|
||||||
expect(lstatRepoFile(".claude-pr/.claude/CLAUDE.md").isSymbolicLink()).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
expect(readRepoFile(".claude/settings.json")).toBe(
|
|
||||||
`${JSON.stringify({ source: "base" })}\n`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not modify an existing .gitignore", () => {
|
test("does not modify an existing .gitignore", () => {
|
||||||
writeRepoFile(".gitignore", "node_modules\n");
|
writeRepoFile(".gitignore", "node_modules\n");
|
||||||
git(["add", ".gitignore"]);
|
git(["add", ".gitignore"]);
|
||||||
@ -200,29 +156,6 @@ describe("restoreConfigFromBase", () => {
|
|||||||
return existsSync(join(repoDir, path));
|
return existsSync(join(repoDir, path));
|
||||||
}
|
}
|
||||||
|
|
||||||
function symlinkRepoFile(path: string, target: string): void {
|
|
||||||
const fullPath = join(repoDir, path);
|
|
||||||
mkdirSync(dirname(fullPath), { recursive: true });
|
|
||||||
symlinkSync(target, fullPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
function lstatRepoFile(path: string) {
|
|
||||||
return lstatSync(join(repoDir, path));
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupSymlinkedMainBranch(): void {
|
|
||||||
git(["checkout", "main"]);
|
|
||||||
rmSync(join(repoDir, "CLAUDE.md"), { force: true });
|
|
||||||
writeRepoFile("AGENTS.md", "shared agent instructions\n");
|
|
||||||
symlinkRepoFile("CLAUDE.md", "AGENTS.md");
|
|
||||||
symlinkRepoFile(".claude/CLAUDE.md", "../AGENTS.md");
|
|
||||||
git(["add", "AGENTS.md", "CLAUDE.md", ".claude/CLAUDE.md"]);
|
|
||||||
git(["commit", "-m", "add symlinked claude files"]);
|
|
||||||
git(["push", "origin", "main"]);
|
|
||||||
git(["branch", "-D", "pr"]);
|
|
||||||
git(["checkout", "-b", "pr"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function countClaudePrExcludeEntries(): number {
|
function countClaudePrExcludeEntries(): number {
|
||||||
return readFileSync(getExcludePath(), "utf8")
|
return readFileSync(getExcludePath(), "utf8")
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
|
|||||||
@ -59,22 +59,6 @@ describe("stripMarkdownImageAltText", () => {
|
|||||||
it("should handle empty alt text", () => {
|
it("should handle empty alt text", () => {
|
||||||
expect(stripMarkdownImageAltText("")).toBe("");
|
expect(stripMarkdownImageAltText("")).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should remove alt text from reference-style images", () => {
|
|
||||||
expect(stripMarkdownImageAltText("![example alt text][img1]")).toBe(
|
|
||||||
"![][img1]",
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
stripMarkdownImageAltText("Text ![description][ref] more text"),
|
|
||||||
).toBe("Text ![][ref] more text");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should preserve the reference label of a reference-style image", () => {
|
|
||||||
// the [ref] label must survive so the image definition still resolves;
|
|
||||||
// only the alt text (the injection channel) is removed
|
|
||||||
expect(stripMarkdownImageAltText("![alt][my-ref]")).toBe("![][my-ref]");
|
|
||||||
expect(stripMarkdownImageAltText("![][keep]")).toBe("![][keep]");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("stripMarkdownLinkTitles", () => {
|
describe("stripMarkdownLinkTitles", () => {
|
||||||
@ -292,16 +276,6 @@ describe("redactGitHubTokens", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should redact user-to-server tokens (ghu_)", () => {
|
|
||||||
const token = "ghu_16C7e42F292c6912E7710c838347Ae178B4a";
|
|
||||||
expect(redactGitHubTokens(`User token: ${token}`)).toBe(
|
|
||||||
"User token: [REDACTED_GITHUB_TOKEN]",
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
redactGitHubTokens(`In a URL: x-access-token:${token}@github.com`),
|
|
||||||
).toBe("In a URL: x-access-token:[REDACTED_GITHUB_TOKEN]@github.com");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should redact installation tokens (ghs_)", () => {
|
it("should redact installation tokens (ghs_)", () => {
|
||||||
const token = "ghs_xz7yzju2SZjGPa0dUNMAx0SH4xDOCS31LXQW";
|
const token = "ghs_xz7yzju2SZjGPa0dUNMAx0SH4xDOCS31LXQW";
|
||||||
expect(redactGitHubTokens(`Install token: ${token}`)).toBe(
|
expect(redactGitHubTokens(`Install token: ${token}`)).toBe(
|
||||||
|
|||||||
@ -64,26 +64,6 @@ describe("validateBranchName", () => {
|
|||||||
expect(() => validateBranchName("feature/paris,france")).not.toThrow();
|
expect(() => validateBranchName("feature/paris,france")).not.toThrow();
|
||||||
expect(() => validateBranchName("fix/issue-1,2,3")).not.toThrow();
|
expect(() => validateBranchName("fix/issue-1,2,3")).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should accept branch names containing @ (git-valid, used in team and tooling conventions)", () => {
|
|
||||||
// Reported in #998: branches like "TICKET-123@add-feature" were rejected, even
|
|
||||||
// though git check-ref-format and GitHub both accept @ anywhere in a ref name.
|
|
||||||
// Also common as a leading prefix (e.g. "@hotfix/...") and in agent-generated
|
|
||||||
// names ("task@sessionid"). Bare "@" and "@{" are still rejected.
|
|
||||||
expect(() => validateBranchName("TICKET-123@add-feature")).not.toThrow();
|
|
||||||
expect(() => validateBranchName("@hotfix/login-timeout")).not.toThrow();
|
|
||||||
expect(() => validateBranchName("agent/task@abc123")).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should accept branch names starting with underscore (git-valid, common for release branches)", () => {
|
|
||||||
// Leading underscores are valid per git check-ref-format and a common
|
|
||||||
// convention for release/internal branches. Rejecting them broke the
|
|
||||||
// action on any open PR whose base branch was e.g. "_release/v1.2.3",
|
|
||||||
// since setupBranch validates the PR's baseRefName after checkout.
|
|
||||||
expect(() => validateBranchName("_release/v1.2.3")).not.toThrow();
|
|
||||||
expect(() => validateBranchName("_internal")).not.toThrow();
|
|
||||||
expect(() => validateBranchName("_wip/feature-x")).not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("command injection attempts", () => {
|
describe("command injection attempts", () => {
|
||||||
@ -157,12 +137,6 @@ describe("validateBranchName", () => {
|
|||||||
expect(() => validateBranchName("HEAD@{yesterday}")).toThrow(/@{/);
|
expect(() => validateBranchName("HEAD@{yesterday}")).toThrow(/@{/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should reject the single character @", () => {
|
|
||||||
// Per git-check-ref-format, a refname cannot be the single character "@";
|
|
||||||
// "@" also resolves to HEAD in git revision syntax.
|
|
||||||
expect(() => validateBranchName("@")).toThrow(/single character '@'/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject .lock suffix", () => {
|
it("should reject .lock suffix", () => {
|
||||||
expect(() => validateBranchName("branch.lock")).toThrow(/\.lock/);
|
expect(() => validateBranchName("branch.lock")).toThrow(/\.lock/);
|
||||||
expect(() => validateBranchName("feature.lock")).toThrow(/\.lock/);
|
expect(() => validateBranchName("feature.lock")).toThrow(/\.lock/);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user