@expo/code-review-cli 0.3.0 → 0.5.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 +307 -47
- package/build/cli.js +24 -17
- package/build/commands/ci.js +410 -43
- package/build/commands/dismiss.js +16 -16
- package/build/commands/doctor.js +219 -26
- package/build/commands/init.js +244 -34
- package/build/commands/review.js +118 -30
- package/build/commands/verify-config.js +252 -0
- package/build/config/load.js +200 -55
- package/build/config/routing.js +122 -0
- package/build/config/schema.js +153 -19
- package/build/core/auth.js +237 -75
- package/build/core/coordinator.js +7 -7
- package/build/core/diff.js +19 -19
- package/build/core/exec.js +10 -10
- package/build/core/log.js +3 -3
- package/build/core/noise.js +52 -52
- package/build/core/opencode.js +495 -95
- package/build/core/prompts.js +220 -150
- package/build/core/render.js +202 -48
- package/build/core/review.js +277 -102
- package/build/core/router.js +10 -10
- package/build/core/schema.js +26 -12
- package/build/core/step-summary.js +18 -0
- package/build/core/suppress.js +7 -7
- package/build/core/tools.js +9 -9
- package/build/core/util.js +2 -2
- package/build/core/verify.js +28 -26
- package/build/reporters/github.js +103 -51
- package/build/reporters/terminal.js +19 -19
- package/build/sources/github-pr.js +21 -21
- package/build/sources/local-git.js +20 -20
- package/build/sources/source.js +35 -1
- package/package.json +8 -3
- package/templates/agents/security.md +5 -0
- package/templates/command.yml +167 -0
- package/templates/config.jsonc +26 -13
- package/templates/coordinator.md +5 -3
- package/templates/dismiss.yml +110 -0
- package/templates/routing.jsonc +27 -0
- package/templates/scope-config.jsonc +25 -0
- package/templates/shared.md +12 -0
- package/templates/workflow.yml +61 -26
package/README.md
CHANGED
|
@@ -41,21 +41,33 @@ Scaffold, add credentials, verify.
|
|
|
41
41
|
npx @expo/code-review-cli init
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
-
Then give it model credentials. **
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
Then give it model credentials. **Default: an OpenAI API key** — the scaffolded
|
|
45
|
+
config reviews with GPT via `auth.mode "api-key"`.
|
|
46
|
+
|
|
47
|
+
Create the key in the OpenAI dashboard, scoped to the minimum the reviewer needs:
|
|
48
|
+
|
|
49
|
+
- Put it in a **dedicated project** (not "Default project") so you can set a
|
|
50
|
+
monthly budget + alert on it and see the reviewer's spend in isolation.
|
|
51
|
+
- Make it a **Restricted** key with exactly two permissions, both under *Model
|
|
52
|
+
capabilities*: **Responses (/v1/responses) → Request** and **Chat completions
|
|
53
|
+
(/v1/chat/completions) → Request**. Everything else — including *List models* —
|
|
54
|
+
stays **None** (the reviewer resolves model ids from its own catalog and only
|
|
55
|
+
ever makes inference requests).
|
|
47
56
|
|
|
48
57
|
```bash
|
|
49
|
-
|
|
50
|
-
claude setup-token
|
|
51
|
-
# Export it under the env var your config.jsonc's auth.tokenEnv names
|
|
52
|
-
export ANTHROPIC_OAUTH_API_KEY=sk-ant-oat...
|
|
58
|
+
export OPENAI_API_KEY=sk-proj-...
|
|
53
59
|
# Check env, config, and credentials
|
|
54
60
|
npx @expo/code-review-cli doctor
|
|
55
61
|
```
|
|
56
62
|
|
|
57
|
-
|
|
58
|
-
|
|
63
|
+
In CI, store the same key as the `OPENAI_API_KEY` repo secret (the scaffolded
|
|
64
|
+
workflow forwards it).
|
|
65
|
+
|
|
66
|
+
**Have a ChatGPT Plus/Pro (Codex) subscription? Use both.** The recommended
|
|
67
|
+
production setup pairs the subscription (runs the default models at no marginal
|
|
68
|
+
cost) with the usage-based key (covers only the pro-tier models the subscription
|
|
69
|
+
excludes) — see [the mixed setup](#other-providers) below. Prefer
|
|
70
|
+
**Anthropic/Claude** or another provider? Same section.
|
|
59
71
|
|
|
60
72
|
### Reviewing (already configured)
|
|
61
73
|
|
|
@@ -74,7 +86,7 @@ Options (most to least common):
|
|
|
74
86
|
| --- | --- |
|
|
75
87
|
| `--pr <n>` | Review GitHub PR #n by number (diff fetched via `gh`, no checkout); not combinable with `--base`/`--head`/`--staged`. |
|
|
76
88
|
| `--post` | With `--pr`, also post the result as the PR comment (needs `gh` auth). Omit to preview only; re-run with `--post` to publish. |
|
|
77
|
-
| `--staged` | Review only staged changes. |
|
|
89
|
+
| `--staged` | Review only staged changes (index vs HEAD; not combinable with `--base`/`--head`). |
|
|
78
90
|
| `--base <ref>` | Base ref to diff against (default: merge-base with the default branch). |
|
|
79
91
|
| `--head <ref>` | Head ref to diff (default: working tree, incl. uncommitted changes). |
|
|
80
92
|
| `--agents <a,b>` | Run only these agents (comma-separated ids); default: all. |
|
|
@@ -101,14 +113,132 @@ is a ready example to adapt.
|
|
|
101
113
|
| Command | What it does |
|
|
102
114
|
| --- | --- |
|
|
103
115
|
| `ecr init [--no-workflow] [--force]` | Scaffold `.expo-code-review/` (config, agents, prompts) + a CI workflow. |
|
|
116
|
+
| `ecr init --monorepo` | …and add a `routing.jsonc` routing manifest (one default scope). |
|
|
117
|
+
| `ecr init --scope <dir>` | Scaffold a per-team scope under `<dir>` and register it in the manifest. |
|
|
104
118
|
| `ecr review [options]` | Review local changes and print an advisory review (default command). |
|
|
119
|
+
| `ecr review --scope <name>` | Review only one routing scope over just that scope's changed files. |
|
|
105
120
|
| `ecr ci` | Review the current GitHub PR and post/update a comment. For GitHub Actions. |
|
|
106
|
-
| `ecr doctor` | Check environment, config, and
|
|
121
|
+
| `ecr doctor [--list-scopes]` | Check environment, config, credentials, and (with a manifest) scopes. |
|
|
122
|
+
|
|
123
|
+
Extra flags for monorepos: `review`/`ci` `--config-dir <dir>` (load config from an
|
|
124
|
+
alternate dir; also `ECR_CONFIG_DIR`), `ci --scopes a,b` (limit the fan-out to
|
|
125
|
+
named scopes), `ci --comment single|per-scope` (override the manifest).
|
|
107
126
|
|
|
108
127
|
(When developing this repo itself, use `bun run src/cli.ts <command>`.)
|
|
109
128
|
|
|
110
129
|
---
|
|
111
130
|
|
|
131
|
+
## Monorepos (routing manifest)
|
|
132
|
+
|
|
133
|
+
A monorepo can route different subtrees to different reviewer rosters from a single
|
|
134
|
+
infra-owned manifest. There is still **one workflow, one `ecr ci` process** per PR:
|
|
135
|
+
it reads the changed files once, assigns each to exactly one scope, reviews each
|
|
136
|
+
active scope over only its files, and renders one comment. Because it is a single
|
|
137
|
+
writer in a single process there is no comment/check race and no locking.
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
your-monorepo/
|
|
141
|
+
.expo-code-review/
|
|
142
|
+
routing.jsonc # the manifest — infra-owned, ordered scope list + locked defaults
|
|
143
|
+
config.jsonc # the default/root scope; the ONLY place auth/tokenEnv lives
|
|
144
|
+
shared.md coordinator.md agents/
|
|
145
|
+
server/
|
|
146
|
+
www/.expo-code-review/{config.jsonc(NO auth),coordinator.md,agents/} # www team
|
|
147
|
+
website/.expo-code-review/{config.jsonc(NO auth),coordinator.md,agents/} # website team
|
|
148
|
+
.github/workflows/expo-code-review.yml # unchanged shape: one workflow, one `ecr ci`
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
```jsonc
|
|
152
|
+
// .expo-code-review/routing.jsonc
|
|
153
|
+
{
|
|
154
|
+
// Central guardrails every scope inherits and CANNOT override.
|
|
155
|
+
"defaults": {
|
|
156
|
+
// The ONLY place auth/tokenEnv is honored (besides the root config.jsonc).
|
|
157
|
+
"auth": { "mode": "api-key", "provider": "openai", "tokenEnv": "OPENAI_API_KEY" },
|
|
158
|
+
"enforceAgents": ["security"], // always runs on every scope, roster or not
|
|
159
|
+
"commentTag": "expo-ai-code-reviewer" // per-scope markers derive from this
|
|
160
|
+
},
|
|
161
|
+
"comment": "single", // "single" = one aggregated comment (default) | "per-scope"
|
|
162
|
+
// Ordered; the LAST matching scope wins per changed file (CODEOWNERS discipline).
|
|
163
|
+
"scopes": [
|
|
164
|
+
{ "name": "default", "paths": ["**/*"], "config": "." },
|
|
165
|
+
{ "name": "server-www", "paths": ["server/www/**"], "config": "server/www" },
|
|
166
|
+
{ "name": "server-website", "paths": ["server/website/**"], "config": "server/website" }
|
|
167
|
+
]
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
```jsonc
|
|
172
|
+
// server/www/.expo-code-review/config.jsonc (the www team owns this)
|
|
173
|
+
{
|
|
174
|
+
// NO "auth" block — locked centrally; a tokenEnv here is rejected by loader + CI guard.
|
|
175
|
+
"model": "openai/gpt-5.5",
|
|
176
|
+
"policy": { "includeSuggestions": false },
|
|
177
|
+
"noise": { "additionalIgnores": ["server/www/**/__generated__/**"] }
|
|
178
|
+
// shared.md, coordinator.md, agents/*.md live beside this file — the www team's roster.
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
- **Path glob dialect** — `**` crosses `/`, `*` matches within a segment. Keep a
|
|
183
|
+
`**/*` catch-all scope so no changed file goes unreviewed (`ecr doctor` flags a
|
|
184
|
+
coverage gap otherwise). Scopes are ordered and the **last** match wins, so put
|
|
185
|
+
broad scopes first and specific ones after (CODEOWNERS/Renovate discipline).
|
|
186
|
+
- **Comment modes** — `single` posts one aggregated comment (a scope summary table
|
|
187
|
+
+ a collapsed `<details>` per scope) under the existing marker; `per-scope` posts
|
|
188
|
+
one cleanly-namespaced comment per scope. A scope with zero matched files gets its
|
|
189
|
+
stale comment deleted.
|
|
190
|
+
- **Scoped flags** — `ecr ci --scopes a,b` limits the fan-out; `ecr ci --comment
|
|
191
|
+
single|per-scope` overrides the manifest; `ecr review --scope <name>` runs one
|
|
192
|
+
scope locally; `ecr review`/`ecr ci --config-dir <dir>` (or `ECR_CONFIG_DIR`)
|
|
193
|
+
load config from an alternate directory; `ecr doctor --list-scopes` prints the
|
|
194
|
+
scope table. **`--config-dir` designates an alternate ROOT config dir: both
|
|
195
|
+
`config.jsonc` and `routing.jsonc` are read from it, so the root config and its
|
|
196
|
+
manifest always travel together. Scope `config` paths stay repo-root-relative —
|
|
197
|
+
the override swaps the root config/manifest against the *real* scope tree, it
|
|
198
|
+
does not relocate the scopes themselves.**
|
|
199
|
+
- **Passes budget** — `defaults`-level `budget` bounds total review time:
|
|
200
|
+
`totalPassesMinutes` (default 55) is split across active scopes (which run
|
|
201
|
+
sequentially in one `ecr ci`), clamped up to `minScopeMinutes` (default 5) so a
|
|
202
|
+
single scope still gets a workable window. When enough scopes are active that the
|
|
203
|
+
floor would overshoot the total, `ecr ci` keeps the floor but warns, and `ecr
|
|
204
|
+
doctor` flags the worst case (`scopes × floor` vs total) — raise the workflow
|
|
205
|
+
`timeout-minutes` or trim scopes.
|
|
206
|
+
- **Adoption is incremental** — with no `routing.jsonc`, behavior is exactly as
|
|
207
|
+
before (single config). Add the manifest with just a default scope → still one
|
|
208
|
+
comment, identical behavior. Land per-team scope dirs one at a time; everything
|
|
209
|
+
else keeps hitting the default scope.
|
|
210
|
+
|
|
211
|
+
### Security
|
|
212
|
+
|
|
213
|
+
- **auth is locked to the root.** `tokenEnv` (which env var becomes the model
|
|
214
|
+
credential) is honored in exactly one place: the root `config.jsonc` or
|
|
215
|
+
`routing.jsonc` `defaults.auth`. A scope config declaring `auth`/`breakGlass`
|
|
216
|
+
**fails to parse** (Zod-level rejection), and the CI guard step independently
|
|
217
|
+
sweeps every `.expo-code-review/config.jsonc`/`routing.jsonc` repo-wide and refuses
|
|
218
|
+
to run unless `tokenEnv` appears exactly once, in a root-owned file, equal to
|
|
219
|
+
`ECR_EXPECTED_TOKEN_ENV`. A routing manifest can never widen exposure — globs only
|
|
220
|
+
choose *which roster* reviews a file, never *which secret* is sent.
|
|
221
|
+
- **enforceAgents can't be weakened.** Agents listed in `defaults.enforceAgents`
|
|
222
|
+
(e.g. `security`) are injected into every scope with `alwaysRun`, taken from the
|
|
223
|
+
root roster — a scope defining a same-id agent gets the root one, so a team can't
|
|
224
|
+
shadow the enforced reviewer with a weaker version on its own subtree.
|
|
225
|
+
- **Config comes from the checked-out ref (documented tradeoff).** The scaffolded
|
|
226
|
+
auto workflow checks out the PR **merge ref**, so a PR *can* edit rosters, prompts,
|
|
227
|
+
and routing globs for its own advisory review — that only steers what the
|
|
228
|
+
comment-only reviewer says about that PR. What a PR can **never** do is touch
|
|
229
|
+
credentials: `auth` is locked by the scope-schema rejection, the CLI's runtime
|
|
230
|
+
`ECR_EXPECTED_TOKEN_ENV` check, and the guard step above, all three of which run
|
|
231
|
+
against whatever ref is checked out. A `/review`-command workflow that checks out
|
|
232
|
+
only the trusted base ref (see eas-cli's) closes the prompt-tampering vector too;
|
|
233
|
+
resolving config from the base ref on the auto path as well is on the
|
|
234
|
+
[roadmap](./ROADMAP.md).
|
|
235
|
+
|
|
236
|
+
Ownership is enforced with CODEOWNERS: `/.expo-code-review/routing.jsonc @your-infra`
|
|
237
|
+
(the single authoritative router) and `/server/www/.expo-code-review/ @your-www-team`
|
|
238
|
+
(each team owns only its own scope dir). Rerouting globs is gated behind infra review.
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
112
242
|
<details>
|
|
113
243
|
<summary><b>How it works</b></summary>
|
|
114
244
|
|
|
@@ -131,7 +261,51 @@ is a ready example to adapt.
|
|
|
131
261
|
suppressed.
|
|
132
262
|
|
|
133
263
|
Built on the [OpenCode](https://opencode.ai) SDK, which spawns the model provider
|
|
134
|
-
and applies
|
|
264
|
+
and applies the provider's prompt caching automatically.
|
|
265
|
+
|
|
266
|
+
</details>
|
|
267
|
+
|
|
268
|
+
<details>
|
|
269
|
+
<summary><b>Tokens, cost & prompt caching</b></summary>
|
|
270
|
+
|
|
271
|
+
Every run reports what it spent and how much of it was served from the prompt
|
|
272
|
+
cache, in three places:
|
|
273
|
+
|
|
274
|
+
- **Job log (stderr)** — one line at the end of the run:
|
|
275
|
+
`Token usage — input …, output …, cache read …, cache write … (cost $…)`.
|
|
276
|
+
- **GitHub Actions step summary** — a per-pass table (one row per agent, plus the
|
|
277
|
+
cross-cutting pass, coordinator, and verifier), the run's cache hit rate, and
|
|
278
|
+
the exact comment that was posted. The PR comment is updated in place on every
|
|
279
|
+
run, so the step summary is where past runs' comments remain readable.
|
|
280
|
+
- **`.expo-code-review/.runs/reviews.jsonl`** — one JSON line per run (uploaded as
|
|
281
|
+
a CI artifact) with the same totals plus per-pass `agentTokens`, the raw
|
|
282
|
+
per-agent findings, coverage notes, and what the verifier dropped.
|
|
283
|
+
|
|
284
|
+
**How the caching works.** Provider prompt caching is a *prefix match*: the
|
|
285
|
+
provider caches the rendered prompt up to a point, and any byte change anywhere
|
|
286
|
+
in that prefix invalidates everything after it. The reviewer is laid out so the
|
|
287
|
+
prefix is stable — the system prompt (`shared.md` + the agent's own `.md`) is
|
|
288
|
+
byte-identical for every chunk an agent reviews, while the volatile parts (the
|
|
289
|
+
diff, file lists, PR metadata) travel in the user message *after* the prefix and
|
|
290
|
+
never touch it. OpenAI caches automatically (no write premium; cached input is
|
|
291
|
+
billed at a steep discount and shows up as `cache read`); Anthropic charges a
|
|
292
|
+
small premium to **write** the cache (~1.25× input) and ~0.1× input to **read**
|
|
293
|
+
it. Entries live minutes, refreshed on use — comfortably covering a run's
|
|
294
|
+
concurrent calls.
|
|
295
|
+
|
|
296
|
+
**Reading the numbers.** Hit rate = `cache read / (cache read + input)` — the
|
|
297
|
+
share of prompt tokens served from cache instead of being reprocessed at full
|
|
298
|
+
price. Multi-chunk reviews should show a high rate; single-chunk reviews mostly
|
|
299
|
+
show writes (there is nothing to re-read within the run).
|
|
300
|
+
|
|
301
|
+
**Keeping hits high:**
|
|
302
|
+
|
|
303
|
+
- Keep `shared.md` and `agents/*.md` stable. Any edit writes a new prefix — one
|
|
304
|
+
extra cache write per agent on the next run, then it is warm again. Never put
|
|
305
|
+
varying text (dates, PR numbers) into prompt files.
|
|
306
|
+
- Very short prompts may show `cache read 0`: prompts below the model's minimum
|
|
307
|
+
cacheable size (~1–4K tokens depending on the model) are silently not cached.
|
|
308
|
+
That is expected, not a bug.
|
|
135
309
|
|
|
136
310
|
</details>
|
|
137
311
|
|
|
@@ -156,7 +330,7 @@ and applies Anthropic prompt caching automatically.
|
|
|
156
330
|
---
|
|
157
331
|
description: One line the router uses to decide relevance.
|
|
158
332
|
alwaysRun: true # run even when the router would skip this agent
|
|
159
|
-
model:
|
|
333
|
+
model: openai/gpt-5.5-pro # override the default model
|
|
160
334
|
temperature: 0.1
|
|
161
335
|
---
|
|
162
336
|
|
|
@@ -165,21 +339,24 @@ temperature: 0.1
|
|
|
165
339
|
|
|
166
340
|
For a real-world example, see eas-cli's
|
|
167
341
|
[`.expo-code-review/`](https://github.com/expo/eas-cli/tree/main/.expo-code-review)
|
|
168
|
-
— correctness/security/consistency agents,
|
|
169
|
-
per-repo `noise.additionalIgnores`.
|
|
342
|
+
— correctness/security/consistency agents, a stronger model for security + the
|
|
343
|
+
coordinator, and per-repo `noise.additionalIgnores`.
|
|
170
344
|
|
|
171
345
|
`config.jsonc` (JSONC — comments + trailing commas supported):
|
|
172
346
|
|
|
173
347
|
```jsonc
|
|
174
348
|
{
|
|
175
|
-
"model": "
|
|
349
|
+
"model": "openai/gpt-5.5", // default model for the specialists
|
|
176
350
|
"policy": { "includeSuggestions": false }, // suppress suggestion-severity findings
|
|
177
351
|
"chunk": { "maxChangedLines": 1000, "maxFiles": 20, "concurrency": 6 },
|
|
178
352
|
"noise": { "additionalIgnores": ["packages/*/build/**"] },
|
|
353
|
+
"review": { "trigger": "all", // which PRs `ecr ci` reviews: "all"
|
|
354
|
+
"label": "ai-review", // (default, except ai-review:skip) or
|
|
355
|
+
"skipLabel": "ai-review:skip" }, // "label" (only labeled PRs)
|
|
179
356
|
"breakGlass": { "marker": "/skip-review" }, // PR body marker that skips the review
|
|
180
357
|
"commentTag": "expo-ai-code-reviewer", // hidden tag used to find/update the comment
|
|
181
|
-
"auth": { "mode": "
|
|
182
|
-
"tokenEnv": "
|
|
358
|
+
"auth": { "mode": "api-key", "provider": "openai",
|
|
359
|
+
"tokenEnv": "OPENAI_API_KEY" }
|
|
183
360
|
}
|
|
184
361
|
```
|
|
185
362
|
|
|
@@ -193,14 +370,19 @@ Precedence: **`REVIEWER_MODEL` env** (global override) → per-file **frontmatte
|
|
|
193
370
|
setup, and a developer can override everything locally.
|
|
194
371
|
|
|
195
372
|
- **Specialist agents** (correctness/security/consistency) benefit from a
|
|
196
|
-
reasoning-tier model —
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
(set in `security.md` frontmatter), the rest on
|
|
373
|
+
reasoning-tier model — **`openai/gpt-5.5`** is the quality/speed sweet spot
|
|
374
|
+
(the scaffolded default). The **pro tier** finds more but is slower and more
|
|
375
|
+
expensive, so scope it to the highest-stakes agent: **security runs on
|
|
376
|
+
`openai/gpt-5.5-pro`** (set in `security.md` frontmatter), the rest on the default.
|
|
200
377
|
- **The coordinator** makes the final call (dedupe / re-judge / decide) — worth a
|
|
201
378
|
strong model; set it in `coordinator.md` frontmatter.
|
|
202
379
|
- If latency/timeouts dominate on big PRs, moving the specialists to a faster model
|
|
203
|
-
is the most direct lever (a real recall tradeoff —
|
|
380
|
+
(e.g. `openai/gpt-5.4-mini`) is the most direct lever (a real recall tradeoff —
|
|
381
|
+
measure it).
|
|
382
|
+
- **Every run logs which model actually answered each pass** — in the job log
|
|
383
|
+
(`Models used — …`), the Actions step summary table, and the run log's
|
|
384
|
+
`agentModels` — and warns loudly if a pass ran on a different model than
|
|
385
|
+
configured, so a provider-side substitution can never pass unnoticed.
|
|
204
386
|
|
|
205
387
|
There is no automatic cross-provider "equivalent" fallback — that would silently
|
|
206
388
|
change which model reviewed your code. Use an explicit override instead.
|
|
@@ -210,19 +392,43 @@ change which model reviewed your code. Use an explicit override instead.
|
|
|
210
392
|
<details>
|
|
211
393
|
<summary><b>Reliability</b> — never hangs, never silently drops work</summary>
|
|
212
394
|
|
|
213
|
-
- **Per-task time caps** — chunk passes 15 min;
|
|
214
|
-
|
|
215
|
-
|
|
395
|
+
- **Per-task time caps** — chunk passes 15 min; coordinator 10 min. A global passes
|
|
396
|
+
budget (55 min) bounds all passes incl. the subdivision waves, fitting inside the
|
|
397
|
+
CI job's `timeout-minutes` (90).
|
|
398
|
+
- **The cross-file pass is elastic** — it gets whatever is left of the passes budget
|
|
399
|
+
rather than a fixed cap, because it's the one pass whose scope can't be traded for
|
|
400
|
+
convergence: halving its file set deletes exactly the coverage it exists for. Chunk
|
|
401
|
+
passes run alongside it under their own caps, so a long cross-file pass doesn't
|
|
402
|
+
starve them.
|
|
216
403
|
- **Tool-call cap** — a pass that makes too many `read`/`grep` calls without
|
|
217
404
|
finishing is *wandering*, not converging; hitting the cap trips the soft landing.
|
|
405
|
+
The cross-file ceiling scales with the diff's file count (its diffs are inlined, so
|
|
406
|
+
tool calls go to *tracing*, not fetching).
|
|
407
|
+
- **Stall detection** — a pass whose reply stops changing entirely (no new tool call,
|
|
408
|
+
no streamed text or reasoning, no token growth) has a wedged model request, not a
|
|
409
|
+
hard problem. After 4 min of silence it's abandoned and retried once from a clean
|
|
410
|
+
session, inside the same budget — instead of spending the whole cap on a dead
|
|
411
|
+
request. Progress lines say how long a reply has been silent, so this is legible in
|
|
412
|
+
the CI log.
|
|
218
413
|
- **Soft landing on timeout** — at either cap, the run is interrupted and the agent
|
|
219
414
|
is asked to return the findings it already has, rather than discarding its work.
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
415
|
+
Tools are disabled for that request, so the salvage step can't resume investigating
|
|
416
|
+
instead of answering.
|
|
417
|
+
- **Subdivide-on-timeout** — a reviewer pass that times out with nothing to show has
|
|
418
|
+
its chunk split in half and the halves re-reviewed (recursively, down to a single
|
|
419
|
+
file), then a fast **no-tools fallback** over the inlined diff (the cross-file pass
|
|
420
|
+
skips straight to the fallback, which still sees the whole diff). Only a genuinely
|
|
223
421
|
un-reducible pass reports a coverage gap — and it is always reported, never silent.
|
|
224
422
|
- **Parse failures are retried** (same session, then once in a bounded fresh
|
|
225
423
|
session) — separate from the timeout path.
|
|
424
|
+
- **Transient API errors are retried** (bounded backoff on 429/5xx/network) —
|
|
425
|
+
distinct from both the timeout path (abandon) and the parse path; a one-off blip
|
|
426
|
+
no longer drops an entire pass.
|
|
427
|
+
- **Auth failures surface once, and fail fast** — `ecr` checks the configured
|
|
428
|
+
provider's credential at startup and stops with one clear message if it's missing
|
|
429
|
+
(rather than failing every pass); a credential rejected mid-run (401/403)
|
|
430
|
+
collapses into a single actionable coverage note pointing at
|
|
431
|
+
`auth.tokenEnv`/`REVIEWER_MODEL`.
|
|
226
432
|
- **A failed run never reads as "Approve"** — all passes fail → "could not
|
|
227
433
|
complete"; some fail → never a clean approve, and coverage-reduced.
|
|
228
434
|
- **The coordinator can't sink the run** — if consolidation fails, findings are
|
|
@@ -243,9 +449,12 @@ line: **comments = one-shot actions, labels = persistent configuration.**
|
|
|
243
449
|
- **command workflow** — one-shot `/review` comments (maintainers): `/review`
|
|
244
450
|
(router picks agents), `/review all`, `/review correctness security`. Never
|
|
245
451
|
changes configuration.
|
|
246
|
-
- **auto workflow** — continuous review
|
|
247
|
-
|
|
248
|
-
|
|
452
|
+
- **auto workflow** — continuous review. **Which PRs get reviewed is set in
|
|
453
|
+
`config.jsonc` → `review.trigger`**: `"all"` (default) reviews every PR except
|
|
454
|
+
those labeled `ai-review:skip`; `"label"` reviews only PRs labeled `ai-review`
|
|
455
|
+
(or `ai-review:<agent>` to scope agents). `ecr ci` self-gates on this policy;
|
|
456
|
+
the workflow's `if:` is an optional coarse gate layered on top. `ai-review:skip`
|
|
457
|
+
always wins and is write-gated, so a contributor can't opt their own PR out.
|
|
249
458
|
- **dismiss workflow** — `/dismiss <id> [… -- reason]` / `/undismiss <id>`
|
|
250
459
|
(maintainers). Each finding shows a short `` `id:…` ``. Dismissal is a **display
|
|
251
460
|
filter only** — the reviewer still analyzes everything, and a `critical`/`secrets`
|
|
@@ -263,7 +472,9 @@ as the published package via `npx`, so no PR-controlled code is built.
|
|
|
263
472
|
Each run appends a JSON line to `.expo-code-review/.runs/reviews.jsonl` with the
|
|
264
473
|
inputs, decision, finding count, duration, per-agent cost, and aggregate token
|
|
265
474
|
usage (incl. prompt-cache read/write counts) — for auditing and measuring
|
|
266
|
-
cost/latency/cache reuse over time.
|
|
475
|
+
cost/latency/cache reuse over time. The same totals are printed as a one-line
|
|
476
|
+
summary to the terminal / CI job log at the end of each run, so cache reuse is
|
|
477
|
+
visible even in CI (where the run log is ephemeral).
|
|
267
478
|
|
|
268
479
|
</details>
|
|
269
480
|
|
|
@@ -271,23 +482,72 @@ cost/latency/cache reuse over time.
|
|
|
271
482
|
<details>
|
|
272
483
|
<summary><b>Other providers & auth modes</b></summary>
|
|
273
484
|
|
|
274
|
-
The recommended setup is
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
- **
|
|
278
|
-
|
|
279
|
-
the
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
485
|
+
The recommended setup is an OpenAI API key — see Usage above. Alternatives, all
|
|
486
|
+
set in `config.auth` (credentials come from OpenCode):
|
|
487
|
+
|
|
488
|
+
- **ChatGPT/Codex subscription (OAuth) + usage-based API key — the recommended
|
|
489
|
+
mix.** OpenAI permits subscription auth in third-party tools, and OpenCode
|
|
490
|
+
ships the plugin for it — so the reviewer runs its default models on the
|
|
491
|
+
subscription (zero marginal cost) and reserves the metered key for pro-tier
|
|
492
|
+
models the subscription doesn't offer (`gpt-5.5-pro` is subscription-excluded).
|
|
493
|
+
Use the per-provider map form:
|
|
494
|
+
|
|
495
|
+
```jsonc
|
|
496
|
+
"auth": { "providers": {
|
|
497
|
+
"openai": { "mode": "oauth", "tokenEnv": "CODEX_OAUTH_REFRESH_TOKEN" },
|
|
498
|
+
"openai-api": { "mode": "api-key", "tokenEnv": "OPENAI_API_KEY", "upstream": "openai" }
|
|
499
|
+
} }
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
`openai-api` is an alias the reviewer synthesizes in the OpenCode config
|
|
503
|
+
(`upstream` names the SDK it's backed by): agents reference `openai-api/gpt-5.5-pro`
|
|
504
|
+
in frontmatter while everything else stays on `openai/gpt-5.5`. Notes:
|
|
505
|
+
|
|
506
|
+
- **The oauth `tokenEnv` holds the refresh token** from an `opencode auth login`
|
|
507
|
+
ChatGPT sign-in (copy `.openai.refresh` out of OpenCode's `auth.json`) —
|
|
508
|
+
access tokens are short-lived, so the refresh token is the durable secret and
|
|
509
|
+
OpenCode mints access tokens on demand. Refresh-token reuse across runs is
|
|
510
|
+
verified, so a static CI secret works.
|
|
511
|
+
- **The API key needs the same two permissions** as the default setup above
|
|
512
|
+
(Responses + Chat completions → Request; all else None), in a budget-capped
|
|
513
|
+
project.
|
|
514
|
+
- **In CI**, set the `ECR_EXPECTED_TOKEN_ENV` repo variable to the
|
|
515
|
+
comma-separated set of both env names
|
|
516
|
+
(`CODEX_OAUTH_REFRESH_TOKEN,OPENAI_API_KEY`) and pass both secrets in the
|
|
517
|
+
workflow.
|
|
518
|
+
- **Auditability**: every pass logs which provider/model answered it (job log,
|
|
519
|
+
step summary, run log), so the subscription/API split is visible per run.
|
|
520
|
+
One caveat: OpenCode can't price alias models (they're config-declared), so
|
|
521
|
+
pro passes report `$0` in the run log's cost column — token counts are
|
|
522
|
+
correct, and the OpenAI project dashboard is the source of truth for spend.
|
|
523
|
+
- **Anthropic / Claude (API key)** — set `auth.provider` to `"anthropic"`, point
|
|
524
|
+
`tokenEnv` at the env var holding a Console API key (e.g. `ANTHROPIC_API_KEY`),
|
|
525
|
+
and use `anthropic/...` model ids; the key is sent as `x-api-key`. Note that
|
|
526
|
+
Claude Pro/Max **subscription** tokens cannot be used here: Anthropic prohibits
|
|
527
|
+
them in third-party tools, and OpenCode has no Anthropic OAuth support — only an
|
|
528
|
+
API key works.
|
|
529
|
+
- **Another provider** — the current path is the `REVIEWER_MODEL`
|
|
284
530
|
env override: `opencode auth login` once (pick the provider), then run with
|
|
285
|
-
e.g. `REVIEWER_MODEL=
|
|
286
|
-
and uses your OpenCode login, so no `auth` block is needed. *(
|
|
287
|
-
|
|
288
|
-
|
|
531
|
+
e.g. `REVIEWER_MODEL=google/gemini-3-pro`. It overrides every agent's model
|
|
532
|
+
and uses your OpenCode login, so no `auth` block is needed. *(Per-agent
|
|
533
|
+
provider mixing beyond the alias mechanism above is on the
|
|
534
|
+
[roadmap](./ROADMAP.md).)*
|
|
289
535
|
|
|
290
536
|
There is no shared fallback key; if a run fails for lack of credentials, authenticate
|
|
291
537
|
a provider in OpenCode. `ecr doctor` diagnoses setup.
|
|
292
538
|
|
|
539
|
+
**Setup errors fail fast, with the fix in the message.** A bad credential or model id
|
|
540
|
+
would otherwise fail every pass identically — a run that spends its whole budget
|
|
541
|
+
rediscovering one fixable thing, then reports N coverage gaps. So before any pass runs:
|
|
542
|
+
|
|
543
|
+
- **The credential's shape is checked.** OpenCode refuses a malformed credential by
|
|
544
|
+
dropping the provider entirely, which then surfaces as "model not found" for every
|
|
545
|
+
model, with nothing pointing at the credential. A truncated value, surrounding
|
|
546
|
+
whitespace, or a token that can't work for the configured `auth.mode` is rejected
|
|
547
|
+
by name.
|
|
548
|
+
- **Configured model ids are checked against the running server**, so a typo or an id
|
|
549
|
+
the provider doesn't have is reported once, up front, with the close matches.
|
|
550
|
+
- **`ecr doctor` reports the `opencode` version actually in use** and warns when a
|
|
551
|
+
different one is first on your `PATH` — runs use the version this package pins.
|
|
552
|
+
|
|
293
553
|
</details>
|
package/build/cli.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { ciCommand } from
|
|
3
|
-
import { dismissCommand } from
|
|
4
|
-
import { doctorCommand } from
|
|
5
|
-
import { initCommand } from
|
|
6
|
-
import { reviewCommand } from
|
|
2
|
+
import { ciCommand } from "./commands/ci.js";
|
|
3
|
+
import { dismissCommand } from "./commands/dismiss.js";
|
|
4
|
+
import { doctorCommand } from "./commands/doctor.js";
|
|
5
|
+
import { initCommand } from "./commands/init.js";
|
|
6
|
+
import { reviewCommand } from "./commands/review.js";
|
|
7
|
+
import { verifyConfigCommand } from "./commands/verify-config.js";
|
|
7
8
|
const USAGE = `expo-code-review (ecr) — config-driven AI code reviewer
|
|
8
9
|
|
|
9
10
|
Usage:
|
|
@@ -11,41 +12,47 @@ Usage:
|
|
|
11
12
|
ecr ci Review the current PR and post a comment (GitHub Actions).
|
|
12
13
|
ecr dismiss --pr <n> <id...> Hide a finding on a PR (see \`ecr dismiss --help\`).
|
|
13
14
|
ecr undismiss --pr <n> <id...> Restore a dismissed finding.
|
|
14
|
-
ecr init [--
|
|
15
|
-
ecr doctor
|
|
15
|
+
ecr init [--monorepo] [--scope <dir>] Scaffold .expo-code-review/ in this repo.
|
|
16
|
+
ecr doctor [--list-scopes] Check environment, config, credentials, and scopes.
|
|
17
|
+
ecr verify-config [--expected <env>] [--json] Refuse to run if a config could redirect the credential (CI guard).
|
|
16
18
|
|
|
17
19
|
Agents live in each repo under .expo-code-review/. This CLI is the engine.
|
|
20
|
+
|
|
21
|
+
Monorepos: .expo-code-review/routing.jsonc routes paths to per-team scopes (see README).
|
|
18
22
|
`;
|
|
19
23
|
async function main() {
|
|
20
24
|
const [, , sub, ...rest] = process.argv;
|
|
21
|
-
if (sub ===
|
|
25
|
+
if (sub === "-h" || sub === "--help" || sub === "help") {
|
|
22
26
|
process.stdout.write(USAGE);
|
|
23
27
|
return;
|
|
24
28
|
}
|
|
25
29
|
// No subcommand (or a leading flag) defaults to `review`.
|
|
26
|
-
if (!sub || sub.startsWith(
|
|
30
|
+
if (!sub || sub.startsWith("-")) {
|
|
27
31
|
await reviewCommand(process.argv.slice(2));
|
|
28
32
|
return;
|
|
29
33
|
}
|
|
30
34
|
switch (sub) {
|
|
31
|
-
case
|
|
35
|
+
case "review":
|
|
32
36
|
await reviewCommand(rest);
|
|
33
37
|
break;
|
|
34
|
-
case
|
|
38
|
+
case "ci":
|
|
35
39
|
await ciCommand(rest);
|
|
36
40
|
break;
|
|
37
|
-
case
|
|
38
|
-
await dismissCommand(rest,
|
|
41
|
+
case "dismiss":
|
|
42
|
+
await dismissCommand(rest, "add");
|
|
39
43
|
break;
|
|
40
|
-
case
|
|
41
|
-
await dismissCommand(rest,
|
|
44
|
+
case "undismiss":
|
|
45
|
+
await dismissCommand(rest, "remove");
|
|
42
46
|
break;
|
|
43
|
-
case
|
|
47
|
+
case "init":
|
|
44
48
|
await initCommand(rest);
|
|
45
49
|
break;
|
|
46
|
-
case
|
|
50
|
+
case "doctor":
|
|
47
51
|
await doctorCommand(rest);
|
|
48
52
|
break;
|
|
53
|
+
case "verify-config":
|
|
54
|
+
await verifyConfigCommand(rest);
|
|
55
|
+
break;
|
|
49
56
|
default:
|
|
50
57
|
process.stderr.write(`Unknown command: ${sub}\n\n${USAGE}`);
|
|
51
58
|
process.exitCode = 2;
|