@expo/code-review-cli 0.9.2 → 0.10.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 +67 -56
- 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 +18 -11
- package/build/core/router.js +2 -1
- package/build/core/schema.js +23 -21
- 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/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.
|
|
@@ -387,7 +390,7 @@ show writes (there is nothing to re-read within the run).
|
|
|
387
390
|
---
|
|
388
391
|
description: One line the router uses to decide relevance.
|
|
389
392
|
alwaysRun: true # run even when the router would skip this agent
|
|
390
|
-
model:
|
|
393
|
+
model: anthropic/claude-opus-5 # override the default model
|
|
391
394
|
temperature: 0.1
|
|
392
395
|
---
|
|
393
396
|
|
|
@@ -396,14 +399,14 @@ temperature: 0.1
|
|
|
396
399
|
|
|
397
400
|
For a real-world example, see eas-cli's
|
|
398
401
|
[`.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
|
-
|
|
402
|
+
— correctness/security/consistency agents, a stronger model for security, and
|
|
403
|
+
per-repo `noise.additionalIgnores`.
|
|
401
404
|
|
|
402
405
|
`config.jsonc` (JSONC — comments + trailing commas supported):
|
|
403
406
|
|
|
404
407
|
```jsonc
|
|
405
408
|
{
|
|
406
|
-
"model": "
|
|
409
|
+
"model": "anthropic/claude-sonnet-5", // default model for the specialists
|
|
407
410
|
"policy": { "includeSuggestions": false }, // suppress suggestion-severity findings
|
|
408
411
|
"chunk": { "maxChangedLines": 1000, "maxFiles": 20 }, // concurrency defaults: 6 (API key) / 3 (subscription)
|
|
409
412
|
"noise": { "additionalIgnores": ["packages/*/build/**"] },
|
|
@@ -412,8 +415,8 @@ coordinator, and per-repo `noise.additionalIgnores`.
|
|
|
412
415
|
"skipLabel": "ai-review:skip" }, // "label" (only labeled PRs)
|
|
413
416
|
"breakGlass": { "marker": "/skip-review" }, // PR body marker that skips the review
|
|
414
417
|
"commentTag": "expo-ai-code-reviewer", // hidden tag used to find/update the comment
|
|
415
|
-
"auth": { "
|
|
416
|
-
|
|
418
|
+
"auth": { "providers": {
|
|
419
|
+
"anthropic": { "tokenEnv": "CLAUDE_CODE_REVIEW_SHARED_API_TOKEN" } } }
|
|
417
420
|
}
|
|
418
421
|
```
|
|
419
422
|
|
|
@@ -427,15 +430,17 @@ Precedence: **`REVIEWER_MODEL` env** (global override) → per-file **frontmatte
|
|
|
427
430
|
setup, and a developer can override everything locally.
|
|
428
431
|
|
|
429
432
|
- **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
|
-
`
|
|
433
|
+
reasoning-tier model — **`anthropic/claude-sonnet-5`** is the quality/speed
|
|
434
|
+
sweet spot (the scaffolded default). The **Opus tier** finds more but is slower
|
|
435
|
+
and more expensive, so scope it to the highest-stakes agent: **security runs on
|
|
436
|
+
`anthropic/claude-opus-5`** (set in `security.md` frontmatter), the rest on the
|
|
437
|
+
default.
|
|
434
438
|
- **The coordinator** makes the final call (dedupe / re-judge / decide) — worth a
|
|
435
|
-
strong model;
|
|
439
|
+
strong model; the scaffold pins it to `anthropic/claude-opus-5` in
|
|
440
|
+
`coordinator.md` frontmatter.
|
|
436
441
|
- If latency/timeouts dominate on big PRs, moving the specialists to a faster model
|
|
437
|
-
(e.g. `
|
|
438
|
-
measure it).
|
|
442
|
+
(e.g. `anthropic/claude-haiku-4-5`) is the most direct lever (a real recall
|
|
443
|
+
tradeoff — measure it).
|
|
439
444
|
- **Every run logs which model actually answered each pass** — in the job log
|
|
440
445
|
(`Models used — …`), the Actions step summary table, and the run log's
|
|
441
446
|
`agentModels` — and warns loudly if a pass ran on a different model than
|
|
@@ -650,11 +655,35 @@ visible even in CI (where the run log is ephemeral).
|
|
|
650
655
|
<details>
|
|
651
656
|
<summary><b>Other providers & auth modes</b></summary>
|
|
652
657
|
|
|
653
|
-
The
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
+
The scaffolded default is **Anthropic via the Claude Code CLI** — see First-time
|
|
659
|
+
setup above and the Anthropic bullet below for how it authenticates and is
|
|
660
|
+
sandboxed. Everything is set in `config.auth`; non-anthropic providers get their
|
|
661
|
+
credentials through OpenCode.
|
|
662
|
+
|
|
663
|
+
- **Anthropic / Claude (the default)** — use `anthropic/...` model ids and every
|
|
664
|
+
anthropic pass runs through the **Claude Code CLI** (`claude -p
|
|
665
|
+
--output-format stream-json --verbose`), inferred from the model. The
|
|
666
|
+
credential is (in order) a `tokenEnv` you name, an ambient
|
|
667
|
+
`CLAUDE_CODE_OAUTH_TOKEN`, or your local
|
|
668
|
+
`claude` login — an `auth` entry is entirely optional. Run `claude setup-token`
|
|
669
|
+
for a Max/Team subscription token or point `tokenEnv` at an Anthropic Console
|
|
670
|
+
API key (`sk-ant-api…`, forwarded as `ANTHROPIC_API_KEY`); the CLI reads
|
|
671
|
+
either. `ecr setup-auth` walks you through it. Each pass is trust-isolated and
|
|
672
|
+
read-only: it runs with `--safe-mode` (no `CLAUDE.md`/hooks/MCP/plugins),
|
|
673
|
+
`--strict-mcp-config`, `--permission-mode dontAsk`, and only the
|
|
674
|
+
`Read`/`Grep`/`Glob` tools — never `Bash`/`Edit`/`Write`/`WebFetch`/`WebSearch`.
|
|
675
|
+
The child env is an allowlist that omits ambient `ANTHROPIC_API_KEY`/`ANTHROPIC_AUTH_TOKEN`
|
|
676
|
+
(only the configured credential is re-injected).
|
|
677
|
+
```jsonc
|
|
678
|
+
// The scaffolded default. No anthropic entry at all falls back to your
|
|
679
|
+
// `claude` login.
|
|
680
|
+
"auth": { "providers": {
|
|
681
|
+
"anthropic": { "tokenEnv": "CLAUDE_CODE_REVIEW_SHARED_API_TOKEN" }
|
|
682
|
+
} }
|
|
683
|
+
```
|
|
684
|
+
- **OpenAI: ChatGPT/Codex subscription (OAuth) + usage-based API key — the
|
|
685
|
+
recommended mix if you review with OpenAI.** OpenAI permits subscription auth
|
|
686
|
+
in third-party tools, and OpenCode
|
|
658
687
|
ships the plugin for it — so the reviewer runs its default models on the
|
|
659
688
|
subscription (zero marginal cost) and reserves the metered key for pro-tier
|
|
660
689
|
models the subscription doesn't offer (`gpt-5.5-pro` is subscription-excluded).
|
|
@@ -693,25 +722,6 @@ set in `config.auth` (credentials come from OpenCode):
|
|
|
693
722
|
One caveat: OpenCode can't price alias models (they're config-declared), so
|
|
694
723
|
pro passes report `$0` in the run log's cost column — token counts are
|
|
695
724
|
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
725
|
- **Another provider** — the current path is the `REVIEWER_MODEL`
|
|
716
726
|
env override: `opencode auth login` once (pick the provider), then run with
|
|
717
727
|
e.g. `REVIEWER_MODEL=google/gemini-3-pro`. It overrides every agent's model
|
|
@@ -724,8 +734,9 @@ any other) OpenCode provider, and each agent's `model` selects its engine.
|
|
|
724
734
|
`REVIEWER_MODEL` still overrides every agent's model (and therefore every agent's
|
|
725
735
|
engine), converging the whole run onto one engine.
|
|
726
736
|
|
|
727
|
-
There is no shared fallback key; if a run fails for lack of credentials,
|
|
728
|
-
a provider in OpenCode. `ecr doctor`
|
|
737
|
+
There is no shared fallback key; if a run fails for lack of credentials, log in
|
|
738
|
+
with `claude` (the default) or authenticate a provider in OpenCode. `ecr doctor`
|
|
739
|
+
diagnoses setup.
|
|
729
740
|
|
|
730
741
|
**Setup errors fail fast, with the fix in the message.** A bad credential or model id
|
|
731
742
|
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
|