@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
@@ -0,0 +1,82 @@
1
+ import { cp, mkdir, writeFile } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { CONFIG_DIRNAME } from '../config/load.js';
6
+ import { repoRoot } from '../core/exec.js';
7
+ import { errorMessage } from '../core/util.js';
8
+ const TEMPLATES_DIR = fileURLToPath(new URL('../../templates/', import.meta.url));
9
+ const USAGE = `ecr init — scaffold .expo-code-review/ in the current repo
10
+
11
+ Usage:
12
+ ecr init [--with-workflow] [--force]
13
+
14
+ Options:
15
+ --with-workflow Also write .github/workflows/expo-code-review.yml
16
+ --force Overwrite existing files
17
+ -h, --help Show this help
18
+ `;
19
+ export async function initCommand(argv) {
20
+ if (argv.includes('-h') || argv.includes('--help')) {
21
+ process.stdout.write(USAGE);
22
+ return;
23
+ }
24
+ try {
25
+ await scaffold(argv);
26
+ }
27
+ catch (error) {
28
+ process.stderr.write(`init failed: ${errorMessage(error)}\n`);
29
+ process.exitCode = 2;
30
+ }
31
+ }
32
+ /** Scaffold .expo-code-review/ (and optionally the CI workflow) into the repo. */
33
+ async function scaffold(argv) {
34
+ const force = argv.includes('--force');
35
+ const withWorkflow = argv.includes('--with-workflow');
36
+ const root = (await repoRoot()) ?? process.cwd();
37
+ const configDir = path.join(root, CONFIG_DIRNAME);
38
+ // Create only the config dir; let copyInto create prompts/ so it reports
39
+ // accurately as created vs skipped.
40
+ await mkdir(configDir, { recursive: true });
41
+ const created = [];
42
+ const skipped = [];
43
+ await copyInto(path.join(TEMPLATES_DIR, 'config.jsonc'), path.join(configDir, 'config.jsonc'), force, created, skipped, root);
44
+ await copyInto(path.join(TEMPLATES_DIR, 'shared.md'), path.join(configDir, 'shared.md'), force, created, skipped, root);
45
+ await copyInto(path.join(TEMPLATES_DIR, 'coordinator.md'), path.join(configDir, 'coordinator.md'), force, created, skipped, root);
46
+ await copyInto(path.join(TEMPLATES_DIR, 'agents'), path.join(configDir, 'agents'), force, created, skipped, root);
47
+ const gitignorePath = path.join(configDir, '.gitignore');
48
+ if (force || !existsSync(gitignorePath)) {
49
+ await writeFile(gitignorePath, '.runs/\n', 'utf8');
50
+ created.push(path.relative(root, gitignorePath));
51
+ }
52
+ else {
53
+ skipped.push(path.relative(root, gitignorePath));
54
+ }
55
+ if (withWorkflow) {
56
+ const workflowDir = path.join(root, '.github', 'workflows');
57
+ await mkdir(workflowDir, { recursive: true });
58
+ await copyInto(path.join(TEMPLATES_DIR, 'workflow.yml'), path.join(workflowDir, 'expo-code-review.yml'), force, created, skipped, root);
59
+ }
60
+ for (const file of created) {
61
+ process.stdout.write(` created ${file}\n`);
62
+ }
63
+ for (const file of skipped) {
64
+ process.stdout.write(` skipped ${file} (exists; use --force to overwrite)\n`);
65
+ }
66
+ process.stdout.write([
67
+ '',
68
+ 'Next steps:',
69
+ ` 1. Customize ${CONFIG_DIRNAME}/agents/*.md (and shared.md, coordinator.md) for this repo.`,
70
+ ' 2. Configure a model provider in OpenCode (or set REVIEWER_MODEL).',
71
+ ' 3. Run `ecr doctor`, then `ecr review`.',
72
+ withWorkflow
73
+ ? ' 4. Add the model-key secret referenced by the workflow.'
74
+ : ' 4. Run `ecr init --with-workflow` to add the CI workflow.',
75
+ '',
76
+ ].join('\n'));
77
+ }
78
+ async function copyInto(src, dest, force, created, skipped, root) {
79
+ const existed = existsSync(dest);
80
+ await cp(src, dest, { recursive: true, force, errorOnExist: false });
81
+ (existed && !force ? skipped : created).push(path.relative(root, dest));
82
+ }
@@ -0,0 +1,191 @@
1
+ import { loadReviewConfig } from '../config/load.js';
2
+ import { repoRoot, resolveRepo } from '../core/exec.js';
3
+ import { errorMessage } from '../core/util.js';
4
+ import { runReview } from '../core/review.js';
5
+ import { LocalGitSource } from '../sources/local-git.js';
6
+ import { GitHubPRSource } from '../sources/github-pr.js';
7
+ import { TerminalReporter } from '../reporters/terminal.js';
8
+ import { GitHubReporter } from '../reporters/github.js';
9
+ const USAGE = `ecr review — AI code review, printed to your terminal
10
+
11
+ Usage:
12
+ ecr review [options] review local changes
13
+ ecr review --pr <n> [--post] review a GitHub PR by number
14
+
15
+ Source (pick one):
16
+ (default) diff the working tree against the merge-base
17
+ --base <ref> base ref to diff against
18
+ --head <ref> head ref to diff
19
+ --staged review only staged changes
20
+ --pr <n> review GitHub PR #n by number (diff fetched via \`gh\`, no
21
+ checkout needed); can't be combined with --base/--head/--staged
22
+
23
+ Options:
24
+ --repo <owner/repo> repo for --pr (default: inferred from the current checkout)
25
+ --post with --pr: also post the result as the PR comment (needs
26
+ \`gh\` auth). Omit to only preview here; re-run with --post
27
+ to publish.
28
+ --agents <a,b> run only these agents (comma-separated ids); default: all
29
+ --route let the router pick relevant agents from the diff
30
+ --json emit machine-readable JSON on stdout
31
+ --no-fail always exit 0, even on request-changes
32
+ -h, --help show this help
33
+
34
+ Note: agents read the local working tree for surrounding context, so --pr uses the
35
+ PR's diff (authoritative) but your checked-out files for context. For full fidelity
36
+ on a PR, \`gh pr checkout <n>\` first, then run a plain \`ecr review\`.
37
+
38
+ Exit codes: 0 approve / approve-with-comments, 1 request-changes, 2 error.
39
+ `;
40
+ function requireValue(flag, value) {
41
+ if (value === undefined || value.startsWith('--')) {
42
+ throw new Error(`${flag} requires a value`);
43
+ }
44
+ return value;
45
+ }
46
+ function parseArgs(argv) {
47
+ const args = {
48
+ staged: false,
49
+ post: false,
50
+ route: false,
51
+ json: false,
52
+ noFail: false,
53
+ help: false,
54
+ };
55
+ for (let i = 0; i < argv.length; i++) {
56
+ const arg = argv[i];
57
+ switch (arg) {
58
+ case '--base':
59
+ args.base = requireValue(arg, argv[++i]);
60
+ break;
61
+ case '--head':
62
+ args.head = requireValue(arg, argv[++i]);
63
+ break;
64
+ case '--staged':
65
+ args.staged = true;
66
+ break;
67
+ case '--pr': {
68
+ const value = requireValue(arg, argv[++i]);
69
+ const number = Number(value);
70
+ if (!Number.isInteger(number) || number <= 0) {
71
+ throw new Error(`--pr requires a positive PR number (got "${value}")`);
72
+ }
73
+ args.pr = number;
74
+ break;
75
+ }
76
+ case '--repo':
77
+ args.repo = requireValue(arg, argv[++i]);
78
+ break;
79
+ case '--post':
80
+ args.post = true;
81
+ break;
82
+ case '--agents':
83
+ args.agents = requireValue(arg, argv[++i])
84
+ .split(',')
85
+ .map(id => id.trim())
86
+ .filter(Boolean);
87
+ break;
88
+ case '--route':
89
+ args.route = true;
90
+ break;
91
+ case '--json':
92
+ args.json = true;
93
+ break;
94
+ case '--no-fail':
95
+ args.noFail = true;
96
+ break;
97
+ case '-h':
98
+ case '--help':
99
+ args.help = true;
100
+ break;
101
+ default:
102
+ throw new Error(`Unknown argument: ${arg}`);
103
+ }
104
+ }
105
+ return args;
106
+ }
107
+ export async function reviewCommand(argv) {
108
+ let args;
109
+ try {
110
+ args = parseArgs(argv);
111
+ }
112
+ catch (error) {
113
+ process.stderr.write(`${errorMessage(error)}\n\n${USAGE}`);
114
+ process.exitCode = 2;
115
+ return;
116
+ }
117
+ if (args.help) {
118
+ process.stdout.write(USAGE);
119
+ return;
120
+ }
121
+ try {
122
+ validateArgs(args);
123
+ }
124
+ catch (error) {
125
+ process.stderr.write(`${errorMessage(error)}\n\n${USAGE}`);
126
+ process.exitCode = 2;
127
+ return;
128
+ }
129
+ // The OpenCode server roots at process.cwd(); run from the repo root so agents
130
+ // can read the whole checkout and diff paths resolve correctly.
131
+ const root = await repoRoot();
132
+ if (root && root !== process.cwd()) {
133
+ process.chdir(root);
134
+ }
135
+ try {
136
+ const config = await loadReviewConfig(process.cwd());
137
+ const cwd = process.cwd();
138
+ const source = args.pr != null
139
+ ? new GitHubPRSource({ prNumber: args.pr, repo: args.repo, cwd })
140
+ : new LocalGitSource({ base: args.base, head: args.head, staged: args.staged, cwd });
141
+ const review = await runReview(source, {
142
+ config,
143
+ mode: 'local',
144
+ agents: args.agents,
145
+ route: args.route,
146
+ onProgress: message => process.stderr.write(`${message}\n`),
147
+ });
148
+ // Always print the result here first.
149
+ await new TerminalReporter({ json: args.json, noFail: args.noFail }).report(review);
150
+ // Then, only if asked, publish the same result to the PR.
151
+ if (args.post && args.pr != null) {
152
+ const repo = args.repo ?? (await resolveRepo(cwd));
153
+ const reporter = new GitHubReporter({
154
+ prNumber: args.pr,
155
+ repo,
156
+ commentTag: config.commentTag,
157
+ breakGlassMarker: config.breakGlassMarker,
158
+ cwd,
159
+ });
160
+ // Respect the author's break-glass opt-out, same as the CI path.
161
+ let breakGlass = false;
162
+ try {
163
+ breakGlass = await reporter.checkBreakGlass();
164
+ }
165
+ catch {
166
+ breakGlass = false;
167
+ }
168
+ if (breakGlass) {
169
+ process.stderr.write(`\nNot posting: ${config.breakGlassMarker} is set on ${repo}#${args.pr} (break-glass).\n`);
170
+ }
171
+ else {
172
+ await reporter.report(review);
173
+ process.stderr.write(`\nPosted review to ${repo}#${args.pr}.\n`);
174
+ }
175
+ }
176
+ }
177
+ catch (error) {
178
+ process.stderr.write(`AI review failed: ${errorMessage(error)}\n`);
179
+ process.exitCode = 2;
180
+ }
181
+ }
182
+ /** Reject flag combinations that don't make sense together. */
183
+ function validateArgs(args) {
184
+ if (args.pr != null && (args.base || args.head || args.staged)) {
185
+ throw new Error('--pr reviews a PR by its diff and cannot be combined with --base/--head/--staged.');
186
+ }
187
+ if (args.pr == null && (args.repo || args.post)) {
188
+ throw new Error('--repo/--post only apply together with --pr.');
189
+ }
190
+ }
191
+ /** Resolve owner/repo from the current checkout via gh (for --post). */
@@ -0,0 +1,205 @@
1
+ import { readdir, readFile } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { ReviewConfigSchema } from './schema.js';
5
+ import { toolMap } from '../core/tools.js';
6
+ export const CONFIG_DIRNAME = '.expo-code-review';
7
+ /** Default OpenCode tool toggles for a reviewer: read the repo, never mutate it. */
8
+ const DEFAULT_AGENT_TOOLS = toolMap(['read', 'grep', 'glob', 'list']);
9
+ export function configDirFor(repoRoot) {
10
+ return path.join(repoRoot, CONFIG_DIRNAME);
11
+ }
12
+ export function hasConfig(repoRoot) {
13
+ const dir = configDirFor(repoRoot);
14
+ return existsSync(path.join(dir, 'config.jsonc')) || existsSync(path.join(dir, 'config.json'));
15
+ }
16
+ /**
17
+ * Discover and fully resolve a repo's review config from `.expo-code-review/`:
18
+ * parse config.jsonc, read every prompt file, and resolve models (with an
19
+ * optional REVIEWER_MODEL env override applied to all agents + the coordinator).
20
+ */
21
+ export async function loadReviewConfig(repoRoot) {
22
+ const dir = configDirFor(repoRoot);
23
+ const configPath = ['config.jsonc', 'config.json']
24
+ .map(name => path.join(dir, name))
25
+ .find(candidate => existsSync(candidate));
26
+ if (!configPath) {
27
+ throw new Error(`No ${CONFIG_DIRNAME}/config.jsonc found in ${repoRoot}. Run \`ecr init\` to scaffold one.`);
28
+ }
29
+ const raw = await readFile(configPath, 'utf8');
30
+ const parsed = ReviewConfigSchema.parse(JSON.parse(stripTrailingCommas(stripJsonComments(raw))));
31
+ const override = process.env.REVIEWER_MODEL;
32
+ const defaultModel = override ?? parsed.model;
33
+ const resolveModel = (frontmatterModel) => override ?? frontmatterModel ?? defaultModel;
34
+ const resolveTemp = (value, fallback) => {
35
+ const n = value == null ? NaN : Number(value);
36
+ return Number.isFinite(n) ? n : fallback;
37
+ };
38
+ // shared.md is optional; the coordinator is required.
39
+ const sharedPath = path.join(dir, 'shared.md');
40
+ const sharedPromptText = existsSync(sharedPath)
41
+ ? parseFrontmatter(await readFile(sharedPath, 'utf8')).body
42
+ : '';
43
+ const coordinatorPath = path.join(dir, 'coordinator.md');
44
+ if (!existsSync(coordinatorPath)) {
45
+ throw new Error(`Missing ${CONFIG_DIRNAME}/coordinator.md`);
46
+ }
47
+ const coordinatorMd = parseFrontmatter(await readFile(coordinatorPath, 'utf8'));
48
+ // Every markdown file in agents/ is a reviewer agent (id = filename).
49
+ const agentsDir = path.join(dir, 'agents');
50
+ if (!existsSync(agentsDir)) {
51
+ throw new Error(`Missing ${CONFIG_DIRNAME}/agents/ directory. Run \`ecr init\`.`);
52
+ }
53
+ const agentFiles = (await readdir(agentsDir)).filter(name => name.endsWith('.md')).sort();
54
+ if (agentFiles.length === 0) {
55
+ throw new Error(`No agent markdown files in ${CONFIG_DIRNAME}/agents/.`);
56
+ }
57
+ const agents = [];
58
+ for (const file of agentFiles) {
59
+ const md = parseFrontmatter(await readFile(path.join(agentsDir, file), 'utf8'));
60
+ const id = file.replace(/\.md$/, '');
61
+ agents.push({
62
+ id,
63
+ description: md.data.description ?? '',
64
+ alwaysRun: /^(true|yes|1)$/i.test(md.data.alwaysRun ?? ''),
65
+ model: resolveModel(md.data.model),
66
+ temperature: resolveTemp(md.data.temperature, 0.1),
67
+ tools: DEFAULT_AGENT_TOOLS,
68
+ promptText: md.body,
69
+ });
70
+ }
71
+ return {
72
+ configDir: dir,
73
+ sharedPromptText,
74
+ agents,
75
+ coordinator: {
76
+ model: resolveModel(coordinatorMd.data.model),
77
+ temperature: resolveTemp(coordinatorMd.data.temperature, 0),
78
+ promptText: coordinatorMd.body,
79
+ },
80
+ policy: parsed.policy,
81
+ chunk: parsed.chunk,
82
+ noise: parsed.noise,
83
+ breakGlassMarker: parsed.breakGlass.marker,
84
+ commentTag: parsed.commentTag,
85
+ auth: {
86
+ mode: parsed.auth.mode,
87
+ provider: parsed.auth.provider,
88
+ tokenEnv: parsed.auth.tokenEnv,
89
+ },
90
+ };
91
+ }
92
+ /**
93
+ * Parse optional YAML-ish frontmatter (simple `key: value` scalars) from the top
94
+ * of a markdown file. Returns the parsed keys and the body with frontmatter
95
+ * stripped. Supports per-agent overrides like `model:` and `temperature:`.
96
+ */
97
+ export function parseFrontmatter(md) {
98
+ if (!md.startsWith('---')) {
99
+ return { data: {}, body: md };
100
+ }
101
+ const end = md.indexOf('\n---', 3);
102
+ if (end === -1) {
103
+ return { data: {}, body: md };
104
+ }
105
+ const header = md.slice(3, end).trim();
106
+ const body = md.slice(end + 4).replace(/^\r?\n/, '');
107
+ const data = {};
108
+ for (const line of header.split('\n')) {
109
+ const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
110
+ if (match) {
111
+ data[match[1]] = match[2].trim().replace(/^["']|["']$/g, '');
112
+ }
113
+ }
114
+ return { data, body };
115
+ }
116
+ /**
117
+ * Strip // line and /* *\/ block comments from JSONC, ignoring anything inside
118
+ * string literals. The config is trusted (in-repo), so a light scanner suffices.
119
+ */
120
+ export function stripJsonComments(input) {
121
+ let out = '';
122
+ let inString = false;
123
+ let inLine = false;
124
+ let inBlock = false;
125
+ for (let i = 0; i < input.length; i++) {
126
+ const char = input[i];
127
+ const next = input[i + 1];
128
+ if (inLine) {
129
+ if (char === '\n') {
130
+ inLine = false;
131
+ out += char;
132
+ }
133
+ continue;
134
+ }
135
+ if (inBlock) {
136
+ if (char === '*' && next === '/') {
137
+ inBlock = false;
138
+ i++;
139
+ }
140
+ continue;
141
+ }
142
+ if (inString) {
143
+ out += char;
144
+ if (char === '\\') {
145
+ out += input[i + 1] ?? '';
146
+ i++;
147
+ }
148
+ else if (char === '"') {
149
+ inString = false;
150
+ }
151
+ continue;
152
+ }
153
+ if (char === '"') {
154
+ inString = true;
155
+ out += char;
156
+ }
157
+ else if (char === '/' && next === '/') {
158
+ inLine = true;
159
+ i++;
160
+ }
161
+ else if (char === '/' && next === '*') {
162
+ inBlock = true;
163
+ i++;
164
+ }
165
+ else {
166
+ out += char;
167
+ }
168
+ }
169
+ return out;
170
+ }
171
+ /** Remove trailing commas before `}`/`]` (JSONC), ignoring string contents. */
172
+ export function stripTrailingCommas(input) {
173
+ let out = '';
174
+ let inString = false;
175
+ for (let i = 0; i < input.length; i++) {
176
+ const char = input[i];
177
+ if (inString) {
178
+ out += char;
179
+ if (char === '\\') {
180
+ out += input[i + 1] ?? '';
181
+ i++;
182
+ }
183
+ else if (char === '"') {
184
+ inString = false;
185
+ }
186
+ continue;
187
+ }
188
+ if (char === '"') {
189
+ inString = true;
190
+ out += char;
191
+ continue;
192
+ }
193
+ if (char === ',') {
194
+ let j = i + 1;
195
+ while (j < input.length && /\s/.test(input[j])) {
196
+ j++;
197
+ }
198
+ if (input[j] === '}' || input[j] === ']') {
199
+ continue; // drop the trailing comma
200
+ }
201
+ }
202
+ out += char;
203
+ }
204
+ return out;
205
+ }
@@ -0,0 +1,65 @@
1
+ import { z } from 'zod';
2
+ export const ReviewConfigSchema = z.object({
3
+ /** Default model for every agent + the coordinator. Override per-agent via
4
+ * frontmatter in the agent's markdown, or globally via REVIEWER_MODEL. */
5
+ model: z.string().default('anthropic/claude-sonnet-5'),
6
+ policy: z
7
+ .object({
8
+ includeSuggestions: z.boolean().default(false),
9
+ maxFindings: z.number().int().positive().optional(),
10
+ })
11
+ .default({ includeSuggestions: false }),
12
+ chunk: z
13
+ .object({
14
+ // Chunking is bounded by changed lines (added + removed), not file count —
15
+ // "how much code the model must actually reason about" is what dilutes
16
+ // attention, and 20 one-line tweaks are nothing like 3 files of 800 lines.
17
+ //
18
+ // A diff whose total changed lines fit in one chunk is reviewed in a single
19
+ // full-context pass (no chunking, no cross-cutting overhead). Larger diffs
20
+ // split into focused chunks, plus a cross-cutting pass for diff-spanning
21
+ // issues.
22
+ //
23
+ // Why 1000: it's a heuristic, not a measured optimum. Most real PRs change
24
+ // well under ~1000 lines, so they get a single full-context pass and skip
25
+ // chunking; only genuinely large PRs split. It also keeps each chunk small
26
+ // enough that the reasoning-heavy correctness agent finishes within its time
27
+ // cap — on real 50-file PRs a 1500-line chunk pushed correctness past 15 min,
28
+ // so smaller/more chunks (each finishing faster, run in parallel) beat fewer/
29
+ // larger ones. Coupled to `model`.
30
+ //
31
+ // When to tweak:
32
+ // - LOWER it if passes hit their time cap on large PRs, if the reviewer
33
+ // misses issues, or if you use a cheaper/smaller/faster model.
34
+ // - RAISE it to cut the number of passes when the model handles big diffs
35
+ // well and passes finish comfortably within their caps.
36
+ // - Re-tune from real-PR data (cap-hit rate + false-negative rate), not guesses.
37
+ maxChangedLines: z.number().int().positive().default(1000),
38
+ // Secondary guard so a chunk isn't an absurd number of tiny-diff files.
39
+ maxFiles: z.number().int().positive().default(20),
40
+ // Max concurrent reviewer calls across all agents/chunks.
41
+ concurrency: z.number().int().positive().default(6),
42
+ })
43
+ .default({ maxChangedLines: 1000, maxFiles: 20, concurrency: 6 }),
44
+ noise: z
45
+ .object({
46
+ additionalIgnores: z.array(z.string()).default([]),
47
+ additionalMarkers: z.array(z.string()).default([]),
48
+ })
49
+ .default({ additionalIgnores: [], additionalMarkers: [] }),
50
+ breakGlass: z
51
+ .object({ marker: z.string().default('/skip-review') })
52
+ .default({ marker: '/skip-review' }),
53
+ commentTag: z.string().default('expo-ai-code-reviewer'),
54
+ auth: z
55
+ .object({
56
+ // "api-key": the token env is sent as the provider's API key (x-api-key).
57
+ // "oauth": the token env is a Claude Pro/Max style OAuth token, injected
58
+ // into an isolated OpenCode auth.json so it's sent as a Bearer token.
59
+ mode: z.enum(['api-key', 'oauth']).default('api-key'),
60
+ provider: z.string().default('anthropic'),
61
+ /** Env var holding the key/token. */
62
+ tokenEnv: z.string().optional(),
63
+ })
64
+ .default({ mode: 'api-key', provider: 'anthropic' }),
65
+ });
@@ -0,0 +1,102 @@
1
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import path from 'node:path';
4
+ /** Env var each provider's SDK reads for an API key (x-api-key style). */
5
+ const PROVIDER_KEY_ENV = {
6
+ anthropic: 'ANTHROPIC_API_KEY',
7
+ openai: 'OPENAI_API_KEY',
8
+ google: 'GOOGLE_GENERATIVE_AI_API_KEY',
9
+ openrouter: 'OPENROUTER_API_KEY',
10
+ };
11
+ /**
12
+ * Env vars that must NEVER be forwarded to a model provider. `auth.tokenEnv` names
13
+ * the env var whose value becomes the provider credential — but that config is
14
+ * loaded from the repo, and in the CI auto-review it can be PR-controlled. A PR
15
+ * that pointed `tokenEnv` at one of these would exfiltrate that secret to the
16
+ * external model provider. The provider credential must only ever be a token
17
+ * minted for that provider, so we hard-refuse these well-known unrelated secrets.
18
+ * Defense-in-depth alongside loading config only from the trusted base ref.
19
+ */
20
+ const FORBIDDEN_TOKEN_ENVS = new Set([
21
+ 'GITHUB_TOKEN',
22
+ 'GH_TOKEN',
23
+ 'ACTIONS_RUNTIME_TOKEN',
24
+ 'ACTIONS_ID_TOKEN_REQUEST_TOKEN',
25
+ 'AWS_ACCESS_KEY_ID',
26
+ 'AWS_SECRET_ACCESS_KEY',
27
+ 'AWS_SESSION_TOKEN',
28
+ 'GOOGLE_APPLICATION_CREDENTIALS',
29
+ 'GCP_SERVICE_ACCOUNT_KEY',
30
+ 'NPM_TOKEN',
31
+ 'NODE_AUTH_TOKEN',
32
+ 'SSH_PRIVATE_KEY',
33
+ ]);
34
+ const YEAR_MS = 365 * 24 * 60 * 60 * 1000;
35
+ /**
36
+ * Prepare model credentials for the OpenCode server based on the repo's auth mode.
37
+ * Must run before the server starts (it mutates env). Returns a cleanup handle.
38
+ *
39
+ * - `api-key`: copy the configured token env into the provider's API-key env var
40
+ * (so the workflow can pass a namespaced secret and OpenCode still finds it).
41
+ * - `oauth`: write an isolated OpenCode `auth.json` with the token as a Bearer
42
+ * OAuth credential and point OpenCode at it via XDG_DATA_HOME, so it uses its
43
+ * native Claude Pro/Max path (correct bearer + oauth headers) rather than
44
+ * x-api-key. Isolated so it never touches the developer's real auth.json.
45
+ */
46
+ export async function prepareAuth(config) {
47
+ const noop = { cleanup: async () => { } };
48
+ const { mode, provider, tokenEnv } = config.auth;
49
+ // REVIEWER_MODEL is an explicit "use this model with my own creds" override — a
50
+ // common local case (e.g. the repo config targets Claude OAuth in CI, but a dev
51
+ // runs against their own OpenAI login). Don't inject the configured provider's
52
+ // auth; let OpenCode use whatever it's logged into for the override model.
53
+ if (process.env.REVIEWER_MODEL) {
54
+ return noop;
55
+ }
56
+ // Refuse to forward a well-known unrelated secret as the provider credential,
57
+ // even if the (repo/PR-controlled) config names one — that would leak it.
58
+ if (tokenEnv && FORBIDDEN_TOKEN_ENVS.has(tokenEnv)) {
59
+ throw new Error(`auth.tokenEnv is set to "${tokenEnv}", a well-known non-provider secret. Refusing ` +
60
+ `to forward it to the model provider (that would leak the secret). Point auth.tokenEnv ` +
61
+ `at a token minted for the model provider instead.`);
62
+ }
63
+ if (mode === 'api-key') {
64
+ if (tokenEnv) {
65
+ const value = process.env[tokenEnv];
66
+ const target = PROVIDER_KEY_ENV[provider] ?? 'ANTHROPIC_API_KEY';
67
+ // The explicitly-configured tokenEnv is authoritative — set it even if the
68
+ // provider env is already present, so config wins over ambient env.
69
+ if (value) {
70
+ process.env[target] = value;
71
+ }
72
+ }
73
+ return noop;
74
+ }
75
+ // oauth
76
+ if (!tokenEnv) {
77
+ throw new Error('auth.mode "oauth" requires auth.tokenEnv to name the env var holding the OAuth token.');
78
+ }
79
+ const token = process.env[tokenEnv];
80
+ if (!token) {
81
+ throw new Error(`OAuth token env "${tokenEnv}" is not set.`);
82
+ }
83
+ const dir = await mkdtemp(path.join(tmpdir(), 'ecr-auth-'));
84
+ await mkdir(path.join(dir, 'opencode'), { recursive: true });
85
+ const authJson = {
86
+ [provider]: {
87
+ type: 'oauth',
88
+ access: token,
89
+ refresh: '',
90
+ // Far-future expiry so OpenCode uses the token as-is and does not try to
91
+ // refresh it (setup-token tokens are long-lived and carry no refresh).
92
+ expires: Date.now() + YEAR_MS,
93
+ },
94
+ };
95
+ await writeFile(path.join(dir, 'opencode', 'auth.json'), JSON.stringify(authJson), 'utf8');
96
+ process.env.XDG_DATA_HOME = dir;
97
+ return {
98
+ cleanup: async () => {
99
+ await rm(dir, { recursive: true, force: true });
100
+ },
101
+ };
102
+ }
@@ -0,0 +1,24 @@
1
+ import { promptAndParse } from './opencode.js';
2
+ import { buildCoordinatorSystem, buildCoordinatorTask } from './prompts.js';
3
+ import { parseCoordinatorOutput } from './schema.js';
4
+ /**
5
+ * Single LLM call that dedupes, re-judges severity, and decides. Structured so it
6
+ * could later own a spawn tool, but for now stays a plain consolidation pass.
7
+ */
8
+ // The coordinator only re-judges text (no repo tools), so it's usually quick; the
9
+ // cap is a backstop. It runs AFTER all passes, so this adds to the worst-case
10
+ // serial chain — keep it within the CI job timeout (see review.ts / workflows).
11
+ const COORDINATOR_TIMEOUT_MS = 10 * 60 * 1000;
12
+ export async function coordinate(handle, config, metadata, agentFindings, coverageNotes = []) {
13
+ const system = buildCoordinatorSystem(config);
14
+ const text = buildCoordinatorTask(metadata, agentFindings, coverageNotes);
15
+ const { value, cost, tokens, truncated } = await promptAndParse(handle, {
16
+ agent: 'coordinator',
17
+ system,
18
+ text,
19
+ title: 'review-coordinator',
20
+ maxWaitMs: COORDINATOR_TIMEOUT_MS,
21
+ finalizeOnTimeout: true,
22
+ }, parseCoordinatorOutput);
23
+ return { output: value, cost, tokens, truncated };
24
+ }