@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
|
@@ -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
|
+
}
|