@expo/code-review-cli 0.5.2 → 0.7.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/README.md +58 -22
- package/build/commands/ci.js +156 -39
- package/build/commands/doctor.js +167 -33
- package/build/commands/review.js +5 -3
- package/build/commands/setup-auth.js +83 -11
- package/build/config/load.js +15 -0
- package/build/config/schema.js +7 -3
- package/build/core/auth.js +122 -9
- package/build/core/claude-code.js +680 -0
- package/build/core/exec.js +278 -9
- package/build/core/opencode.js +95 -15
- package/build/core/prompts.js +19 -2
- package/build/core/render.js +21 -2
- package/build/core/review.js +212 -37
- package/build/core/schema.js +6 -1
- package/build/core/scrub.js +120 -0
- package/build/core/throttle.js +10 -0
- package/build/core/util.js +17 -0
- package/build/core/verify.js +13 -1
- package/build/reporters/github.js +79 -13
- package/build/sources/github-pr.js +110 -22
- package/build/sources/local-git.js +3 -2
- package/package.json +3 -3
- package/templates/command.yml +7 -1
- package/templates/config.jsonc +21 -3
- package/templates/dismiss.yml +3 -0
- package/templates/shared.md +28 -0
- package/templates/workflow.yml +17 -5
|
@@ -1,12 +1,30 @@
|
|
|
1
1
|
import { writeFile, mkdtemp, rm } from "node:fs/promises";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { run } from "../core/exec.js";
|
|
4
|
+
import { resolveTrustedTool, run } from "../core/exec.js";
|
|
5
5
|
import { parseUnifiedDiff } from "../core/diff.js";
|
|
6
6
|
import { buildDiffLineIndex, commentMarker, parseReviewState, renderAggregateMarkdown, renderMarkdown, } from "../core/render.js";
|
|
7
7
|
import { fingerprintFinding, scopedFingerprint } from "../core/schema.js";
|
|
8
8
|
import { appendStepSummary } from "../core/step-summary.js";
|
|
9
9
|
const MAINTAINER_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
|
|
10
|
+
/**
|
|
11
|
+
* The reviewer's OWN marker comments, oldest-first: carrying the marker AND authored
|
|
12
|
+
* by `ownLogin`. The body marker alone is not identity — it defaults to a hardcoded,
|
|
13
|
+
* public literal and is readable in the base-branch config, so anyone who can comment
|
|
14
|
+
* on the PR (the untrusted PR author included) could post a comment carrying it plus a
|
|
15
|
+
* forged embedded review state; a newest-marker-wins lookup would then adopt that
|
|
16
|
+
* state and carry its `dismissed` list forward, silently suppressing real findings.
|
|
17
|
+
* GitHub sets a comment's author from the authenticated identity and it cannot be
|
|
18
|
+
* spoofed, so matching on author closes that. When `ownLogin` is null the author
|
|
19
|
+
* cannot be confirmed, so NOTHING is treated as ours (fail closed). Pure; exported for
|
|
20
|
+
* tests.
|
|
21
|
+
*/
|
|
22
|
+
export function selectOwnComments(comments, marker, ownLogin) {
|
|
23
|
+
if (!ownLogin) {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
return comments.filter((comment) => comment.body?.includes(marker) && comment.user?.login === ownLogin);
|
|
27
|
+
}
|
|
10
28
|
/**
|
|
11
29
|
* Maintains exactly one PR comment, updating it in place across re-reviews (and
|
|
12
30
|
* cleaning up duplicates) so the review converges instead of churning. Runs the
|
|
@@ -15,10 +33,48 @@ const MAINTAINER_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
|
|
|
15
33
|
export class GitHubReporter {
|
|
16
34
|
options;
|
|
17
35
|
marker;
|
|
36
|
+
/** Memoized login of the account this reporter posts as (see resolveOwnLogin). */
|
|
37
|
+
ownLoginResolution;
|
|
18
38
|
constructor(options) {
|
|
19
39
|
this.options = options;
|
|
20
40
|
this.marker = commentMarker(options.commentTag);
|
|
21
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* The login of the account this reporter comments as, so its own comment is
|
|
44
|
+
* recognized by AUTHOR (see selectOwnComments for why the body marker is not enough).
|
|
45
|
+
* Resolution: `gh api user` (a user/PAT token), else the scaffolded workflow's default
|
|
46
|
+
* GITHUB_TOKEN identity, `github-actions[bot]`, when running under Actions — an
|
|
47
|
+
* installation token can't read `/user`. Null when neither is available, which makes
|
|
48
|
+
* selectOwnComments treat no comment as ours (fail closed). Memoized: the identity is
|
|
49
|
+
* stable for the process, and every reporter method consults it.
|
|
50
|
+
*/
|
|
51
|
+
resolveOwnLogin() {
|
|
52
|
+
this.ownLoginResolution ??= (async () => {
|
|
53
|
+
try {
|
|
54
|
+
const gh = await resolveTrustedTool("gh");
|
|
55
|
+
const { stdout } = await run(gh, ["api", "user", "--jq", ".login"], {
|
|
56
|
+
cwd: this.options.cwd,
|
|
57
|
+
});
|
|
58
|
+
const login = stdout.trim();
|
|
59
|
+
if (login) {
|
|
60
|
+
return login;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// The default GITHUB_TOKEN is an installation token: `/user` returns 403.
|
|
65
|
+
}
|
|
66
|
+
return process.env.GITHUB_ACTIONS ? "github-actions[bot]" : null;
|
|
67
|
+
})();
|
|
68
|
+
return this.ownLoginResolution;
|
|
69
|
+
}
|
|
70
|
+
/** This reporter's own marker comments, author-verified (see selectOwnComments). */
|
|
71
|
+
async ownComments() {
|
|
72
|
+
const [comments, ownLogin] = await Promise.all([
|
|
73
|
+
this.fetchAllComments(),
|
|
74
|
+
this.resolveOwnLogin(),
|
|
75
|
+
]);
|
|
76
|
+
return selectOwnComments(comments, this.marker, ownLogin);
|
|
77
|
+
}
|
|
22
78
|
async checkBreakGlass() {
|
|
23
79
|
const comments = await this.fetchAllComments();
|
|
24
80
|
return comments.some((comment) => typeof comment.body === "string" &&
|
|
@@ -65,8 +121,9 @@ export class GitHubReporter {
|
|
|
65
121
|
* reviewdog #1911 lesson).
|
|
66
122
|
*/
|
|
67
123
|
async clear() {
|
|
68
|
-
|
|
69
|
-
|
|
124
|
+
// Only ever delete comments WE authored — never touch a look-alike posted by
|
|
125
|
+
// someone else (see selectOwnComments).
|
|
126
|
+
for (const comment of await this.ownComments()) {
|
|
70
127
|
await this.deleteComment(comment.id);
|
|
71
128
|
}
|
|
72
129
|
}
|
|
@@ -88,7 +145,8 @@ export class GitHubReporter {
|
|
|
88
145
|
await Promise.all([
|
|
89
146
|
(async () => {
|
|
90
147
|
try {
|
|
91
|
-
const
|
|
148
|
+
const gh = await resolveTrustedTool("gh");
|
|
149
|
+
const { stdout } = await run(gh, ["pr", "diff", ...prArgs], { cwd });
|
|
92
150
|
link.diffLines = buildDiffLineIndex(parseUnifiedDiff(stdout));
|
|
93
151
|
}
|
|
94
152
|
catch {
|
|
@@ -97,7 +155,8 @@ export class GitHubReporter {
|
|
|
97
155
|
})(),
|
|
98
156
|
(async () => {
|
|
99
157
|
try {
|
|
100
|
-
const
|
|
158
|
+
const gh = await resolveTrustedTool("gh");
|
|
159
|
+
const { stdout } = await run(gh, ["pr", "view", ...prArgs, "--json", "baseRefOid"], {
|
|
101
160
|
cwd,
|
|
102
161
|
});
|
|
103
162
|
const oid = JSON.parse(stdout).baseRefOid;
|
|
@@ -146,10 +205,10 @@ export class GitHubReporter {
|
|
|
146
205
|
await this.patchComment(existing.id, body);
|
|
147
206
|
return { dismissedCount: dismissed.length, matched, unmatched };
|
|
148
207
|
}
|
|
149
|
-
/** Newest
|
|
208
|
+
/** Newest comment WE authored carrying our marker (id + body), or null if none. */
|
|
150
209
|
async findExistingComment() {
|
|
151
|
-
const
|
|
152
|
-
const keep =
|
|
210
|
+
const own = await this.ownComments();
|
|
211
|
+
const keep = own[own.length - 1];
|
|
153
212
|
return keep ? { id: keep.id, body: keep.body ?? "" } : null;
|
|
154
213
|
}
|
|
155
214
|
// Safety cap on pagination (100/page): 30 pages = 3000 comments. Bounds a
|
|
@@ -165,8 +224,9 @@ export class GitHubReporter {
|
|
|
165
224
|
*/
|
|
166
225
|
async fetchAllComments() {
|
|
167
226
|
const all = [];
|
|
227
|
+
const gh = await resolveTrustedTool("gh");
|
|
168
228
|
for (let page = 1; page <= GitHubReporter.MAX_COMMENT_PAGES; page++) {
|
|
169
|
-
const { stdout } = await run(
|
|
229
|
+
const { stdout } = await run(gh, [
|
|
170
230
|
"api",
|
|
171
231
|
"-X",
|
|
172
232
|
"GET",
|
|
@@ -199,7 +259,10 @@ export class GitHubReporter {
|
|
|
199
259
|
* is the newest and is the keeper.
|
|
200
260
|
*/
|
|
201
261
|
async upsertComment(body) {
|
|
202
|
-
|
|
262
|
+
// Update/clean up only comments WE authored, never a look-alike posted by
|
|
263
|
+
// someone else (see selectOwnComments) — otherwise the newest forged marker
|
|
264
|
+
// comment would be adopted as "ours" and edited/patched in its place.
|
|
265
|
+
const marked = await this.ownComments();
|
|
203
266
|
if (marked.length === 0) {
|
|
204
267
|
await this.createComment(body);
|
|
205
268
|
}
|
|
@@ -227,7 +290,8 @@ export class GitHubReporter {
|
|
|
227
290
|
}
|
|
228
291
|
}
|
|
229
292
|
async createComment(body) {
|
|
230
|
-
|
|
293
|
+
const gh = await resolveTrustedTool("gh");
|
|
294
|
+
await this.withBodyFile(body, (jsonPath) => run(gh, [
|
|
231
295
|
"api",
|
|
232
296
|
"-X",
|
|
233
297
|
"POST",
|
|
@@ -237,7 +301,8 @@ export class GitHubReporter {
|
|
|
237
301
|
], { cwd: this.options.cwd }));
|
|
238
302
|
}
|
|
239
303
|
async patchComment(commentId, body) {
|
|
240
|
-
|
|
304
|
+
const gh = await resolveTrustedTool("gh");
|
|
305
|
+
await this.withBodyFile(body, (jsonPath) => run(gh, [
|
|
241
306
|
"api",
|
|
242
307
|
"-X",
|
|
243
308
|
"PATCH",
|
|
@@ -247,6 +312,7 @@ export class GitHubReporter {
|
|
|
247
312
|
], { cwd: this.options.cwd }));
|
|
248
313
|
}
|
|
249
314
|
async deleteComment(commentId) {
|
|
250
|
-
|
|
315
|
+
const gh = await resolveTrustedTool("gh");
|
|
316
|
+
await run(gh, ["api", "-X", "DELETE", `repos/${this.options.repo}/issues/comments/${commentId}`], { cwd: this.options.cwd });
|
|
251
317
|
}
|
|
252
318
|
}
|
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
import { mkdtemp, rm } from "node:fs/promises";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { run } from "../core/exec.js";
|
|
4
|
+
import { resolveTrustedTool, run } from "../core/exec.js";
|
|
5
5
|
import { parseUnifiedDiff } from "../core/diff.js";
|
|
6
|
+
import { removeEscapingSymlinks, scrubAmbientRuntimeConfig } from "../core/scrub.js";
|
|
7
|
+
/** A full 40-hex-char commit OID — the only ref form passed to security-sensitive git calls. */
|
|
8
|
+
export function isCommitOid(value) {
|
|
9
|
+
return typeof value === "string" && /^[0-9a-f]{40}$/i.test(value);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Append gh as a git credential helper for a single command. The token comes from
|
|
13
|
+
* GH_TOKEN via the credential-helper protocol — never argv, never `.git/config` —
|
|
14
|
+
* so a base-SHA checkout with `persist-credentials: false` (no extraheader) can
|
|
15
|
+
* still fetch a private repo. Appending (not replacing) keeps a local user's own
|
|
16
|
+
* helpers first, so developer machines behave exactly as before.
|
|
17
|
+
*/
|
|
18
|
+
const GH_CREDENTIAL_HELPER_ARGS = ["-c", "credential.helper=!gh auth git-credential"];
|
|
6
19
|
/**
|
|
7
20
|
* Pulls PR diff + metadata through the `gh` CLI, which is preinstalled and
|
|
8
21
|
* authenticated on GitHub Actions runners via GH_TOKEN.
|
|
@@ -12,17 +25,30 @@ export class GitHubPRSource {
|
|
|
12
25
|
constructor(options) {
|
|
13
26
|
this.options = options;
|
|
14
27
|
}
|
|
28
|
+
metadataPromise;
|
|
15
29
|
repoArgs() {
|
|
16
30
|
return this.options.repo ? ["--repo", this.options.repo] : [];
|
|
17
31
|
}
|
|
18
|
-
|
|
19
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Memoized internally (not just by memoizeSource) because the materialization
|
|
34
|
+
* paths below need the immutable OIDs and must not depend on the caller having
|
|
35
|
+
* called getMetadata() first.
|
|
36
|
+
*/
|
|
37
|
+
getMetadata() {
|
|
38
|
+
return (this.metadataPromise ??= this.fetchMetadata());
|
|
39
|
+
}
|
|
40
|
+
async fetchMetadata() {
|
|
41
|
+
const gh = await resolveTrustedTool("gh");
|
|
42
|
+
const { stdout } = await run(gh, [
|
|
20
43
|
"pr",
|
|
21
44
|
"view",
|
|
22
45
|
String(this.options.prNumber),
|
|
23
46
|
...this.repoArgs(),
|
|
24
47
|
"--json",
|
|
25
|
-
|
|
48
|
+
// baseRefOid/headRefOid are the immutable commit OIDs backing this PR at
|
|
49
|
+
// this moment; every materialization below pins to them so a rename,
|
|
50
|
+
// force-push, or deleted head between API calls can't swap a tree.
|
|
51
|
+
"title,body,baseRefName,headRefName,baseRefOid,headRefOid",
|
|
26
52
|
], { cwd: this.options.cwd });
|
|
27
53
|
const parsed = JSON.parse(stdout);
|
|
28
54
|
return {
|
|
@@ -30,40 +56,45 @@ export class GitHubPRSource {
|
|
|
30
56
|
body: parsed.body ?? "",
|
|
31
57
|
baseRef: parsed.baseRefName ?? "",
|
|
32
58
|
headRef: parsed.headRefName ?? "",
|
|
59
|
+
baseOid: isCommitOid(parsed.baseRefOid) ? parsed.baseRefOid.toLowerCase() : undefined,
|
|
60
|
+
headOid: isCommitOid(parsed.headRefOid) ? parsed.headRefOid.toLowerCase() : undefined,
|
|
33
61
|
};
|
|
34
62
|
}
|
|
35
63
|
async getChangedFiles() {
|
|
36
|
-
const
|
|
64
|
+
const gh = await resolveTrustedTool("gh");
|
|
65
|
+
const { stdout } = await run(gh, ["pr", "diff", String(this.options.prNumber), ...this.repoArgs()], { cwd: this.options.cwd });
|
|
37
66
|
return parseUnifiedDiff(stdout);
|
|
38
67
|
}
|
|
39
68
|
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* repo hosts even for fork PRs — so it's always the correct PR, independent of the
|
|
44
|
-
* local `origin`. Fails SOFT: any problem (not a git repo, fetch/worktree error)
|
|
45
|
-
* returns null, and the review falls back to reading the current checkout.
|
|
69
|
+
* Fetch `ref` from the repo's own HTTPS URL and materialize `oid` as a detached
|
|
70
|
+
* throwaway worktree under a fresh temp dir. Never falls back to a branch name:
|
|
71
|
+
* `oid` is validated as a full commit hash before reaching git.
|
|
46
72
|
*/
|
|
47
|
-
async
|
|
48
|
-
const cwd = this.options.cwd;
|
|
73
|
+
async materializeWorktreeAsync(ref, oid) {
|
|
49
74
|
if (!this.options.repo) {
|
|
50
|
-
|
|
51
|
-
|
|
75
|
+
throw new Error("cannot materialize a PR tree without an explicit owner/repo");
|
|
76
|
+
}
|
|
77
|
+
if (!isCommitOid(oid)) {
|
|
78
|
+
throw new Error(`refusing to materialize a non-OID ref: "${oid}"`);
|
|
52
79
|
}
|
|
80
|
+
const cwd = this.options.cwd;
|
|
53
81
|
const url = `https://github.com/${this.options.repo}.git`;
|
|
54
|
-
const
|
|
82
|
+
const gitPath = await resolveTrustedTool("git");
|
|
55
83
|
let parent;
|
|
56
84
|
try {
|
|
57
|
-
await run(
|
|
58
|
-
parent = await mkdtemp(path.join(tmpdir(), "ecr-
|
|
59
|
-
const dir = path.join(parent, "
|
|
60
|
-
|
|
85
|
+
await run(gitPath, [...GH_CREDENTIAL_HELPER_ARGS, "fetch", "--no-tags", "--depth=1", url, ref], { cwd });
|
|
86
|
+
parent = await mkdtemp(path.join(tmpdir(), "ecr-tree-"));
|
|
87
|
+
const dir = path.join(parent, "tree"); // must not pre-exist for `worktree add`
|
|
88
|
+
// Check out the OID (not FETCH_HEAD): if the ref moved between the API call
|
|
89
|
+
// and this fetch, the OID is absent and this fails instead of silently
|
|
90
|
+
// materializing a different tree than the one the diff was fetched for.
|
|
91
|
+
await run(gitPath, ["worktree", "add", "--detach", dir, oid], { cwd });
|
|
61
92
|
const removeParent = parent;
|
|
62
93
|
return {
|
|
63
94
|
dir,
|
|
64
95
|
cleanup: async () => {
|
|
65
96
|
try {
|
|
66
|
-
await run(
|
|
97
|
+
await run(gitPath, ["worktree", "remove", "--force", dir], { cwd });
|
|
67
98
|
}
|
|
68
99
|
catch {
|
|
69
100
|
// best effort — fall through to removing the temp dir
|
|
@@ -72,11 +103,68 @@ export class GitHubPRSource {
|
|
|
72
103
|
},
|
|
73
104
|
};
|
|
74
105
|
}
|
|
75
|
-
catch {
|
|
106
|
+
catch (error) {
|
|
76
107
|
if (parent) {
|
|
77
108
|
await rm(parent, { recursive: true, force: true }).catch(() => { });
|
|
78
109
|
}
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Check the PR HEAD out into a throwaway worktree so the agents and verifier
|
|
115
|
+
* read the PR's versions of files (not whatever branch happens to be checked
|
|
116
|
+
* out), pinned to the immutable head OID. The fetch uses `refs/pull/<n>/head`,
|
|
117
|
+
* which the base repo hosts even for fork PRs.
|
|
118
|
+
*
|
|
119
|
+
* The worktree is SCRUBBED of ambient runtime config (opencode.json, .opencode
|
|
120
|
+
* plugins, AGENTS.md/CLAUDE.md, .mcp.json, .env*, …) before it's returned: the
|
|
121
|
+
* OpenCode server is started with this directory as its project root, and
|
|
122
|
+
* anything it discovers there is attacker-controlled PR content executing or
|
|
123
|
+
* injecting inside a process that holds the model credential and GH_TOKEN.
|
|
124
|
+
* Out-of-tree symlinks are stripped in the same pass: read tools are scoped by
|
|
125
|
+
* the literal path argument but follow symlinks underneath, so a PR-committed
|
|
126
|
+
* link escaping the tree would otherwise read arbitrary host files.
|
|
127
|
+
*
|
|
128
|
+
* Returns null only when no owner/repo is configured (a local `--pr` run
|
|
129
|
+
* without --repo, where the current checkout is an acceptable read root).
|
|
130
|
+
* Materialization FAILURES throw — the caller decides per mode whether that is
|
|
131
|
+
* fatal (CI: fail closed) or a soft fallback (local: the user's own checkout).
|
|
132
|
+
*/
|
|
133
|
+
async prepareReadRootAsync() {
|
|
134
|
+
if (!this.options.repo) {
|
|
135
|
+
// Without an explicit owner/repo we can't build the fetch URL safely.
|
|
79
136
|
return null;
|
|
80
137
|
}
|
|
138
|
+
const metadata = await this.getMetadata();
|
|
139
|
+
if (!isCommitOid(metadata.headOid)) {
|
|
140
|
+
throw new Error("GitHub did not report an immutable head OID for this PR");
|
|
141
|
+
}
|
|
142
|
+
const root = await this.materializeWorktreeAsync(`refs/pull/${this.options.prNumber}/head`, metadata.headOid);
|
|
143
|
+
try {
|
|
144
|
+
await scrubAmbientRuntimeConfig(root.dir);
|
|
145
|
+
await removeEscapingSymlinks(root.dir);
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
// A half-scrubbed tree must never become the runtime's project root.
|
|
149
|
+
await root.cleanup().catch(() => { });
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
return root;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Materialize the PR's BASE commit as the trusted configuration root: review
|
|
156
|
+
* policy, prompts, routing, and auth mapping load from here, so a PR cannot
|
|
157
|
+
* change the reviewer that evaluates it (config changes activate on merge).
|
|
158
|
+
* Failures throw — `ecr ci` must fail closed, never fall back to the checkout.
|
|
159
|
+
*/
|
|
160
|
+
async prepareTrustedConfigRootAsync() {
|
|
161
|
+
const metadata = await this.getMetadata();
|
|
162
|
+
if (!isCommitOid(metadata.baseOid)) {
|
|
163
|
+
throw new Error("GitHub did not report an immutable base OID for this PR");
|
|
164
|
+
}
|
|
165
|
+
// The base OID is fetchable by SHA (it's the tip of the base branch as of the
|
|
166
|
+
// API call; GitHub serves reachable SHAs — the same mechanism actions/checkout
|
|
167
|
+
// uses for `ref:` pins).
|
|
168
|
+
return this.materializeWorktreeAsync(metadata.baseOid, metadata.baseOid);
|
|
81
169
|
}
|
|
82
170
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { git, run } from "../core/exec.js";
|
|
1
|
+
import { git, resolveTrustedTool, run } from "../core/exec.js";
|
|
2
2
|
import { parseUnifiedDiff } from "../core/diff.js";
|
|
3
3
|
/**
|
|
4
4
|
* Reads local git state. No network calls. Default compares the working tree
|
|
@@ -94,7 +94,8 @@ export class LocalGitSource {
|
|
|
94
94
|
const chunks = [];
|
|
95
95
|
for (const file of files) {
|
|
96
96
|
// `--` so a filename beginning with `-` can't be read as a git option.
|
|
97
|
-
const
|
|
97
|
+
const gitPath = await resolveTrustedTool("git");
|
|
98
|
+
const { stdout } = await run(gitPath, ["diff", "--no-index", "--", "/dev/null", file], {
|
|
98
99
|
cwd: this.cwd,
|
|
99
100
|
check: false,
|
|
100
101
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@expo/code-review-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Generic, config-driven AI code reviewer engine. Repos supply their agents via .expo-code-review/.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -28,8 +28,8 @@
|
|
|
28
28
|
"clean": "rimraf build",
|
|
29
29
|
"typecheck": "tsc --noEmit",
|
|
30
30
|
"lint": "oxlint src",
|
|
31
|
-
"fmt": "oxfmt src",
|
|
32
|
-
"fmt:check": "oxfmt --check src",
|
|
31
|
+
"fmt": "oxfmt --threads=1 src",
|
|
32
|
+
"fmt:check": "oxfmt --threads=1 --check src",
|
|
33
33
|
"dev": "bun run src/cli.ts",
|
|
34
34
|
"test:unit": "bun test",
|
|
35
35
|
"release": "bash scripts/release.sh",
|
package/templates/command.yml
CHANGED
|
@@ -85,12 +85,18 @@ jobs:
|
|
|
85
85
|
# config, and never `gh pr checkout` the PR head. The reviewer engine itself
|
|
86
86
|
# is the PUBLISHED @expo/code-review-cli (fetched by npx), not built from any
|
|
87
87
|
# checkout, so attacker-controlled PR code never runs here. The diff + PR
|
|
88
|
-
# metadata come from the API (`gh pr diff`/`gh pr view`)
|
|
88
|
+
# metadata come from the API (`gh pr diff`/`gh pr view`); `ecr ci` loads
|
|
89
|
+
# configuration from the PR's immutable base commit and reads source from a
|
|
90
|
+
# head worktree scrubbed of ambient runtime config (opencode.json, plugins,
|
|
91
|
+
# AGENTS.md, .env, …) — the same trust model as the pull_request workflow.
|
|
89
92
|
- name: Checkout (base ref only — never the PR head)
|
|
90
93
|
if: steps.cmd.outputs.run == 'true'
|
|
91
94
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
92
95
|
with:
|
|
93
96
|
fetch-depth: 1
|
|
97
|
+
# The CLI's own git fetches authenticate through `gh` from GH_TOKEN, so
|
|
98
|
+
# the token never lands in .git/config.
|
|
99
|
+
persist-credentials: false
|
|
94
100
|
|
|
95
101
|
- name: Set up Node
|
|
96
102
|
if: steps.cmd.outputs.run == 'true'
|
package/templates/config.jsonc
CHANGED
|
@@ -49,9 +49,9 @@
|
|
|
49
49
|
// store the key as a repo secret and pass it under that env var
|
|
50
50
|
// (the scaffolded workflow does). If you omit `auth` entirely,
|
|
51
51
|
// OpenCode's own login / ambient provider env vars are used.
|
|
52
|
-
// For Anthropic/Claude,
|
|
53
|
-
//
|
|
54
|
-
//
|
|
52
|
+
// For Anthropic/Claude, see the dedicated block below (it now runs through the
|
|
53
|
+
// Claude Code CLI). For another provider, omit `auth` and set REVIEWER_MODEL
|
|
54
|
+
// after an `opencode auth login` for that provider.
|
|
55
55
|
//
|
|
56
56
|
// MIXED setup (a ChatGPT/Codex subscription for the default models, plus a
|
|
57
57
|
// metered API key for pro-tier models the subscription doesn't offer): use the
|
|
@@ -65,6 +65,24 @@
|
|
|
65
65
|
// (openai oauth: tokenEnv holds the ACCESS token from an `opencode auth login`
|
|
66
66
|
// ChatGPT sign-in — `ecr setup-auth` extracts it. NEVER share the refresh
|
|
67
67
|
// token: it is single-use and dies on first rotation.)
|
|
68
|
+
//
|
|
69
|
+
// Anthropic runs through the Claude Code CLI, inferred from the model — set your
|
|
70
|
+
// models to "anthropic/…" (e.g. anthropic/claude-opus-5, anthropic/claude-sonnet-5)
|
|
71
|
+
// and run `claude setup-token`, exporting the token as CLAUDE_CODE_OAUTH_TOKEN (CI)
|
|
72
|
+
// or rely on your local `claude` login. An auth entry is OPTIONAL (tokenEnv just
|
|
73
|
+
// names the credential env); `ecr setup-auth` walks you through it.
|
|
74
|
+
// "auth": { "providers": {
|
|
75
|
+
// "anthropic": { "tokenEnv": "CLAUDE_CODE_OAUTH_TOKEN" }
|
|
76
|
+
// } }
|
|
77
|
+
//
|
|
78
|
+
// MIXING engines is supported: the engine is inferred per agent from its model, so
|
|
79
|
+
// an anthropic entry may coexist with an openai (or any other) OpenCode provider.
|
|
80
|
+
// Each agent's `model` selects its engine — an `anthropic/…` agent runs through the
|
|
81
|
+
// Claude Code CLI while an `openai/…` agent runs through OpenCode, in the SAME run.
|
|
82
|
+
// "auth": { "providers": {
|
|
83
|
+
// "anthropic": { "tokenEnv": "CLAUDE_CODE_OAUTH_TOKEN" },
|
|
84
|
+
// "openai": { "mode": "oauth", "tokenEnv": "CODEX_OAUTH_ACCESS_TOKEN" }
|
|
85
|
+
// } }
|
|
68
86
|
"auth": {
|
|
69
87
|
"mode": "api-key",
|
|
70
88
|
"provider": "openai",
|
package/templates/dismiss.yml
CHANGED
|
@@ -83,6 +83,9 @@ jobs:
|
|
|
83
83
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
84
84
|
with:
|
|
85
85
|
fetch-depth: 1
|
|
86
|
+
# Dismiss never fetches; gh authenticates from GH_TOKEN. Keep the token
|
|
87
|
+
# out of .git/config.
|
|
88
|
+
persist-credentials: false
|
|
86
89
|
|
|
87
90
|
- name: Set up Node
|
|
88
91
|
if: steps.cmd.outputs.run == 'true'
|
package/templates/shared.md
CHANGED
|
@@ -63,6 +63,34 @@ firehose. When in doubt, stay silent.
|
|
|
63
63
|
**For now, report only `critical` and `warning` findings. Do not emit
|
|
64
64
|
`suggestion`-level items at all.**
|
|
65
65
|
|
|
66
|
+
## Write findings in Simplified Technical English
|
|
67
|
+
|
|
68
|
+
Your findings are read by engineers in many countries. Many of them do not speak
|
|
69
|
+
English as a first language. Write every piece of prose you emit — `title`,
|
|
70
|
+
`rationale`, `suggestion` — under the ASD-STE100 Simplified Technical English
|
|
71
|
+
rules:
|
|
72
|
+
|
|
73
|
+
- **One word, one meaning.** Choose one term for a thing and reuse it. Do not
|
|
74
|
+
alternate between synonyms for the same object ("the handler" / "the callback"
|
|
75
|
+
/ "the hook").
|
|
76
|
+
- **Short sentences.** Use 20 words or fewer. Split a long sentence into two.
|
|
77
|
+
- **Active voice.** Write "the parser drops the flag", not "the flag is dropped
|
|
78
|
+
by the parser". Name the actor.
|
|
79
|
+
- **Plain words.** Write "use", not "utilize"; "before", not "prior to";
|
|
80
|
+
"because", not "due to the fact that". Remove hedges ("arguably", "it seems
|
|
81
|
+
that") and intensifiers ("very", "extremely").
|
|
82
|
+
- **One topic per paragraph.** Keep paragraphs short.
|
|
83
|
+
- **No idiom, metaphor, or sarcasm.** State what happens.
|
|
84
|
+
|
|
85
|
+
This rule is about prose only. `evidence` and any code you quote are copied
|
|
86
|
+
verbatim and are never rewritten to fit these rules. Identifiers, file paths,
|
|
87
|
+
error strings, and the `severity`/`category` values also stay exactly as they
|
|
88
|
+
are.
|
|
89
|
+
|
|
90
|
+
Simple language must not cost precision. Keep the concrete failure path, the
|
|
91
|
+
condition that triggers it, and the names of the affected code. Short sentences
|
|
92
|
+
are a way to say the same thing, not a way to say less.
|
|
93
|
+
|
|
66
94
|
## Output contract
|
|
67
95
|
|
|
68
96
|
Return **only** a single fenced ```json code block, an object of this shape:
|
package/templates/workflow.yml
CHANGED
|
@@ -39,10 +39,21 @@ jobs:
|
|
|
39
39
|
# A reviewer failure must never fail the PR's checks.
|
|
40
40
|
continue-on-error: true
|
|
41
41
|
steps:
|
|
42
|
+
# SECURITY: check out the PR's immutable BASE commit, never the PR head or
|
|
43
|
+
# merge ref. Everything security-sensitive on this runner (`ecr verify-config`'s
|
|
44
|
+
# sweep, and any ambient files) therefore comes from a commit that already
|
|
45
|
+
# merged. `ecr ci` additionally enforces this itself: it materializes the base
|
|
46
|
+
# commit via the GitHub API for configuration and the head commit (scrubbed of
|
|
47
|
+
# runtime config) for source reads, so this checkout is defense in depth, not
|
|
48
|
+
# the only line. persist-credentials off — the CLI's own git fetches
|
|
49
|
+
# authenticate through `gh` from GH_TOKEN, so the token never lands in
|
|
50
|
+
# .git/config.
|
|
42
51
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
43
52
|
with:
|
|
53
|
+
ref: ${{ github.event.pull_request.base.sha }}
|
|
44
54
|
# Shallow is enough — the reviewer gets the diff from the API (`gh`).
|
|
45
55
|
fetch-depth: 1
|
|
56
|
+
persist-credentials: false
|
|
46
57
|
|
|
47
58
|
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
|
48
59
|
with:
|
|
@@ -52,16 +63,17 @@ jobs:
|
|
|
52
63
|
# to save an empty cache and error).
|
|
53
64
|
package-manager-cache: false
|
|
54
65
|
|
|
55
|
-
# SECURITY:
|
|
66
|
+
# SECURITY: the TRUSTED BASE checkout above includes every
|
|
56
67
|
# .expo-code-review/config.jsonc + routing.jsonc, whose auth.tokenEnv names the
|
|
57
68
|
# env var the CLI forwards as the model credential. The canonical guard ships
|
|
58
69
|
# with the CLI: `ecr verify-config` sweeps every config (root + routing + all
|
|
59
70
|
# scopes, referenced or not) with the engine's real JSONC parser and refuses
|
|
60
71
|
# unless tokenEnv appears exactly once, in a ROOT-owned file, equal to the
|
|
61
|
-
# expected value (repo var ECR_EXPECTED_TOKEN_ENV) — so a
|
|
62
|
-
# another runner secret, sneak in a JSON-escaped key, or stage an
|
|
63
|
-
# scope config with its own auth. This is layer 2; layer 1 is the
|
|
64
|
-
# ECR_EXPECTED_TOKEN_ENV lock in `ecr ci` itself, so guard/loader drift
|
|
72
|
+
# expected value (repo var ECR_EXPECTED_TOKEN_ENV) — so a config change can't
|
|
73
|
+
# repoint it at another runner secret, sneak in a JSON-escaped key, or stage an
|
|
74
|
+
# unreferenced scope config with its own auth. This is layer 2; layer 1 is the
|
|
75
|
+
# runtime ECR_EXPECTED_TOKEN_ENV lock in `ecr ci` itself, so guard/loader drift
|
|
76
|
+
# fails safe.
|
|
65
77
|
#
|
|
66
78
|
# This step MUST run BEFORE `ecr ci` (before any PR code is built or loaded).
|
|
67
79
|
# Only setup-node (runtime install) precedes it; running the PUBLISHED package
|