@integrity-labs/agt-cli 0.28.834 → 0.28.835
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/dist/assets/review/obiwan-auth.mjs +215 -0
- package/dist/assets/review/post-review-findings.mjs +539 -0
- package/dist/bin/agt.js +3 -3
- package/dist/{chunk-LLIAJK4F.js → chunk-REBHUZ7N.js} +2 -2
- package/dist/lib/manager-worker.js +400 -347
- package/dist/lib/manager-worker.js.map +1 -1
- package/package.json +2 -2
- /package/dist/{chunk-LLIAJK4F.js.map → chunk-REBHUZ7N.js.map} +0 -0
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* VENDORED (ENG-10001) — THIS MONOREPO COPY IS CANONICAL.
|
|
4
|
+
*
|
|
5
|
+
* Reaches managed agent hosts via `provisionReviewPoster()`
|
|
6
|
+
* (apps/cli/src/lib/review-poster-asset.ts), which copies this file and its
|
|
7
|
+
* sibling `obiwan-auth.mjs` into the agent's dir at session provision time.
|
|
8
|
+
*
|
|
9
|
+
* It is delivered that way, and NOT from `packages/claudecode-plugin-augmented/`,
|
|
10
|
+
* because the managed fleet never installs that plugin — the ENG-6233 mistake,
|
|
11
|
+
* recorded in-tree at `apps/cli/src/lib/manager-worker.ts` ("which the managed
|
|
12
|
+
* fleet never installs"), whose ENG-6268 fix named manager provisioning as "the
|
|
13
|
+
* actual fleet delivery path".
|
|
14
|
+
*
|
|
15
|
+
* Origin: the Smithers Claude Code plugin (~/.claude/plugins/smithers/scripts/).
|
|
16
|
+
* That copy is now DOWNSTREAM of this one. Do NOT add an equality sync test:
|
|
17
|
+
* this copy is expected to diverge (fail-closed identity with login
|
|
18
|
+
* verification, and a `--min-severity` floor — ENG-10001 items 2 and 3).
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* post-review-findings — deliver `/s:code-review` and `/s:security-review`
|
|
22
|
+
* findings to a PR as inline threads, and track them across rounds.
|
|
23
|
+
*
|
|
24
|
+
* THIS MODULE CAN NEVER SUBMIT A BLOCKING VERDICT, and that is its central
|
|
25
|
+
* design constraint rather than a setting. Measured on Integrity-Labs/augmented
|
|
26
|
+
* on 2026-09-03: five PRs are permanently unmergeable and twenty-five more sit
|
|
27
|
+
* in CHANGES_REQUESTED, because a reviewer that submits CHANGES_REQUESTED can
|
|
28
|
+
* only clear it by submitting APPROVED — and a clean re-scan submits COMMENTED,
|
|
29
|
+
* which clears nothing. `dismiss_stale_reviews_on_push` does not rescue this:
|
|
30
|
+
* GitHub defines it as dismissing review APPROVALS, so it never touches a change
|
|
31
|
+
* request. The repo's real remedy today is a bot calling the dismissals API by
|
|
32
|
+
* hand, one review at a time, with a written justification each.
|
|
33
|
+
*
|
|
34
|
+
* Nothing is given up by refusing the verdict. `required_approving_review_count`
|
|
35
|
+
* is 0, so the verdict gates no merge; `required_review_thread_resolution` is
|
|
36
|
+
* true, so the THREADS do the gating — per finding, and they clear cleanly.
|
|
37
|
+
*
|
|
38
|
+
* Because threads gate the merge, a reviewer that opens them must be able to
|
|
39
|
+
* close them, or it has built a blocker only a human can clear. Hence
|
|
40
|
+
* `--resolve`, which REQUIRES a reason and posts that reason before resolving.
|
|
41
|
+
* A reviewer that silently resolves its own findings is worse than one that
|
|
42
|
+
* never posted.
|
|
43
|
+
*
|
|
44
|
+
* Usage:
|
|
45
|
+
* post-review-findings.mjs --pr 5409 # dry run (default)
|
|
46
|
+
* post-review-findings.mjs --pr 5409 --post
|
|
47
|
+
* post-review-findings.mjs --pr 5409 --status
|
|
48
|
+
* post-review-findings.mjs --pr 5409 --resolve <id> --reason "..."
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
import { execFileSync } from 'node:child_process';
|
|
52
|
+
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
53
|
+
import { resolve } from 'node:path';
|
|
54
|
+
import { createHash } from 'node:crypto';
|
|
55
|
+
import { join } from 'node:path';
|
|
56
|
+
import { resolveIdentity, redact } from './obiwan-auth.mjs';
|
|
57
|
+
import { realpathSync } from 'node:fs';
|
|
58
|
+
import { pathToFileURL } from 'node:url';
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The ONLY review event this module will ever submit.
|
|
62
|
+
*
|
|
63
|
+
* Asserted at the point of construction, not merely used as a default — a
|
|
64
|
+
* default is a value a caller can pass past. `eng-9927-posting-layer.test.mjs`
|
|
65
|
+
* drives the real `submitReview()` against a stub and reads the event off the
|
|
66
|
+
* payload it actually built. A source grep for 'REQUEST_CHANGES' would be
|
|
67
|
+
* satisfied by this very docblock, which is why the proof is behavioural.
|
|
68
|
+
*/
|
|
69
|
+
export const REVIEW_EVENT = 'COMMENT';
|
|
70
|
+
|
|
71
|
+
export const REVIEW_DIR = '.smithers/review';
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A finding's identity across rounds. Deliberately EXCLUDES the line number: a
|
|
75
|
+
* finding whose anchor shifted because unrelated code was inserted above it is
|
|
76
|
+
* the same finding, and keying on line would repost it as new on every push.
|
|
77
|
+
* File + category + summary is what makes two reports the same claim.
|
|
78
|
+
*/
|
|
79
|
+
export function findingId(f) {
|
|
80
|
+
return createHash('sha256')
|
|
81
|
+
.update(`${f.file} ${f.category ?? ''} ${f.summary ?? ''}`)
|
|
82
|
+
.digest('hex')
|
|
83
|
+
.slice(0, 12);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The marker that makes a posted comment recognisably ours on a later run. */
|
|
87
|
+
export function marker(id) {
|
|
88
|
+
return `<!-- smithers-finding:${id} -->`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function buildBody(f, id) {
|
|
92
|
+
const sev = String(f.severity ?? 'minor').toUpperCase();
|
|
93
|
+
const cat = f.category ? ` · ${f.category}` : '';
|
|
94
|
+
return [
|
|
95
|
+
`**${sev}${cat}** — ${f.summary}`,
|
|
96
|
+
'',
|
|
97
|
+
f.why ?? f.attack ?? '',
|
|
98
|
+
'',
|
|
99
|
+
'<sub>Posted by `/s:code-review`. This reviewer never submits a blocking verdict.',
|
|
100
|
+
'Reply if this is deliberate and it becomes a rule in `docs/reference/review-rules.md`.</sub>',
|
|
101
|
+
marker(id),
|
|
102
|
+
].join('\n');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Thin `gh` wrapper. `input` is piped to stdin, for endpoints taking a body. */
|
|
106
|
+
let AUTH_TOKEN = null;
|
|
107
|
+
|
|
108
|
+
/** Authenticate subsequent gh() calls as this token. Null keeps gh's own auth. */
|
|
109
|
+
export function setAuthToken(t) {
|
|
110
|
+
AUTH_TOKEN = t || null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function gh(args, { json = true, input } = {}) {
|
|
114
|
+
let out;
|
|
115
|
+
try {
|
|
116
|
+
out = execFileSync('gh', args, {
|
|
117
|
+
encoding: 'utf8',
|
|
118
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
119
|
+
// The token goes in the ENVIRONMENT. A command line is world-readable via
|
|
120
|
+
// `ps` and is copied verbatim into CI logs and SSM run history.
|
|
121
|
+
...(AUTH_TOKEN ? { env: { ...process.env, GH_TOKEN: AUTH_TOKEN } } : {}),
|
|
122
|
+
...(input === undefined ? {} : { input }),
|
|
123
|
+
});
|
|
124
|
+
} catch (e) {
|
|
125
|
+
// gh echoes the failing request, so an auth error can carry the token.
|
|
126
|
+
e.message = redact(e.message, AUTH_TOKEN);
|
|
127
|
+
if (e.stderr) e.stderr = redact(e.stderr, AUTH_TOKEN);
|
|
128
|
+
throw e;
|
|
129
|
+
}
|
|
130
|
+
return json && out.trim() !== '' ? JSON.parse(out) : out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Lines GitHub will accept an inline comment on: the RIGHT side of each hunk in
|
|
135
|
+
* the PR diff. A comment on any other line is rejected outright, so this is
|
|
136
|
+
* what separates a placeable finding from one that needs the summary body.
|
|
137
|
+
*/
|
|
138
|
+
export function commentableLines(patchesByFile) {
|
|
139
|
+
const ok = new Map();
|
|
140
|
+
for (const [file, patch] of patchesByFile) {
|
|
141
|
+
const lines = new Set();
|
|
142
|
+
let n = 0;
|
|
143
|
+
for (const l of String(patch ?? '').split('\n')) {
|
|
144
|
+
const h = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(l);
|
|
145
|
+
if (h) {
|
|
146
|
+
n = Number(h[1]);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (l.startsWith('-')) continue;
|
|
150
|
+
if (l.startsWith('+') || l.startsWith(' ')) {
|
|
151
|
+
lines.add(n);
|
|
152
|
+
n += 1;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
ok.set(file, lines);
|
|
156
|
+
}
|
|
157
|
+
return ok;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Read every findings file written for this PR head.
|
|
162
|
+
*
|
|
163
|
+
* A document whose review did not COMPLETE is refused rather than contributing
|
|
164
|
+
* an empty `findings` array. Merged into a real file, an incomplete review's
|
|
165
|
+
* empty list is indistinguishable from a clean one — the precise conflation
|
|
166
|
+
* both review commands carry a `status` field to prevent.
|
|
167
|
+
*/
|
|
168
|
+
export function loadFindings(dir, pr, head) {
|
|
169
|
+
const want = `${pr}-${String(head).slice(0, 12)}.`;
|
|
170
|
+
|
|
171
|
+
// Three different situations, told apart. REVIEW_DIR is relative to the cwd,
|
|
172
|
+
// so running this from anywhere but the repo produced a bare ENOENT stack
|
|
173
|
+
// out of readdirSync — a Node internal frame in place of the one sentence
|
|
174
|
+
// that says what to do. And an absent directory is not the same as a
|
|
175
|
+
// directory holding no review for THIS head: the first means no review has
|
|
176
|
+
// ever run here, the second means the PR moved since the last one.
|
|
177
|
+
if (!existsSync(dir)) {
|
|
178
|
+
throw new Error(
|
|
179
|
+
`no findings directory at ${resolve(dir)} — run /s:code-review from the repo first, ` +
|
|
180
|
+
'or pass --dir pointing at an existing .smithers/review',
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
const out = [];
|
|
184
|
+
const present = readdirSync(dir).sort();
|
|
185
|
+
const forThisPr = present.filter((n) => n.startsWith(`${pr}-`));
|
|
186
|
+
if (!present.some((n) => n.startsWith(want))) {
|
|
187
|
+
throw new Error(
|
|
188
|
+
forThisPr.length > 0
|
|
189
|
+
? `no review for PR #${pr} at head ${String(head).slice(0, 12)} — found ${forThisPr.join(', ')}. ` +
|
|
190
|
+
'The PR has moved since it was reviewed; re-run /s:code-review.'
|
|
191
|
+
: `no review found for PR #${pr} in ${resolve(dir)} — run /s:code-review ${pr} first`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
for (const name of present) {
|
|
195
|
+
if (!name.startsWith(want) || !name.endsWith('.json')) continue;
|
|
196
|
+
const doc = JSON.parse(readFileSync(join(dir, name), 'utf8'));
|
|
197
|
+
if (doc.status !== 'reviewed') {
|
|
198
|
+
throw new Error(
|
|
199
|
+
`${name}: status is "${doc.status}" — refusing to post from an incomplete review`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
for (const f of doc.findings ?? []) out.push({ ...f, _source: name });
|
|
203
|
+
}
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Split findings into those that can be anchored inline and those that cannot.
|
|
209
|
+
*
|
|
210
|
+
* An unanchorable finding is NOT dropped — it is returned for the review body.
|
|
211
|
+
* A reviewer that silently discards what it could not place reports fewer
|
|
212
|
+
* findings than it made, with nothing on the wire to say so, which is the same
|
|
213
|
+
* silent-failure shape these reviews exist to find in other people's code.
|
|
214
|
+
*/
|
|
215
|
+
export function partitionByAnchor(findings, commentable) {
|
|
216
|
+
const inline = [];
|
|
217
|
+
const outside = [];
|
|
218
|
+
for (const f of findings) {
|
|
219
|
+
const lines = commentable.get(f.file);
|
|
220
|
+
if (lines && f.line && lines.has(f.line)) inline.push(f);
|
|
221
|
+
else {
|
|
222
|
+
outside.push({
|
|
223
|
+
...f,
|
|
224
|
+
_why: lines ? `line ${f.line} is not in the diff` : 'file is not in the diff',
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return { inline, outside };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Findings already posted on this PR, by id — so a round adds only what is new.
|
|
233
|
+
* Keyed on the marker, which survives an anchor shift; see {@link findingId}.
|
|
234
|
+
*/
|
|
235
|
+
export function postedIds(comments) {
|
|
236
|
+
const seen = new Set();
|
|
237
|
+
for (const c of comments) {
|
|
238
|
+
const m = /<!-- smithers-finding:([0-9a-f]{12}) -->/.exec(String(c.body ?? ''));
|
|
239
|
+
if (m) seen.add(m[1]);
|
|
240
|
+
}
|
|
241
|
+
return seen;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Build and submit the review.
|
|
246
|
+
*
|
|
247
|
+
* The event is read from the frozen constant and re-asserted here, so neither a
|
|
248
|
+
* caller nor a later edit can widen this into a verdict without the assertion
|
|
249
|
+
* failing first.
|
|
250
|
+
*/
|
|
251
|
+
export function submitReview({ repo, pr, comments, body, api = gh }) {
|
|
252
|
+
if (REVIEW_EVENT !== 'COMMENT') {
|
|
253
|
+
throw new Error(
|
|
254
|
+
`refusing to submit review event "${REVIEW_EVENT}" — see this module's header`,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
const payload = { event: REVIEW_EVENT, body, comments };
|
|
258
|
+
api(['api', `repos/${repo}/pulls/${pr}/reviews`, '--method', 'POST', '--input', '-'], {
|
|
259
|
+
json: false,
|
|
260
|
+
input: JSON.stringify(payload),
|
|
261
|
+
});
|
|
262
|
+
return payload;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Every thread this reviewer opened on a PR, with its resolution state.
|
|
267
|
+
*
|
|
268
|
+
* You cannot act on feedback you cannot enumerate. Without this the finding ids
|
|
269
|
+
* lived only in the output of the run that posted them, so resolving one meant
|
|
270
|
+
* re-deriving the id or reading it out of an HTML comment in the PR.
|
|
271
|
+
*/
|
|
272
|
+
export async function listFindingThreads({ repo, pr, api = gh }) {
|
|
273
|
+
const [owner, name] = repo.split('/');
|
|
274
|
+
const q = `query($owner:String!,$name:String!,$pr:Int!){repository(owner:$owner,name:$name){pullRequest(number:$pr){reviewThreads(first:100){pageInfo{hasNextPage} nodes{id isResolved isOutdated path line comments(first:1){nodes{body author{login} url}}}}}}}`;
|
|
275
|
+
const res = api(['api', 'graphql', '-f', `query=${q}`, '-F', `owner=${owner}`, '-F', `name=${name}`, '-F', `pr=${pr}`]);
|
|
276
|
+
const conn = res.data.repository.pullRequest.reviewThreads;
|
|
277
|
+
if (conn.pageInfo.hasNextPage) {
|
|
278
|
+
throw new Error('more than 100 review threads — refusing to report a partial list');
|
|
279
|
+
}
|
|
280
|
+
const out = [];
|
|
281
|
+
for (const t of conn.nodes) {
|
|
282
|
+
const body = String(t.comments?.nodes?.[0]?.body ?? '');
|
|
283
|
+
const m = /<!-- smithers-finding:([0-9a-f]{12}) -->/.exec(body);
|
|
284
|
+
if (!m) continue;
|
|
285
|
+
out.push({
|
|
286
|
+
id: m[1],
|
|
287
|
+
resolved: t.isResolved,
|
|
288
|
+
outdated: t.isOutdated,
|
|
289
|
+
path: t.path,
|
|
290
|
+
line: t.line,
|
|
291
|
+
summary: body.split('\n')[0].replace(/\*\*/g, '').slice(0, 88),
|
|
292
|
+
url: t.comments?.nodes?.[0]?.url,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
return out;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Resolve one finding's thread, with the reason posted into the thread first.
|
|
300
|
+
*
|
|
301
|
+
* The reason is REQUIRED. Resolving is how a merge blocker this tool created is
|
|
302
|
+
* cleared, so an unexplained resolve is indistinguishable from the tool hiding
|
|
303
|
+
* a finding it could not answer.
|
|
304
|
+
*/
|
|
305
|
+
export function resolveFinding({ repo, pr, id, reason, api = gh }) {
|
|
306
|
+
if (!reason || !String(reason).trim()) {
|
|
307
|
+
throw new Error('--resolve requires --reason: an unexplained resolve hides the finding it closes');
|
|
308
|
+
}
|
|
309
|
+
const [owner, name] = repo.split('/');
|
|
310
|
+
const q = `query($owner:String!,$name:String!,$pr:Int!){repository(owner:$owner,name:$name){pullRequest(number:$pr){reviewThreads(first:100){pageInfo{hasNextPage} nodes{id isResolved comments(first:1){nodes{body databaseId}}}}}}}`;
|
|
311
|
+
const res = api(['api', 'graphql', '-f', `query=${q}`, '-F', `owner=${owner}`, '-F', `name=${name}`, '-F', `pr=${pr}`]);
|
|
312
|
+
const conn = res.data.repository.pullRequest.reviewThreads;
|
|
313
|
+
if (conn.pageInfo.hasNextPage) {
|
|
314
|
+
throw new Error('more than 100 review threads — refusing to resolve from a truncated read');
|
|
315
|
+
}
|
|
316
|
+
const thread = conn.nodes.find((t) =>
|
|
317
|
+
String(t.comments?.nodes?.[0]?.body ?? '').includes(marker(id)),
|
|
318
|
+
);
|
|
319
|
+
if (!thread) throw new Error(`no thread found carrying finding ${id}`);
|
|
320
|
+
if (thread.isResolved) return { id, alreadyResolved: true };
|
|
321
|
+
|
|
322
|
+
const commentId = thread.comments.nodes[0].databaseId;
|
|
323
|
+
api(
|
|
324
|
+
['api', `repos/${repo}/pulls/${pr}/comments/${commentId}/replies`, '--method', 'POST', '--input', '-'],
|
|
325
|
+
{ json: false, input: JSON.stringify({ body: `Resolved: ${reason}` }) },
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
// GITHUB APPS CANNOT RESOLVE REVIEW THREADS. Measured, not assumed: with
|
|
329
|
+
// `pull_requests: write` the App reads threads and posts replies fine, and
|
|
330
|
+
// `resolveReviewThread` returns "Resource not accessible by integration".
|
|
331
|
+
// There is no permission that grants it — the mutation requires a user.
|
|
332
|
+
//
|
|
333
|
+
// So the design goal "whatever opens a blocker can close it" is NOT
|
|
334
|
+
// achievable by the App, and pretending otherwise would leave threads open
|
|
335
|
+
// with a reply claiming they were resolved. The reply is posted as the bot,
|
|
336
|
+
// because it is the reviewer's statement; the resolve falls back to the
|
|
337
|
+
// caller's own gh credentials, because deciding a finding is addressed is a
|
|
338
|
+
// human judgement and should be attributed to the human who made it.
|
|
339
|
+
//
|
|
340
|
+
// The consequence is worth stating plainly rather than hiding in a retry: an
|
|
341
|
+
// UNATTENDED run cannot resolve anything. It can post findings and it can
|
|
342
|
+
// post the reason; a person has to close the thread.
|
|
343
|
+
const mutation = `mutation{resolveReviewThread(input:{threadId:"${thread.id}"}){thread{isResolved}}}`;
|
|
344
|
+
try {
|
|
345
|
+
api(['api', 'graphql', '-f', `query=${mutation}`]);
|
|
346
|
+
return { id, resolved: true, reason, resolvedBy: 'app' };
|
|
347
|
+
} catch (e) {
|
|
348
|
+
if (!/not accessible by integration/i.test(String(e.stderr ?? e.message))) throw e;
|
|
349
|
+
}
|
|
350
|
+
const prev = AUTH_TOKEN;
|
|
351
|
+
setAuthToken(null); // the caller's own gh auth
|
|
352
|
+
try {
|
|
353
|
+
api(['api', 'graphql', '-f', `query=${mutation}`]);
|
|
354
|
+
return { id, resolved: true, reason, resolvedBy: 'you (the App cannot resolve threads)' };
|
|
355
|
+
} catch (e) {
|
|
356
|
+
return {
|
|
357
|
+
id,
|
|
358
|
+
resolved: false,
|
|
359
|
+
reason,
|
|
360
|
+
note:
|
|
361
|
+
'the reason was posted, but the thread is still open: GitHub Apps cannot resolve ' +
|
|
362
|
+
'review threads and your own gh credentials could not either. Resolve it in the UI.',
|
|
363
|
+
};
|
|
364
|
+
} finally {
|
|
365
|
+
setAuthToken(prev);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
// CLI
|
|
371
|
+
// ---------------------------------------------------------------------------
|
|
372
|
+
|
|
373
|
+
export function parseArgs(argv) {
|
|
374
|
+
const a = { post: false, status: false };
|
|
375
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
376
|
+
const k = argv[i];
|
|
377
|
+
if (k === '--post') a.post = true;
|
|
378
|
+
else if (k === '--status') a.status = true;
|
|
379
|
+
else if (k === '--pr') a.pr = Number(argv[++i]);
|
|
380
|
+
else if (k === '--repo') a.repo = argv[++i];
|
|
381
|
+
else if (k === '--dir') a.dir = argv[++i];
|
|
382
|
+
else if (k === '--resolve') a.resolve = argv[++i];
|
|
383
|
+
else if (k === '--reason') a.reason = argv[++i];
|
|
384
|
+
}
|
|
385
|
+
return a;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Which modes may run against a PR in this state — one place, so the answer
|
|
390
|
+
* cannot differ between the branches that ask it.
|
|
391
|
+
*
|
|
392
|
+
* The carve-out for `--status` was added because a read-only listing is useful
|
|
393
|
+
* on a closed PR. Written as `!a.status` it was too wide: `--resolve` returns
|
|
394
|
+
* BEFORE the status branch, so `--status --resolve <id>` satisfied the
|
|
395
|
+
* carve-out and then took a reply through the door on a merged PR — the very
|
|
396
|
+
* defect the carve-out was written alongside, one argument along (CodeRabbit,
|
|
397
|
+
* #156). Read-only therefore means `--status` and NOTHING ELSE.
|
|
398
|
+
*
|
|
399
|
+
* The conflicting pair is refused outright rather than silently resolved in
|
|
400
|
+
* `--resolve`'s favour, because a flag that is accepted and then ignored is
|
|
401
|
+
* indistinguishable from one that was honoured.
|
|
402
|
+
*
|
|
403
|
+
* Returns a refusal message, or null to proceed.
|
|
404
|
+
*/
|
|
405
|
+
export function refuseMode({ state, status, resolve, pr }) {
|
|
406
|
+
if (status && resolve) {
|
|
407
|
+
return '--status and --resolve are separate modes: --resolve runs first and --status would be ignored. Pick one.';
|
|
408
|
+
}
|
|
409
|
+
const readOnly = Boolean(status) && !resolve;
|
|
410
|
+
if (state !== 'OPEN' && !readOnly) {
|
|
411
|
+
return (
|
|
412
|
+
`PR #${pr} is ${state} — refusing to write to it. Findings and replies on a ` +
|
|
413
|
+
'closed PR reach no one; if they still matter, they belong on a follow-up.'
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async function main() {
|
|
420
|
+
const a = parseArgs(process.argv.slice(2));
|
|
421
|
+
if (!a.pr) throw new Error('--pr is required');
|
|
422
|
+
const repo = a.repo ?? gh(['repo', 'view', '--json', 'nameWithOwner'], {}).nameWithOwner;
|
|
423
|
+
const dir = a.dir ?? REVIEW_DIR;
|
|
424
|
+
|
|
425
|
+
// Resolve identity before anything is written, and print it. A reviewer that
|
|
426
|
+
// posts under the operator's name when the App is misconfigured has produced
|
|
427
|
+
// the exact outcome the App exists to prevent, so the fallback is announced
|
|
428
|
+
// rather than merely permitted.
|
|
429
|
+
const identity = await resolveIdentity({ repo });
|
|
430
|
+
setAuthToken(identity.token);
|
|
431
|
+
console.log(
|
|
432
|
+
identity.kind === 'app'
|
|
433
|
+
? `identity: ${identity.why} (token expires ${identity.expiresAt})`
|
|
434
|
+
: `identity: FALLBACK — posting as your own gh account (${identity.why})`,
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
// Read PR state BEFORE the --resolve branch, not after it (CodeRabbit, #156).
|
|
438
|
+
// --resolve returned early, and resolveFinding posts a reply before it
|
|
439
|
+
// resolves — so a merged PR could still take a bot comment through this door
|
|
440
|
+
// while the posting door refused. The same defect, one branch along, which is
|
|
441
|
+
// what a guard placed at one call site rather than at the boundary tends to
|
|
442
|
+
// leave behind. What may run against which state now lives in refuseMode(),
|
|
443
|
+
// so the answer is one function rather than a condition per branch.
|
|
444
|
+
const view = gh(['pr', 'view', String(a.pr), '--repo', repo, '--json', 'headRefOid,state']);
|
|
445
|
+
const refusal = refuseMode({ state: view.state, status: a.status, resolve: a.resolve, pr: a.pr });
|
|
446
|
+
if (refusal) throw new Error(refusal);
|
|
447
|
+
|
|
448
|
+
if (a.resolve) {
|
|
449
|
+
console.log(JSON.stringify(resolveFinding({ repo, pr: a.pr, id: a.resolve, reason: a.reason }), null, 2));
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (a.status) {
|
|
454
|
+
const threads = await listFindingThreads({ repo, pr: a.pr });
|
|
455
|
+
console.log(`\n${threads.length} thread(s) opened by this reviewer:`);
|
|
456
|
+
for (const t of threads) {
|
|
457
|
+
const mark = t.resolved ? 'RESOLVED' : t.outdated ? 'OUTDATED' : 'OPEN ';
|
|
458
|
+
console.log(` ${mark} ${t.id} ${t.path}:${t.line ?? '-'} ${t.summary}`);
|
|
459
|
+
}
|
|
460
|
+
const open = threads.filter((t) => !t.resolved).length;
|
|
461
|
+
if (open > 0) {
|
|
462
|
+
console.log(
|
|
463
|
+
`\n${open} open. Resolve one only once it is ADDRESSED or judged not to apply:\n` +
|
|
464
|
+
` post-review-findings.mjs --pr ${a.pr} --resolve <id> --reason "what changed, or why it does not apply"\n` +
|
|
465
|
+
'The reason is posted into the thread before it closes — a resolve with no\n' +
|
|
466
|
+
'explanation is indistinguishable from the reviewer hiding a finding.',
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
// A merged or closed PR takes comments happily and shows them to nobody.
|
|
472
|
+
const head = view.headRefOid;
|
|
473
|
+
const findings = loadFindings(dir, a.pr, head);
|
|
474
|
+
// --slurp (gh >= 2.44) is REQUIRED with --paginate here: without it gh emits
|
|
475
|
+
// one JSON array PER PAGE, and gh()'s JSON.parse of the concatenation throws
|
|
476
|
+
// `Unexpected token [`. That makes --post fail on exactly the large PRs it is
|
|
477
|
+
// most needed on, and never on the small ones it gets tested against.
|
|
478
|
+
const files = gh(['api', `repos/${repo}/pulls/${a.pr}/files`, '--paginate', '--slurp']).flat();
|
|
479
|
+
const commentable = commentableLines(files.map((f) => [f.filename, f.patch]));
|
|
480
|
+
const { inline, outside } = partitionByAnchor(findings, commentable);
|
|
481
|
+
|
|
482
|
+
const existing = gh(['api', `repos/${repo}/pulls/${a.pr}/comments`, '--paginate', '--slurp']).flat();
|
|
483
|
+
// ENG-10001 (CodeRabbit, major): `/pulls/{n}/comments` returns INLINE review
|
|
484
|
+
// comments only. An outside-diff finding's marker is written into the review
|
|
485
|
+
// BODY (see the `<details>` block below), which that endpoint never returns —
|
|
486
|
+
// so deduping on inline comments alone reposts every outside-diff finding on
|
|
487
|
+
// each --post, and undercounts "already posted". Reviews carry `.body` too,
|
|
488
|
+
// so postedIds reads both without change.
|
|
489
|
+
const reviews = gh(['api', `repos/${repo}/pulls/${a.pr}/reviews`, '--paginate', '--slurp']).flat();
|
|
490
|
+
const already = postedIds([...existing, ...reviews]);
|
|
491
|
+
const newInline = inline.filter((f) => !already.has(findingId(f)));
|
|
492
|
+
const newOutside = outside.filter((f) => !already.has(findingId(f)));
|
|
493
|
+
|
|
494
|
+
console.log(`PR #${a.pr} @ ${head.slice(0, 9)} — ${findings.length} finding(s) loaded`);
|
|
495
|
+
console.log(` inline-anchorable: ${inline.length} outside-diff: ${outside.length}`);
|
|
496
|
+
console.log(` already posted: ${inline.length + outside.length - newInline.length - newOutside.length}`);
|
|
497
|
+
console.log(` would post now: ${newInline.length} inline + ${newOutside.length} in the body\n`);
|
|
498
|
+
for (const f of newInline) console.log(` [inline] ${f.file}:${f.line} ${findingId(f)} ${f.summary.slice(0, 78)}`);
|
|
499
|
+
for (const f of newOutside) console.log(` [body] ${f.file} ${findingId(f)} (${f._why}) ${f.summary.slice(0, 60)}`);
|
|
500
|
+
|
|
501
|
+
if (!a.post) {
|
|
502
|
+
console.log(`\nDRY RUN — nothing posted. Re-run with --post to deliver.`);
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
if (newInline.length + newOutside.length === 0) {
|
|
506
|
+
console.log('Nothing new to post.');
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const comments = newInline.map((f) => ({
|
|
511
|
+
path: f.file,
|
|
512
|
+
line: f.line,
|
|
513
|
+
side: 'RIGHT',
|
|
514
|
+
body: buildBody(f, findingId(f)),
|
|
515
|
+
}));
|
|
516
|
+
const bodyParts = [
|
|
517
|
+
`**${newInline.length + newOutside.length} finding(s)** from \`/s:code-review\` and \`/s:security-review\`.`,
|
|
518
|
+
'',
|
|
519
|
+
'This reviewer never submits a blocking verdict — see `scripts/post-review-findings.mjs`.',
|
|
520
|
+
];
|
|
521
|
+
if (newOutside.length > 0) {
|
|
522
|
+
bodyParts.push('', `<details><summary>${newOutside.length} finding(s) outside the diff range</summary>`, '');
|
|
523
|
+
for (const f of newOutside) bodyParts.push(`- \`${f.file}\` — ${f.summary}\n\n ${f.why ?? f.attack ?? ''}\n\n ${marker(findingId(f))}`);
|
|
524
|
+
bodyParts.push('', '</details>');
|
|
525
|
+
}
|
|
526
|
+
submitReview({ repo, pr: a.pr, comments, body: bodyParts.join('\n') });
|
|
527
|
+
console.log(`Posted ${comments.length} inline + ${newOutside.length} in the body, as COMMENT.`);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Entrypoint check via the REAL path.
|
|
532
|
+
*
|
|
533
|
+
* `~/.claude/plugins/smithers` is a symlink, and Node gives `import.meta.url`
|
|
534
|
+
* the resolved target while `process.argv[1]` keeps the symlink the caller
|
|
535
|
+
* typed. Comparing them directly meant the CLI never ran when invoked the only
|
|
536
|
+
* way anyone invokes it — exiting 0, printing nothing, with no error to say the
|
|
537
|
+
* work had not happened. Proved by `--cli-symlink` in this module's test.
|
|
538
|
+
*/
|
|
539
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) main();
|
package/dist/bin/agt.js
CHANGED
|
@@ -40,7 +40,7 @@ import {
|
|
|
40
40
|
success,
|
|
41
41
|
table,
|
|
42
42
|
warn
|
|
43
|
-
} from "../chunk-
|
|
43
|
+
} from "../chunk-REBHUZ7N.js";
|
|
44
44
|
import {
|
|
45
45
|
getProjectDir,
|
|
46
46
|
isSessionResumeDisabled,
|
|
@@ -5467,7 +5467,7 @@ import { execFileSync, execSync } from "child_process";
|
|
|
5467
5467
|
import { existsSync as existsSync11, realpathSync as realpathSync2 } from "fs";
|
|
5468
5468
|
import chalk18 from "chalk";
|
|
5469
5469
|
import ora16 from "ora";
|
|
5470
|
-
var cliVersion = true ? "0.28.
|
|
5470
|
+
var cliVersion = true ? "0.28.835" : "dev";
|
|
5471
5471
|
async function fetchLatestVersion() {
|
|
5472
5472
|
const host2 = getHost();
|
|
5473
5473
|
if (!host2) return null;
|
|
@@ -6658,7 +6658,7 @@ function handleError(err) {
|
|
|
6658
6658
|
}
|
|
6659
6659
|
|
|
6660
6660
|
// src/bin/agt.ts
|
|
6661
|
-
var cliVersion2 = true ? "0.28.
|
|
6661
|
+
var cliVersion2 = true ? "0.28.835" : "dev";
|
|
6662
6662
|
var program = new Command();
|
|
6663
6663
|
program.name("agt").description("Augmented CLI \u2014 agent provisioning and management").version(cliVersion2).option("--json", "Emit machine-readable JSON output (suppress spinners and colors)").option("--skip-update-check", "Skip the automatic update check on startup");
|
|
6664
6664
|
program.hook("preAction", async (thisCommand, actionCommand) => {
|
|
@@ -6537,7 +6537,7 @@ function exchangeFailureKind(err) {
|
|
|
6537
6537
|
}
|
|
6538
6538
|
|
|
6539
6539
|
// src/lib/api-client.ts
|
|
6540
|
-
var agtCliVersion = true ? "0.28.
|
|
6540
|
+
var agtCliVersion = true ? "0.28.835" : "dev";
|
|
6541
6541
|
var lastConfigHash = null;
|
|
6542
6542
|
function setConfigHash(hash) {
|
|
6543
6543
|
lastConfigHash = hash && hash.length > 0 ? hash : null;
|
|
@@ -10895,4 +10895,4 @@ export {
|
|
|
10895
10895
|
managerInstallSystemUnitCommand,
|
|
10896
10896
|
managerUninstallSystemUnitCommand
|
|
10897
10897
|
};
|
|
10898
|
-
//# sourceMappingURL=chunk-
|
|
10898
|
+
//# sourceMappingURL=chunk-REBHUZ7N.js.map
|