@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.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +260 -0
  3. package/build/cli.js +54 -0
  4. package/build/commands/ci.js +130 -0
  5. package/build/commands/dismiss.js +97 -0
  6. package/build/commands/doctor.js +81 -0
  7. package/build/commands/init.js +82 -0
  8. package/build/commands/review.js +191 -0
  9. package/build/config/load.js +205 -0
  10. package/build/config/schema.js +65 -0
  11. package/build/core/auth.js +102 -0
  12. package/build/core/coordinator.js +24 -0
  13. package/build/core/diff.js +86 -0
  14. package/build/core/exec.js +61 -0
  15. package/build/core/log.js +10 -0
  16. package/build/core/noise.js +186 -0
  17. package/build/core/opencode.js +412 -0
  18. package/build/core/prompts.js +288 -0
  19. package/build/core/render.js +153 -0
  20. package/build/core/review.js +550 -0
  21. package/build/core/router.js +33 -0
  22. package/build/core/schema.js +107 -0
  23. package/build/core/suppress.js +60 -0
  24. package/build/core/tools.js +16 -0
  25. package/build/core/util.js +11 -0
  26. package/build/core/verify.js +93 -0
  27. package/build/reporters/github.js +166 -0
  28. package/build/reporters/reporter.js +1 -0
  29. package/build/reporters/terminal.js +93 -0
  30. package/build/sources/github-pr.js +36 -0
  31. package/build/sources/local-git.js +107 -0
  32. package/build/sources/source.js +1 -0
  33. package/package.json +43 -0
  34. package/templates/agents/consistency.md +53 -0
  35. package/templates/agents/correctness.md +32 -0
  36. package/templates/agents/security.md +51 -0
  37. package/templates/config.jsonc +44 -0
  38. package/templates/coordinator.md +62 -0
  39. package/templates/shared.md +79 -0
  40. 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
+ }