@expo/code-review-cli 0.1.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/LICENSE +21 -0
- package/README.md +260 -0
- package/build/cli.js +54 -0
- package/build/commands/ci.js +130 -0
- package/build/commands/dismiss.js +97 -0
- package/build/commands/doctor.js +81 -0
- package/build/commands/init.js +82 -0
- package/build/commands/review.js +191 -0
- package/build/config/load.js +205 -0
- package/build/config/schema.js +65 -0
- package/build/core/auth.js +102 -0
- package/build/core/coordinator.js +24 -0
- package/build/core/diff.js +86 -0
- package/build/core/exec.js +61 -0
- package/build/core/log.js +10 -0
- package/build/core/noise.js +186 -0
- package/build/core/opencode.js +412 -0
- package/build/core/prompts.js +288 -0
- package/build/core/render.js +153 -0
- package/build/core/review.js +550 -0
- package/build/core/router.js +33 -0
- package/build/core/schema.js +107 -0
- package/build/core/suppress.js +60 -0
- package/build/core/tools.js +16 -0
- package/build/core/util.js +11 -0
- package/build/core/verify.js +93 -0
- package/build/reporters/github.js +166 -0
- package/build/reporters/reporter.js +1 -0
- package/build/reporters/terminal.js +93 -0
- package/build/sources/github-pr.js +36 -0
- package/build/sources/local-git.js +107 -0
- package/build/sources/source.js +1 -0
- package/package.json +43 -0
- package/templates/agents/consistency.md +53 -0
- package/templates/agents/correctness.md +32 -0
- package/templates/agents/security.md +51 -0
- package/templates/config.jsonc +44 -0
- package/templates/coordinator.md +62 -0
- package/templates/shared.md +79 -0
- package/templates/workflow.yml +43 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 650 Industries, Inc. (Expo)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# expo-code-review
|
|
2
|
+
|
|
3
|
+
A config-driven, multi-agent AI code reviewer. Specialist agents review a diff in
|
|
4
|
+
parallel; a coordinator consolidates their findings into one structured review.
|
|
5
|
+
Runs the same engine locally (advisory) and in CI (posts a PR comment).
|
|
6
|
+
|
|
7
|
+
> **Status: experimental.** Phase 1 is **comment-only and non-blocking** — it
|
|
8
|
+
> never blocks a merge and never auto-approves. The package is incubated inside
|
|
9
|
+
> `eas-cli` for fast iteration and is intended to graduate into its own repo; see
|
|
10
|
+
> [`ROADMAP.md`](./ROADMAP.md).
|
|
11
|
+
|
|
12
|
+
The CLI is the **engine**. Each repo supplies its own agents and settings under
|
|
13
|
+
`.expo-code-review/`, so behavior is configured per-repo, not baked in.
|
|
14
|
+
|
|
15
|
+
## How it works
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
diff source ─▶ noise filter ─▶ chunk ─▶ agents (parallel) ─▶ coordinator ─▶ reporter
|
|
19
|
+
(git / gh) drop lockfiles, by each agent reviews dedupe, one PR comment
|
|
20
|
+
generated, changed every chunk + re-judge, (CI) or terminal
|
|
21
|
+
binary files lines a cross-cutting pass decide output (local)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
- **Source** — local git (working tree, staged, or a ref range) or a GitHub PR
|
|
25
|
+
(diff + metadata fetched over the `gh` API).
|
|
26
|
+
- **Noise filter** — drops lockfiles, generated bundles/maps, snapshots, files
|
|
27
|
+
matching the repo's `additionalIgnores`, and binary files (no textual diff to
|
|
28
|
+
review). Filtered files are recorded, not silently dropped.
|
|
29
|
+
- **Chunking** — small PRs run in a single pass; large PRs are split into chunks
|
|
30
|
+
bounded by changed lines, plus one combined **cross-cutting pass** that looks
|
|
31
|
+
for issues spanning multiple changed files across every agent's concern.
|
|
32
|
+
- **Agents** — every `.md` file in `.expo-code-review/agents/` is an agent. They
|
|
33
|
+
run in parallel with read-only repo tools (`read`/`grep`/`glob`/`list`).
|
|
34
|
+
- **Coordinator** — a single pass that dedupes, re-judges severity, and produces
|
|
35
|
+
the final `{ decision, findings, summary }`.
|
|
36
|
+
- **Reporter** — posts/updates a single fingerprinted PR comment (CI), or prints
|
|
37
|
+
a grouped summary (local). Findings below the configured severity floor are
|
|
38
|
+
suppressed.
|
|
39
|
+
|
|
40
|
+
Built on the [OpenCode](https://opencode.ai) SDK, which spawns the model provider
|
|
41
|
+
and applies Anthropic prompt caching automatically.
|
|
42
|
+
|
|
43
|
+
## Commands
|
|
44
|
+
|
|
45
|
+
Run via the workspace during incubation (`yarn workspace expo-code-review dev …`),
|
|
46
|
+
or as the `ecr` / `expo-code-review` binary once built/installed.
|
|
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/`
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
.expo-code-review/
|
|
88
|
+
config.jsonc # model, policy, noise, auth, break-glass, comment tag
|
|
89
|
+
shared.md # instructions prepended to every agent (optional)
|
|
90
|
+
coordinator.md # the consolidation prompt (required)
|
|
91
|
+
agents/
|
|
92
|
+
correctness.md # each .md here is an agent (id = filename)
|
|
93
|
+
security.md
|
|
94
|
+
consistency.md
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`shared.md` and `coordinator.md` are reserved names; every other `.md` in
|
|
98
|
+
`agents/` becomes an agent. Per-agent overrides go in each file's frontmatter:
|
|
99
|
+
|
|
100
|
+
```markdown
|
|
101
|
+
---
|
|
102
|
+
description: One line the router uses to decide relevance.
|
|
103
|
+
alwaysRun: true # run even when the router would skip this agent
|
|
104
|
+
model: anthropic/claude-sonnet-5 # override the default model (e.g. haiku for the coordinator)
|
|
105
|
+
temperature: 0.1
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
# Agent instructions in Markdown…
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### `config.jsonc`
|
|
112
|
+
|
|
113
|
+
```jsonc
|
|
114
|
+
{
|
|
115
|
+
"model": "anthropic/claude-sonnet-5", // default model for the specialists
|
|
116
|
+
"policy": { "includeSuggestions": false }, // suppress suggestion-severity findings
|
|
117
|
+
"chunk": { "maxChangedLines": 1000, "maxFiles": 20, "concurrency": 6 },
|
|
118
|
+
"noise": { "additionalIgnores": ["packages/*/build/**"] },
|
|
119
|
+
"breakGlass": { "marker": "/skip-review" }, // PR body marker that skips the review
|
|
120
|
+
"commentTag": "expo-ai-code-reviewer", // hidden tag used to find/update the comment
|
|
121
|
+
"auth": { "mode": "oauth", "provider": "anthropic",
|
|
122
|
+
"tokenEnv": "DO_NOT_USE_EXPERIMENTAL_ANTHROPIC_API_KEY" }
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
JSONC (comments + trailing commas) is supported.
|
|
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.
|
|
142
|
+
|
|
143
|
+
Run `ecr doctor` to diagnose setup.
|
|
144
|
+
|
|
145
|
+
## Model selection
|
|
146
|
+
|
|
147
|
+
Models are resolved with this precedence: **`REVIEWER_MODEL` env** (global override)
|
|
148
|
+
→ per-file **frontmatter `model:`** → **`config.jsonc` `model`** (the default). So a
|
|
149
|
+
repo can run a mixed setup, and a developer can override everything locally.
|
|
150
|
+
|
|
151
|
+
Rules of thumb for the reviewer's workload:
|
|
152
|
+
|
|
153
|
+
- **Specialist agents** (correctness/security/consistency) do the real bug-finding
|
|
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.
|
|
164
|
+
- If latency/timeouts dominate on big PRs, moving the specialists to a faster model
|
|
165
|
+
is the most direct lever (a real recall tradeoff — measure it).
|
|
166
|
+
|
|
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
|
+
There is no automatic cross-provider "equivalent" fallback — that would silently
|
|
186
|
+
change which model reviewed your code. Use an explicit override instead.
|
|
187
|
+
|
|
188
|
+
## Reliability
|
|
189
|
+
|
|
190
|
+
A review must never hang, silently produce nothing, or present an unreviewed
|
|
191
|
+
change as "looks good":
|
|
192
|
+
|
|
193
|
+
- **Per-task time caps** — focused chunk passes get 15 min; the cross-cutting pass
|
|
194
|
+
gets 25 min; the coordinator gets 10 min. A global passes budget (32 min) bounds
|
|
195
|
+
all passes incl. the subdivision waves below, so everything fits inside the CI
|
|
196
|
+
job's `timeout-minutes` (60), since the coordinator + verification run afterward.
|
|
197
|
+
- **Tool-call cap** — a pass that makes too many `read`/`grep` calls without
|
|
198
|
+
finishing is *wandering*, not converging (the usual cause of a non-convergent
|
|
199
|
+
timeout). Hitting the cap trips the same soft landing as the time cap.
|
|
200
|
+
- **Soft landing on timeout** — at either cap, the run is interrupted and the agent
|
|
201
|
+
is asked to return the findings it already has, rather than discarding its work.
|
|
202
|
+
- **Subdivide-on-timeout — the reviewer never silently drops work.** If a pass
|
|
203
|
+
times out with nothing to show, its chunk is split in half and the halves are
|
|
204
|
+
re-reviewed (recursively, down to a single file). A chunk that won't converge at
|
|
205
|
+
13 files almost always converges at 6. If even a single file won't converge, a
|
|
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.
|
|
209
|
+
- **Parse failures are retried** (same session, then once in a bounded fresh
|
|
210
|
+
session); that is separate from the timeout path above.
|
|
211
|
+
- **A failed run never reads as "Approve"** — if every pass fails, the review says
|
|
212
|
+
it could not complete (treat as unreviewed); if some passes fail, the decision
|
|
213
|
+
is never a clean approve, and the coordinator is told coverage was reduced.
|
|
214
|
+
- **The coordinator can't sink the run** — if the consolidation step fails, findings
|
|
215
|
+
are merged deterministically and still posted, rather than thrown away.
|
|
216
|
+
- **Coverage notes** — passes that timed out or failed are listed so a real
|
|
217
|
+
coverage gap is never silent (routine noise filtering is *not* flagged — it's
|
|
218
|
+
expected and stays in the run log).
|
|
219
|
+
- **CI always gets a terminal state** — on any failure the PR gets a comment saying
|
|
220
|
+
the reviewer didn't run, not a stuck reaction and silence.
|
|
221
|
+
|
|
222
|
+
## CI usage
|
|
223
|
+
|
|
224
|
+
`ecr init --with-workflow` scaffolds a `pull_request` workflow. In this repo the
|
|
225
|
+
reviewer runs via two workflows, split along a clean line: **comments = one-shot
|
|
226
|
+
actions, labels = persistent configuration.**
|
|
227
|
+
|
|
228
|
+
- **`expo-code-review-command.yml`** — one-shot `/review` comments (maintainers):
|
|
229
|
+
- `/review` — run once now; the router picks the agents
|
|
230
|
+
- `/review all` — run once with every agent
|
|
231
|
+
- `/review correctness security` — run once with just those agents
|
|
232
|
+
|
|
233
|
+
These never change configuration.
|
|
234
|
+
- **`expo-code-review.yml`** — continuous review, configured by **labels**:
|
|
235
|
+
- `ai-review` — auto-review every push; the router picks the agents
|
|
236
|
+
- `ai-review:all` — auto-review with every agent
|
|
237
|
+
- `ai-review:<agent>` — auto-review with only those agents (e.g.
|
|
238
|
+
`ai-review:security`); combine several to widen the set
|
|
239
|
+
- `ai-review:skip` — never auto-review this PR (opt-out)
|
|
240
|
+
- **`expo-code-review-dismiss.yml`** — dismiss/restore a finding on a PR (maintainers):
|
|
241
|
+
- `/dismiss <id> [<id> …] [-- reason]` — hide finding(s); they move to a collapsed
|
|
242
|
+
"Dismissed" section and stay dismissed across re-reviews
|
|
243
|
+
- `/undismiss <id> …` — restore them
|
|
244
|
+
|
|
245
|
+
Each finding shows a short `` `id:…` `` in the comment. Dismissal is a **display
|
|
246
|
+
filter only** — the reviewer still analyzes the code every run, and a `critical`
|
|
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
|
|
256
|
+
|
|
257
|
+
Each run appends a JSON line to `.expo-code-review/.runs/reviews.jsonl` with the
|
|
258
|
+
inputs, decision, finding count, duration, per-agent cost, and aggregate token
|
|
259
|
+
usage (including prompt-cache read/write counts) — for auditing and measuring
|
|
260
|
+
cost/latency/cache reuse over time.
|
package/build/cli.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
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
|
+
const USAGE = `expo-code-review (ecr) — config-driven AI code reviewer
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
ecr review [options] Review local changes (default). See \`ecr review --help\`.
|
|
11
|
+
ecr ci Review the current PR and post a comment (GitHub Actions).
|
|
12
|
+
ecr dismiss --pr <n> <id...> Hide a finding on a PR (see \`ecr dismiss --help\`).
|
|
13
|
+
ecr undismiss --pr <n> <id...> Restore a dismissed finding.
|
|
14
|
+
ecr init [--with-workflow] [--force] Scaffold .expo-code-review/ in this repo.
|
|
15
|
+
ecr doctor Check environment, config, and credentials.
|
|
16
|
+
|
|
17
|
+
Agents live in each repo under .expo-code-review/. This CLI is the engine.
|
|
18
|
+
`;
|
|
19
|
+
async function main() {
|
|
20
|
+
const [, , sub, ...rest] = process.argv;
|
|
21
|
+
if (sub === '-h' || sub === '--help' || sub === 'help') {
|
|
22
|
+
process.stdout.write(USAGE);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
// No subcommand (or a leading flag) defaults to `review`.
|
|
26
|
+
if (!sub || sub.startsWith('-')) {
|
|
27
|
+
await reviewCommand(process.argv.slice(2));
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
switch (sub) {
|
|
31
|
+
case 'review':
|
|
32
|
+
await reviewCommand(rest);
|
|
33
|
+
break;
|
|
34
|
+
case 'ci':
|
|
35
|
+
await ciCommand(rest);
|
|
36
|
+
break;
|
|
37
|
+
case 'dismiss':
|
|
38
|
+
await dismissCommand(rest, 'add');
|
|
39
|
+
break;
|
|
40
|
+
case 'undismiss':
|
|
41
|
+
await dismissCommand(rest, 'remove');
|
|
42
|
+
break;
|
|
43
|
+
case 'init':
|
|
44
|
+
await initCommand(rest);
|
|
45
|
+
break;
|
|
46
|
+
case 'doctor':
|
|
47
|
+
await doctorCommand(rest);
|
|
48
|
+
break;
|
|
49
|
+
default:
|
|
50
|
+
process.stderr.write(`Unknown command: ${sub}\n\n${USAGE}`);
|
|
51
|
+
process.exitCode = 2;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
void main();
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { loadReviewConfig } from '../config/load.js';
|
|
3
|
+
import { repoRoot } from '../core/exec.js';
|
|
4
|
+
import { errorMessage } from '../core/util.js';
|
|
5
|
+
import { runReview } from '../core/review.js';
|
|
6
|
+
import { GitHubPRSource } from '../sources/github-pr.js';
|
|
7
|
+
import { GitHubReporter } from '../reporters/github.js';
|
|
8
|
+
/** Resolve the PR number from the Actions event payload or GITHUB_REF. */
|
|
9
|
+
async function resolvePrNumber() {
|
|
10
|
+
const eventPath = process.env.GITHUB_EVENT_PATH;
|
|
11
|
+
if (eventPath) {
|
|
12
|
+
try {
|
|
13
|
+
const event = JSON.parse(await readFile(eventPath, 'utf8'));
|
|
14
|
+
const number = event.pull_request?.number ?? event.issue?.number ?? event.number;
|
|
15
|
+
if (typeof number === 'number') {
|
|
16
|
+
return number;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
// fall through
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const match = (process.env.GITHUB_REF ?? '').match(/refs\/pull\/(\d+)\//);
|
|
24
|
+
return match ? Number(match[1]) : null;
|
|
25
|
+
}
|
|
26
|
+
const CI_USAGE = `ecr ci — review the current GitHub PR and post/update one comment.
|
|
27
|
+
|
|
28
|
+
For GitHub Actions: reads the PR number + repo from the event/env, gets the diff
|
|
29
|
+
via \`gh pr diff\`, runs the reviewer, and upserts a single PR comment. Comment-only
|
|
30
|
+
and non-blocking (a reviewer failure never fails the PR's checks).
|
|
31
|
+
|
|
32
|
+
Options:
|
|
33
|
+
--agents <a,b> Run only these agents (comma-separated ids); default: all
|
|
34
|
+
--route Let the router pick relevant agents from the diff
|
|
35
|
+
-h, --help Show this help
|
|
36
|
+
|
|
37
|
+
Env: GITHUB_REPOSITORY, GITHUB_EVENT_PATH/GITHUB_REF (PR number), GH_TOKEN,
|
|
38
|
+
and model credentials per .expo-code-review/config.jsonc (or REVIEWER_MODEL).
|
|
39
|
+
`;
|
|
40
|
+
export async function ciCommand(argv = []) {
|
|
41
|
+
if (argv.includes('-h') || argv.includes('--help')) {
|
|
42
|
+
process.stdout.write(CI_USAGE);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const agents = parseAgents(argv);
|
|
46
|
+
const route = argv.includes('--route');
|
|
47
|
+
const root = await repoRoot();
|
|
48
|
+
if (root && root !== process.cwd()) {
|
|
49
|
+
process.chdir(root);
|
|
50
|
+
}
|
|
51
|
+
const repo = process.env.GITHUB_REPOSITORY;
|
|
52
|
+
const prNumber = await resolvePrNumber();
|
|
53
|
+
if (!repo || prNumber == null) {
|
|
54
|
+
process.stderr.write('CI reviewer: could not determine repository or PR number from the environment. Skipping.\n');
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
let config;
|
|
58
|
+
try {
|
|
59
|
+
config = await loadReviewConfig(process.cwd());
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
process.stderr.write(`CI reviewer: ${errorMessage(error)}\n`);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const reporter = new GitHubReporter({
|
|
66
|
+
prNumber,
|
|
67
|
+
repo,
|
|
68
|
+
commentTag: config.commentTag,
|
|
69
|
+
breakGlassMarker: config.breakGlassMarker,
|
|
70
|
+
cwd: process.cwd(),
|
|
71
|
+
});
|
|
72
|
+
try {
|
|
73
|
+
if (await reporter.checkBreakGlass()) {
|
|
74
|
+
process.stderr.write(`CI reviewer: ${config.breakGlassMarker} detected; skipping.\n`);
|
|
75
|
+
await reporter.postSkipNote();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
process.stderr.write(`CI reviewer: break-glass check failed (continuing): ${errorMessage(error)}\n`);
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
const review = await runReview(new GitHubPRSource({ prNumber, repo, cwd: process.cwd() }), {
|
|
84
|
+
config,
|
|
85
|
+
mode: 'ci',
|
|
86
|
+
agents,
|
|
87
|
+
route,
|
|
88
|
+
onProgress: message => process.stderr.write(`${message}\n`),
|
|
89
|
+
});
|
|
90
|
+
await reporter.report(review);
|
|
91
|
+
process.stderr.write(`CI reviewer: posted review (${review.decision}).\n`);
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
// A reviewer failure must never fail the PR's checks — but it must also not be
|
|
95
|
+
// silent. Post a terminal state to the PR so the maintainer who triggered it
|
|
96
|
+
// (e.g. a `/review` with a typo'd agent name, or a crash) gets feedback
|
|
97
|
+
// instead of a stuck 👀 reaction and nothing else.
|
|
98
|
+
const reason = errorMessage(error);
|
|
99
|
+
process.stderr.write(`CI reviewer: run failed (non-blocking): ${reason}\n`);
|
|
100
|
+
try {
|
|
101
|
+
await reporter.report({
|
|
102
|
+
decision: 'approve_with_comments',
|
|
103
|
+
findings: [],
|
|
104
|
+
summary: `⚠️ The AI reviewer failed to run, so this change was **not** reviewed:\n\n> ${reason}`,
|
|
105
|
+
incomplete: [],
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
catch (postError) {
|
|
109
|
+
process.stderr.write(`CI reviewer: also failed to post the failure notice: ${errorMessage(postError)}\n`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/** Parse `--agents a,b,c` from argv (undefined = all agents). */
|
|
114
|
+
function parseAgents(argv) {
|
|
115
|
+
const index = argv.indexOf('--agents');
|
|
116
|
+
if (index === -1) {
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
const value = argv[index + 1];
|
|
120
|
+
// A missing value, or the next token being another flag (e.g. `--agents --route`),
|
|
121
|
+
// means no agent list was given — treat as "all" rather than misparsing `--route`
|
|
122
|
+
// as an agent id. Mirrors review.ts's requireValue.
|
|
123
|
+
if (!value || value.startsWith('--')) {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
return value
|
|
127
|
+
.split(',')
|
|
128
|
+
.map(id => id.trim())
|
|
129
|
+
.filter(Boolean);
|
|
130
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { loadReviewConfig } from '../config/load.js';
|
|
2
|
+
import { repoRoot, resolveRepo } from '../core/exec.js';
|
|
3
|
+
import { errorMessage } from '../core/util.js';
|
|
4
|
+
import { GitHubReporter } from '../reporters/github.js';
|
|
5
|
+
const USAGE = `ecr dismiss / undismiss — hide (or restore) a finding on a PR
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
ecr dismiss --pr <n> [--repo <owner/repo>] <id...> [--reason <text>] [--by <user>]
|
|
9
|
+
ecr undismiss --pr <n> [--repo <owner/repo>] <id...>
|
|
10
|
+
|
|
11
|
+
<id> is a finding's short id (shown as \`id:...\` in the reviewer comment). Dismissed
|
|
12
|
+
findings move to a collapsed "Dismissed on this PR" section and stay there across
|
|
13
|
+
re-reviews; the review still runs — this only affects display.
|
|
14
|
+
`;
|
|
15
|
+
function parseArgs(argv) {
|
|
16
|
+
const args = { ids: [] };
|
|
17
|
+
for (let i = 0; i < argv.length; i++) {
|
|
18
|
+
const arg = argv[i];
|
|
19
|
+
switch (arg) {
|
|
20
|
+
case '--pr':
|
|
21
|
+
args.pr = Number(argv[++i]);
|
|
22
|
+
break;
|
|
23
|
+
case '--repo':
|
|
24
|
+
args.repo = argv[++i];
|
|
25
|
+
break;
|
|
26
|
+
case '--reason':
|
|
27
|
+
args.reason = argv[++i];
|
|
28
|
+
break;
|
|
29
|
+
case '--by':
|
|
30
|
+
args.by = argv[++i];
|
|
31
|
+
break;
|
|
32
|
+
default:
|
|
33
|
+
if (arg.startsWith('--')) {
|
|
34
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
35
|
+
}
|
|
36
|
+
// Bare arg = a finding id. Sanitize to the fingerprint alphabet.
|
|
37
|
+
args.ids.push(arg.replace(/[^a-f0-9]/g, ''));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
args.ids = args.ids.filter(Boolean);
|
|
41
|
+
return args;
|
|
42
|
+
}
|
|
43
|
+
export async function dismissCommand(argv, mode) {
|
|
44
|
+
if (argv.includes('-h') || argv.includes('--help')) {
|
|
45
|
+
process.stdout.write(USAGE);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
let args;
|
|
49
|
+
try {
|
|
50
|
+
args = parseArgs(argv);
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
process.stderr.write(`${errorMessage(error)}\n\n${USAGE}`);
|
|
54
|
+
process.exitCode = 2;
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (args.pr == null || !Number.isInteger(args.pr) || args.pr <= 0) {
|
|
58
|
+
process.stderr.write('dismiss: --pr <number> is required.\n');
|
|
59
|
+
process.exitCode = 2;
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (args.ids.length === 0) {
|
|
63
|
+
process.stderr.write('dismiss: provide at least one finding id.\n');
|
|
64
|
+
process.exitCode = 2;
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const root = await repoRoot();
|
|
68
|
+
if (root && root !== process.cwd()) {
|
|
69
|
+
process.chdir(root);
|
|
70
|
+
}
|
|
71
|
+
const cwd = process.cwd();
|
|
72
|
+
try {
|
|
73
|
+
const config = await loadReviewConfig(cwd);
|
|
74
|
+
const repo = args.repo ?? (await resolveRepo(cwd));
|
|
75
|
+
const reporter = new GitHubReporter({
|
|
76
|
+
prNumber: args.pr,
|
|
77
|
+
repo,
|
|
78
|
+
commentTag: config.commentTag,
|
|
79
|
+
breakGlassMarker: config.breakGlassMarker,
|
|
80
|
+
cwd,
|
|
81
|
+
});
|
|
82
|
+
const result = await reporter.applyDismissal(mode === 'add' ? args.ids : [], mode === 'remove' ? args.ids : [], args.by, args.reason);
|
|
83
|
+
if (mode === 'add') {
|
|
84
|
+
process.stderr.write(`Dismissed ${result.matched.length} finding(s) on ${repo}#${args.pr}.\n`);
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
process.stderr.write(`Restored finding(s) on ${repo}#${args.pr}.\n`);
|
|
88
|
+
}
|
|
89
|
+
if (result.unmatched.length > 0) {
|
|
90
|
+
process.stderr.write(`Unknown id(s) (no matching finding): ${result.unmatched.join(', ')}\n`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
process.stderr.write(`dismiss failed: ${errorMessage(error)}\n`);
|
|
95
|
+
process.exitCode = 2;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { loadReviewConfig, hasConfig } from '../config/load.js';
|
|
2
|
+
import { onPath, repoRoot, run } from '../core/exec.js';
|
|
3
|
+
import { errorMessage } from '../core/util.js';
|
|
4
|
+
const USAGE = `ecr doctor — check environment, config, and credentials
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
ecr doctor
|
|
8
|
+
|
|
9
|
+
Verifies: opencode + git (+ gh for \`ecr ci\`) on PATH, .expo-code-review/ config is
|
|
10
|
+
valid, agent prompts resolve, and the configured model's token env is set.
|
|
11
|
+
`;
|
|
12
|
+
/** Preflight checks so a broken setup surfaces clearly instead of silently no-opping. */
|
|
13
|
+
export async function doctorCommand(argv = []) {
|
|
14
|
+
if (argv.includes('-h') || argv.includes('--help')) {
|
|
15
|
+
process.stdout.write(USAGE);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const root = (await repoRoot()) ?? process.cwd();
|
|
19
|
+
let ok = true;
|
|
20
|
+
const line = (pass, message) => {
|
|
21
|
+
if (!pass) {
|
|
22
|
+
ok = false;
|
|
23
|
+
}
|
|
24
|
+
process.stdout.write(` ${pass ? '✓' : '✗'} ${message}\n`);
|
|
25
|
+
};
|
|
26
|
+
process.stdout.write(`expo-code-review doctor (repo: ${root})\n`);
|
|
27
|
+
const opencodeInstalled = await onPath('opencode');
|
|
28
|
+
line(opencodeInstalled, opencodeInstalled
|
|
29
|
+
? 'opencode CLI found on PATH'
|
|
30
|
+
: 'opencode CLI NOT on PATH (install `opencode-ai`, or add node_modules/.bin to PATH)');
|
|
31
|
+
line(await onPath('git'), 'git found on PATH');
|
|
32
|
+
// `gh` is only needed for `ecr ci` (posting PR comments), so treat it as
|
|
33
|
+
// informational (ℹ) rather than a hard failure for local `ecr review` users.
|
|
34
|
+
const info = (message) => {
|
|
35
|
+
process.stdout.write(` ℹ ${message}\n`);
|
|
36
|
+
};
|
|
37
|
+
if (await onPath('gh')) {
|
|
38
|
+
let authed = false;
|
|
39
|
+
try {
|
|
40
|
+
await run('gh', ['auth', 'status'], { cwd: root });
|
|
41
|
+
authed = true;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
authed = false;
|
|
45
|
+
}
|
|
46
|
+
if (authed) {
|
|
47
|
+
line(true, 'gh CLI found and authenticated (used by `ecr ci`)');
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
info('gh CLI found but not authenticated — run `gh auth login` before `ecr ci`');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
info('gh CLI not on PATH — only needed for `ecr ci` (posting PR comments)');
|
|
55
|
+
}
|
|
56
|
+
if (!hasConfig(root)) {
|
|
57
|
+
line(false, `no ${'.expo-code-review'}/config.jsonc (run \`ecr init\`)`);
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
try {
|
|
61
|
+
const config = await loadReviewConfig(root);
|
|
62
|
+
line(true, `config valid: ${config.agents.length} agent(s) [${config.agents.map(a => a.id).join(', ')}], coordinator model ${config.coordinator.model}`);
|
|
63
|
+
line(config.agents.every(a => Boolean(a.promptText.trim())), 'all agent prompt files resolved and non-empty');
|
|
64
|
+
const { mode, provider, tokenEnv } = config.auth;
|
|
65
|
+
if (tokenEnv) {
|
|
66
|
+
const present = Boolean(process.env[tokenEnv]);
|
|
67
|
+
line(present, present
|
|
68
|
+
? `auth: ${mode} for ${provider}; token env ${tokenEnv} is set`
|
|
69
|
+
: `auth: ${mode} for ${provider}; token env ${tokenEnv} is NOT set`);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
line(true, `auth: ${mode} for ${provider}; no tokenEnv configured — relying on OpenCode's own login or REVIEWER_MODEL`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
line(false, `config invalid: ${errorMessage(error)}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
process.stdout.write(ok ? '\nAll good.\n' : '\nIssues found (see ✗ above).\n');
|
|
80
|
+
process.exitCode = ok ? 0 : 1;
|
|
81
|
+
}
|