@expo/code-review-cli 0.1.0 → 0.2.1
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 +204 -173
- package/build/commands/init.js +10 -7
- package/build/core/prompts.js +21 -9
- package/build/core/verify.js +77 -41
- package/package.json +2 -1
- package/templates/config.jsonc +7 -5
- package/templates/shared.md +7 -5
- package/templates/workflow.yml +40 -14
package/README.md
CHANGED
|
@@ -1,26 +1,115 @@
|
|
|
1
|
-
# expo
|
|
1
|
+
# @expo/code-review-cli
|
|
2
2
|
|
|
3
3
|
A config-driven, multi-agent AI code reviewer. Specialist agents review a diff in
|
|
4
4
|
parallel; a coordinator consolidates their findings into one structured review.
|
|
5
|
-
|
|
5
|
+
The same engine runs locally (advisory) and in CI (posts one PR comment). The CLI
|
|
6
|
+
is the **engine** — each repo supplies its own agents and settings under
|
|
7
|
+
`.expo-code-review/`, so behavior is configured per-repo, not baked in.
|
|
6
8
|
|
|
7
|
-
> **Status: experimental.**
|
|
8
|
-
>
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
> **Status: experimental.** Comment-only and non-blocking — it never blocks a merge
|
|
10
|
+
> and never auto-approves. See [`ROADMAP.md`](./ROADMAP.md).
|
|
11
|
+
|
|
12
|
+
Inspired in part by Cloudflare's [_How we built our AI code review bot_](https://blog.cloudflare.com/ai-code-review/).
|
|
13
|
+
|
|
14
|
+
```mermaid
|
|
15
|
+
flowchart TD
|
|
16
|
+
SRC["Source<br/>local git · GitHub PR (gh)"] --> FILTER["Noise filter<br/>drop lockfiles · generated · binary"]
|
|
17
|
+
FILTER --> CHUNK["Chunk<br/>by changed lines (large diffs only)"]
|
|
18
|
+
CHUNK --> AGENTS["Agents (parallel)<br/>each .md in agents/ · read·grep·glob·list"]
|
|
19
|
+
CHUNK --> XCUT["Cross-cutting pass<br/>multi-file issues (large diffs)"]
|
|
20
|
+
AGENTS --> COORD["Coordinator<br/>dedupe · re-judge · decide"]
|
|
21
|
+
XCUT --> COORD
|
|
22
|
+
COORD --> VERIFY["Verify<br/>quote-ground · adversarially verify criticals"]
|
|
23
|
+
VERIFY --> REPORT["Reporter<br/>one PR comment (CI) · terminal (local)"]
|
|
24
|
+
```
|
|
11
25
|
|
|
12
|
-
|
|
13
|
-
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
Run via `npx @expo/code-review-cli <command>` (or the `ecr` / `expo-code-review`
|
|
29
|
+
binary once installed).
|
|
30
|
+
|
|
31
|
+
Reviewing a PR (`--pr`/`ci`) needs the GitHub CLI — `brew install gh && gh auth login`.
|
|
32
|
+
Everything else the reviewer needs (including the `opencode` runtime) ships with the
|
|
33
|
+
package.
|
|
34
|
+
|
|
35
|
+
### First-time setup
|
|
36
|
+
|
|
37
|
+
Scaffold, add credentials, verify.
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
# Scaffold .expo-code-review/ + a CI workflow (--no-workflow to skip)
|
|
41
|
+
npx @expo/code-review-cli init
|
|
42
|
+
```
|
|
14
43
|
|
|
15
|
-
|
|
44
|
+
Then give it model credentials. **Recommended: a Claude Pro/Max subscription** — the
|
|
45
|
+
scaffolded config uses OAuth by default, so just mint a token and export it under the
|
|
46
|
+
env var your `config.jsonc`'s `auth.tokenEnv` names:
|
|
16
47
|
|
|
48
|
+
```bash
|
|
49
|
+
# Mint a Claude Pro/Max token (prints an sk-ant-oat… token)
|
|
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...
|
|
53
|
+
# Check env, config, and credentials
|
|
54
|
+
npx @expo/code-review-cli doctor
|
|
17
55
|
```
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
56
|
+
|
|
57
|
+
Prefer an Anthropic **API key**, or **OpenAI/GPT** or another provider? See
|
|
58
|
+
[Other providers & auth modes](#other-providers) at the bottom.
|
|
59
|
+
|
|
60
|
+
### Reviewing (already configured)
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
# Review working-tree changes; prints here, posts nothing
|
|
64
|
+
ecr review
|
|
65
|
+
# Review a GitHub PR by number (preview only)
|
|
66
|
+
ecr review --pr 4057
|
|
67
|
+
# …and post it as the PR comment
|
|
68
|
+
ecr review --pr 4057 --post
|
|
22
69
|
```
|
|
23
70
|
|
|
71
|
+
Options (most to least common):
|
|
72
|
+
|
|
73
|
+
| Flag | What it does |
|
|
74
|
+
| --- | --- |
|
|
75
|
+
| `--pr <n>` | Review GitHub PR #n by number (diff fetched via `gh`, no checkout); not combinable with `--base`/`--head`/`--staged`. |
|
|
76
|
+
| `--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. |
|
|
78
|
+
| `--base <ref>` | Base ref to diff against (default: merge-base with the default branch). |
|
|
79
|
+
| `--head <ref>` | Head ref to diff (default: working tree, incl. uncommitted changes). |
|
|
80
|
+
| `--agents <a,b>` | Run only these agents (comma-separated ids); default: all. |
|
|
81
|
+
| `--route` | Let an LLM router pick the relevant agents from the diff. |
|
|
82
|
+
| `--repo <owner/repo>` | Repo for `--pr` (default: inferred from the current checkout). |
|
|
83
|
+
| `--json` | Emit machine-readable JSON on stdout. |
|
|
84
|
+
| `--no-fail` | Always exit 0 (otherwise a `request_changes` decision exits non-zero). |
|
|
85
|
+
| `-h`, `--help` | Show help. |
|
|
86
|
+
|
|
87
|
+
`--pr` uses the PR's diff (authoritative) but reads your checked-out files for
|
|
88
|
+
surrounding context; for full fidelity, `gh pr checkout <n>` first and run a plain
|
|
89
|
+
`ecr review`.
|
|
90
|
+
|
|
91
|
+
In CI it runs automatically from the scaffolded workflows — by label or a `/review`
|
|
92
|
+
comment (see **CI usage**). From Claude Code (or another agent), add a slash command
|
|
93
|
+
that runs it; eas-cli's
|
|
94
|
+
[`/expo-review`](https://github.com/expo/eas-cli/blob/main/.claude/commands/expo-review.md)
|
|
95
|
+
is a ready example to adapt.
|
|
96
|
+
|
|
97
|
+
### Command reference
|
|
98
|
+
|
|
99
|
+
| Command | What it does |
|
|
100
|
+
| --- | --- |
|
|
101
|
+
| `ecr init [--no-workflow] [--force]` | Scaffold `.expo-code-review/` (config, agents, prompts) + a CI workflow. |
|
|
102
|
+
| `ecr review [options]` | Review local changes and print an advisory review (default command). |
|
|
103
|
+
| `ecr ci` | Review the current GitHub PR and post/update a comment. For GitHub Actions. |
|
|
104
|
+
| `ecr doctor` | Check environment, config, and model credentials. |
|
|
105
|
+
|
|
106
|
+
(When developing this repo itself, use `bun run src/cli.ts <command>`.)
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
<details>
|
|
111
|
+
<summary><b>How it works</b></summary>
|
|
112
|
+
|
|
24
113
|
- **Source** — local git (working tree, staged, or a ref range) or a GitHub PR
|
|
25
114
|
(diff + metadata fetched over the `gh` API).
|
|
26
115
|
- **Noise filter** — drops lockfiles, generated bundles/maps, snapshots, files
|
|
@@ -33,6 +122,8 @@ diff source ─▶ noise filter ─▶ chunk ─▶ agents (parallel) ─▶ coo
|
|
|
33
122
|
run in parallel with read-only repo tools (`read`/`grep`/`glob`/`list`).
|
|
34
123
|
- **Coordinator** — a single pass that dedupes, re-judges severity, and produces
|
|
35
124
|
the final `{ decision, findings, summary }`.
|
|
125
|
+
- **Verify** — quote-grounds every finding against the real file and adversarially
|
|
126
|
+
verifies criticals, so a confident-but-wrong finding doesn't ship.
|
|
36
127
|
- **Reporter** — posts/updates a single fingerprinted PR comment (CI), or prints
|
|
37
128
|
a grouped summary (local). Findings below the configured severity floor are
|
|
38
129
|
suppressed.
|
|
@@ -40,48 +131,10 @@ diff source ─▶ noise filter ─▶ chunk ─▶ agents (parallel) ─▶ coo
|
|
|
40
131
|
Built on the [OpenCode](https://opencode.ai) SDK, which spawns the model provider
|
|
41
132
|
and applies Anthropic prompt caching automatically.
|
|
42
133
|
|
|
43
|
-
|
|
134
|
+
</details>
|
|
44
135
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
| Command | What it does |
|
|
49
|
-
| --- | --- |
|
|
50
|
-
| `ecr review [options]` | Review local changes and print an advisory review (default command). |
|
|
51
|
-
| `ecr ci` | Review the current GitHub PR and post/update a comment. For GitHub Actions. |
|
|
52
|
-
| `ecr init [--with-workflow] [--force]` | Scaffold `.expo-code-review/` (config, agents, prompts) in this repo. |
|
|
53
|
-
| `ecr doctor` | Check environment, config, and model credentials. |
|
|
54
|
-
|
|
55
|
-
### `ecr review` options
|
|
56
|
-
|
|
57
|
-
```
|
|
58
|
-
--base <ref> Base ref to diff against (default: merge-base with default branch)
|
|
59
|
-
--head <ref> Head ref to diff (default: working tree, incl. uncommitted changes)
|
|
60
|
-
--staged Review only staged changes
|
|
61
|
-
--pr <n> Review GitHub PR #n by number (diff fetched via gh, no checkout);
|
|
62
|
-
not combinable with --base/--head/--staged
|
|
63
|
-
--repo <owner/repo> Repo for --pr (default: inferred from the current checkout)
|
|
64
|
-
--post With --pr: also post the result as the PR comment (needs gh auth).
|
|
65
|
-
Omit to preview only; re-run with --post to publish.
|
|
66
|
-
--agents <a,b> Run only these agents (comma-separated ids); default: all
|
|
67
|
-
--route Let an LLM router pick the relevant agents from the diff
|
|
68
|
-
--json Emit machine-readable JSON on stdout
|
|
69
|
-
--no-fail Always exit 0 (otherwise a request_changes decision exits non-zero)
|
|
70
|
-
-h, --help Show help
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
Reviewing a PR without checking it out — preview, then optionally post:
|
|
74
|
-
|
|
75
|
-
```bash
|
|
76
|
-
ecr review --pr 4057 # print the review here; posts nothing
|
|
77
|
-
ecr review --pr 4057 --post # re-run and post it as the PR comment
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
`--pr` uses the PR's diff (authoritative) but reads your checked-out files for
|
|
81
|
-
surrounding context; for full fidelity, `gh pr checkout <n>` first and run a plain
|
|
82
|
-
`ecr review`.
|
|
83
|
-
|
|
84
|
-
## Configuration — `.expo-code-review/`
|
|
136
|
+
<details>
|
|
137
|
+
<summary><b>Configuration — <code>.expo-code-review/</code></b></summary>
|
|
85
138
|
|
|
86
139
|
```
|
|
87
140
|
.expo-code-review/
|
|
@@ -101,14 +154,19 @@ surrounding context; for full fidelity, `gh pr checkout <n>` first and run a pla
|
|
|
101
154
|
---
|
|
102
155
|
description: One line the router uses to decide relevance.
|
|
103
156
|
alwaysRun: true # run even when the router would skip this agent
|
|
104
|
-
model: anthropic/claude-sonnet-5 # override the default model
|
|
157
|
+
model: anthropic/claude-sonnet-5 # override the default model
|
|
105
158
|
temperature: 0.1
|
|
106
159
|
---
|
|
107
160
|
|
|
108
161
|
# Agent instructions in Markdown…
|
|
109
162
|
```
|
|
110
163
|
|
|
111
|
-
|
|
164
|
+
For a real-world example, see eas-cli's
|
|
165
|
+
[`.expo-code-review/`](https://github.com/expo/eas-cli/tree/main/.expo-code-review)
|
|
166
|
+
— correctness/security/consistency agents, Opus for security + the coordinator, and
|
|
167
|
+
per-repo `noise.additionalIgnores`.
|
|
168
|
+
|
|
169
|
+
`config.jsonc` (JSONC — comments + trailing commas supported):
|
|
112
170
|
|
|
113
171
|
```jsonc
|
|
114
172
|
{
|
|
@@ -119,142 +177,115 @@ temperature: 0.1
|
|
|
119
177
|
"breakGlass": { "marker": "/skip-review" }, // PR body marker that skips the review
|
|
120
178
|
"commentTag": "expo-ai-code-reviewer", // hidden tag used to find/update the comment
|
|
121
179
|
"auth": { "mode": "oauth", "provider": "anthropic",
|
|
122
|
-
"tokenEnv": "
|
|
180
|
+
"tokenEnv": "ANTHROPIC_OAUTH_API_KEY" }
|
|
123
181
|
}
|
|
124
182
|
```
|
|
125
183
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
## Authentication
|
|
129
|
-
|
|
130
|
-
Model credentials come from OpenCode. Two modes, set in `config.auth`:
|
|
131
|
-
|
|
132
|
-
- **`api-key`** — the token in `tokenEnv` is copied into the provider's API-key
|
|
133
|
-
env var (e.g. `ANTHROPIC_API_KEY`).
|
|
134
|
-
- **`oauth`** — a Claude Pro/Max token (from `claude setup-token`, an
|
|
135
|
-
`sk-ant-oat…` token, *not* an x-api-key) is written into an isolated OpenCode
|
|
136
|
-
`auth.json` as a bearer credential, so it uses the native subscription path.
|
|
137
|
-
|
|
138
|
-
Set **`REVIEWER_MODEL`** to override the model for every agent and use your own
|
|
139
|
-
OpenCode login instead of the repo's configured credentials — handy locally
|
|
140
|
-
(e.g. `REVIEWER_MODEL=openai/gpt-5.4-mini-fast`). There is no shared fallback key;
|
|
141
|
-
if a run fails for lack of credentials, authenticate a provider in OpenCode.
|
|
184
|
+
</details>
|
|
142
185
|
|
|
143
|
-
|
|
186
|
+
<details>
|
|
187
|
+
<summary><b>Model selection</b></summary>
|
|
144
188
|
|
|
145
|
-
|
|
189
|
+
Precedence: **`REVIEWER_MODEL` env** (global override) → per-file **frontmatter
|
|
190
|
+
`model:`** → **`config.jsonc` `model`** (the default). So a repo can run a mixed
|
|
191
|
+
setup, and a developer can override everything locally.
|
|
146
192
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
and benefit from a reasoning-tier model — **Sonnet** is the quality/speed sweet
|
|
155
|
-
spot (the default for correctness/consistency). **Opus** finds more but is slower
|
|
156
|
-
and more rate-limited, which makes large-PR timeouts worse — so scope it to the
|
|
157
|
-
one agent where the extra threat-model reasoning pays off most: **security runs on
|
|
158
|
-
Opus** (set in `security.md` frontmatter), the rest on Sonnet. This keeps the
|
|
159
|
-
latency/rate-limit cost to a single agent, and the timeout handling (subdivide +
|
|
160
|
-
per-fetch deadline) keeps a slow Opus pass from hanging the run.
|
|
161
|
-
- **The coordinator** only consolidates text (no repo tools), so a fast, cheap
|
|
162
|
-
model — **Haiku** — fits well and keeps the serial tail short. Set it in
|
|
163
|
-
`coordinator.md` frontmatter.
|
|
193
|
+
- **Specialist agents** (correctness/security/consistency) benefit from a
|
|
194
|
+
reasoning-tier model — **Sonnet** is the quality/speed sweet spot (default for
|
|
195
|
+
correctness/consistency). **Opus** finds more but is slower and more
|
|
196
|
+
rate-limited, so scope it to the highest-stakes agent: **security runs on Opus**
|
|
197
|
+
(set in `security.md` frontmatter), the rest on Sonnet.
|
|
198
|
+
- **The coordinator** makes the final call (dedupe / re-judge / decide) — worth a
|
|
199
|
+
strong model; set it in `coordinator.md` frontmatter.
|
|
164
200
|
- If latency/timeouts dominate on big PRs, moving the specialists to a faster model
|
|
165
201
|
is the most direct lever (a real recall tradeoff — measure it).
|
|
166
202
|
|
|
167
|
-
Example mixed setup:
|
|
168
|
-
|
|
169
|
-
```jsonc
|
|
170
|
-
// config.jsonc
|
|
171
|
-
"model": "anthropic/claude-sonnet-5" // default: specialists + cross-file pass
|
|
172
|
-
```
|
|
173
|
-
```markdown
|
|
174
|
-
<!-- security.md frontmatter --> → Opus for the highest-stakes agent
|
|
175
|
-
---
|
|
176
|
-
model: anthropic/claude-opus-4-8
|
|
177
|
-
---
|
|
178
|
-
|
|
179
|
-
<!-- coordinator.md frontmatter --> → Haiku for the text-only consolidation
|
|
180
|
-
---
|
|
181
|
-
model: anthropic/claude-haiku-4-5-20251001
|
|
182
|
-
---
|
|
183
|
-
```
|
|
184
|
-
|
|
185
203
|
There is no automatic cross-provider "equivalent" fallback — that would silently
|
|
186
204
|
change which model reviewed your code. Use an explicit override instead.
|
|
187
205
|
|
|
188
|
-
|
|
206
|
+
</details>
|
|
189
207
|
|
|
190
|
-
|
|
191
|
-
|
|
208
|
+
<details>
|
|
209
|
+
<summary><b>Reliability</b> — never hangs, never silently drops work</summary>
|
|
192
210
|
|
|
193
|
-
- **Per-task time caps** —
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
job's `timeout-minutes` (60), since the coordinator + verification run afterward.
|
|
211
|
+
- **Per-task time caps** — chunk passes 15 min; cross-cutting 25 min; coordinator
|
|
212
|
+
10 min. A global passes budget (32 min) bounds all passes incl. the subdivision
|
|
213
|
+
waves, fitting inside the CI job's `timeout-minutes` (60).
|
|
197
214
|
- **Tool-call cap** — a pass that makes too many `read`/`grep` calls without
|
|
198
|
-
finishing is *wandering*, not converging
|
|
199
|
-
timeout). Hitting the cap trips the same soft landing as the time cap.
|
|
215
|
+
finishing is *wandering*, not converging; hitting the cap trips the soft landing.
|
|
200
216
|
- **Soft landing on timeout** — at either cap, the run is interrupted and the agent
|
|
201
217
|
is asked to return the findings it already has, rather than discarding its work.
|
|
202
|
-
- **Subdivide-on-timeout —
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
fast **no-tools fallback** reviews just its inlined diff (a lighter review, but
|
|
207
|
-
never nothing). Only if *that* can't finish inside the budget is a coverage gap
|
|
208
|
-
reported — and it is always reported, never silent.
|
|
218
|
+
- **Subdivide-on-timeout** — a pass that times out with nothing to show has its
|
|
219
|
+
chunk split in half and the halves re-reviewed (recursively, down to a single
|
|
220
|
+
file), then a fast **no-tools fallback** over the inlined diff. Only a genuinely
|
|
221
|
+
un-reducible pass reports a coverage gap — and it is always reported, never silent.
|
|
209
222
|
- **Parse failures are retried** (same session, then once in a bounded fresh
|
|
210
|
-
session)
|
|
211
|
-
- **A failed run never reads as "Approve"** —
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
actions, labels = persistent configuration.**
|
|
227
|
-
|
|
228
|
-
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
or `secrets` finding can never be hidden this way. (Also: an inline
|
|
248
|
-
`expo-code-review-ignore` comment on/above a line suppresses that line's findings,
|
|
249
|
-
same critical/secrets carve-out.)
|
|
250
|
-
|
|
251
|
-
These workflows are comment-only (they never fail the PR's checks). For security,
|
|
252
|
-
they build/run only the trusted base ref (never the PR head) — see the comment at
|
|
253
|
-
the top of each file.
|
|
254
|
-
|
|
255
|
-
## Run logs
|
|
223
|
+
session) — separate from the timeout path.
|
|
224
|
+
- **A failed run never reads as "Approve"** — all passes fail → "could not
|
|
225
|
+
complete"; some fail → never a clean approve, and coverage-reduced.
|
|
226
|
+
- **The coordinator can't sink the run** — if consolidation fails, findings are
|
|
227
|
+
merged deterministically and still posted.
|
|
228
|
+
- **Coverage notes** — passes that timed out/failed are listed (routine noise
|
|
229
|
+
filtering is *not* flagged — it's expected and stays in the run log).
|
|
230
|
+
- **CI always gets a terminal state** — on any failure the PR gets a "didn't run"
|
|
231
|
+
comment, not a stuck reaction and silence.
|
|
232
|
+
|
|
233
|
+
</details>
|
|
234
|
+
|
|
235
|
+
<details>
|
|
236
|
+
<summary><b>CI usage</b></summary>
|
|
237
|
+
|
|
238
|
+
`ecr init --with-workflow` scaffolds a `pull_request` workflow. Split along a clean
|
|
239
|
+
line: **comments = one-shot actions, labels = persistent configuration.**
|
|
240
|
+
|
|
241
|
+
- **command workflow** — one-shot `/review` comments (maintainers): `/review`
|
|
242
|
+
(router picks agents), `/review all`, `/review correctness security`. Never
|
|
243
|
+
changes configuration.
|
|
244
|
+
- **auto workflow** — continuous review, configured by **labels**: `ai-review`
|
|
245
|
+
(router), `ai-review:all`, `ai-review:<agent>` (e.g. `ai-review:security`;
|
|
246
|
+
combine to widen), `ai-review:skip` (opt-out).
|
|
247
|
+
- **dismiss workflow** — `/dismiss <id> [… -- reason]` / `/undismiss <id>`
|
|
248
|
+
(maintainers). Each finding shows a short `` `id:…` ``. Dismissal is a **display
|
|
249
|
+
filter only** — the reviewer still analyzes everything, and a `critical`/`secrets`
|
|
250
|
+
finding can never be hidden. (An inline `expo-code-review-ignore` comment on/above
|
|
251
|
+
a line does the same, with the same critical/secrets carve-out.)
|
|
252
|
+
|
|
253
|
+
These workflows are comment-only (they never fail the PR's checks). The engine runs
|
|
254
|
+
as the published package via `npx`, so no PR-controlled code is built.
|
|
255
|
+
|
|
256
|
+
</details>
|
|
257
|
+
|
|
258
|
+
<details>
|
|
259
|
+
<summary><b>Run logs</b></summary>
|
|
256
260
|
|
|
257
261
|
Each run appends a JSON line to `.expo-code-review/.runs/reviews.jsonl` with the
|
|
258
262
|
inputs, decision, finding count, duration, per-agent cost, and aggregate token
|
|
259
|
-
usage (
|
|
263
|
+
usage (incl. prompt-cache read/write counts) — for auditing and measuring
|
|
260
264
|
cost/latency/cache reuse over time.
|
|
265
|
+
|
|
266
|
+
</details>
|
|
267
|
+
|
|
268
|
+
<a id="other-providers"></a>
|
|
269
|
+
<details>
|
|
270
|
+
<summary><b>Other providers & auth modes</b></summary>
|
|
271
|
+
|
|
272
|
+
The recommended setup is a Claude Pro/Max subscription (OAuth) — see Usage above.
|
|
273
|
+
Alternatives, all set in `config.auth` (credentials come from OpenCode):
|
|
274
|
+
|
|
275
|
+
- **Anthropic API key** — set `auth.mode` to `"api-key"` and point `tokenEnv` at the
|
|
276
|
+
env var holding the key (e.g. `ANTHROPIC_API_KEY`); it's sent as `x-api-key`. Omit
|
|
277
|
+
the `auth` block entirely to fall back to OpenCode's own login / `ANTHROPIC_API_KEY`.
|
|
278
|
+
- **OAuth (Pro/Max)** — `tokenEnv` holds an `sk-ant-oat…` token from
|
|
279
|
+
`claude setup-token` (*not* an x-api-key); it's written to an isolated OpenCode
|
|
280
|
+
`auth.json` as a bearer credential, using the native subscription path.
|
|
281
|
+
- **OpenAI / GPT, or another provider** — the current path is the `REVIEWER_MODEL`
|
|
282
|
+
env override: `opencode auth login` once (pick the provider), then run with
|
|
283
|
+
e.g. `REVIEWER_MODEL=openai/gpt-5.4-mini-fast`. It overrides every agent's model
|
|
284
|
+
and uses your OpenCode login, so no `auth` block is needed. *(First-class
|
|
285
|
+
per-provider config — Anthropic/OpenAI/others in `config.jsonc`, and mixing them
|
|
286
|
+
per agent — is on the [roadmap](./ROADMAP.md).)*
|
|
287
|
+
|
|
288
|
+
There is no shared fallback key; if a run fails for lack of credentials, authenticate
|
|
289
|
+
a provider in OpenCode. `ecr doctor` diagnoses setup.
|
|
290
|
+
|
|
291
|
+
</details>
|
package/build/commands/init.js
CHANGED
|
@@ -9,12 +9,12 @@ const TEMPLATES_DIR = fileURLToPath(new URL('../../templates/', import.meta.url)
|
|
|
9
9
|
const USAGE = `ecr init — scaffold .expo-code-review/ in the current repo
|
|
10
10
|
|
|
11
11
|
Usage:
|
|
12
|
-
ecr init [--
|
|
12
|
+
ecr init [--no-workflow] [--force]
|
|
13
13
|
|
|
14
14
|
Options:
|
|
15
|
-
--
|
|
16
|
-
--force
|
|
17
|
-
-h, --help
|
|
15
|
+
--no-workflow Skip writing the CI workflow (.github/workflows/expo-code-review.yml)
|
|
16
|
+
--force Overwrite existing files
|
|
17
|
+
-h, --help Show this help
|
|
18
18
|
`;
|
|
19
19
|
export async function initCommand(argv) {
|
|
20
20
|
if (argv.includes('-h') || argv.includes('--help')) {
|
|
@@ -32,7 +32,10 @@ export async function initCommand(argv) {
|
|
|
32
32
|
/** Scaffold .expo-code-review/ (and optionally the CI workflow) into the repo. */
|
|
33
33
|
async function scaffold(argv) {
|
|
34
34
|
const force = argv.includes('--force');
|
|
35
|
-
|
|
35
|
+
// The CI workflow is scaffolded by default (most repos adopting this want it);
|
|
36
|
+
// `--no-workflow` opts out. `--with-workflow` is still accepted as a no-op for
|
|
37
|
+
// back-compat.
|
|
38
|
+
const withWorkflow = !argv.includes('--no-workflow');
|
|
36
39
|
const root = (await repoRoot()) ?? process.cwd();
|
|
37
40
|
const configDir = path.join(root, CONFIG_DIRNAME);
|
|
38
41
|
// Create only the config dir; let copyInto create prompts/ so it reports
|
|
@@ -70,8 +73,8 @@ async function scaffold(argv) {
|
|
|
70
73
|
' 2. Configure a model provider in OpenCode (or set REVIEWER_MODEL).',
|
|
71
74
|
' 3. Run `ecr doctor`, then `ecr review`.',
|
|
72
75
|
withWorkflow
|
|
73
|
-
? ' 4. Add the model-key secret referenced by the workflow.'
|
|
74
|
-
: ' 4.
|
|
76
|
+
? ' 4. Add the model-key secret referenced by the workflow, then add an `ai-review` label to a PR.'
|
|
77
|
+
: ' 4. (No CI workflow written — re-run without `--no-workflow` to add it.)',
|
|
75
78
|
'',
|
|
76
79
|
].join('\n'));
|
|
77
80
|
}
|
package/build/core/prompts.js
CHANGED
|
@@ -182,25 +182,34 @@ export function buildCrossCuttingTask(allFiles, filtered = []) {
|
|
|
182
182
|
export function buildVerifierSystem() {
|
|
183
183
|
return [
|
|
184
184
|
'You are a skeptical verifier of a single code-review finding. Your default is',
|
|
185
|
-
'DISTRUST. Using your read/grep tools, open the cited file
|
|
186
|
-
'
|
|
185
|
+
'DISTRUST. Using your read/grep tools, open the cited file (search nearby files',
|
|
186
|
+
'if the code is not exactly there), locate the relevant code, and judge whether',
|
|
187
|
+
'the described PROBLEM is actually present in the source.',
|
|
188
|
+
'',
|
|
189
|
+
'Judge the SUBSTANCE, not the wording. The finding\'s quoted "evidence" may be',
|
|
190
|
+
'paraphrased, abbreviated, quoted across non-adjacent lines, or slightly',
|
|
191
|
+
'misquoted, and its file/line may be approximate. None of that alone makes the',
|
|
192
|
+
'finding false — verify against what the code actually does. Do NOT reject merely',
|
|
193
|
+
'because the quoted snippet is not a verbatim match; reject only if the',
|
|
194
|
+
'underlying problem is not real.',
|
|
187
195
|
'',
|
|
188
196
|
'Mark verified=false (reject) if any of these hold:',
|
|
189
|
-
'- the
|
|
190
|
-
'
|
|
191
|
-
'- the described failure/exploit cannot actually
|
|
197
|
+
'- the described problem does not actually occur in the code (it misread or',
|
|
198
|
+
' invented the behavior),',
|
|
199
|
+
'- the described failure/exploit cannot actually happen,',
|
|
192
200
|
"- the claim is internally contradictory (e.g. asserts a type error in code that",
|
|
193
201
|
' compiles), or',
|
|
194
|
-
'- you cannot substantiate
|
|
202
|
+
'- you cannot substantiate the underlying issue after reading the file.',
|
|
195
203
|
'',
|
|
196
|
-
'
|
|
197
|
-
'
|
|
204
|
+
'Mark verified=true when you have CONFIRMED, from the real source, that the',
|
|
205
|
+
'described problem genuinely exists. When genuinely unsure whether it is real,',
|
|
206
|
+
'reject.',
|
|
198
207
|
'',
|
|
199
208
|
'Return ONLY this JSON object and nothing else:',
|
|
200
209
|
'{"verified": true|false, "reason": "one concise sentence grounded in the file"}',
|
|
201
210
|
].join('\n');
|
|
202
211
|
}
|
|
203
|
-
export function buildVerifierTask(finding) {
|
|
212
|
+
export function buildVerifierTask(finding, opts = {}) {
|
|
204
213
|
const lines = [
|
|
205
214
|
'Verify this finding by reading the real source (do not trust its wording):',
|
|
206
215
|
'',
|
|
@@ -214,6 +223,9 @@ export function buildVerifierTask(finding) {
|
|
|
214
223
|
if (finding.evidence) {
|
|
215
224
|
lines.push('- code the finding claims is present (UNTRUSTED — verify it against the file):', '<<<EVIDENCE', finding.evidence, 'EVIDENCE');
|
|
216
225
|
}
|
|
226
|
+
if (opts.evidenceUngrounded) {
|
|
227
|
+
lines.push('', 'NOTE: the quoted evidence could NOT be located verbatim in the file. It may be', 'a paraphrase, an elision, or a slightly wrong location — do not reject on that', 'basis alone. Read the file (and nearby files) and judge whether the described', 'problem is genuinely present.');
|
|
228
|
+
}
|
|
217
229
|
lines.push('', 'Open the file, find the relevant code, and return the single verdict JSON object.');
|
|
218
230
|
return lines.join('\n');
|
|
219
231
|
}
|
package/build/core/verify.js
CHANGED
|
@@ -10,16 +10,46 @@ const VERIFY_TIMEOUT_MS = 3 * 60 * 1000;
|
|
|
10
10
|
// Evidence shorter than this (normalized) is too weak to conclude "hallucinated".
|
|
11
11
|
const MIN_EVIDENCE_LEN = 12;
|
|
12
12
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* Break `evidence` into normalized, substantive fragments for fuzzy matching:
|
|
14
|
+
* split on newlines AND ellipses (the model often elides with `…`/`...`), strip
|
|
15
|
+
* leading diff markers (`+`/`-`) and comment markers (`//`, `#`, `*`) it may have
|
|
16
|
+
* copied along, normalize, and keep only fragments long enough to be meaningful.
|
|
17
17
|
*/
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
export function evidenceFragments(evidence) {
|
|
19
|
+
return evidence
|
|
20
|
+
.split(/\r?\n|…|\.\.\./)
|
|
21
|
+
.map(line => line.replace(/^[+\-\s]*/, '').replace(/^(\/\/+|#+|\*+|\/\*)\s?/, ''))
|
|
22
|
+
.map(normalizeCode)
|
|
23
|
+
.filter(fragment => fragment.length >= MIN_EVIDENCE_LEN);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Does the finding's `evidence` correspond to code in the file?
|
|
27
|
+
* - exact (whitespace-normalized) substring → 'present'
|
|
28
|
+
* - else any substantive line/fragment present verbatim → 'present' (fuzzy: this
|
|
29
|
+
* rescues cross-line quotes, ellipsis elisions, and copied comment/diff markers)
|
|
30
|
+
* - a real quote that matches nothing → 'absent'
|
|
31
|
+
* Returns 'unknown' (don't judge) when evidence is too short to conclude anything
|
|
32
|
+
* or the file can't be read, so we never drop a finding we couldn't actually check.
|
|
33
|
+
* NOTE: 'absent' is NOT terminal — the caller escalates it to the LLM verifier
|
|
34
|
+
* rather than dropping, because an imperfect quote does not mean a false finding.
|
|
35
|
+
*/
|
|
36
|
+
export function matchEvidence(evidence, content) {
|
|
37
|
+
const normEvidence = normalizeCode(evidence);
|
|
38
|
+
if (normEvidence.length < MIN_EVIDENCE_LEN) {
|
|
39
|
+
return 'unknown';
|
|
40
|
+
}
|
|
41
|
+
const normContent = normalizeCode(content);
|
|
42
|
+
if (normContent.includes(normEvidence)) {
|
|
43
|
+
return 'present';
|
|
44
|
+
}
|
|
45
|
+
const fragments = evidenceFragments(evidence);
|
|
46
|
+
if (fragments.length === 0) {
|
|
21
47
|
return 'unknown';
|
|
22
48
|
}
|
|
49
|
+
return fragments.some(fragment => normContent.includes(fragment)) ? 'present' : 'absent';
|
|
50
|
+
}
|
|
51
|
+
/** Read the cited file and grade the evidence against it (see matchEvidence). */
|
|
52
|
+
async function evidencePresence(finding, cwd) {
|
|
23
53
|
let content;
|
|
24
54
|
try {
|
|
25
55
|
content = await readFile(path.resolve(cwd, finding.file), 'utf8');
|
|
@@ -27,67 +57,73 @@ async function evidencePresence(finding, cwd) {
|
|
|
27
57
|
catch {
|
|
28
58
|
return 'unknown';
|
|
29
59
|
}
|
|
30
|
-
return
|
|
60
|
+
return matchEvidence(finding.evidence ?? '', content);
|
|
31
61
|
}
|
|
32
62
|
/**
|
|
33
|
-
* Guard against hallucinated findings before they're surfaced
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
63
|
+
* Guard against hallucinated findings before they're surfaced, WITHOUT silently
|
|
64
|
+
* dropping real ones on an imperfect quote:
|
|
65
|
+
* 1. Quote-grounding (deterministic, all findings): grade each finding's `evidence`
|
|
66
|
+
* against the file (exact + fuzzy — see matchEvidence).
|
|
67
|
+
* 2. LLM verify (adversarial, in parallel) runs for a finding when EITHER:
|
|
68
|
+
* - its evidence is `absent` (any severity) — the quote isn't grounded, but
|
|
69
|
+
* that alone doesn't make the finding false, so the verifier re-reads the
|
|
70
|
+
* real file (and nearby files) and judges the underlying problem; or
|
|
71
|
+
* - it's a `critical` (even if grounded) — a skeptical double-check.
|
|
72
|
+
* A finding is dropped ONLY when the verifier refutes it. `present`/`unknown`
|
|
73
|
+
* non-criticals are kept without an LLM call (the fast, cheap path).
|
|
74
|
+
* Fails OPEN — if a verify call itself errors, the finding is kept (better a
|
|
75
|
+
* possible false positive than hiding a real finding on an infra hiccup).
|
|
76
|
+
*
|
|
77
|
+
* This replaces the old "absent evidence → hard drop" rule, which was suppressing
|
|
78
|
+
* real findings whose natural evidence (a structural/absence bug, a cross-line
|
|
79
|
+
* quote, a slightly-wrong location) wasn't a verbatim substring.
|
|
40
80
|
*/
|
|
41
81
|
export async function verifyFindings(handle, findings, cwd, onProgress) {
|
|
42
82
|
const dropped = [];
|
|
43
83
|
let cost = 0;
|
|
44
84
|
const tokens = {};
|
|
45
|
-
// Phase 1 — quote-grounding for every finding.
|
|
85
|
+
// Phase 1 — deterministic quote-grounding for every finding.
|
|
46
86
|
const checked = await Promise.all(findings.map(async (finding) => ({ finding, presence: await evidencePresence(finding, cwd) })));
|
|
47
|
-
|
|
87
|
+
// Decide which findings need an LLM check vs. can be kept directly.
|
|
88
|
+
const verdicts = new Map();
|
|
89
|
+
const toVerify = [];
|
|
48
90
|
for (const { finding, presence } of checked) {
|
|
49
|
-
if (presence === 'absent') {
|
|
50
|
-
|
|
51
|
-
onProgress?.(` verify: dropped ${finding.severity} "${finding.title}" — quoted code not in ${finding.file}`);
|
|
91
|
+
if (presence === 'absent' || finding.severity === 'critical') {
|
|
92
|
+
toVerify.push({ finding, presence });
|
|
52
93
|
}
|
|
53
94
|
else {
|
|
54
|
-
|
|
95
|
+
verdicts.set(finding, 'keep'); // grounded (or uncheckable) non-critical
|
|
55
96
|
}
|
|
56
97
|
}
|
|
57
|
-
// Phase 2 —
|
|
58
|
-
|
|
59
|
-
await Promise.all(survivors
|
|
60
|
-
.filter(finding => finding.severity === 'critical')
|
|
61
|
-
.map(async (finding, index) => {
|
|
98
|
+
// Phase 2 — LLM verify (parallel). Refuted → drop; verified or errored → keep.
|
|
99
|
+
await Promise.all(toVerify.map(async ({ finding, presence }, index) => {
|
|
62
100
|
try {
|
|
63
101
|
const { value, cost: verifyCost, tokens: verifyTokens } = await promptAndParse(handle, {
|
|
64
102
|
agent: VERIFIER_AGENT,
|
|
65
103
|
system: buildVerifierSystem(),
|
|
66
|
-
text: buildVerifierTask(finding),
|
|
104
|
+
text: buildVerifierTask(finding, { evidenceUngrounded: presence === 'absent' }),
|
|
67
105
|
title: `verify-${index}`,
|
|
68
106
|
maxWaitMs: VERIFY_TIMEOUT_MS,
|
|
69
107
|
finalizeOnTimeout: true,
|
|
70
108
|
}, parseVerdict);
|
|
71
109
|
cost += verifyCost;
|
|
72
110
|
addTokenUsage(tokens, verifyTokens);
|
|
73
|
-
if (
|
|
74
|
-
|
|
75
|
-
|
|
111
|
+
if (value.verified) {
|
|
112
|
+
verdicts.set(finding, 'keep');
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
verdicts.set(finding, 'drop');
|
|
116
|
+
dropped.push({ finding, reason: value.reason || 'refuted by verifier' });
|
|
117
|
+
onProgress?.(` verify: dropped ${finding.severity} "${finding.title}" — ${value.reason || 'refuted by verifier'}`);
|
|
76
118
|
}
|
|
77
119
|
}
|
|
78
120
|
catch (error) {
|
|
79
|
-
// Fail open: keep the
|
|
80
|
-
|
|
121
|
+
// Fail open: keep the finding if verification itself failed.
|
|
122
|
+
verdicts.set(finding, 'keep');
|
|
123
|
+
onProgress?.(` verify: could not verify "${finding.title}" (${errorMessage(error)}); keeping it`);
|
|
81
124
|
}
|
|
82
125
|
}));
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if (refuted.has(finding)) {
|
|
86
|
-
dropped.push({ finding, reason: 'refuted by verifier' });
|
|
87
|
-
}
|
|
88
|
-
else {
|
|
89
|
-
kept.push(finding);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
126
|
+
// Preserve original order.
|
|
127
|
+
const kept = findings.filter(finding => verdicts.get(finding) === 'keep');
|
|
92
128
|
return { kept, dropped, cost, tokens };
|
|
93
129
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@expo/code-review-cli",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Generic, config-driven AI code reviewer engine. Repos supply their agents via .expo-code-review/.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"typecheck": "tsc --noEmit",
|
|
29
29
|
"dev": "bun run src/cli.ts",
|
|
30
30
|
"test:unit": "bun test",
|
|
31
|
+
"release": "bash scripts/release.sh",
|
|
31
32
|
"prepublishOnly": "rimraf build && tsc -p tsconfig.build.json"
|
|
32
33
|
},
|
|
33
34
|
"dependencies": {
|
package/templates/config.jsonc
CHANGED
|
@@ -31,14 +31,16 @@
|
|
|
31
31
|
// HTML marker used to find + update the single PR comment. Keep it stable.
|
|
32
32
|
"commentTag": "expo-ai-code-reviewer",
|
|
33
33
|
|
|
34
|
-
// How model credentials are provided.
|
|
35
|
-
// "api-key": tokenEnv holds a provider API key (sent as x-api-key). If you
|
|
36
|
-
// omit `auth`, OpenCode's own login / ANTHROPIC_API_KEY is used.
|
|
34
|
+
// How model credentials are provided. Default: a Claude Pro/Max subscription.
|
|
37
35
|
// "oauth": tokenEnv holds a Claude Pro/Max OAuth token (from
|
|
38
36
|
// `claude setup-token`); it's injected as a bearer credential.
|
|
37
|
+
// "api-key": tokenEnv holds a provider API key (sent as x-api-key). If you
|
|
38
|
+
// omit `auth`, OpenCode's own login / ANTHROPIC_API_KEY is used.
|
|
39
|
+
// For OpenAI/GPT or another provider, omit `auth` and set REVIEWER_MODEL after an
|
|
40
|
+
// `opencode auth login` for that provider.
|
|
39
41
|
"auth": {
|
|
40
|
-
"mode": "
|
|
42
|
+
"mode": "oauth",
|
|
41
43
|
"provider": "anthropic",
|
|
42
|
-
"tokenEnv": "
|
|
44
|
+
"tokenEnv": "ANTHROPIC_OAUTH_API_KEY"
|
|
43
45
|
}
|
|
44
46
|
}
|
package/templates/shared.md
CHANGED
|
@@ -65,7 +65,7 @@ Return **only** a single fenced ```json code block, an object of this shape:
|
|
|
65
65
|
"line": 142,
|
|
66
66
|
"title": "short one-line summary",
|
|
67
67
|
"rationale": "why this is a problem, with the concrete failure/exploit path",
|
|
68
|
-
"evidence": "
|
|
68
|
+
"evidence": "one contiguous line of the flagged code, copied VERBATIM",
|
|
69
69
|
"suggestion": "optional concrete fix, or omit"
|
|
70
70
|
}
|
|
71
71
|
]
|
|
@@ -73,7 +73,9 @@ Return **only** a single fenced ```json code block, an object of this shape:
|
|
|
73
73
|
```
|
|
74
74
|
|
|
75
75
|
`line` is the start line in the new version of the file, or `null` if not
|
|
76
|
-
line-specific. `evidence`
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
76
|
+
line-specific. `evidence` is used to help verify the finding, so make it easy to
|
|
77
|
+
locate: copy **one contiguous line** of the flagged code **verbatim** (not spanning
|
|
78
|
+
multiple lines, no `…` elisions, no paraphrasing). For a structural/"missing" issue,
|
|
79
|
+
quote the single most relevant real line (e.g. the early `return` that skips the
|
|
80
|
+
handling). If you have nothing to report, return `{ "findings": [] }`. Emit no prose
|
|
81
|
+
outside the JSON block.
|
package/templates/workflow.yml
CHANGED
|
@@ -2,12 +2,13 @@ name: AI code review
|
|
|
2
2
|
|
|
3
3
|
on:
|
|
4
4
|
pull_request:
|
|
5
|
-
types: [opened, synchronize, reopened]
|
|
5
|
+
types: [opened, synchronize, reopened, labeled, unlabeled]
|
|
6
6
|
|
|
7
|
-
# Comment-only:
|
|
7
|
+
# Comment-only: read the repo, write PR comments (issue comments API).
|
|
8
8
|
permissions:
|
|
9
9
|
contents: read
|
|
10
10
|
pull-requests: write
|
|
11
|
+
issues: write
|
|
11
12
|
|
|
12
13
|
concurrency:
|
|
13
14
|
group: ai-code-review-${{ github.event.pull_request.number }}
|
|
@@ -16,28 +17,53 @@ concurrency:
|
|
|
16
17
|
jobs:
|
|
17
18
|
review:
|
|
18
19
|
runs-on: ubuntu-latest
|
|
20
|
+
# Opt-in per PR: only run when the `ai-review` label is present. Remove this
|
|
21
|
+
# line to review every PR automatically.
|
|
22
|
+
if: contains(join(github.event.pull_request.labels.*.name, ','), 'ai-review')
|
|
23
|
+
# Backstop so a stalled review fails fast instead of hanging.
|
|
24
|
+
timeout-minutes: 60
|
|
19
25
|
# A reviewer failure must never fail the PR's checks.
|
|
20
26
|
continue-on-error: true
|
|
21
27
|
steps:
|
|
22
|
-
- uses: actions/checkout@
|
|
28
|
+
- uses: actions/checkout@v5
|
|
23
29
|
with:
|
|
24
|
-
|
|
30
|
+
# Shallow is enough — the reviewer gets the diff from the API (`gh`).
|
|
31
|
+
fetch-depth: 1
|
|
25
32
|
|
|
26
|
-
|
|
33
|
+
# SECURITY: this workflow checks out the PR's code, including
|
|
34
|
+
# .expo-code-review/config.jsonc, whose auth.tokenEnv names the env var the CLI
|
|
35
|
+
# forwards as the model credential. Refuse to run unless it's the expected value
|
|
36
|
+
# (below / repo var ECR_EXPECTED_TOKEN_ENV) so a PR can't repoint it at another
|
|
37
|
+
# secret in the runner. Keep this in sync with auth.tokenEnv in config.jsonc.
|
|
38
|
+
- name: Guard config.jsonc tokenEnv
|
|
39
|
+
env:
|
|
40
|
+
EXPECTED: ${{ vars.ECR_EXPECTED_TOKEN_ENV || 'ANTHROPIC_OAUTH_API_KEY' }}
|
|
41
|
+
run: |
|
|
42
|
+
values=$(grep -oE '"tokenEnv"[[:space:]]*:[[:space:]]*"[A-Za-z0-9_]+"' .expo-code-review/config.jsonc | sed -E 's/.*"([A-Za-z0-9_]+)"$/\1/')
|
|
43
|
+
count=$(printf '%s\n' "$values" | grep -c .)
|
|
44
|
+
if [ "$count" != "1" ] || [ "$values" != "$EXPECTED" ]; then
|
|
45
|
+
echo "::error::.expo-code-review/config.jsonc auth.tokenEnv must be \"$EXPECTED\" (found: \"${values:-none}\"). Refusing to run so a PR can't redirect which secret is forwarded to the model provider."
|
|
46
|
+
exit 1
|
|
47
|
+
fi
|
|
48
|
+
|
|
49
|
+
- uses: actions/setup-node@v5
|
|
27
50
|
with:
|
|
28
51
|
node-version: 24
|
|
52
|
+
# The reviewer runs via npx and never installs with a package manager, so
|
|
53
|
+
# disable setup-node's auto package-manager cache (its post step would try
|
|
54
|
+
# to save an empty cache and error).
|
|
55
|
+
package-manager-cache: false
|
|
29
56
|
|
|
30
57
|
- name: Run AI review
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
|
|
34
|
-
# PATH for this process.
|
|
35
|
-
run: npx --yes expo-code-review@latest ci
|
|
58
|
+
# npx installs @expo/code-review-cli and its bundled `opencode` binary and
|
|
59
|
+
# puts them on PATH for this process. Pin @latest to a version to freeze it.
|
|
60
|
+
run: npx --yes -p "@expo/code-review-cli@latest" ecr ci
|
|
36
61
|
continue-on-error: true
|
|
37
62
|
env:
|
|
38
63
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
|
|
42
|
-
|
|
64
|
+
# Claude Pro/Max OAuth token (from `claude setup-token`) — the env var
|
|
65
|
+
# named by auth.tokenEnv in config.jsonc. Store it as a repo secret.
|
|
66
|
+
# (For an API key instead, set auth.mode "api-key" and pass that key here.)
|
|
67
|
+
ANTHROPIC_OAUTH_API_KEY: ${{ secrets.ANTHROPIC_OAUTH_API_KEY }}
|
|
68
|
+
# Optional: override the model for every agent (uses your OpenCode login).
|
|
43
69
|
REVIEWER_MODEL: ${{ vars.REVIEWER_MODEL }}
|