@expo/code-review-cli 0.9.2 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -60
- package/build/commands/ci.js +137 -26
- package/build/commands/init.js +18 -13
- package/build/core/adjudicate.js +1 -0
- package/build/core/auth.js +4 -1
- package/build/core/claude-code.js +315 -43
- package/build/core/coordinator.js +2 -1
- package/build/core/exec.js +10 -0
- package/build/core/opencode.js +8 -9
- package/build/core/render.js +10 -3
- package/build/core/review-cache.js +108 -0
- package/build/core/review.js +81 -13
- package/build/core/router.js +2 -1
- package/build/core/schema.js +78 -23
- package/build/core/stack-confirm.js +2 -1
- package/build/core/verify.js +1 -0
- package/build/reporters/github.js +3 -3
- package/package.json +1 -1
- package/templates/agents/security.md +3 -3
- package/templates/atlantis.yml +11 -5
- package/templates/command.yml +14 -7
- package/templates/config.jsonc +31 -39
- package/templates/coordinator.md +2 -2
- package/templates/shared.md +18 -3
- package/templates/workflow.yml +15 -7
package/README.md
CHANGED
|
@@ -48,21 +48,19 @@ npx @expo/code-review-cli setup-auth
|
|
|
48
48
|
npx @expo/code-review-cli doctor
|
|
49
49
|
```
|
|
50
50
|
|
|
51
|
-
`setup-auth` reads the repo's config and walks through each credential it needs
|
|
52
|
-
|
|
53
|
-
and
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
excludes) — see [the mixed setup](#other-providers) below. Prefer
|
|
65
|
-
**Anthropic/Claude** or another provider? Same section.
|
|
51
|
+
`setup-auth` reads the repo's config and walks through each credential it needs.
|
|
52
|
+
The scaffolded default is **Anthropic via the Claude Code CLI**: locally your
|
|
53
|
+
`claude` login is enough, and for CI it helps you mint a token with
|
|
54
|
+
`claude setup-token`. It also handles the alternatives (an OpenAI **API key**,
|
|
55
|
+
a **ChatGPT/Codex subscription** sign-in). `doctor` offers to run it whenever a
|
|
56
|
+
credential is missing.
|
|
57
|
+
|
|
58
|
+
In CI, store the credential as the repo secret the scaffolded workflow forwards
|
|
59
|
+
(`CLAUDE_CODE_REVIEW_SHARED_API_TOKEN` by default — an `sk-ant-oat…` token from
|
|
60
|
+
`claude setup-token`, or an `sk-ant-api…` Console key; the CLI reads either).
|
|
61
|
+
|
|
62
|
+
Prefer **OpenAI** (API key, or a ChatGPT/Codex subscription, or both mixed) or
|
|
63
|
+
another provider? See [Other providers & auth modes](#other-providers) below.
|
|
66
64
|
|
|
67
65
|
### Reviewing (already configured)
|
|
68
66
|
|
|
@@ -196,7 +194,7 @@ your-monorepo/
|
|
|
196
194
|
// Central guardrails every scope inherits and CANNOT override.
|
|
197
195
|
"defaults": {
|
|
198
196
|
// The ONLY place auth/tokenEnv is honored (besides the root config.jsonc).
|
|
199
|
-
"auth": { "
|
|
197
|
+
"auth": { "providers": { "anthropic": { "tokenEnv": "CLAUDE_CODE_REVIEW_SHARED_API_TOKEN" } } },
|
|
200
198
|
"enforceAgents": ["security"], // always runs on every scope, roster or not
|
|
201
199
|
"commentTag": "expo-ai-code-reviewer" // per-scope markers derive from this
|
|
202
200
|
},
|
|
@@ -214,7 +212,7 @@ your-monorepo/
|
|
|
214
212
|
// apps/api/.expo-code-review/config.jsonc (the api team owns this)
|
|
215
213
|
{
|
|
216
214
|
// NO "auth" block — locked centrally; a tokenEnv here is rejected by loader + CI guard.
|
|
217
|
-
"model": "
|
|
215
|
+
"model": "anthropic/claude-sonnet-5",
|
|
218
216
|
"policy": { "includeSuggestions": false },
|
|
219
217
|
"noise": { "additionalIgnores": ["apps/api/**/__generated__/**"] }
|
|
220
218
|
// shared.md, coordinator.md, agents/*.md live beside this file — the api team's roster.
|
|
@@ -316,6 +314,11 @@ Ownership is enforced with CODEOWNERS: `/.expo-code-review/routing.jsonc @your-i
|
|
|
316
314
|
- **Reporter** — posts/updates a single fingerprinted PR comment (CI), or prints
|
|
317
315
|
a grouped summary (local). Findings below the configured severity floor are
|
|
318
316
|
suppressed.
|
|
317
|
+
- **Whole-review reuse** — automated CI stores a hash of each review job's inputs
|
|
318
|
+
in the hidden state of that comment. If a restack leaves a scope's files and
|
|
319
|
+
review configuration unchanged, its complete prior result is reused. Manual
|
|
320
|
+
`/review`, partial/failed reviews, stack-aware review, and model-adjudicated
|
|
321
|
+
feedback always run fresh.
|
|
319
322
|
|
|
320
323
|
Built on the [OpenCode](https://opencode.ai) SDK, which spawns the model provider
|
|
321
324
|
and applies the provider's prompt caching automatically.
|
|
@@ -336,7 +339,18 @@ cache, in three places:
|
|
|
336
339
|
run, so the step summary is where past runs' comments remain readable.
|
|
337
340
|
- **`.expo-code-review/.runs/reviews.jsonl`** — one JSON line per run (uploaded as
|
|
338
341
|
a CI artifact) with the same totals plus per-pass `agentTokens`, the raw
|
|
339
|
-
per-agent findings, coverage notes, and what the verifier
|
|
342
|
+
per-agent findings, bounded reviewer traces, coverage notes, and what the verifier
|
|
343
|
+
dropped.
|
|
344
|
+
|
|
345
|
+
Each reviewer can also return a compact trace with up to three concrete checks and
|
|
346
|
+
two unresolved questions. The reporter stores it only inside the existing base64
|
|
347
|
+
`<!-- <commentTag>:state=… -->` comment marker as `review.reviewTrace`; it does not
|
|
348
|
+
render in the visible review. Agents and other machine consumers can decode that
|
|
349
|
+
state to see what a clean review covered. The payload declares
|
|
350
|
+
`trust: "unverified-model-diagnostics"`: it contains bounded conclusions, never a
|
|
351
|
+
raw transcript or chain-of-thought, and must not be treated as a verified finding.
|
|
352
|
+
The engine sorts agent ids and caps the complete decoded trace at 6 KB so this hidden
|
|
353
|
+
diagnostic cannot crowd visible findings out of GitHub's comment-size limit.
|
|
340
354
|
|
|
341
355
|
**How the caching works.** Provider prompt caching is a *prefix match*: the
|
|
342
356
|
provider caches the rendered prompt up to a point, and any byte change anywhere
|
|
@@ -387,7 +401,7 @@ show writes (there is nothing to re-read within the run).
|
|
|
387
401
|
---
|
|
388
402
|
description: One line the router uses to decide relevance.
|
|
389
403
|
alwaysRun: true # run even when the router would skip this agent
|
|
390
|
-
model:
|
|
404
|
+
model: anthropic/claude-opus-5 # override the default model
|
|
391
405
|
temperature: 0.1
|
|
392
406
|
---
|
|
393
407
|
|
|
@@ -396,14 +410,14 @@ temperature: 0.1
|
|
|
396
410
|
|
|
397
411
|
For a real-world example, see eas-cli's
|
|
398
412
|
[`.expo-code-review/`](https://github.com/expo/eas-cli/tree/main/.expo-code-review)
|
|
399
|
-
— correctness/security/consistency agents, a stronger model for security
|
|
400
|
-
|
|
413
|
+
— correctness/security/consistency agents, a stronger model for security, and
|
|
414
|
+
per-repo `noise.additionalIgnores`.
|
|
401
415
|
|
|
402
416
|
`config.jsonc` (JSONC — comments + trailing commas supported):
|
|
403
417
|
|
|
404
418
|
```jsonc
|
|
405
419
|
{
|
|
406
|
-
"model": "
|
|
420
|
+
"model": "anthropic/claude-sonnet-5", // default model for the specialists
|
|
407
421
|
"policy": { "includeSuggestions": false }, // suppress suggestion-severity findings
|
|
408
422
|
"chunk": { "maxChangedLines": 1000, "maxFiles": 20 }, // concurrency defaults: 6 (API key) / 3 (subscription)
|
|
409
423
|
"noise": { "additionalIgnores": ["packages/*/build/**"] },
|
|
@@ -412,8 +426,8 @@ coordinator, and per-repo `noise.additionalIgnores`.
|
|
|
412
426
|
"skipLabel": "ai-review:skip" }, // "label" (only labeled PRs)
|
|
413
427
|
"breakGlass": { "marker": "/skip-review" }, // PR body marker that skips the review
|
|
414
428
|
"commentTag": "expo-ai-code-reviewer", // hidden tag used to find/update the comment
|
|
415
|
-
"auth": { "
|
|
416
|
-
|
|
429
|
+
"auth": { "providers": {
|
|
430
|
+
"anthropic": { "tokenEnv": "CLAUDE_CODE_REVIEW_SHARED_API_TOKEN" } } }
|
|
417
431
|
}
|
|
418
432
|
```
|
|
419
433
|
|
|
@@ -427,15 +441,17 @@ Precedence: **`REVIEWER_MODEL` env** (global override) → per-file **frontmatte
|
|
|
427
441
|
setup, and a developer can override everything locally.
|
|
428
442
|
|
|
429
443
|
- **Specialist agents** (correctness/security/consistency) benefit from a
|
|
430
|
-
reasoning-tier model — **`
|
|
431
|
-
(the scaffolded default). The **
|
|
432
|
-
expensive, so scope it to the highest-stakes agent: **security runs on
|
|
433
|
-
`
|
|
444
|
+
reasoning-tier model — **`anthropic/claude-sonnet-5`** is the quality/speed
|
|
445
|
+
sweet spot (the scaffolded default). The **Opus tier** finds more but is slower
|
|
446
|
+
and more expensive, so scope it to the highest-stakes agent: **security runs on
|
|
447
|
+
`anthropic/claude-opus-5`** (set in `security.md` frontmatter), the rest on the
|
|
448
|
+
default.
|
|
434
449
|
- **The coordinator** makes the final call (dedupe / re-judge / decide) — worth a
|
|
435
|
-
strong model;
|
|
450
|
+
strong model; the scaffold pins it to `anthropic/claude-opus-5` in
|
|
451
|
+
`coordinator.md` frontmatter.
|
|
436
452
|
- If latency/timeouts dominate on big PRs, moving the specialists to a faster model
|
|
437
|
-
(e.g. `
|
|
438
|
-
measure it).
|
|
453
|
+
(e.g. `anthropic/claude-haiku-4-5`) is the most direct lever (a real recall
|
|
454
|
+
tradeoff — measure it).
|
|
439
455
|
- **Every run logs which model actually answered each pass** — in the job log
|
|
440
456
|
(`Models used — …`), the Actions step summary table, and the run log's
|
|
441
457
|
`agentModels` — and warns loudly if a pass ran on a different model than
|
|
@@ -640,9 +656,10 @@ variables: `ATLANTIS_BOT_LOGIN` (the Atlantis bot's comment login, e.g.
|
|
|
640
656
|
Each run appends a JSON line to `.expo-code-review/.runs/reviews.jsonl` with the
|
|
641
657
|
inputs, decision, finding count, duration, per-agent cost, and aggregate token
|
|
642
658
|
usage (incl. prompt-cache read/write counts) — for auditing and measuring
|
|
643
|
-
cost/latency/cache reuse over time.
|
|
644
|
-
|
|
645
|
-
|
|
659
|
+
cost/latency/cache reuse over time. It also records the same bounded `reviewTrace`
|
|
660
|
+
that the PR comment embeds for machine consumers. The same totals are printed as a
|
|
661
|
+
one-line summary to the terminal / CI job log at the end of each run, so cache reuse
|
|
662
|
+
is visible even in CI (where the run log is ephemeral).
|
|
646
663
|
|
|
647
664
|
</details>
|
|
648
665
|
|
|
@@ -650,11 +667,35 @@ visible even in CI (where the run log is ephemeral).
|
|
|
650
667
|
<details>
|
|
651
668
|
<summary><b>Other providers & auth modes</b></summary>
|
|
652
669
|
|
|
653
|
-
The
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
670
|
+
The scaffolded default is **Anthropic via the Claude Code CLI** — see First-time
|
|
671
|
+
setup above and the Anthropic bullet below for how it authenticates and is
|
|
672
|
+
sandboxed. Everything is set in `config.auth`; non-anthropic providers get their
|
|
673
|
+
credentials through OpenCode.
|
|
674
|
+
|
|
675
|
+
- **Anthropic / Claude (the default)** — use `anthropic/...` model ids and every
|
|
676
|
+
anthropic pass runs through the **Claude Code CLI** (`claude -p
|
|
677
|
+
--output-format stream-json --verbose`), inferred from the model. The
|
|
678
|
+
credential is (in order) a `tokenEnv` you name, an ambient
|
|
679
|
+
`CLAUDE_CODE_OAUTH_TOKEN`, or your local
|
|
680
|
+
`claude` login — an `auth` entry is entirely optional. Run `claude setup-token`
|
|
681
|
+
for a Max/Team subscription token or point `tokenEnv` at an Anthropic Console
|
|
682
|
+
API key (`sk-ant-api…`, forwarded as `ANTHROPIC_API_KEY`); the CLI reads
|
|
683
|
+
either. `ecr setup-auth` walks you through it. Each pass is trust-isolated and
|
|
684
|
+
read-only: it runs with `--safe-mode` (no `CLAUDE.md`/hooks/MCP/plugins),
|
|
685
|
+
`--strict-mcp-config`, `--permission-mode dontAsk`, and only the
|
|
686
|
+
`Read`/`Grep`/`Glob` tools — never `Bash`/`Edit`/`Write`/`WebFetch`/`WebSearch`.
|
|
687
|
+
The child env is an allowlist that omits ambient `ANTHROPIC_API_KEY`/`ANTHROPIC_AUTH_TOKEN`
|
|
688
|
+
(only the configured credential is re-injected).
|
|
689
|
+
```jsonc
|
|
690
|
+
// The scaffolded default. No anthropic entry at all falls back to your
|
|
691
|
+
// `claude` login.
|
|
692
|
+
"auth": { "providers": {
|
|
693
|
+
"anthropic": { "tokenEnv": "CLAUDE_CODE_REVIEW_SHARED_API_TOKEN" }
|
|
694
|
+
} }
|
|
695
|
+
```
|
|
696
|
+
- **OpenAI: ChatGPT/Codex subscription (OAuth) + usage-based API key — the
|
|
697
|
+
recommended mix if you review with OpenAI.** OpenAI permits subscription auth
|
|
698
|
+
in third-party tools, and OpenCode
|
|
658
699
|
ships the plugin for it — so the reviewer runs its default models on the
|
|
659
700
|
subscription (zero marginal cost) and reserves the metered key for pro-tier
|
|
660
701
|
models the subscription doesn't offer (`gpt-5.5-pro` is subscription-excluded).
|
|
@@ -693,25 +734,6 @@ set in `config.auth` (credentials come from OpenCode):
|
|
|
693
734
|
One caveat: OpenCode can't price alias models (they're config-declared), so
|
|
694
735
|
pro passes report `$0` in the run log's cost column — token counts are
|
|
695
736
|
correct, and the OpenAI project dashboard is the source of truth for spend.
|
|
696
|
-
- **Anthropic / Claude** — use `anthropic/...` model ids and every anthropic pass
|
|
697
|
-
runs through the **Claude Code CLI** (`claude -p --output-format json`), inferred
|
|
698
|
-
from the model. The credential is (in order) a `tokenEnv` you name, an ambient
|
|
699
|
-
`CLAUDE_CODE_OAUTH_TOKEN`, or your local `claude` login — an `auth` entry is
|
|
700
|
-
entirely optional. Run `claude setup-token` for a Max/Team subscription token
|
|
701
|
-
(forwarded as `CLAUDE_CODE_OAUTH_TOKEN`) or point `tokenEnv` at an Anthropic
|
|
702
|
-
Console API key (`sk-ant-api…`, forwarded as `ANTHROPIC_API_KEY`); the CLI reads
|
|
703
|
-
either. `ecr setup-auth` walks you through it. Each pass is trust-isolated and
|
|
704
|
-
read-only: it runs with `--safe-mode` (no `CLAUDE.md`/hooks/MCP/plugins),
|
|
705
|
-
`--strict-mcp-config`, `--permission-mode dontAsk`, and only the
|
|
706
|
-
`Read`/`Grep`/`Glob` tools — never `Bash`/`Edit`/`Write`/`WebFetch`/`WebSearch`.
|
|
707
|
-
The child env is an allowlist that omits ambient `ANTHROPIC_API_KEY`/`ANTHROPIC_AUTH_TOKEN`
|
|
708
|
-
(only the configured credential is re-injected).
|
|
709
|
-
```jsonc
|
|
710
|
-
// Optional — no anthropic entry at all falls back to your `claude` login.
|
|
711
|
-
"auth": { "providers": {
|
|
712
|
-
"anthropic": { "tokenEnv": "CLAUDE_CODE_OAUTH_TOKEN" }
|
|
713
|
-
} }
|
|
714
|
-
```
|
|
715
737
|
- **Another provider** — the current path is the `REVIEWER_MODEL`
|
|
716
738
|
env override: `opencode auth login` once (pick the provider), then run with
|
|
717
739
|
e.g. `REVIEWER_MODEL=google/gemini-3-pro`. It overrides every agent's model
|
|
@@ -724,8 +746,9 @@ any other) OpenCode provider, and each agent's `model` selects its engine.
|
|
|
724
746
|
`REVIEWER_MODEL` still overrides every agent's model (and therefore every agent's
|
|
725
747
|
engine), converging the whole run onto one engine.
|
|
726
748
|
|
|
727
|
-
There is no shared fallback key; if a run fails for lack of credentials,
|
|
728
|
-
a provider in OpenCode. `ecr doctor`
|
|
749
|
+
There is no shared fallback key; if a run fails for lack of credentials, log in
|
|
750
|
+
with `claude` (the default) or authenticate a provider in OpenCode. `ecr doctor`
|
|
751
|
+
diagnoses setup.
|
|
729
752
|
|
|
730
753
|
**Setup errors fail fast, with the fix in the message.** A bad credential or model id
|
|
731
754
|
would otherwise fail every pass identically — a run that spends its whole budget
|
package/build/commands/ci.js
CHANGED
|
@@ -10,6 +10,7 @@ import { buildDiffLineIndex } from "../core/render.js";
|
|
|
10
10
|
import { applyPins, collectPins, scopedFingerprint } from "../core/schema.js";
|
|
11
11
|
import { dropStaleVerdict, feedbackApplied, feedbackNeedsRunSeam } from "../core/adjudicate.js";
|
|
12
12
|
import { runReview } from "../core/review.js";
|
|
13
|
+
import { reviewCanBeReused, reviewInputHash, reviewMatchesInput } from "../core/review-cache.js";
|
|
13
14
|
import { GitHubPRSource } from "../sources/github-pr.js";
|
|
14
15
|
import { memoizeSource, stackConfirmFromConfig, stackWalkFromConfig } from "../sources/source.js";
|
|
15
16
|
import { GitHubReporter } from "../reporters/github.js";
|
|
@@ -428,9 +429,11 @@ async function runLegacyCi(source, repo, prNumber, cwd, configRoot, options) {
|
|
|
428
429
|
// unchanged (see mergeFeedback). Memoized on the source — no extra API call. A
|
|
429
430
|
// metadata failure leaves it unknown, which re-judges replies instead of trusting a
|
|
430
431
|
// verdict against source we cannot pin.
|
|
432
|
+
let metadata;
|
|
431
433
|
let headSha;
|
|
432
434
|
try {
|
|
433
|
-
|
|
435
|
+
metadata = await source.getMetadata();
|
|
436
|
+
headSha = metadata.headOid;
|
|
434
437
|
}
|
|
435
438
|
catch {
|
|
436
439
|
// leave headSha unset → stored verdicts do not carry (fail safe, costs budget only)
|
|
@@ -454,24 +457,67 @@ async function runLegacyCi(source, repo, prNumber, cwd, configRoot, options) {
|
|
|
454
457
|
catch (error) {
|
|
455
458
|
process.stderr.write(`CI reviewer: break-glass check failed (continuing): ${errorMessage(error)}\n`);
|
|
456
459
|
}
|
|
460
|
+
const stack = resolveStackWalk(config.stack, noStackAware);
|
|
461
|
+
const stackConfirm = resolveStackConfirm(config.stack, noStackAware);
|
|
462
|
+
const feedback = adjudicationSeam(config, reporter);
|
|
463
|
+
// Dynamic stack context and model-backed reply adjudication have inputs outside
|
|
464
|
+
// the scoped diff. Keep those paths fresh until their inputs join the cache key.
|
|
465
|
+
// A maintainer's explicit /review is also always a real rerun.
|
|
466
|
+
const cacheAllowed = !bypassTriggerGate && !stack && !feedback && metadata !== undefined;
|
|
467
|
+
let inputHash;
|
|
457
468
|
try {
|
|
469
|
+
if (cacheAllowed) {
|
|
470
|
+
try {
|
|
471
|
+
const prepared = await source.prepareReadRootAsync?.();
|
|
472
|
+
try {
|
|
473
|
+
inputHash = await reviewInputHash({
|
|
474
|
+
files: await source.getChangedFiles(),
|
|
475
|
+
config,
|
|
476
|
+
metadata: metadata,
|
|
477
|
+
readRoot: prepared?.dir ?? cwd,
|
|
478
|
+
agents,
|
|
479
|
+
route,
|
|
480
|
+
contextText,
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
finally {
|
|
484
|
+
await prepared?.cleanup();
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
catch (error) {
|
|
488
|
+
process.stderr.write(`CI reviewer: could not hash the review input (continuing fresh): ${errorMessage(error)}\n`);
|
|
489
|
+
}
|
|
490
|
+
if (inputHash) {
|
|
491
|
+
try {
|
|
492
|
+
const prior = await reporter.readState();
|
|
493
|
+
if (prior && reviewMatchesInput(prior.review, prior.inputHash, inputHash)) {
|
|
494
|
+
await reporter.report(prior.review, undefined, inputHash);
|
|
495
|
+
process.stderr.write("CI reviewer: unchanged review input; reused the previous result.\n");
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
catch (error) {
|
|
500
|
+
process.stderr.write(`CI reviewer: could not read the previous review cache (continuing fresh): ${errorMessage(error)}\n`);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
458
504
|
const review = await runReview(source, {
|
|
459
505
|
config,
|
|
460
506
|
mode: "ci",
|
|
461
507
|
agents,
|
|
462
508
|
route,
|
|
463
509
|
contextText,
|
|
464
|
-
stack
|
|
465
|
-
stackConfirm
|
|
510
|
+
stack,
|
|
511
|
+
stackConfirm,
|
|
466
512
|
runsDir: workspaceRunsDir(cwd),
|
|
467
513
|
// Adjudicate mode judges the matched replies against the source before the
|
|
468
514
|
// comment is rendered; annotate mode lets the reporter match them at report
|
|
469
515
|
// time. Either way the feedback path is fail-open (runReview swallows its own
|
|
470
516
|
// errors), so it never fails the PR's checks.
|
|
471
|
-
feedback
|
|
517
|
+
feedback,
|
|
472
518
|
onProgress: (message) => process.stderr.write(`${message}\n`),
|
|
473
519
|
});
|
|
474
|
-
await reporter.report(review, review.feedback);
|
|
520
|
+
await reporter.report(review, review.feedback, inputHash && reviewCanBeReused(review) ? inputHash : undefined);
|
|
475
521
|
process.stderr.write(`CI reviewer: posted review (${review.decision}).\n`);
|
|
476
522
|
}
|
|
477
523
|
catch (error) {
|
|
@@ -600,13 +646,14 @@ async function runRoutedCi(source, manifest, repo, prNumber, cwd, configRoot, op
|
|
|
600
646
|
};
|
|
601
647
|
// The reviewed head OID also binds each adjudication verdict to the source it judged
|
|
602
648
|
// (see mergeFeedback); unresolved leaves it unknown, so verdicts are re-judged.
|
|
649
|
+
let metadata;
|
|
603
650
|
let headSha;
|
|
604
651
|
try {
|
|
605
|
-
|
|
606
|
-
if (baseOid) {
|
|
607
|
-
link.baseSha = baseOid;
|
|
652
|
+
metadata = await source.getMetadata();
|
|
653
|
+
if (metadata.baseOid) {
|
|
654
|
+
link.baseSha = metadata.baseOid;
|
|
608
655
|
}
|
|
609
|
-
headSha = headOid;
|
|
656
|
+
headSha = metadata.headOid;
|
|
610
657
|
}
|
|
611
658
|
catch {
|
|
612
659
|
// leave baseSha unset → out-of-diff findings degrade to plain text
|
|
@@ -651,11 +698,38 @@ async function runRoutedCi(source, manifest, repo, prNumber, cwd, configRoot, op
|
|
|
651
698
|
// login. `withLink` is inert for the seam (matchAdjudicationItems renders nothing), so
|
|
652
699
|
// one link-carrying reporter serves both uses.
|
|
653
700
|
const scopeReporter = memoizeByScope((name) => reporterFor(scopedCommentTag(rootTag, name), true));
|
|
701
|
+
const cacheAllowed = !bypassTriggerGate &&
|
|
702
|
+
!stackWalk &&
|
|
703
|
+
!feedbackNeedsRunSeam(rootConfig.feedback) &&
|
|
704
|
+
metadata !== undefined;
|
|
705
|
+
let cacheReadRoot;
|
|
706
|
+
if (cacheAllowed) {
|
|
707
|
+
try {
|
|
708
|
+
const prepared = await source.prepareReadRootAsync?.();
|
|
709
|
+
cacheReadRoot = prepared?.dir ?? cwd;
|
|
710
|
+
await prepared?.cleanup();
|
|
711
|
+
}
|
|
712
|
+
catch (error) {
|
|
713
|
+
// The fresh run will retry the same materialization and apply CI's ordinary
|
|
714
|
+
// fail-closed behavior. Cache setup itself never changes that outcome.
|
|
715
|
+
process.stderr.write(`CI reviewer: could not prepare the review cache input (continuing fresh): ${errorMessage(error)}\n`);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
let priorAggregateState = null;
|
|
719
|
+
if (cacheReadRoot && mode === "single") {
|
|
720
|
+
try {
|
|
721
|
+
priorAggregateState = await singleModeReporter.readState();
|
|
722
|
+
}
|
|
723
|
+
catch (error) {
|
|
724
|
+
process.stderr.write(`CI reviewer: could not read the previous aggregate cache (continuing fresh): ${errorMessage(error)}\n`);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
654
727
|
const results = [];
|
|
655
728
|
for (const scope of active) {
|
|
656
729
|
const scopeDef = manifest.scopes.find((entry) => entry.name === scope.name);
|
|
657
730
|
const isDefault = scope.configDir === ".";
|
|
658
731
|
let review;
|
|
732
|
+
let inputHash;
|
|
659
733
|
try {
|
|
660
734
|
// A scope whose config dir doesn't exist at the TRUSTED base commit is a
|
|
661
735
|
// scope this PR introduces: review it with the root config rather than
|
|
@@ -679,26 +753,63 @@ async function runRoutedCi(source, manifest, repo, prNumber, cwd, configRoot, op
|
|
|
679
753
|
? (finding) => scopedFingerprint(isDefault ? null : scope.name, finding)
|
|
680
754
|
: undefined)
|
|
681
755
|
: undefined;
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
756
|
+
let cached;
|
|
757
|
+
if (cacheReadRoot) {
|
|
758
|
+
try {
|
|
759
|
+
const included = new Set(scope.files);
|
|
760
|
+
inputHash = await reviewInputHash({
|
|
761
|
+
files: changed.filter((file) => included.has(file.path)),
|
|
762
|
+
config,
|
|
763
|
+
metadata: metadata,
|
|
764
|
+
readRoot: cacheReadRoot,
|
|
765
|
+
agents,
|
|
766
|
+
route,
|
|
767
|
+
contextText,
|
|
768
|
+
});
|
|
769
|
+
if (mode === "single") {
|
|
770
|
+
cached = priorAggregateState?.scopes?.find((entry) => entry.scope === scope.name);
|
|
771
|
+
}
|
|
772
|
+
else {
|
|
773
|
+
cached = (await scopeReporter(scope.name).readState()) ?? undefined;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
catch (error) {
|
|
777
|
+
inputHash = undefined;
|
|
778
|
+
process.stderr.write(`CI reviewer: [${scope.name}] could not use the previous cache (continuing fresh): ${errorMessage(error)}\n`);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
if (cached && reviewMatchesInput(cached.review, cached.inputHash, inputHash)) {
|
|
782
|
+
review = cached.review;
|
|
783
|
+
process.stderr.write(`CI reviewer: [${scope.name}] unchanged review input; reused the previous result.\n`);
|
|
784
|
+
}
|
|
785
|
+
else {
|
|
786
|
+
review = await runReview(source, {
|
|
787
|
+
config,
|
|
788
|
+
mode: "ci",
|
|
789
|
+
agents,
|
|
790
|
+
route,
|
|
791
|
+
includePaths: scope.files,
|
|
792
|
+
contextText,
|
|
793
|
+
stack: stackWalk,
|
|
794
|
+
stackConfirm,
|
|
795
|
+
passesBudgetMs: budget,
|
|
796
|
+
runsDir: workspaceRunsDir(cwd),
|
|
797
|
+
feedback: feedbackSeam,
|
|
798
|
+
onProgress: (message) => process.stderr.write(`[${scope.name}] ${message}\n`),
|
|
799
|
+
});
|
|
800
|
+
}
|
|
696
801
|
}
|
|
697
802
|
catch (error) {
|
|
698
803
|
process.stderr.write(`CI reviewer: [${scope.name}] failed (non-blocking): ${errorMessage(error)}\n`);
|
|
699
804
|
review = failureReview(scope.name, publicFailureReason(error));
|
|
805
|
+
inputHash = undefined;
|
|
700
806
|
}
|
|
701
|
-
results.push({
|
|
807
|
+
results.push({
|
|
808
|
+
scope: scope.name,
|
|
809
|
+
isDefault,
|
|
810
|
+
review,
|
|
811
|
+
...(inputHash && reviewCanBeReused(review) ? { inputHash } : {}),
|
|
812
|
+
});
|
|
702
813
|
}
|
|
703
814
|
if (mode === "single") {
|
|
704
815
|
const aggregate = singleModeReporter;
|
|
@@ -707,7 +818,7 @@ async function runRoutedCi(source, manifest, repo, prNumber, cwd, configRoot, op
|
|
|
707
818
|
// A partial run (--scopes) is authoritative ONLY for the named scopes: merge
|
|
708
819
|
// the other scopes' previous results out of the existing aggregate comment's
|
|
709
820
|
// state so re-running one scope doesn't silently discard the rest.
|
|
710
|
-
const prior = (await aggregate.readState())?.scopes ?? [];
|
|
821
|
+
const prior = (priorAggregateState ?? (await aggregate.readState()))?.scopes ?? [];
|
|
711
822
|
finalResults = mergePartialAggregate(results, prior, scopesFilter, manifest.scopes.map((scope) => scope.name));
|
|
712
823
|
}
|
|
713
824
|
if (finalResults.length === 0) {
|
|
@@ -754,7 +865,7 @@ async function runRoutedCi(source, manifest, repo, prNumber, cwd, configRoot, op
|
|
|
754
865
|
const reporter = scopeReporter(scope.name);
|
|
755
866
|
const result = results.find((entry) => entry.scope === scope.name);
|
|
756
867
|
if (result) {
|
|
757
|
-
await reporter.report(result.review, result.review.feedback);
|
|
868
|
+
await reporter.report(result.review, result.review.feedback, result.inputHash);
|
|
758
869
|
}
|
|
759
870
|
else {
|
|
760
871
|
// A reconciled scope with zero matched files gets its stale comment deleted.
|
package/build/commands/init.js
CHANGED
|
@@ -24,9 +24,10 @@ Options:
|
|
|
24
24
|
--no-workflow Skip writing the CI workflows (review, command, and dismiss
|
|
25
25
|
under .github/workflows/)
|
|
26
26
|
--token-env <name[,name…]>
|
|
27
|
-
Env var(s) holding the model credential (default
|
|
28
|
-
e.g.
|
|
29
|
-
the matching repo secret(s) and
|
|
27
|
+
Env var(s) holding the model credential (default
|
|
28
|
+
CLAUDE_CODE_REVIEW_SHARED_API_TOKEN, e.g. OPENAI_API_KEY). The
|
|
29
|
+
scaffolded workflows forward the matching repo secret(s) and
|
|
30
|
+
expect this tokenEnv
|
|
30
31
|
--force Overwrite existing files
|
|
31
32
|
--force-workflows
|
|
32
33
|
Overwrite only the CI workflow files, keeping your customized
|
|
@@ -34,7 +35,7 @@ Options:
|
|
|
34
35
|
--token-env on an already-scaffolded repo.
|
|
35
36
|
-h, --help Show this help
|
|
36
37
|
`;
|
|
37
|
-
const DEFAULT_TOKEN_ENV = "
|
|
38
|
+
const DEFAULT_TOKEN_ENV = "CLAUDE_CODE_REVIEW_SHARED_API_TOKEN";
|
|
38
39
|
export async function initCommand(argv) {
|
|
39
40
|
if (argv.includes("-h") || argv.includes("--help")) {
|
|
40
41
|
process.stdout.write(USAGE);
|
|
@@ -96,7 +97,7 @@ async function scaffold(argv) {
|
|
|
96
97
|
}
|
|
97
98
|
// The reverse of the guard above: --force-workflows (or --force) always rewrites
|
|
98
99
|
// the review workflows from the pristine template, which forwards the default
|
|
99
|
-
//
|
|
100
|
+
// Anthropic token env unless --token-env names the credential again. An adopter who
|
|
100
101
|
// scaffolded with a non-default tokenEnv and later refreshes the workflow YAML
|
|
101
102
|
// without re-passing --token-env would silently lose the forwarded secret, and
|
|
102
103
|
// CI would keep passing the auth lock but run with an empty credential. Refuse
|
|
@@ -109,7 +110,7 @@ async function scaffold(argv) {
|
|
|
109
110
|
`${baked}, but this run has no --token-env, so rewriting them would restore the ` +
|
|
110
111
|
`default ${DEFAULT_TOKEN_ENV} and CI would run with an empty credential. Re-run with ` +
|
|
111
112
|
`--token-env ${baked} to keep the current credential, or --token-env ${DEFAULT_TOKEN_ENV} ` +
|
|
112
|
-
`to reset to
|
|
113
|
+
`to reset to the default on purpose.`);
|
|
113
114
|
}
|
|
114
115
|
}
|
|
115
116
|
// Create only the config dir; let copyInto create prompts/ so it reports
|
|
@@ -149,14 +150,15 @@ async function scaffold(argv) {
|
|
|
149
150
|
const steps = [
|
|
150
151
|
`Customize ${CONFIG_DIRNAME}/agents/*.md (and shared.md, coordinator.md) for this repo.`,
|
|
151
152
|
// --token-env only rewires the workflows; the scaffolded config.jsonc still
|
|
152
|
-
// declares
|
|
153
|
-
// until the config's tokenEnv set matches the workflow's
|
|
153
|
+
// declares the default Anthropic tokenEnv, and CI's `ecr verify-config`
|
|
154
|
+
// refuses to review until the config's tokenEnv set matches the workflow's
|
|
155
|
+
// expected set.
|
|
154
156
|
...(tokenEnvs.join(",") !== DEFAULT_TOKEN_ENV
|
|
155
157
|
? [
|
|
156
158
|
`Point ${CONFIG_DIRNAME}/config.jsonc at ${tokenEnvs.length > 1 ? "these credentials" : "this credential"}: set \`auth\` (and \`model\`) per the file's comments — CI's \`ecr verify-config\` refuses to review until the config names ${names}.`,
|
|
157
159
|
]
|
|
158
160
|
: []),
|
|
159
|
-
"
|
|
161
|
+
"Log in with `claude` (or run `ecr setup-auth`) so local runs have a credential.",
|
|
160
162
|
"Run `ecr doctor`, then `ecr review`.",
|
|
161
163
|
withWorkflow
|
|
162
164
|
? `Add the ${names} repo secret${tokenEnvs.length > 1 ? "s" : ""} referenced by the workflow, then add an \`ai-review\` label to a PR.`
|
|
@@ -491,11 +493,14 @@ export function substituteTokenEnv(raw, tokenEnvs) {
|
|
|
491
493
|
return raw;
|
|
492
494
|
}
|
|
493
495
|
const expectedFallback = `vars.ECR_EXPECTED_TOKEN_ENV || '${DEFAULT_TOKEN_ENV}'`;
|
|
496
|
+
// Byte-identical to the credential block in templates/workflow.yml AND
|
|
497
|
+
// templates/command.yml (both flow through this function).
|
|
494
498
|
const credentialBlock = [
|
|
495
|
-
" #
|
|
496
|
-
" # Store it as a repo secret
|
|
497
|
-
" #
|
|
498
|
-
"
|
|
499
|
+
" # Anthropic review credential — the env var named by auth.tokenEnv in",
|
|
500
|
+
" # config.jsonc. Store it as a repo secret: an `sk-ant-oat…` token minted",
|
|
501
|
+
" # by `claude setup-token`, or an `sk-ant-api…` Console key (the Claude",
|
|
502
|
+
" # Code CLI reads either).",
|
|
503
|
+
" CLAUDE_CODE_REVIEW_SHARED_API_TOKEN: ${{ secrets.CLAUDE_CODE_REVIEW_SHARED_API_TOKEN }}",
|
|
499
504
|
].join("\n");
|
|
500
505
|
if (!raw.includes(expectedFallback) || !raw.includes(credentialBlock)) {
|
|
501
506
|
throw new Error("workflow template drifted: tokenEnv markers not found (report this bug)");
|
package/build/core/adjudicate.js
CHANGED
|
@@ -166,6 +166,7 @@ export async function adjudicateFeedback(handle, items, config, debug = () => {
|
|
|
166
166
|
title: `adjudicate-${index}`,
|
|
167
167
|
maxWaitMs: ADJUDICATE_TIMEOUT_MS,
|
|
168
168
|
finalizeOnTimeout: true,
|
|
169
|
+
onActivity: (line) => debug(` [adjudicator] #${index + 1}: ${line}`),
|
|
169
170
|
}, parseAdjudication);
|
|
170
171
|
cost += callCost;
|
|
171
172
|
addTokenUsage(tokens, callTokens);
|
package/build/core/auth.js
CHANGED
|
@@ -13,7 +13,9 @@ const PROVIDER_KEY_ENV = {
|
|
|
13
13
|
* Provider-owned credential env vars BEYOND the x-api-key ones above: Anthropic's
|
|
14
14
|
* OAuth/subscription bearer envs. CLAUDE_CODE_OAUTH_TOKEN holds the long-lived (1-year)
|
|
15
15
|
* Claude Max/Team subscription token that `ecr setup-auth`/`claude setup-token` export,
|
|
16
|
-
*
|
|
16
|
+
* ANTHROPIC_AUTH_TOKEN is Anthropic's documented bearer var, and
|
|
17
|
+
* CLAUDE_CODE_REVIEW_SHARED_API_TOKEN is the SCAFFOLDED DEFAULT tokenEnv (see
|
|
18
|
+
* templates/config.jsonc) — it always holds an Anthropic credential. They belong to
|
|
17
19
|
* anthropic, so the cross-provider guard below refuses a non-anthropic entry that names
|
|
18
20
|
* one — without this, `{provider:"openai", tokenEnv:"CLAUDE_CODE_OAUTH_TOKEN"}` passes
|
|
19
21
|
* (neither a FORBIDDEN secret nor a PROVIDER_KEY_ENV value) and prepareAuth forwards the
|
|
@@ -23,6 +25,7 @@ const PROVIDER_KEY_ENV = {
|
|
|
23
25
|
const ANTHROPIC_TOKEN_ENVS = {
|
|
24
26
|
CLAUDE_CODE_OAUTH_TOKEN: "anthropic",
|
|
25
27
|
ANTHROPIC_AUTH_TOKEN: "anthropic",
|
|
28
|
+
CLAUDE_CODE_REVIEW_SHARED_API_TOKEN: "anthropic",
|
|
26
29
|
};
|
|
27
30
|
/**
|
|
28
31
|
* Env vars that must NEVER be forwarded to a model provider. `auth.tokenEnv` names
|