@jphutchins/code-review 0.1.0-alpha.4 → 0.1.0-alpha.40
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 +80 -7
- package/dist/index.js +3591 -411
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/schema/VERSIONING.md +24 -1
- package/schema/findings.schema.json +88 -14
- package/schema/v0.2/findings.schema.json +88 -0
- package/templates/comment.eta +115 -45
- package/templates/inline.eta +32 -5
package/dist/index.js
CHANGED
|
@@ -1,13 +1,183 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { defineCommand, runMain } from 'citty';
|
|
3
|
-
import { readFileSync, writeFileSync } from 'fs';
|
|
4
|
-
import {
|
|
3
|
+
import { readFileSync, writeFileSync, copyFileSync, statSync, readdirSync } from 'fs';
|
|
4
|
+
import { randomBytes } from 'crypto';
|
|
5
|
+
import { resolve as resolve$1, join, dirname, basename, extname } from 'path';
|
|
5
6
|
import { Eta } from 'eta';
|
|
7
|
+
import * as t from 'io-ts';
|
|
6
8
|
import parseDiff from 'parse-diff';
|
|
7
9
|
import { Ajv2020 } from 'ajv/dist/2020.js';
|
|
8
10
|
import _addFormats from 'ajv-formats';
|
|
9
|
-
import * as t from 'io-ts';
|
|
10
11
|
import { execFile } from 'child_process';
|
|
12
|
+
import { performance } from 'perf_hooks';
|
|
13
|
+
import { PathReporter } from 'io-ts/lib/PathReporter.js';
|
|
14
|
+
|
|
15
|
+
var SeverityCodec = t.union([
|
|
16
|
+
t.literal("critical"),
|
|
17
|
+
t.literal("major"),
|
|
18
|
+
t.literal("minor"),
|
|
19
|
+
t.literal("nit")
|
|
20
|
+
]);
|
|
21
|
+
var SideCodec = t.union([t.literal("RIGHT"), t.literal("LEFT")]);
|
|
22
|
+
var VerdictCodec = t.union([
|
|
23
|
+
t.literal("approve"),
|
|
24
|
+
t.literal("comment"),
|
|
25
|
+
t.literal("changes"),
|
|
26
|
+
t.literal("error")
|
|
27
|
+
]);
|
|
28
|
+
var LineNumber = t.refinement(
|
|
29
|
+
t.number,
|
|
30
|
+
(n) => Number.isInteger(n) && n >= 1,
|
|
31
|
+
"LineNumber"
|
|
32
|
+
);
|
|
33
|
+
var Confidence = t.refinement(t.number, (n) => n >= 0 && n <= 1, "Confidence");
|
|
34
|
+
var SCHEMA_VERSION_RE = /^(0|[1-9]\d*)\.(\d+)\.(\d+)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
35
|
+
var SchemaVersion = t.refinement(
|
|
36
|
+
t.string,
|
|
37
|
+
(s) => SCHEMA_VERSION_RE.test(s),
|
|
38
|
+
"SchemaVersion"
|
|
39
|
+
);
|
|
40
|
+
var UriString = t.refinement(
|
|
41
|
+
t.string,
|
|
42
|
+
(s) => !/\s/.test(s) && URL.canParse(s),
|
|
43
|
+
"UriString"
|
|
44
|
+
);
|
|
45
|
+
var FindingRuleCodec = t.partial({
|
|
46
|
+
code: t.string,
|
|
47
|
+
code_url: UriString
|
|
48
|
+
});
|
|
49
|
+
var FindingShape = t.intersection([
|
|
50
|
+
t.type({
|
|
51
|
+
path: t.string,
|
|
52
|
+
start_line: LineNumber,
|
|
53
|
+
end_line: LineNumber,
|
|
54
|
+
severity: SeverityCodec,
|
|
55
|
+
title: t.string,
|
|
56
|
+
description: t.string,
|
|
57
|
+
reasoning: t.string,
|
|
58
|
+
confidence: Confidence
|
|
59
|
+
}),
|
|
60
|
+
FindingRuleCodec,
|
|
61
|
+
t.partial({
|
|
62
|
+
side: SideCodec,
|
|
63
|
+
recommendation: t.string,
|
|
64
|
+
patch: t.string
|
|
65
|
+
})
|
|
66
|
+
]);
|
|
67
|
+
var EndGeStart = t.refinement(
|
|
68
|
+
FindingShape,
|
|
69
|
+
(f) => f.end_line >= f.start_line,
|
|
70
|
+
"EndGeStart"
|
|
71
|
+
);
|
|
72
|
+
var FindingCodec = t.exact(EndGeStart);
|
|
73
|
+
var SystemicRequired = t.type({
|
|
74
|
+
title: t.string,
|
|
75
|
+
description: t.string,
|
|
76
|
+
severity: SeverityCodec,
|
|
77
|
+
reasoning: t.string,
|
|
78
|
+
confidence: Confidence
|
|
79
|
+
});
|
|
80
|
+
var SystemicOptional = t.partial({
|
|
81
|
+
finding_codes: t.array(t.string),
|
|
82
|
+
paths: t.array(t.string)
|
|
83
|
+
});
|
|
84
|
+
var SystemicProblemShape = t.intersection([SystemicRequired, FindingRuleCodec, SystemicOptional]);
|
|
85
|
+
var SYSTEMIC_KEYS = /* @__PURE__ */ new Set([
|
|
86
|
+
...Object.keys(SystemicRequired.props),
|
|
87
|
+
...Object.keys(FindingRuleCodec.props),
|
|
88
|
+
...Object.keys(SystemicOptional.props)
|
|
89
|
+
]);
|
|
90
|
+
var SystemicProblemStrict = t.refinement(
|
|
91
|
+
SystemicProblemShape,
|
|
92
|
+
(s) => Object.keys(s).every((k) => SYSTEMIC_KEYS.has(k)),
|
|
93
|
+
"SystemicProblemStrict"
|
|
94
|
+
);
|
|
95
|
+
var SystemicProblemCodec = t.exact(SystemicProblemStrict);
|
|
96
|
+
var FindingsCodec = t.exact(
|
|
97
|
+
t.intersection([
|
|
98
|
+
t.type({
|
|
99
|
+
schema_version: SchemaVersion,
|
|
100
|
+
summary: t.string,
|
|
101
|
+
verdict: VerdictCodec,
|
|
102
|
+
findings: t.array(FindingCodec)
|
|
103
|
+
}),
|
|
104
|
+
t.partial({
|
|
105
|
+
systemic_problems: t.array(SystemicProblemCodec)
|
|
106
|
+
})
|
|
107
|
+
])
|
|
108
|
+
);
|
|
109
|
+
var TriageCodec = t.type({
|
|
110
|
+
safe: t.boolean,
|
|
111
|
+
reasons: t.string
|
|
112
|
+
});
|
|
113
|
+
var TokenCount = t.refinement(
|
|
114
|
+
t.number,
|
|
115
|
+
(n) => Number.isInteger(n) && n >= 0,
|
|
116
|
+
"TokenCount"
|
|
117
|
+
);
|
|
118
|
+
var ModelUsageEntryCodec = t.intersection([
|
|
119
|
+
t.type({
|
|
120
|
+
model: t.string,
|
|
121
|
+
input_tokens: TokenCount,
|
|
122
|
+
output_tokens: TokenCount
|
|
123
|
+
}),
|
|
124
|
+
t.partial({
|
|
125
|
+
cache_read_tokens: TokenCount,
|
|
126
|
+
cache_write_tokens: TokenCount
|
|
127
|
+
})
|
|
128
|
+
]);
|
|
129
|
+
var ResultEnvelopeCodec = t.intersection([
|
|
130
|
+
t.type({
|
|
131
|
+
schema_version: t.string,
|
|
132
|
+
findings: FindingsCodec,
|
|
133
|
+
models: t.array(ModelUsageEntryCodec),
|
|
134
|
+
turns: TokenCount,
|
|
135
|
+
duration_ms: TokenCount
|
|
136
|
+
}),
|
|
137
|
+
t.partial({
|
|
138
|
+
vendor_cost_usd: t.union([t.number, t.null]),
|
|
139
|
+
route: t.string,
|
|
140
|
+
effort: t.string,
|
|
141
|
+
// The run produced a notice rather than a completed review (security-gate block, agent kill, no
|
|
142
|
+
// recoverable findings). An empty `findings` array alone can't say this — a genuine clean review
|
|
143
|
+
// is also empty — so the render suppresses "clean review" and the sticky precedence guard refuses
|
|
144
|
+
// to bury a completed review under it. Absent ⇒ a completed review.
|
|
145
|
+
incomplete: t.boolean
|
|
146
|
+
})
|
|
147
|
+
]);
|
|
148
|
+
var ModelPricesCodec = t.type({
|
|
149
|
+
in: t.number,
|
|
150
|
+
out: t.number,
|
|
151
|
+
cache_read: t.number,
|
|
152
|
+
cache_write: t.number
|
|
153
|
+
});
|
|
154
|
+
var PriceMapCodec = t.type({
|
|
155
|
+
_updated: t.string,
|
|
156
|
+
_unit: t.string,
|
|
157
|
+
models: t.record(t.string, ModelPricesCodec)
|
|
158
|
+
});
|
|
159
|
+
var TestFailureCodec = t.intersection([
|
|
160
|
+
t.type({ name: t.string }),
|
|
161
|
+
t.partial({ message: t.string })
|
|
162
|
+
]);
|
|
163
|
+
var TestSummaryCodec = t.intersection([
|
|
164
|
+
t.type({
|
|
165
|
+
passed: t.number,
|
|
166
|
+
failed: t.number,
|
|
167
|
+
total: t.number
|
|
168
|
+
}),
|
|
169
|
+
t.partial({
|
|
170
|
+
failures: t.array(TestFailureCodec)
|
|
171
|
+
})
|
|
172
|
+
]);
|
|
173
|
+
var DEFAULT_SCHEMA_VERSION = "0.6.0";
|
|
174
|
+
var incompleteFindings = (summary) => ({
|
|
175
|
+
schema_version: DEFAULT_SCHEMA_VERSION,
|
|
176
|
+
summary,
|
|
177
|
+
verdict: "error",
|
|
178
|
+
findings: []
|
|
179
|
+
});
|
|
180
|
+
var isIncompleteFindings = (findings) => findings.verdict === "error" && findings.findings.length === 0;
|
|
11
181
|
|
|
12
182
|
// src/cost.ts
|
|
13
183
|
var defaultWarn = (message) => {
|
|
@@ -53,44 +223,498 @@ var computeCost = (models, prices, warn = defaultWarn) => {
|
|
|
53
223
|
};
|
|
54
224
|
};
|
|
55
225
|
|
|
226
|
+
// src/patch.ts
|
|
227
|
+
var HUNK_HEADER_RE = /^@@ -(\d+)(?:,\d+)? \+\d+(?:,\d+)? @@/;
|
|
228
|
+
var hunkOldStart = (line) => {
|
|
229
|
+
const raw = HUNK_HEADER_RE.exec(line)?.[1];
|
|
230
|
+
return raw !== void 0 ? Number(raw) : null;
|
|
231
|
+
};
|
|
232
|
+
var classifyBodyLine = (line) => {
|
|
233
|
+
if (line.startsWith(" ")) return { kind: "context", text: line.slice(1) };
|
|
234
|
+
if (line.startsWith("-")) return { kind: "removed", text: line.slice(1) };
|
|
235
|
+
if (line.startsWith("+")) return { kind: "added", text: line.slice(1) };
|
|
236
|
+
return null;
|
|
237
|
+
};
|
|
238
|
+
var trimmedMiddle = (body) => {
|
|
239
|
+
const first = body.findIndex((l) => l.kind !== "context");
|
|
240
|
+
if (first === -1) return [];
|
|
241
|
+
const last = body.findLastIndex((l) => l.kind !== "context");
|
|
242
|
+
return body.slice(first, last + 1);
|
|
243
|
+
};
|
|
244
|
+
var isContiguousChange = (middle) => {
|
|
245
|
+
if (middle.some((l) => l.kind === "context")) return false;
|
|
246
|
+
const firstAdded = middle.findIndex((l) => l.kind === "added");
|
|
247
|
+
if (firstAdded === -1) return true;
|
|
248
|
+
return middle.slice(0, firstAdded).every((l) => l.kind === "removed") && middle.slice(firstAdded).every((l) => l.kind === "added");
|
|
249
|
+
};
|
|
250
|
+
var removedRange = (body, oldStart) => body.reduce(
|
|
251
|
+
(acc, line) => line.kind === "added" ? acc : {
|
|
252
|
+
lineNumber: acc.lineNumber + 1,
|
|
253
|
+
firstRemoved: line.kind === "removed" && acc.firstRemoved === null ? acc.lineNumber : acc.firstRemoved,
|
|
254
|
+
lastRemoved: line.kind === "removed" ? acc.lineNumber : acc.lastRemoved
|
|
255
|
+
},
|
|
256
|
+
{ lineNumber: oldStart, firstRemoved: null, lastRemoved: null }
|
|
257
|
+
);
|
|
258
|
+
var drop = (reason) => ({
|
|
259
|
+
kind: "drop",
|
|
260
|
+
reason
|
|
261
|
+
});
|
|
262
|
+
var keep = (reason) => ({
|
|
263
|
+
kind: "keep",
|
|
264
|
+
reason
|
|
265
|
+
});
|
|
266
|
+
var parseHunk = (patch) => {
|
|
267
|
+
const rawLines = patch.split("\n");
|
|
268
|
+
const lines = rawLines.length > 0 && rawLines[rawLines.length - 1] === "" ? rawLines.slice(0, -1) : rawLines;
|
|
269
|
+
const headerHits = lines.reduce(
|
|
270
|
+
(acc, line, index) => {
|
|
271
|
+
const oldStart = hunkOldStart(line);
|
|
272
|
+
return oldStart !== null ? [...acc, { index, oldStart }] : acc;
|
|
273
|
+
},
|
|
274
|
+
[]
|
|
275
|
+
);
|
|
276
|
+
if (headerHits.length !== 1) {
|
|
277
|
+
return drop(`expected exactly one hunk, got ${String(headerHits.length)}`);
|
|
278
|
+
}
|
|
279
|
+
const hit = headerHits[0];
|
|
280
|
+
if (hit === void 0) return drop("malformed hunk header");
|
|
281
|
+
const bodyRaw = lines.slice(hit.index + 1).filter((line) => !line.startsWith("\\"));
|
|
282
|
+
const classified = bodyRaw.map(classifyBodyLine);
|
|
283
|
+
if (classified.some((line) => line === null)) return drop("malformed hunk body line");
|
|
284
|
+
const body = classified.filter((line) => line !== null);
|
|
285
|
+
return { kind: "ok", oldStart: hit.oldStart, body };
|
|
286
|
+
};
|
|
287
|
+
var validatePatch = (patch, fileLines) => {
|
|
288
|
+
const parsed = parseHunk(patch);
|
|
289
|
+
if (parsed.kind === "drop") return parsed;
|
|
290
|
+
const { oldStart, body } = parsed;
|
|
291
|
+
const oldSideTexts = body.filter((l) => l.kind !== "added").map((l) => l.text);
|
|
292
|
+
const expected = fileLines.slice(oldStart - 1, oldStart - 1 + oldSideTexts.length);
|
|
293
|
+
const oldSideMatches = expected.length === oldSideTexts.length && expected.every((line, i) => line === oldSideTexts[i]);
|
|
294
|
+
if (!oldSideMatches) {
|
|
295
|
+
return drop(
|
|
296
|
+
`patch context does not match the file at lines ${String(oldStart)}..${String(oldStart + oldSideTexts.length - 1)}`
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
if (!isContiguousChange(trimmedMiddle(body))) {
|
|
300
|
+
return drop("change is not a single contiguous block");
|
|
301
|
+
}
|
|
302
|
+
const removedCount = body.filter((l) => l.kind === "removed").length;
|
|
303
|
+
const addedCount = body.filter((l) => l.kind === "added").length;
|
|
304
|
+
if (removedCount === 0 && addedCount === 0) return drop("hunk contains no changes");
|
|
305
|
+
if (removedCount === 0) {
|
|
306
|
+
return keep("pure insertion applies cleanly but has no removed range to anchor a suggestion");
|
|
307
|
+
}
|
|
308
|
+
const { firstRemoved, lastRemoved } = removedRange(body, oldStart);
|
|
309
|
+
if (firstRemoved === null || lastRemoved === null) return drop("malformed hunk body");
|
|
310
|
+
return { kind: "anchored", startLine: firstRemoved, endLine: lastRemoved };
|
|
311
|
+
};
|
|
312
|
+
var patchToSuggestion = (patch) => {
|
|
313
|
+
const parsed = parseHunk(patch);
|
|
314
|
+
if (parsed.kind === "drop") return parsed;
|
|
315
|
+
const { body } = parsed;
|
|
316
|
+
if (!isContiguousChange(trimmedMiddle(body))) {
|
|
317
|
+
return drop("change is not a single contiguous block");
|
|
318
|
+
}
|
|
319
|
+
const removedCount = body.filter((l) => l.kind === "removed").length;
|
|
320
|
+
const addedLines = body.filter((l) => l.kind === "added");
|
|
321
|
+
if (removedCount === 0 && addedLines.length === 0) return drop("hunk contains no changes");
|
|
322
|
+
if (removedCount === 0) return drop("pure insertion can't be expressed as a suggestion");
|
|
323
|
+
return addedLines.map((l) => l.text).join("\n");
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
// src/surface.ts
|
|
327
|
+
var severityEmoji = (s) => {
|
|
328
|
+
switch (s) {
|
|
329
|
+
case "critical":
|
|
330
|
+
return "\u{1F534}";
|
|
331
|
+
case "major":
|
|
332
|
+
return "\u{1F7E0}";
|
|
333
|
+
case "minor":
|
|
334
|
+
return "\u{1F535}";
|
|
335
|
+
case "nit":
|
|
336
|
+
return "\u26AA";
|
|
337
|
+
default:
|
|
338
|
+
return "\u2753";
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
var EMBED_LIMIT = 40200;
|
|
342
|
+
var AGENTS_STOP_DIRECTIVE = "<!-- AGENTS: STOP \u2014 do not parse the prose below; decode this findings JSON and read schema_version first. -->";
|
|
343
|
+
var b64LengthOf = (document) => Buffer.from(JSON.stringify(document), "utf-8").toString("base64").length;
|
|
344
|
+
var encodeMarker = (document, jsonUrl, limit) => {
|
|
345
|
+
const b64 = Buffer.from(JSON.stringify(document), "utf-8").toString("base64");
|
|
346
|
+
const marker = b64.length <= limit ? `<!-- code-review:findings-json;base64 ${b64} -->` : jsonUrl ? `<!-- code-review:findings-json ${jsonUrl} -->` : "";
|
|
347
|
+
return marker ? `${AGENTS_STOP_DIRECTIVE}
|
|
348
|
+
${marker}` : "";
|
|
349
|
+
};
|
|
350
|
+
var decodeBase64Json = (b64) => {
|
|
351
|
+
try {
|
|
352
|
+
return JSON.parse(Buffer.from(b64, "base64").toString("utf-8"));
|
|
353
|
+
} catch {
|
|
354
|
+
return void 0;
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
var findingsPointer = (findings, jsonUrl, limit = EMBED_LIMIT) => encodeMarker(findings, jsonUrl, limit);
|
|
358
|
+
var findingsMarkerForm = (findings, jsonUrl, limit = EMBED_LIMIT) => {
|
|
359
|
+
if (b64LengthOf(findings) <= limit) return "embedded";
|
|
360
|
+
return jsonUrl ? "link" : "omitted";
|
|
361
|
+
};
|
|
362
|
+
var findingPointer = (finding, schemaVersion, jsonUrl, limit = EMBED_LIMIT) => encodeMarker({ schema_version: schemaVersion, findings: [finding] }, jsonUrl, limit);
|
|
363
|
+
var ZERO_SHA = "0000000000000000000000000000000000000000";
|
|
364
|
+
var parseReviewedSha = (body) => {
|
|
365
|
+
const sha = /<!-- reviewed-sha: ([0-9a-fA-F]{40}) -->/.exec(body)?.[1]?.toLowerCase();
|
|
366
|
+
return sha && sha !== ZERO_SHA ? sha : null;
|
|
367
|
+
};
|
|
368
|
+
var ROUTE_RE = /<!-- reviewed-route: ([^>]*) -->/;
|
|
369
|
+
var parseReviewedRoute = (body) => ROUTE_RE.exec(body)?.[1] || null;
|
|
370
|
+
var isFullReviewSticky = (body) => {
|
|
371
|
+
const route = parseReviewedRoute(body);
|
|
372
|
+
return route === "full review" || route !== "mechanic" && parseRounds(body).length > 0;
|
|
373
|
+
};
|
|
374
|
+
var COMPLETED_ANCESTOR_MARKER = "<!-- review-complete-ancestor -->";
|
|
375
|
+
var parseCompletedAncestor = (body) => body.includes(COMPLETED_ANCESTOR_MARKER);
|
|
376
|
+
var REVIEW_COMPLETE_MARKER = "<!-- review-complete -->";
|
|
377
|
+
var parseReviewComplete = (body) => body.includes(REVIEW_COMPLETE_MARKER);
|
|
378
|
+
var parseFindingsMarker = (body) => {
|
|
379
|
+
const b64 = /<!-- code-review:findings-json;base64 ([A-Za-z0-9+/=]+) -->/.exec(body)?.[1];
|
|
380
|
+
if (b64 === void 0) return null;
|
|
381
|
+
return decodeBase64Json(b64) ?? null;
|
|
382
|
+
};
|
|
383
|
+
var ROUNDS_RE = /<!-- code-review:rounds;base64 ([A-Za-z0-9+/=]+) -->/;
|
|
384
|
+
var SEVERITIES = ["critical", "major", "minor", "nit"];
|
|
385
|
+
var isSeverityCounts = (u) => typeof u === "object" && u !== null && SEVERITIES.every((k) => {
|
|
386
|
+
const v = u[k];
|
|
387
|
+
return typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
|
|
388
|
+
});
|
|
389
|
+
var MAX_CODES_PER_ROUND = 8;
|
|
390
|
+
var hasCode = (codes, code) => codes !== void 0 && Object.prototype.hasOwnProperty.call(codes, code);
|
|
391
|
+
var escapeCodeBackticks = (code) => code.replace(/`/g, "-").replace(/\r?\n/g, " ");
|
|
392
|
+
var normalizeCodeCounts = (codes, priorCodes) => {
|
|
393
|
+
if (typeof codes !== "object" || codes === null || Array.isArray(codes)) return void 0;
|
|
394
|
+
const entries = Object.entries(codes).filter(
|
|
395
|
+
(e) => typeof e[1] === "number" && Number.isSafeInteger(e[1]) && e[1] > 0
|
|
396
|
+
).sort((a, b) => {
|
|
397
|
+
if (b[1] !== a[1]) return b[1] - a[1];
|
|
398
|
+
const aPrior = hasCode(priorCodes, a[0]) ? 1 : 0;
|
|
399
|
+
const bPrior = hasCode(priorCodes, b[0]) ? 1 : 0;
|
|
400
|
+
if (aPrior !== bPrior) return bPrior - aPrior;
|
|
401
|
+
return a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0;
|
|
402
|
+
});
|
|
403
|
+
if (entries.length === 0) return void 0;
|
|
404
|
+
const sorted = entries.sort((a, b) => {
|
|
405
|
+
if (b[1] !== a[1]) return b[1] - a[1];
|
|
406
|
+
const aPrior = hasCode(priorCodes, a[0]) ? 1 : 0;
|
|
407
|
+
const bPrior = hasCode(priorCodes, b[0]) ? 1 : 0;
|
|
408
|
+
if (aPrior !== bPrior) return bPrior - aPrior;
|
|
409
|
+
return a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0;
|
|
410
|
+
});
|
|
411
|
+
const base = sorted.slice(0, MAX_CODES_PER_ROUND);
|
|
412
|
+
const priorKept = sorted.slice(MAX_CODES_PER_ROUND).filter(([code]) => hasCode(priorCodes, code)).slice(0, MAX_CODES_PER_ROUND);
|
|
413
|
+
return Object.fromEntries([...base, ...priorKept]);
|
|
414
|
+
};
|
|
415
|
+
var parseRounds = (body) => {
|
|
416
|
+
const b64 = ROUNDS_RE.exec(body)?.[1];
|
|
417
|
+
if (b64 === void 0) return [];
|
|
418
|
+
const decoded = decodeBase64Json(b64);
|
|
419
|
+
if (!Array.isArray(decoded)) return [];
|
|
420
|
+
return decoded.filter(isSeverityCounts).map((u) => {
|
|
421
|
+
const rec = u;
|
|
422
|
+
const codes = normalizeCodeCounts(rec["codes"]);
|
|
423
|
+
const sha = rec["sha"];
|
|
424
|
+
const shaStr = typeof sha === "string" && sha !== "" ? sha : void 0;
|
|
425
|
+
const round = rec["round"];
|
|
426
|
+
const roundNum = typeof round === "number" && Number.isSafeInteger(round) && round >= 1 ? round : void 0;
|
|
427
|
+
const base = codes === void 0 ? { critical: u.critical, major: u.major, minor: u.minor, nit: u.nit } : { critical: u.critical, major: u.major, minor: u.minor, nit: u.nit, codes };
|
|
428
|
+
const kept = shaStr === void 0 ? base : { ...base, sha: shaStr };
|
|
429
|
+
return roundNum === void 0 ? kept : { ...kept, round: roundNum };
|
|
430
|
+
});
|
|
431
|
+
};
|
|
432
|
+
var ROUNDS_MARKER_LIMIT = 8e3;
|
|
433
|
+
var roundsMarker = (rounds) => {
|
|
434
|
+
if (rounds.length === 0) return "";
|
|
435
|
+
const serialize = (kept2) => `<!-- code-review:rounds;base64 ${Buffer.from(JSON.stringify(kept2), "utf-8").toString("base64")} -->`;
|
|
436
|
+
const stripCodes = (n) => rounds.map(
|
|
437
|
+
(r, i) => i < n ? { critical: r.critical, major: r.major, minor: r.minor, nit: r.nit } : r
|
|
438
|
+
);
|
|
439
|
+
let stripped = 0;
|
|
440
|
+
while (stripped < rounds.length && serialize(stripCodes(stripped)).length > ROUNDS_MARKER_LIMIT) {
|
|
441
|
+
stripped += 1;
|
|
442
|
+
}
|
|
443
|
+
const kept = stripCodes(stripped);
|
|
444
|
+
const bounded = serialize(kept).length > ROUNDS_MARKER_LIMIT ? kept.slice(-8) : kept;
|
|
445
|
+
return serialize(bounded);
|
|
446
|
+
};
|
|
447
|
+
var roundChip = (c) => {
|
|
448
|
+
const parts = SEVERITIES.filter((k) => c[k] > 0).map((k) => `${severityEmoji(k)}${String(c[k])}`);
|
|
449
|
+
return parts.length === 0 ? "clean" : parts.join(" ");
|
|
450
|
+
};
|
|
451
|
+
var TRAJECTORY_CHIPS = 8;
|
|
452
|
+
var roundsSummary = (rounds, count = rounds.length) => {
|
|
453
|
+
if (count === 0) return "";
|
|
454
|
+
const chips = rounds.slice(-TRAJECTORY_CHIPS).map(roundChip);
|
|
455
|
+
const trajectory = chips.length === 0 ? "" : rounds.length > TRAJECTORY_CHIPS ? `\u2026 \u2192 ${chips.join(" \u2192 ")}` : chips.join(" \u2192 ");
|
|
456
|
+
return trajectory === "" ? `**Round ${String(count)}**` : `**Round ${String(count)}** \xB7 ${trajectory}`;
|
|
457
|
+
};
|
|
458
|
+
var computeCodeCounts = (findings, systemic = []) => {
|
|
459
|
+
const counts = /* @__PURE__ */ new Map();
|
|
460
|
+
for (const code of [
|
|
461
|
+
...findings.map((f) => f.code),
|
|
462
|
+
...systemic.flatMap((s) => [s.code, ...s.finding_codes ?? []])
|
|
463
|
+
]) {
|
|
464
|
+
if (code === void 0 || code === "") continue;
|
|
465
|
+
counts.set(code, (counts.get(code) ?? 0) + 1);
|
|
466
|
+
}
|
|
467
|
+
return Object.fromEntries(counts);
|
|
468
|
+
};
|
|
469
|
+
var roundRecord = (counts, codes, priorCodes, sha, round) => {
|
|
470
|
+
const normalized = normalizeCodeCounts(codes, priorCodes);
|
|
471
|
+
const record3 = normalized === void 0 ? { ...counts } : { ...counts, codes: normalized };
|
|
472
|
+
return {
|
|
473
|
+
...record3,
|
|
474
|
+
...sha !== void 0 ? { sha } : {},
|
|
475
|
+
...round !== void 0 ? { round } : {}
|
|
476
|
+
};
|
|
477
|
+
};
|
|
478
|
+
var consecutiveCodeStreaks = (rounds) => {
|
|
479
|
+
const entries = [];
|
|
480
|
+
if (rounds.length === 0) return {};
|
|
481
|
+
const lastCodes = rounds[rounds.length - 1]?.codes;
|
|
482
|
+
if (lastCodes === void 0) return {};
|
|
483
|
+
for (const code of Object.keys(lastCodes)) {
|
|
484
|
+
let streak = 0;
|
|
485
|
+
let startIndex = rounds.length;
|
|
486
|
+
for (let i = rounds.length - 1; i >= 0; i--) {
|
|
487
|
+
const codes = rounds[i]?.codes;
|
|
488
|
+
if (codes === void 0 || !hasCode(codes, code)) break;
|
|
489
|
+
if (i > 0 && rounds[i]?.sha !== void 0 && rounds[i]?.sha === rounds[i - 1]?.sha && hasCode(rounds[i - 1]?.codes, code)) {
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
streak += 1;
|
|
493
|
+
startIndex = i;
|
|
494
|
+
}
|
|
495
|
+
if (streak > 0) {
|
|
496
|
+
entries.push([code, { streak, startRound: rounds[startIndex]?.round ?? startIndex + 1 }]);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
return Object.fromEntries(entries);
|
|
500
|
+
};
|
|
501
|
+
var DEFAULT_METASTASIS_STREAK = 3;
|
|
502
|
+
var metastasisNote = (rounds, minStreak = DEFAULT_METASTASIS_STREAK) => {
|
|
503
|
+
const flagged = Object.entries(consecutiveCodeStreaks(rounds)).filter(([, s]) => s.streak >= minStreak).sort((a, b) => b[1].streak - a[1].streak);
|
|
504
|
+
if (flagged.length === 0) return "";
|
|
505
|
+
const lines = flagged.map(
|
|
506
|
+
([code, s]) => `> **\`${escapeCodeBackticks(code)}\`** \u2014 findings in ${String(s.streak)} consecutive rounds.`
|
|
507
|
+
);
|
|
508
|
+
return [
|
|
509
|
+
"> [!WARNING]",
|
|
510
|
+
"> **Scope metastasis** \u2014 findings keep recurring in the same mechanism across consecutive rounds; each fix keeps enabling the next finding in that machinery. Consider whether a structural fix (change the shape, not the edge case) or a scope narrowing would converge this faster.",
|
|
511
|
+
...lines
|
|
512
|
+
].join("\n");
|
|
513
|
+
};
|
|
514
|
+
var computeSameRootNotes = (priorRounds, findings, currentSha) => {
|
|
515
|
+
const codes = findings.map((f) => f.code).filter((c) => c !== void 0 && c !== "");
|
|
516
|
+
const entries = [];
|
|
517
|
+
for (const code of codes) {
|
|
518
|
+
let lastRound = 0;
|
|
519
|
+
for (let i = priorRounds.length - 1; i >= 0; i--) {
|
|
520
|
+
if (currentSha !== void 0 && priorRounds[i]?.sha === currentSha) continue;
|
|
521
|
+
if (i > 0 && priorRounds[i]?.sha !== void 0 && priorRounds[i]?.sha === priorRounds[i - 1]?.sha && hasCode(priorRounds[i - 1]?.codes, code))
|
|
522
|
+
continue;
|
|
523
|
+
const count = priorRounds[i]?.codes?.[code];
|
|
524
|
+
if (count !== void 0 && count > 0) {
|
|
525
|
+
lastRound = priorRounds[i]?.round ?? i + 1;
|
|
526
|
+
break;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
if (lastRound > 0) {
|
|
530
|
+
entries.push([
|
|
531
|
+
code,
|
|
532
|
+
`Same mechanism as round ${String(lastRound)} (\`${escapeCodeBackticks(code)}\`) \u2014 the prior fix in this area re-opened it; consider a structural fix or a scope narrowing.`
|
|
533
|
+
]);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return Object.fromEntries(entries);
|
|
537
|
+
};
|
|
538
|
+
var CONVERGENCE_WEIGHTS = { critical: 4, major: 2, minor: 1, nit: 0 };
|
|
539
|
+
var DEFAULT_CONVERGENCE_THRESHOLD = 1;
|
|
540
|
+
var convergenceScore = (counts) => SEVERITIES.reduce((sum, k) => sum + counts[k] * CONVERGENCE_WEIGHTS[k], 0);
|
|
541
|
+
var convergenceSummary = (counts, threshold = DEFAULT_CONVERGENCE_THRESHOLD) => {
|
|
542
|
+
const { score, converged } = convergenceSignal(counts, threshold);
|
|
543
|
+
return converged ? `**Convergence** \u{1F3C1} ${String(score)} \u2264 ${String(threshold)} \u2014 converged` : `**Convergence** \u{1F504} ${String(score)} > ${String(threshold)} \u2014 iterating`;
|
|
544
|
+
};
|
|
545
|
+
var SURFACE_SCHEMA_VERSION = "0.7.0";
|
|
546
|
+
var convergenceSignal = (counts, threshold = DEFAULT_CONVERGENCE_THRESHOLD) => {
|
|
547
|
+
const score = convergenceScore(counts);
|
|
548
|
+
return { score, threshold, converged: score <= threshold };
|
|
549
|
+
};
|
|
550
|
+
var signalForRound = (round, counts, threshold = DEFAULT_CONVERGENCE_THRESHOLD) => ({ round, convergence: convergenceSignal(counts, threshold) });
|
|
551
|
+
var surfaceFindings = (findings, signal) => {
|
|
552
|
+
const agentDoc = Object.fromEntries(
|
|
553
|
+
Object.entries(findings).filter(([key2]) => key2 !== "round" && key2 !== "convergence")
|
|
554
|
+
);
|
|
555
|
+
return {
|
|
556
|
+
...agentDoc,
|
|
557
|
+
schema_version: SURFACE_SCHEMA_VERSION,
|
|
558
|
+
...signal === null ? {} : signal
|
|
559
|
+
};
|
|
560
|
+
};
|
|
561
|
+
var signalMarker = (signal) => `<!-- code-review:signal;base64 ${Buffer.from(
|
|
562
|
+
JSON.stringify({ schema_version: SURFACE_SCHEMA_VERSION, ...signal }),
|
|
563
|
+
"utf-8"
|
|
564
|
+
).toString("base64")} -->`;
|
|
565
|
+
var surfacedFindingsPointer = (findings, signal, jsonUrl) => {
|
|
566
|
+
const marker = findingsPointer(surfaceFindings(findings, signal), jsonUrl);
|
|
567
|
+
if (signal === null || marker.includes("<!-- code-review:findings-json;base64 ")) return marker;
|
|
568
|
+
return marker === "" ? signalMarker(signal) : `${marker}
|
|
569
|
+
${signalMarker(signal)}`;
|
|
570
|
+
};
|
|
571
|
+
var SIGNAL_RE = /<!-- code-review:signal;base64 ([A-Za-z0-9+/=]+) -->/;
|
|
572
|
+
var parseSignalMarker = (body) => {
|
|
573
|
+
const b64 = SIGNAL_RE.exec(body)?.[1];
|
|
574
|
+
if (b64 === void 0) return null;
|
|
575
|
+
return parseSurfaceSignal(decodeBase64Json(b64));
|
|
576
|
+
};
|
|
577
|
+
var parseSurfaceSignal = (doc) => {
|
|
578
|
+
if (typeof doc !== "object" || doc === null || Array.isArray(doc)) return null;
|
|
579
|
+
const o = doc;
|
|
580
|
+
const declared = o["schema_version"];
|
|
581
|
+
if (typeof declared !== "string" || !SURFACE_SCHEMA_VERSIONS.includes(declared)) return null;
|
|
582
|
+
const round = o["round"];
|
|
583
|
+
const convergence = o["convergence"];
|
|
584
|
+
if (typeof round !== "number" || !Number.isSafeInteger(round) || round < 1) return null;
|
|
585
|
+
if (typeof convergence !== "object" || convergence === null) return null;
|
|
586
|
+
const c = convergence;
|
|
587
|
+
if (typeof c["score"] !== "number" || !Number.isFinite(c["score"]) || typeof c["threshold"] !== "number" || !Number.isFinite(c["threshold"]) || typeof c["converged"] !== "boolean") {
|
|
588
|
+
return null;
|
|
589
|
+
}
|
|
590
|
+
return {
|
|
591
|
+
round,
|
|
592
|
+
convergence: { score: c["score"], threshold: c["threshold"], converged: c["converged"] }
|
|
593
|
+
};
|
|
594
|
+
};
|
|
595
|
+
var SURFACE_SCHEMA_VERSIONS = [SURFACE_SCHEMA_VERSION];
|
|
596
|
+
var stripSurfaceFields = (doc) => {
|
|
597
|
+
if (typeof doc !== "object" || doc === null || Array.isArray(doc)) return doc;
|
|
598
|
+
const o = doc;
|
|
599
|
+
const declared = o["schema_version"];
|
|
600
|
+
if (typeof declared !== "string" || !SURFACE_SCHEMA_VERSIONS.includes(declared)) return doc;
|
|
601
|
+
const rest = Object.fromEntries(
|
|
602
|
+
Object.entries(o).filter(([key2]) => key2 !== "convergence" && key2 !== "round")
|
|
603
|
+
);
|
|
604
|
+
return { ...rest, schema_version: DEFAULT_SCHEMA_VERSION };
|
|
605
|
+
};
|
|
606
|
+
var carryForwardMarkers = (body) => {
|
|
607
|
+
const findings = /<!-- code-review:findings-json[^>]*-->/.exec(body)?.[0];
|
|
608
|
+
const reviewedSha = /<!-- reviewed-sha: [0-9a-fA-F]{40} -->/.exec(body)?.[0];
|
|
609
|
+
const reviewedRoute = ROUTE_RE.exec(body)?.[0];
|
|
610
|
+
const rounds = ROUNDS_RE.exec(body)?.[0];
|
|
611
|
+
const signal = SIGNAL_RE.exec(body)?.[0];
|
|
612
|
+
const completedAncestor = parseReviewComplete(body) || parseCompletedAncestor(body) ? COMPLETED_ANCESTOR_MARKER : void 0;
|
|
613
|
+
const findingsBlock = findings ? `${AGENTS_STOP_DIRECTIVE}
|
|
614
|
+
${findings}` : void 0;
|
|
615
|
+
return [findingsBlock, reviewedSha, reviewedRoute, completedAncestor, rounds, signal].filter((m) => m !== void 0).join("\n\n");
|
|
616
|
+
};
|
|
617
|
+
var escapeFence = (text) => text.replace(/```/g, "`` ` ``");
|
|
618
|
+
var projectPatch = (patch) => {
|
|
619
|
+
if (patch === void 0) return { kind: "none" };
|
|
620
|
+
const lowered = patchToSuggestion(patch);
|
|
621
|
+
return typeof lowered === "string" ? { kind: "suggestion", text: escapeFence(lowered) } : { kind: "patch", raw: escapeFence(patch) };
|
|
622
|
+
};
|
|
623
|
+
var formatConfidence = (n) => n.toFixed(2);
|
|
624
|
+
var reviewBodyPointer = (headSha, stickyUrl, marker) => {
|
|
625
|
+
const sha7 = headSha.slice(0, 7);
|
|
626
|
+
const linkLine = stickyUrl ? `\u{1F916} Automated code review for \`${sha7}\` \u2014 see the [summary comment](${stickyUrl}) for the verdict, walkthrough, and cost.` : `\u{1F916} Automated code review for \`${sha7}\` \u2014 see the summary comment for the verdict, walkthrough, and cost.`;
|
|
627
|
+
return marker ? `${marker}
|
|
628
|
+
|
|
629
|
+
${linkLine}` : linkLine;
|
|
630
|
+
};
|
|
631
|
+
|
|
56
632
|
// src/render.ts
|
|
57
|
-
var escapeBackticks = (text) => text.replace(/```/g, "`` ` ``");
|
|
58
633
|
var escapePipes = (text) => text.replace(/\|/g, "\\|");
|
|
59
|
-
var escapeCodeBackticks = (text) => text.replace(/`/g, "-");
|
|
60
634
|
var sanitizeFinding = (f) => ({
|
|
61
635
|
...f,
|
|
62
636
|
title: escapePipes(f.title),
|
|
63
637
|
path: escapeCodeBackticks(f.path),
|
|
64
|
-
|
|
638
|
+
patchProjection: projectPatch(f.patch)
|
|
639
|
+
});
|
|
640
|
+
var sanitizeSystemic = (s) => ({
|
|
641
|
+
...s,
|
|
642
|
+
title: escapePipes(s.title),
|
|
643
|
+
...s.paths !== void 0 ? { paths: s.paths.map(escapeCodeBackticks) } : {},
|
|
644
|
+
...s.finding_codes !== void 0 ? { finding_codes: s.finding_codes.map(escapeCodeBackticks) } : {}
|
|
645
|
+
});
|
|
646
|
+
var emptySeverityCounts = () => ({
|
|
647
|
+
critical: 0,
|
|
648
|
+
major: 0,
|
|
649
|
+
minor: 0,
|
|
650
|
+
nit: 0
|
|
65
651
|
});
|
|
652
|
+
var computeSeverityCounts = (findings) => findings.reduce(
|
|
653
|
+
(acc, f) => f.severity in acc ? { ...acc, [f.severity]: acc[f.severity] + 1 } : acc,
|
|
654
|
+
emptySeverityCounts()
|
|
655
|
+
);
|
|
656
|
+
var isReviewVerdict = (verdict) => verdict !== "error";
|
|
657
|
+
var computeRoundCounts = (findings) => (findings.systemic_problems ?? []).reduce(
|
|
658
|
+
(acc, s) => s.severity in acc ? { ...acc, [s.severity]: acc[s.severity] + 1 } : acc,
|
|
659
|
+
computeSeverityCounts(findings.findings)
|
|
660
|
+
);
|
|
661
|
+
var isConvergenceRound = (route, incomplete) => route === "full review" && !incomplete;
|
|
66
662
|
var render = (input) => {
|
|
67
663
|
const eta = new Eta({ autoTrim: false });
|
|
68
664
|
const usageAvailable = input.envelope !== null;
|
|
665
|
+
const hasUsage = input.envelope !== null && input.envelope.models.length > 0;
|
|
666
|
+
const incomplete = (input.incomplete ?? input.envelope?.incomplete ?? false) || isIncompleteFindings(input.findings);
|
|
69
667
|
const costReport = input.envelope ? computeCost(input.envelope.models, input.prices) : null;
|
|
668
|
+
const pricesProvided = input.pricesProvided ?? true;
|
|
70
669
|
const route = input.route ?? input.envelope?.route ?? null;
|
|
71
670
|
const effort = input.effort ?? input.envelope?.effort ?? null;
|
|
72
671
|
const modelNames = input.envelope ? input.envelope.models.map((m) => m.model).join(", ") : "";
|
|
73
|
-
const
|
|
74
|
-
const
|
|
75
|
-
const
|
|
672
|
+
const severityCounts = input.severityCounts ?? computeSeverityCounts(input.findings.findings);
|
|
673
|
+
const rounds = input.rounds ?? [];
|
|
674
|
+
const sameRootNotes = input.sameRootNotes ?? computeSameRootNotes(rounds.slice(0, -1), input.findings.findings);
|
|
675
|
+
const isFullReviewRound = (input.convergenceRound ?? (isConvergenceRound(route, incomplete) && rounds.length > 0)) && isReviewVerdict(input.findings.verdict);
|
|
676
|
+
const convergenceCounts = rounds[rounds.length - 1] ?? computeRoundCounts(input.findings);
|
|
677
|
+
const advisoryAllowed = isFullReviewRound;
|
|
76
678
|
return eta.renderString(input.template, {
|
|
77
|
-
findings:
|
|
679
|
+
findings: input.findings,
|
|
78
680
|
envelope: input.envelope,
|
|
79
681
|
usageAvailable,
|
|
682
|
+
hasUsage,
|
|
683
|
+
incomplete,
|
|
80
684
|
costReport,
|
|
685
|
+
pricesProvided,
|
|
81
686
|
route,
|
|
82
687
|
effort,
|
|
83
688
|
modelNames,
|
|
84
689
|
testReport: input.testReport ?? null,
|
|
85
690
|
reviewedSha: input.reviewedSha ?? "0000000000000000000000000000000000000000",
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
691
|
+
postedAt: input.postedAt ?? "",
|
|
692
|
+
severityCounts,
|
|
693
|
+
convergenceSummary: isFullReviewRound ? convergenceSummary(convergenceCounts, input.convergenceThreshold) : "",
|
|
694
|
+
strays: (input.strays ?? []).map(sanitizeFinding),
|
|
695
|
+
systemic: (input.findings.systemic_problems ?? []).map(sanitizeSystemic),
|
|
696
|
+
unanchoredCount: input.unanchoredCount ?? 0,
|
|
697
|
+
inlineDisposition: input.inlineDisposition ?? null,
|
|
698
|
+
runUrl: input.runUrl ?? null,
|
|
699
|
+
jsonUrl: input.jsonUrl ?? null,
|
|
700
|
+
findingsPointer: input.findingsPointer ?? surfacedFindingsPointer(
|
|
701
|
+
input.findings,
|
|
702
|
+
// The fallback embeds a signal exactly when the badge renders — never beside a suppressed
|
|
703
|
+
// badge, and from the same counts the badge reads. It assumes a post-style history (the
|
|
704
|
+
// caller appends this run's counts last), numbering the round exactly as the trajectory
|
|
705
|
+
// label does; post always supplies the marker, so this path cannot disagree with it in
|
|
706
|
+
// production (issue #141 review r4).
|
|
707
|
+
isFullReviewRound && rounds.length > 0 ? signalForRound(rounds.length, convergenceCounts, input.convergenceThreshold) : null,
|
|
708
|
+
input.jsonUrl
|
|
709
|
+
),
|
|
710
|
+
roundsMarker: roundsMarker(rounds),
|
|
711
|
+
roundsSummary: roundsSummary(rounds, input.roundCount),
|
|
712
|
+
metastasisNote: advisoryAllowed ? metastasisNote(rounds) : "",
|
|
713
|
+
sameRootNotes: advisoryAllowed ? sameRootNotes : {},
|
|
714
|
+
reviewUrl: input.reviewUrl ?? null,
|
|
92
715
|
formatTokens: (n) => Number.isFinite(n) && n >= 0 ? n.toLocaleString("en-US") : "\u2014",
|
|
93
|
-
|
|
716
|
+
// N/A (never a false $0.00) when no real price map was provided — real tokens, no rates to price them.
|
|
717
|
+
formatCost: (n) => !pricesProvided ? "N/A" : Number.isFinite(n) ? n > 0 && n.toFixed(2) === "0.00" ? "<$0.01" : `$${n.toFixed(2)}` : "\u2014",
|
|
94
718
|
formatDuration: (ms) => {
|
|
95
719
|
if (!Number.isFinite(ms) || ms < 0) return "\u2014";
|
|
96
720
|
const s = Math.round(ms / 1e3);
|
|
@@ -104,24 +728,14 @@ var render = (input) => {
|
|
|
104
728
|
return "\u{1F4AC} comment";
|
|
105
729
|
case "changes":
|
|
106
730
|
return "\u{1F527} changes requested";
|
|
731
|
+
case "error":
|
|
732
|
+
return "\u{1F6E0}\uFE0F no review verdict";
|
|
107
733
|
default:
|
|
108
734
|
return `\u2753 ${v}`;
|
|
109
735
|
}
|
|
110
736
|
},
|
|
111
|
-
severityEmoji
|
|
112
|
-
|
|
113
|
-
case "critical":
|
|
114
|
-
return "\u{1F534}";
|
|
115
|
-
case "major":
|
|
116
|
-
return "\u{1F7E0}";
|
|
117
|
-
case "minor":
|
|
118
|
-
return "\u{1F535}";
|
|
119
|
-
case "nit":
|
|
120
|
-
return "\u26AA";
|
|
121
|
-
default:
|
|
122
|
-
return "\u2753";
|
|
123
|
-
}
|
|
124
|
-
}
|
|
737
|
+
severityEmoji,
|
|
738
|
+
formatConfidence
|
|
125
739
|
});
|
|
126
740
|
};
|
|
127
741
|
var key = (path, line) => `${path}:${String(line)}`;
|
|
@@ -178,33 +792,35 @@ var partitionFindings = (findings, index) => {
|
|
|
178
792
|
};
|
|
179
793
|
|
|
180
794
|
// src/inline.ts
|
|
181
|
-
var
|
|
182
|
-
var
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
parts.push(`\`\`\`suggestion
|
|
187
|
-
${safe}
|
|
188
|
-
\`\`\``);
|
|
189
|
-
}
|
|
190
|
-
return parts.join("\n\n");
|
|
191
|
-
};
|
|
192
|
-
var renderCommentBody = (f, eta, template) => {
|
|
193
|
-
return eta.renderString(template, {
|
|
795
|
+
var formatModels = (models) => models.length > 0 ? models.map((m) => `\`${m}\``).join("/") : "an AI model";
|
|
796
|
+
var renderCommentBody = (f, eta, template, modelsText, jsonUrl, pointer, sameRootNote) => (
|
|
797
|
+
// Eta.renderString returns string | Promise<string>; with autoTrim:false it's always sync.
|
|
798
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
|
799
|
+
eta.renderString(template, {
|
|
194
800
|
...f,
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
801
|
+
patchProjection: projectPatch(f.patch),
|
|
802
|
+
severityEmoji,
|
|
803
|
+
formatConfidence,
|
|
804
|
+
modelsText,
|
|
805
|
+
jsonUrl: jsonUrl ?? null,
|
|
806
|
+
findingsPointer: pointer,
|
|
807
|
+
sameRootNote
|
|
808
|
+
})
|
|
809
|
+
);
|
|
810
|
+
var buildInlineComments = (findings, diff, context) => {
|
|
811
|
+
const { inlineTemplate, models = [], jsonUrl, findings: fullFindings } = context;
|
|
199
812
|
const index = indexDiff(diff);
|
|
200
813
|
const { inDiff, strays } = partitionFindings(findings, index);
|
|
201
|
-
const eta =
|
|
814
|
+
const eta = new Eta({ autoTrim: false });
|
|
815
|
+
const modelsText = formatModels(models);
|
|
202
816
|
const comments = inDiff.map((f) => {
|
|
817
|
+
const pointer = fullFindings ? findingPointer(f, fullFindings.schema_version, jsonUrl) : "";
|
|
818
|
+
const sameRootNote = f.code !== void 0 && f.code !== "" && context.sameRootNotes !== void 0 && Object.prototype.hasOwnProperty.call(context.sameRootNotes, f.code) ? context.sameRootNotes[f.code] ?? "" : "";
|
|
203
819
|
const comment = {
|
|
204
820
|
path: f.path,
|
|
205
821
|
line: f.end_line,
|
|
206
822
|
side: defaultSide(f.side),
|
|
207
|
-
body:
|
|
823
|
+
body: renderCommentBody(f, eta, inlineTemplate, modelsText, jsonUrl, pointer, sameRootNote)
|
|
208
824
|
};
|
|
209
825
|
if (f.start_line < f.end_line) {
|
|
210
826
|
return {
|
|
@@ -215,7 +831,7 @@ var buildInlineComments = (findings, diff, inlineTemplate) => {
|
|
|
215
831
|
}
|
|
216
832
|
return comment;
|
|
217
833
|
});
|
|
218
|
-
return { comments, strays };
|
|
834
|
+
return { comments, strays, inDiff };
|
|
219
835
|
};
|
|
220
836
|
var renderStraysSection = (strays) => {
|
|
221
837
|
if (strays.length === 0) return "";
|
|
@@ -231,119 +847,129 @@ var renderStraysSection = (strays) => {
|
|
|
231
847
|
...items
|
|
232
848
|
].join("\n");
|
|
233
849
|
};
|
|
234
|
-
var
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
);
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
(s) => SCHEMA_VERSION_RE.test(s),
|
|
252
|
-
"SchemaVersion"
|
|
253
|
-
);
|
|
254
|
-
var FindingShape = t.intersection([
|
|
255
|
-
t.type({
|
|
256
|
-
path: t.string,
|
|
257
|
-
start_line: LineNumber,
|
|
258
|
-
end_line: LineNumber,
|
|
259
|
-
severity: SeverityCodec,
|
|
260
|
-
title: t.string,
|
|
261
|
-
body: t.string
|
|
262
|
-
}),
|
|
263
|
-
t.partial({
|
|
264
|
-
side: SideCodec,
|
|
265
|
-
suggestion: t.union([t.string, t.null]),
|
|
266
|
-
confidence: Confidence,
|
|
267
|
-
code: t.string,
|
|
268
|
-
code_url: t.string
|
|
269
|
-
})
|
|
270
|
-
]);
|
|
271
|
-
var EndGeStart = t.refinement(
|
|
272
|
-
FindingShape,
|
|
273
|
-
(f) => f.end_line >= f.start_line,
|
|
274
|
-
"EndGeStart"
|
|
275
|
-
);
|
|
276
|
-
var FindingCodec = t.exact(EndGeStart);
|
|
277
|
-
var FindingsCodec = t.exact(
|
|
278
|
-
t.type({
|
|
279
|
-
schema_version: SchemaVersion,
|
|
280
|
-
summary: t.string,
|
|
281
|
-
verdict: VerdictCodec,
|
|
282
|
-
findings: t.array(FindingCodec)
|
|
283
|
-
})
|
|
284
|
-
);
|
|
285
|
-
var TriageCodec = t.type({
|
|
286
|
-
safe: t.boolean,
|
|
287
|
-
reasons: t.string
|
|
288
|
-
});
|
|
289
|
-
var TokenCount = t.refinement(
|
|
290
|
-
t.number,
|
|
291
|
-
(n) => Number.isInteger(n) && n >= 0,
|
|
292
|
-
"TokenCount"
|
|
293
|
-
);
|
|
294
|
-
var ModelUsageEntryCodec = t.intersection([
|
|
295
|
-
t.type({
|
|
296
|
-
model: t.string,
|
|
297
|
-
input_tokens: TokenCount,
|
|
298
|
-
output_tokens: TokenCount
|
|
299
|
-
}),
|
|
300
|
-
t.partial({
|
|
301
|
-
cache_read_tokens: TokenCount,
|
|
302
|
-
cache_write_tokens: TokenCount
|
|
303
|
-
})
|
|
304
|
-
]);
|
|
305
|
-
var ResultEnvelopeCodec = t.intersection([
|
|
306
|
-
t.type({
|
|
307
|
-
schema_version: t.string,
|
|
308
|
-
findings: FindingsCodec,
|
|
309
|
-
models: t.array(ModelUsageEntryCodec),
|
|
310
|
-
turns: TokenCount,
|
|
311
|
-
duration_ms: TokenCount
|
|
312
|
-
}),
|
|
313
|
-
t.partial({
|
|
314
|
-
vendor_cost_usd: t.union([t.number, t.null]),
|
|
315
|
-
route: t.string,
|
|
316
|
-
effort: t.string
|
|
317
|
-
})
|
|
318
|
-
]);
|
|
319
|
-
var ModelPricesCodec = t.type({
|
|
320
|
-
in: t.number,
|
|
321
|
-
out: t.number,
|
|
322
|
-
cache_read: t.number,
|
|
323
|
-
cache_write: t.number
|
|
324
|
-
});
|
|
325
|
-
var PriceMapCodec = t.type({
|
|
326
|
-
_updated: t.string,
|
|
327
|
-
_unit: t.string,
|
|
328
|
-
models: t.record(t.string, ModelPricesCodec)
|
|
329
|
-
});
|
|
330
|
-
var TestFailureCodec = t.intersection([
|
|
331
|
-
t.type({ name: t.string }),
|
|
332
|
-
t.partial({ message: t.string })
|
|
333
|
-
]);
|
|
334
|
-
var TestSummaryCodec = t.intersection([
|
|
335
|
-
t.type({
|
|
336
|
-
passed: t.number,
|
|
337
|
-
failed: t.number,
|
|
338
|
-
total: t.number
|
|
339
|
-
}),
|
|
340
|
-
t.partial({
|
|
341
|
-
failures: t.array(TestFailureCodec)
|
|
342
|
-
})
|
|
343
|
-
]);
|
|
344
|
-
var DEFAULT_SCHEMA_VERSION = "0.2.0";
|
|
850
|
+
var asRecord = (u) => typeof u === "object" && u !== null && !Array.isArray(u) ? u : null;
|
|
851
|
+
var errMsg = (e) => e instanceof Error ? e.message : String(e);
|
|
852
|
+
var annotationSafe = (msg) => msg.replaceAll(/[\r\n]+/g, " ");
|
|
853
|
+
var tryParseJson = (text) => {
|
|
854
|
+
try {
|
|
855
|
+
return { ok: true, value: JSON.parse(text) };
|
|
856
|
+
} catch {
|
|
857
|
+
return { ok: false };
|
|
858
|
+
}
|
|
859
|
+
};
|
|
860
|
+
var readFileOrNull = (path) => {
|
|
861
|
+
try {
|
|
862
|
+
return readFileSync(path, "utf-8");
|
|
863
|
+
} catch {
|
|
864
|
+
return null;
|
|
865
|
+
}
|
|
866
|
+
};
|
|
345
867
|
|
|
346
|
-
// src/
|
|
868
|
+
// src/transcript.ts
|
|
869
|
+
var numField = (rec, key2) => {
|
|
870
|
+
const v = rec[key2];
|
|
871
|
+
return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : 0;
|
|
872
|
+
};
|
|
873
|
+
var messageUsage = (entry) => {
|
|
874
|
+
const rec = asRecord(entry);
|
|
875
|
+
if (rec === null || rec["type"] !== "assistant") return null;
|
|
876
|
+
const msg = asRecord(rec["message"]);
|
|
877
|
+
if (msg === null) return null;
|
|
878
|
+
const model = msg["model"];
|
|
879
|
+
const usage = asRecord(msg["usage"]);
|
|
880
|
+
if (typeof model !== "string" || usage === null) return null;
|
|
881
|
+
const id = msg["id"];
|
|
882
|
+
return {
|
|
883
|
+
id: typeof id === "string" ? id : null,
|
|
884
|
+
model,
|
|
885
|
+
input: numField(usage, "input_tokens"),
|
|
886
|
+
output: numField(usage, "output_tokens"),
|
|
887
|
+
cacheRead: numField(usage, "cache_read_input_tokens"),
|
|
888
|
+
cacheWrite: numField(usage, "cache_creation_input_tokens")
|
|
889
|
+
};
|
|
890
|
+
};
|
|
891
|
+
var tsMsOf = (entry) => {
|
|
892
|
+
const rec = asRecord(entry);
|
|
893
|
+
const ts = rec?.["timestamp"];
|
|
894
|
+
if (typeof ts !== "string") return null;
|
|
895
|
+
const ms = Date.parse(ts);
|
|
896
|
+
return Number.isNaN(ms) ? null : ms;
|
|
897
|
+
};
|
|
898
|
+
var EMPTY_TOTALS = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
899
|
+
var parseJsonl = (text) => text.split("\n").filter((line) => line.trim() !== "").flatMap((line) => {
|
|
900
|
+
try {
|
|
901
|
+
return [JSON.parse(line)];
|
|
902
|
+
} catch {
|
|
903
|
+
return [];
|
|
904
|
+
}
|
|
905
|
+
});
|
|
906
|
+
var sumTranscriptUsage = (entries) => {
|
|
907
|
+
const summed = entries.reduce(
|
|
908
|
+
(acc, entry) => {
|
|
909
|
+
const u = messageUsage(entry);
|
|
910
|
+
if (u === null) return acc;
|
|
911
|
+
if (u.id !== null && acc.seen.has(u.id)) return acc;
|
|
912
|
+
if (u.id !== null) acc.seen.add(u.id);
|
|
913
|
+
const prev = acc.totals.get(u.model) ?? EMPTY_TOTALS;
|
|
914
|
+
acc.totals.set(u.model, {
|
|
915
|
+
input: prev.input + u.input,
|
|
916
|
+
output: prev.output + u.output,
|
|
917
|
+
cacheRead: prev.cacheRead + u.cacheRead,
|
|
918
|
+
cacheWrite: prev.cacheWrite + u.cacheWrite
|
|
919
|
+
});
|
|
920
|
+
return { totals: acc.totals, turns: acc.turns + 1, seen: acc.seen };
|
|
921
|
+
},
|
|
922
|
+
{ totals: /* @__PURE__ */ new Map(), turns: 0, seen: /* @__PURE__ */ new Set() }
|
|
923
|
+
);
|
|
924
|
+
const models = [...summed.totals].map(([model, t7]) => ({
|
|
925
|
+
model,
|
|
926
|
+
input_tokens: t7.input,
|
|
927
|
+
output_tokens: t7.output,
|
|
928
|
+
cache_read_tokens: t7.cacheRead,
|
|
929
|
+
cache_write_tokens: t7.cacheWrite
|
|
930
|
+
}));
|
|
931
|
+
const bounds = entries.reduce(
|
|
932
|
+
(acc, entry) => {
|
|
933
|
+
const ms = tsMsOf(entry);
|
|
934
|
+
if (ms === null) return acc;
|
|
935
|
+
return {
|
|
936
|
+
min: acc.min === null || ms < acc.min ? ms : acc.min,
|
|
937
|
+
max: acc.max === null || ms > acc.max ? ms : acc.max
|
|
938
|
+
};
|
|
939
|
+
},
|
|
940
|
+
{ min: null, max: null }
|
|
941
|
+
);
|
|
942
|
+
return {
|
|
943
|
+
models,
|
|
944
|
+
turns: summed.turns,
|
|
945
|
+
durationMs: bounds.min !== null && bounds.max !== null ? bounds.max - bounds.min : 0,
|
|
946
|
+
firstTsMs: bounds.min,
|
|
947
|
+
lastTsMs: bounds.max
|
|
948
|
+
};
|
|
949
|
+
};
|
|
950
|
+
var subagentFiles = (mainPath) => {
|
|
951
|
+
const dir = join(dirname(mainPath), basename(mainPath, ".jsonl"), "subagents");
|
|
952
|
+
try {
|
|
953
|
+
return readdirSync(dir).filter((name) => name.endsWith(".jsonl")).map((name) => join(dir, name));
|
|
954
|
+
} catch {
|
|
955
|
+
return [];
|
|
956
|
+
}
|
|
957
|
+
};
|
|
958
|
+
var readTranscriptTree = (mainPath) => {
|
|
959
|
+
const mainText = readFileOrNull(mainPath);
|
|
960
|
+
const mainEntries = mainText === null ? [] : parseJsonl(mainText);
|
|
961
|
+
const inlineSidechains = mainEntries.some((e) => asRecord(e)?.["isSidechain"] === true);
|
|
962
|
+
const siblings = inlineSidechains ? [] : subagentFiles(mainPath);
|
|
963
|
+
const siblingReads = siblings.flatMap((path) => {
|
|
964
|
+
const text = readFileOrNull(path);
|
|
965
|
+
return text === null ? [] : [{ path, entries: parseJsonl(text) }];
|
|
966
|
+
});
|
|
967
|
+
return {
|
|
968
|
+
entries: [...mainEntries, ...siblingReads.flatMap((r) => r.entries)],
|
|
969
|
+
files: [...mainText === null ? [] : [mainPath], ...siblingReads.map((r) => r.path)],
|
|
970
|
+
missing: mainText === null
|
|
971
|
+
};
|
|
972
|
+
};
|
|
347
973
|
var addFormats = _addFormats;
|
|
348
974
|
var validatorCache = /* @__PURE__ */ new Map();
|
|
349
975
|
var compileSchema = (schemaPath) => {
|
|
@@ -377,10 +1003,393 @@ var unsafeUnwrap = (decoded) => {
|
|
|
377
1003
|
if (decoded._tag === "Right") return decoded.right;
|
|
378
1004
|
throw new Error("io-ts decode failed \u2014 data does not match expected shape");
|
|
379
1005
|
};
|
|
1006
|
+
|
|
1007
|
+
// src/stop-gate.ts
|
|
1008
|
+
var whatsWrong = (state, draftPath, kind) => {
|
|
1009
|
+
switch (state.kind) {
|
|
1010
|
+
case "missing":
|
|
1011
|
+
return `${draftPath} does not exist yet`;
|
|
1012
|
+
case "unreadable":
|
|
1013
|
+
return `${draftPath} could not be read: ${state.error}`;
|
|
1014
|
+
case "invalid":
|
|
1015
|
+
return `${draftPath} does not validate against the ${kind} schema:
|
|
1016
|
+
${state.errors.map((e) => ` - ${e}`).join("\n")}`;
|
|
1017
|
+
}
|
|
1018
|
+
};
|
|
1019
|
+
var decideGate = (state, nudges, maxNudges, draftPath, kind) => {
|
|
1020
|
+
if (state.kind === "valid") return { kind: "allow" };
|
|
1021
|
+
if (nudges >= maxNudges) return { kind: "allow" };
|
|
1022
|
+
return {
|
|
1023
|
+
kind: "block",
|
|
1024
|
+
reason: [
|
|
1025
|
+
`This review is not complete \u2014 ${whatsWrong(state, draftPath, kind)}`,
|
|
1026
|
+
`The only deliverable is a ${kind} document that validates against the ${kind} schema \u2014 run "code-review print-schema ${kind}" to see the exact shape.`,
|
|
1027
|
+
`Write it to ${draftPath}, then run "code-review validate ${draftPath} --kind ${kind} --explain" until it exits 0 before ending your turn (--explain prints the schema when the shape is wrong).`
|
|
1028
|
+
].join("\n")
|
|
1029
|
+
};
|
|
1030
|
+
};
|
|
1031
|
+
var draftState = (draftPath, resolveSchema) => {
|
|
1032
|
+
let raw;
|
|
1033
|
+
try {
|
|
1034
|
+
raw = readFileSync(draftPath, "utf-8");
|
|
1035
|
+
} catch (err) {
|
|
1036
|
+
if (err instanceof Error && err.code === "ENOENT") {
|
|
1037
|
+
return { kind: "missing" };
|
|
1038
|
+
}
|
|
1039
|
+
return { kind: "unreadable", error: errMsg(err) };
|
|
1040
|
+
}
|
|
1041
|
+
let parsed;
|
|
1042
|
+
try {
|
|
1043
|
+
parsed = JSON.parse(raw);
|
|
1044
|
+
} catch (err) {
|
|
1045
|
+
return {
|
|
1046
|
+
kind: "invalid",
|
|
1047
|
+
errors: [`not valid JSON: ${errMsg(err)}`]
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
let schemaPath;
|
|
1051
|
+
try {
|
|
1052
|
+
schemaPath = resolveSchema(parsed);
|
|
1053
|
+
} catch (err) {
|
|
1054
|
+
return { kind: "invalid", errors: [errMsg(err)] };
|
|
1055
|
+
}
|
|
1056
|
+
try {
|
|
1057
|
+
const { valid, errors } = validateAgainstSchema(parsed, schemaPath);
|
|
1058
|
+
return valid ? { kind: "valid" } : { kind: "invalid", errors };
|
|
1059
|
+
} catch (err) {
|
|
1060
|
+
return { kind: "invalid", errors: [errMsg(err)] };
|
|
1061
|
+
}
|
|
1062
|
+
};
|
|
1063
|
+
var readNudges = (counterPath) => {
|
|
1064
|
+
try {
|
|
1065
|
+
const n = Number.parseInt(readFileSync(counterPath, "utf-8").trim(), 10);
|
|
1066
|
+
return Number.isInteger(n) && n >= 0 ? n : 0;
|
|
1067
|
+
} catch {
|
|
1068
|
+
return 0;
|
|
1069
|
+
}
|
|
1070
|
+
};
|
|
1071
|
+
var bumpNudges = (counterPath, current) => {
|
|
1072
|
+
writeFileSync(counterPath, `${String(current + 1)}
|
|
1073
|
+
`);
|
|
1074
|
+
};
|
|
1075
|
+
var shellQuote = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
1076
|
+
var defaultHookCommand = (draftPath, opts) => [
|
|
1077
|
+
"code-review stop-gate --draft",
|
|
1078
|
+
shellQuote(draftPath),
|
|
1079
|
+
...opts.kind ? ["--kind", shellQuote(opts.kind)] : [],
|
|
1080
|
+
...opts.schema ? ["--schema", shellQuote(opts.schema)] : [],
|
|
1081
|
+
...opts.schemaVersion ? ["--schema-version", shellQuote(opts.schemaVersion)] : [],
|
|
1082
|
+
...opts.maxNudges ? ["--max-nudges", shellQuote(opts.maxNudges)] : [],
|
|
1083
|
+
...opts.counter ? ["--counter", shellQuote(opts.counter)] : []
|
|
1084
|
+
].join(" ");
|
|
1085
|
+
var stopHookSettings = (command) => ({
|
|
1086
|
+
hooks: { Stop: [{ hooks: [{ type: "command", command }] }] }
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
// src/budget.ts
|
|
1090
|
+
var DEADLINE_ENV = "CODE_REVIEW_DEADLINE_EPOCH";
|
|
1091
|
+
var DEFAULT_RESERVE = {
|
|
1092
|
+
frac: 0.15,
|
|
1093
|
+
growth: 0.25,
|
|
1094
|
+
flatUsd: 0.02,
|
|
1095
|
+
flatMs: 12e4
|
|
1096
|
+
};
|
|
1097
|
+
var SOFT_MULTIPLE = 2;
|
|
1098
|
+
var growingReserve = (used, limit, flat, r) => {
|
|
1099
|
+
const usedFrac = Math.min(1, Math.max(0, used / limit));
|
|
1100
|
+
return Math.max(flat, (r.frac + r.growth * usedFrac) * limit);
|
|
1101
|
+
};
|
|
1102
|
+
var costAxis = (i) => i.spentUsd !== null && i.budgetUsd !== null && i.budgetUsd > 0 ? {
|
|
1103
|
+
used: i.spentUsd,
|
|
1104
|
+
limit: i.budgetUsd,
|
|
1105
|
+
hardReserve: growingReserve(i.spentUsd, i.budgetUsd, i.reserve.flatUsd, i.reserve)
|
|
1106
|
+
} : null;
|
|
1107
|
+
var timeAxis = (i) => i.elapsedMs !== null && i.wallMs !== null && i.wallMs > 0 ? {
|
|
1108
|
+
used: i.elapsedMs,
|
|
1109
|
+
limit: i.wallMs,
|
|
1110
|
+
hardReserve: growingReserve(i.elapsedMs, i.wallMs, i.reserve.flatMs, i.reserve)
|
|
1111
|
+
} : null;
|
|
1112
|
+
var axisSeverity = (a) => {
|
|
1113
|
+
const remaining = a.limit - a.used;
|
|
1114
|
+
if (remaining <= a.hardReserve) return 2;
|
|
1115
|
+
if (remaining <= SOFT_MULTIPLE * a.hardReserve) return 1;
|
|
1116
|
+
return 0;
|
|
1117
|
+
};
|
|
1118
|
+
var decideBudget = (i) => {
|
|
1119
|
+
const worst = [costAxis(i), timeAxis(i)].filter((a) => a !== null).reduce((max, a) => Math.max(max, axisSeverity(a)), 0);
|
|
1120
|
+
return worst === 2 ? { kind: "hard" } : worst === 1 ? { kind: "soft" } : { kind: "ok" };
|
|
1121
|
+
};
|
|
1122
|
+
var pct = (n) => `${String(Math.round(n * 100))}%`;
|
|
1123
|
+
var money = (n) => `$${n.toFixed(2)}`;
|
|
1124
|
+
var mins = (ms) => `${(ms / 6e4).toFixed(1)}m`;
|
|
1125
|
+
var spendClause = (i) => i.spentUsd === null ? null : i.budgetUsd !== null && i.budgetUsd > 0 ? `spent ${money(i.spentUsd)}/${money(i.budgetUsd)} (${pct(i.spentUsd / i.budgetUsd)})` : `spent ${money(i.spentUsd)}`;
|
|
1126
|
+
var timeClause = (i) => i.elapsedMs === null ? null : i.wallMs !== null && i.wallMs > 0 ? `${mins(i.elapsedMs)}/${mins(i.wallMs)} elapsed (${pct(i.elapsedMs / i.wallMs)})` : `${mins(i.elapsedMs)} elapsed`;
|
|
1127
|
+
var directive = (phase, draftPath, isSubagent) => {
|
|
1128
|
+
if (isSubagent)
|
|
1129
|
+
return phase.kind === "hard" ? `Budget nearly exhausted \u2014 STOP all new investigation now and report the findings you have back to the main agent in your reply. Do not write ${draftPath} yourself; only the main agent writes it.` : `Wind down investigation and report the findings you have back to the main agent in your reply \u2014 do not write ${draftPath} yourself; the main agent writes it, and you may run out of budget otherwise.`;
|
|
1130
|
+
return phase.kind === "hard" ? `Budget nearly exhausted \u2014 STOP all new investigation now. Do not wait for subagents still running in the background. Write your COMPLETE findings from what you already have to ${draftPath} and run \`code-review validate ${draftPath} --explain\` until it passes (--explain prints the exact schema when the shape is wrong). Other tools are blocked until that draft is written.` : `Wind down investigation and write your COMPLETE findings to ${draftPath} now, then run \`code-review validate ${draftPath} --explain\` (it prints the exact schema if the shape is wrong) \u2014 fold in the subagent reports you already have rather than waiting on stragglers; you may run out of budget before you finish otherwise.`;
|
|
1131
|
+
};
|
|
1132
|
+
var budgetMessage = (i, phase, draftPath, isSubagent) => {
|
|
1133
|
+
const status = [spendClause(i), timeClause(i)].filter((c) => c !== null).join(" \xB7 ");
|
|
1134
|
+
return `Budget check \u2014 ${status}. ${directive(phase, draftPath, isSubagent)}`;
|
|
1135
|
+
};
|
|
1136
|
+
var invokesCodeReviewValidate = (toolInput) => {
|
|
1137
|
+
const cmd = asRecord(toolInput)?.["command"];
|
|
1138
|
+
return typeof cmd === "string" && /\bcode-review\s+validate(?![\w-])/.test(cmd);
|
|
1139
|
+
};
|
|
1140
|
+
var SPAWN_TOOLS = /* @__PURE__ */ new Set(["Agent", "Task"]);
|
|
1141
|
+
var WEB_TOOLS = /* @__PURE__ */ new Set(["WebFetch", "WebSearch"]);
|
|
1142
|
+
var blockedDuringConvergence = (toolName, toolInput) => {
|
|
1143
|
+
if (SPAWN_TOOLS.has(toolName) || WEB_TOOLS.has(toolName)) return true;
|
|
1144
|
+
if (toolName === "Bash") return !invokesCodeReviewValidate(toolInput);
|
|
1145
|
+
return false;
|
|
1146
|
+
};
|
|
1147
|
+
var escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1148
|
+
var WRITE_TOOLS = /* @__PURE__ */ new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
|
|
1149
|
+
var writesToDraft = (toolName, toolInput, draftPath) => {
|
|
1150
|
+
const rec = asRecord(toolInput);
|
|
1151
|
+
const targets = [draftPath, basename(draftPath), "$DRAFT", "${DRAFT}"];
|
|
1152
|
+
if (WRITE_TOOLS.has(toolName)) {
|
|
1153
|
+
const fp = rec?.["file_path"] ?? rec?.["notebook_path"];
|
|
1154
|
+
if (typeof fp === "string" && targets.some((t7) => fp === t7 || basename(fp) === basename(t7)))
|
|
1155
|
+
return true;
|
|
1156
|
+
}
|
|
1157
|
+
if (toolName === "Bash") {
|
|
1158
|
+
const cmd = rec?.["command"];
|
|
1159
|
+
if (typeof cmd !== "string") return false;
|
|
1160
|
+
const alt = targets.map(escapeRegExp).join("|");
|
|
1161
|
+
const end = "(?=$|[\\s|&;)])";
|
|
1162
|
+
const redirect = new RegExp(`>>?\\|?\\s*(['"]?)(?:${alt})\\1${end}`);
|
|
1163
|
+
const teeArg = new RegExp(`\\btee\\b(?:\\s+-{1,2}\\S+)*\\s+(['"]?)(?:${alt})\\1${end}`);
|
|
1164
|
+
return redirect.test(cmd) || teeArg.test(cmd);
|
|
1165
|
+
}
|
|
1166
|
+
return false;
|
|
1167
|
+
};
|
|
1168
|
+
var singleWriterMessage = (draftPath) => `Only the main agent may write ${draftPath}. When a subagent writes it too, the concurrent writers clobber each other and the review comes out empty. Do NOT write, edit, or redirect into ${draftPath} \u2014 instead, return the findings you discovered in your reply (the field names are in the schema); the main agent collects every subagent's reported findings and writes the draft itself.`;
|
|
1169
|
+
var SEED_SENTINEL = "code-review seed sentinel \u2014 not a review; replace this file with your findings review.";
|
|
1170
|
+
var normalizedDraft = (draftText) => typeof draftText === "string" ? draftText.replace(/^\uFEFF/, "").trim() : "";
|
|
1171
|
+
var isSeedSentinel = (draftText) => normalizedDraft(draftText) === SEED_SENTINEL;
|
|
1172
|
+
var mainHasWrittenDraft = (draftText) => {
|
|
1173
|
+
const normalized = normalizedDraft(draftText);
|
|
1174
|
+
return normalized !== "" && normalized !== SEED_SENTINEL;
|
|
1175
|
+
};
|
|
1176
|
+
var sidecarPath = (draftPath, postfix) => {
|
|
1177
|
+
const ext = extname(draftPath);
|
|
1178
|
+
return join(dirname(draftPath), `${basename(draftPath, ext)}${postfix}${ext}`);
|
|
1179
|
+
};
|
|
1180
|
+
var priorContextPath = (draftPath) => sidecarPath(draftPath, ".prior");
|
|
1181
|
+
var lastValidPath = (draftPath) => sidecarPath(draftPath, ".last-valid");
|
|
1182
|
+
var spawnFloorMessage = (draftPath) => `Write your own first-pass findings to ${draftPath} before spawning subagents \u2014 a review must never depend on subagents alone, and the pre-seeded $DRAFT is a non-review sentinel: it does not count until you have replaced it with your own review. Write ${draftPath} from what you have read so far (preliminary findings are fine), run \`code-review validate ${draftPath} --explain\` until it passes, then fan out; your subagents run in the background, so keep refining the draft as their reports arrive.`;
|
|
1183
|
+
var forceBackgroundSpawn = (toolInput) => ({
|
|
1184
|
+
hookSpecificOutput: {
|
|
1185
|
+
hookEventName: "PreToolUse",
|
|
1186
|
+
permissionDecision: "allow",
|
|
1187
|
+
updatedInput: { ...asRecord(toolInput) ?? {}, run_in_background: true }
|
|
1188
|
+
}
|
|
1189
|
+
});
|
|
1190
|
+
var denyPreTool = (reason) => ({
|
|
1191
|
+
hookSpecificOutput: {
|
|
1192
|
+
hookEventName: "PreToolUse",
|
|
1193
|
+
permissionDecision: "deny",
|
|
1194
|
+
permissionDecisionReason: reason
|
|
1195
|
+
}
|
|
1196
|
+
});
|
|
1197
|
+
var isSubagentHookInput = (input) => {
|
|
1198
|
+
const agentId = asRecord(input)?.["agent_id"];
|
|
1199
|
+
return typeof agentId === "string" && agentId.length > 0;
|
|
1200
|
+
};
|
|
1201
|
+
var evaluateBudgetHook = (input, params) => {
|
|
1202
|
+
const rec = asRecord(input);
|
|
1203
|
+
const inputs = {
|
|
1204
|
+
spentUsd: params.spentUsd,
|
|
1205
|
+
budgetUsd: params.budgetUsd,
|
|
1206
|
+
elapsedMs: params.elapsedMs,
|
|
1207
|
+
wallMs: params.wallMs,
|
|
1208
|
+
reserve: params.reserve
|
|
1209
|
+
};
|
|
1210
|
+
const phase = decideBudget(inputs);
|
|
1211
|
+
const isSubagent = isSubagentHookInput(input);
|
|
1212
|
+
switch (rec?.["hook_event_name"]) {
|
|
1213
|
+
case "PostToolBatch":
|
|
1214
|
+
return phase.kind === "ok" ? {} : {
|
|
1215
|
+
hookSpecificOutput: {
|
|
1216
|
+
hookEventName: "PostToolBatch",
|
|
1217
|
+
additionalContext: budgetMessage(inputs, phase, params.draftPath, isSubagent)
|
|
1218
|
+
}
|
|
1219
|
+
};
|
|
1220
|
+
case "PreToolUse": {
|
|
1221
|
+
const toolName = rec["tool_name"];
|
|
1222
|
+
if (typeof toolName !== "string") return {};
|
|
1223
|
+
if (isSubagent && writesToDraft(toolName, rec["tool_input"], params.draftPath))
|
|
1224
|
+
return denyPreTool(singleWriterMessage(params.draftPath));
|
|
1225
|
+
if (phase.kind === "hard" && blockedDuringConvergence(toolName, rec["tool_input"]))
|
|
1226
|
+
return denyPreTool(budgetMessage(inputs, phase, params.draftPath, isSubagent));
|
|
1227
|
+
if (SPAWN_TOOLS.has(toolName)) {
|
|
1228
|
+
if (!isSubagent && !params.mainDraftWritten())
|
|
1229
|
+
return denyPreTool(spawnFloorMessage(params.draftPath));
|
|
1230
|
+
return forceBackgroundSpawn(rec["tool_input"]);
|
|
1231
|
+
}
|
|
1232
|
+
return {};
|
|
1233
|
+
}
|
|
1234
|
+
default:
|
|
1235
|
+
return {};
|
|
1236
|
+
}
|
|
1237
|
+
};
|
|
1238
|
+
var parseWallMs = (raw) => {
|
|
1239
|
+
const m = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/.exec(raw.trim());
|
|
1240
|
+
if (m === null) return null;
|
|
1241
|
+
const [, num = "", unit = "s"] = m;
|
|
1242
|
+
const n = Number.parseFloat(num);
|
|
1243
|
+
if (!Number.isFinite(n)) return null;
|
|
1244
|
+
switch (unit) {
|
|
1245
|
+
case "ms":
|
|
1246
|
+
return n;
|
|
1247
|
+
case "s":
|
|
1248
|
+
return n * 1e3;
|
|
1249
|
+
case "m":
|
|
1250
|
+
return n * 6e4;
|
|
1251
|
+
default:
|
|
1252
|
+
return n * 36e5;
|
|
1253
|
+
}
|
|
1254
|
+
};
|
|
1255
|
+
var parseEpochSecMs = (raw) => {
|
|
1256
|
+
if (raw === void 0) return null;
|
|
1257
|
+
const t7 = raw.trim();
|
|
1258
|
+
if (!/^\d+$/.test(t7)) return null;
|
|
1259
|
+
const n = Number.parseInt(t7, 10);
|
|
1260
|
+
return Number.isFinite(n) && n > 0 ? n * 1e3 : null;
|
|
1261
|
+
};
|
|
1262
|
+
var anchoredElapsedMs = (src) => {
|
|
1263
|
+
if (src.deadlineMs !== null && src.wallMs !== null)
|
|
1264
|
+
return Math.max(0, src.wallMs - (src.deadlineMs - src.nowMs));
|
|
1265
|
+
if (src.firstTsMs !== null) return Math.max(0, src.nowMs - src.firstTsMs);
|
|
1266
|
+
return null;
|
|
1267
|
+
};
|
|
1268
|
+
var deadlineEpochSec = (wallMs, nowMs) => Math.floor(nowMs / 1e3) + Math.ceil(wallMs / 1e3);
|
|
1269
|
+
var parseFraction = (raw, fallback) => {
|
|
1270
|
+
if (raw === void 0) return fallback;
|
|
1271
|
+
const n = Number.parseFloat(raw);
|
|
1272
|
+
return Number.isFinite(n) && n >= 0 && n <= 1 ? n : fallback;
|
|
1273
|
+
};
|
|
1274
|
+
var budgetHookCommand = (draftPath, opts) => [
|
|
1275
|
+
"code-review budget-hook --draft",
|
|
1276
|
+
shellQuote(draftPath),
|
|
1277
|
+
...opts.budgetUsd ? ["--budget-usd", shellQuote(opts.budgetUsd)] : [],
|
|
1278
|
+
...opts.wall ? ["--wall", shellQuote(opts.wall)] : [],
|
|
1279
|
+
...opts.prices ? ["--prices", shellQuote(opts.prices)] : [],
|
|
1280
|
+
...opts.reserveFrac ? ["--reserve-frac", shellQuote(opts.reserveFrac)] : [],
|
|
1281
|
+
...opts.reserveGrowth ? ["--reserve-growth", shellQuote(opts.reserveGrowth)] : [],
|
|
1282
|
+
...opts.reserveUsd ? ["--reserve-usd", shellQuote(opts.reserveUsd)] : [],
|
|
1283
|
+
...opts.reserveWall ? ["--reserve-wall", shellQuote(opts.reserveWall)] : []
|
|
1284
|
+
].join(" ");
|
|
1285
|
+
|
|
1286
|
+
// src/format.ts
|
|
1287
|
+
var FENCE_RE = /^\s*```/;
|
|
1288
|
+
var scanLine = (state, line) => {
|
|
1289
|
+
if (FENCE_RE.test(line)) {
|
|
1290
|
+
return { lines: [...state.lines, line], inFence: !state.inFence, blankRun: 0 };
|
|
1291
|
+
}
|
|
1292
|
+
if (state.inFence) {
|
|
1293
|
+
return { lines: [...state.lines, line], inFence: true, blankRun: 0 };
|
|
1294
|
+
}
|
|
1295
|
+
const trimmed = line.replace(/[ \t]+$/, "");
|
|
1296
|
+
if (trimmed !== "") {
|
|
1297
|
+
return { lines: [...state.lines, trimmed], inFence: false, blankRun: 0 };
|
|
1298
|
+
}
|
|
1299
|
+
const blankRun = state.blankRun + 1;
|
|
1300
|
+
return blankRun === 1 ? { lines: [...state.lines, ""], inFence: false, blankRun } : { ...state, blankRun };
|
|
1301
|
+
};
|
|
1302
|
+
var formatMarkdown = (md) => {
|
|
1303
|
+
const { lines } = md.split("\n").reduce(scanLine, { lines: [], inFence: false, blankRun: 0 });
|
|
1304
|
+
return `${lines.join("\n").replace(/\n+$/, "")}
|
|
1305
|
+
`;
|
|
1306
|
+
};
|
|
1307
|
+
var pad2 = (n) => String(n).padStart(2, "0");
|
|
1308
|
+
var formatUtc = (d) => `${String(d.getUTCFullYear())}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())} ${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())} UTC`;
|
|
1309
|
+
|
|
1310
|
+
// src/notice.ts
|
|
1311
|
+
var NOTICE_KINDS = [
|
|
1312
|
+
"security-blocked",
|
|
1313
|
+
"triage-error",
|
|
1314
|
+
"setup-failed",
|
|
1315
|
+
"checkout-failed",
|
|
1316
|
+
"no-output"
|
|
1317
|
+
];
|
|
1318
|
+
var isNoticeKind = (s) => NOTICE_KINDS.some((k) => k === s);
|
|
1319
|
+
var UNSAFE_IN_SUMMARY = /[\n\r`<>|]/;
|
|
1320
|
+
var MAX_NAMED_HOSTS = 20;
|
|
1321
|
+
var parseAgentAllowlist = (sandboxConfig) => {
|
|
1322
|
+
const domains = sandboxConfig?.network?.allowedDomains;
|
|
1323
|
+
return Array.isArray(domains) ? domains.filter(
|
|
1324
|
+
(d) => typeof d === "string" && d.length <= 256 && !UNSAFE_IN_SUMMARY.test(d)
|
|
1325
|
+
).slice(0, MAX_NAMED_HOSTS) : [];
|
|
1326
|
+
};
|
|
1327
|
+
var egressNote = (agentAllowlist) => agentAllowlist.length === 0 ? "" : `
|
|
1328
|
+
|
|
1329
|
+
If the review failed because the agent could not reach a host it needed, note that its network egress is jailed to only ${agentAllowlist.map((host) => `\`${host}\``).join(
|
|
1330
|
+
", "
|
|
1331
|
+
)}. Add any missing host to the agent's egress allowlist \u2014 the reusable workflow's \`extra_endpoints\` input, or a single-file workflow's \`--extra\` flag on \`sandbox-config\`.`;
|
|
1332
|
+
var blockquote = (text) => text.replaceAll("\n", "\n> ");
|
|
1333
|
+
var reasoned = (lead, noReason, reasons) => typeof reasons === "string" && reasons.trim() !== "" ? `${lead}
|
|
1334
|
+
|
|
1335
|
+
> ${blockquote(reasons)}` : noReason;
|
|
1336
|
+
var noticeSummary = (kind, reasons, agentAllowlist) => {
|
|
1337
|
+
switch (kind) {
|
|
1338
|
+
case "security-blocked":
|
|
1339
|
+
return reasoned(
|
|
1340
|
+
"### \u{1F6D1} Code review skipped by the security gate\n\nThe diff was flagged as unsafe to apply and execute:",
|
|
1341
|
+
"### \u{1F6D1} Code review skipped by the security gate\n\nThe security triage returned an unsafe verdict without a reason. See workflow logs.",
|
|
1342
|
+
reasons
|
|
1343
|
+
);
|
|
1344
|
+
case "triage-error":
|
|
1345
|
+
return reasoned(
|
|
1346
|
+
"### \u{1F6E0}\uFE0F Security gate could not evaluate\n\nThe security triage could not produce a verdict (operational error), so the review failed closed \u2014 this is an infrastructure failure, not a finding about this diff. Re-run to retry a transient fault; a persistent one is a configuration issue (see the workflow logs). The triage step reported:",
|
|
1347
|
+
"### \u{1F6E0}\uFE0F Security gate could not evaluate\n\nThe security triage could not produce a verdict (operational error), so the review failed closed \u2014 this is an infrastructure failure, not a finding about this diff. Re-run to retry a transient fault; a persistent one is a configuration issue (see the workflow logs).",
|
|
1348
|
+
reasons
|
|
1349
|
+
) + egressNote(agentAllowlist);
|
|
1350
|
+
case "setup-failed":
|
|
1351
|
+
return "### \u{1F6E0}\uFE0F Review did not run\n\nThe review job failed before the security triage could run (e.g. dependency install or environment setup). See the workflow logs \u2014 this is an infrastructure failure, not a security verdict.";
|
|
1352
|
+
case "checkout-failed":
|
|
1353
|
+
return "### \u26A0\uFE0F Could not check out the PR head\n\nThe PR head commit could not be fetched or checked out (it may have been force-pushed away, or is otherwise unavailable), so the review was skipped rather than run against the wrong tree. See workflow logs.";
|
|
1354
|
+
case "no-output":
|
|
1355
|
+
return "### \u26A0\uFE0F Review did not complete\n\nThe diff passed triage but the review produced no output. See workflow logs." + egressNote(agentAllowlist);
|
|
1356
|
+
}
|
|
1357
|
+
};
|
|
1358
|
+
var noticeEnvelope = (summary) => ({
|
|
1359
|
+
schema_version: DEFAULT_SCHEMA_VERSION,
|
|
1360
|
+
findings: incompleteFindings(summary),
|
|
1361
|
+
models: [],
|
|
1362
|
+
turns: 0,
|
|
1363
|
+
duration_ms: 0,
|
|
1364
|
+
vendor_cost_usd: null,
|
|
1365
|
+
incomplete: true
|
|
1366
|
+
});
|
|
1367
|
+
var buildNoticeEnvelope = (kind, reasons, agentAllowlist = []) => noticeEnvelope(noticeSummary(kind, reasons, agentAllowlist));
|
|
1368
|
+
var buildUnknownNoticeEnvelope = (kind) => noticeEnvelope(
|
|
1369
|
+
`### \u26A0\uFE0F Review could not be rendered
|
|
1370
|
+
|
|
1371
|
+
The workflow asked for an unrecognized notice kind (\`${kind}\`) \u2014 the pinned code-review CLI is older than the workflow calling it (check that its version matches). Failing closed. See the workflow logs.`
|
|
1372
|
+
);
|
|
380
1373
|
var identity = (decoded) => decoded;
|
|
381
1374
|
var findingsTable = [
|
|
382
1375
|
{
|
|
383
|
-
minor: "0.
|
|
1376
|
+
minor: "0.4",
|
|
1377
|
+
defaultVersion: "0.4.0",
|
|
1378
|
+
schemaFile: "findings.schema.json",
|
|
1379
|
+
codec: FindingsCodec,
|
|
1380
|
+
normalize: identity,
|
|
1381
|
+
latest: false
|
|
1382
|
+
},
|
|
1383
|
+
{
|
|
1384
|
+
minor: "0.5",
|
|
1385
|
+
defaultVersion: "0.5.0",
|
|
1386
|
+
schemaFile: "findings.schema.json",
|
|
1387
|
+
codec: FindingsCodec,
|
|
1388
|
+
normalize: identity,
|
|
1389
|
+
latest: false
|
|
1390
|
+
},
|
|
1391
|
+
{
|
|
1392
|
+
minor: "0.6",
|
|
384
1393
|
defaultVersion: DEFAULT_SCHEMA_VERSION,
|
|
385
1394
|
schemaFile: "findings.schema.json",
|
|
386
1395
|
codec: FindingsCodec,
|
|
@@ -460,39 +1469,90 @@ var resolvers = {
|
|
|
460
1469
|
prices: (raw) => resolveSingleVersion("prices", raw)
|
|
461
1470
|
};
|
|
462
1471
|
var resolve = (kind, raw) => resolvers[kind](raw);
|
|
463
|
-
var
|
|
1472
|
+
var MAX_BUFFER = 100 * 1024 * 1024;
|
|
1473
|
+
var MAX_TIMEOUT_MS = 2147483647;
|
|
1474
|
+
var parseTimeoutMs = (raw, fallback) => {
|
|
1475
|
+
if (raw === void 0 || !/^\d+$/.test(raw)) return fallback;
|
|
1476
|
+
const parsed = Number(raw);
|
|
1477
|
+
return parsed > 0 && parsed <= MAX_TIMEOUT_MS ? parsed : fallback;
|
|
1478
|
+
};
|
|
1479
|
+
var SUBPROCESS_TIMEOUT_ENV = "CODE_REVIEW_SUBPROCESS_TIMEOUT_MS";
|
|
1480
|
+
var DEFAULT_SUBPROCESS_TIMEOUT_MS = 12e4;
|
|
1481
|
+
var subprocessTimeoutMs = () => parseTimeoutMs(process.env[SUBPROCESS_TIMEOUT_ENV], DEFAULT_SUBPROCESS_TIMEOUT_MS);
|
|
1482
|
+
var classifyExecError = (err, stderr, timeoutMs) => {
|
|
1483
|
+
const e = err;
|
|
1484
|
+
const stderrStr = stderr.trim();
|
|
1485
|
+
if (e.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER")
|
|
1486
|
+
return `output exceeded ${String(MAX_BUFFER)} bytes (killed)`;
|
|
1487
|
+
if (e.killed === true)
|
|
1488
|
+
return `no response within ${String(timeoutMs)}ms (killed a hung child)${stderrStr ? `: ${stderrStr}` : ""}`;
|
|
1489
|
+
return stderrStr || errMsg(err);
|
|
1490
|
+
};
|
|
1491
|
+
var execFileWithTimeout = (spec) => new Promise((resolve3, reject) => {
|
|
464
1492
|
const child = execFile(
|
|
465
|
-
|
|
466
|
-
[
|
|
467
|
-
{
|
|
1493
|
+
spec.command,
|
|
1494
|
+
[...spec.args],
|
|
1495
|
+
{
|
|
1496
|
+
...spec.env ? { env: { ...process.env, ...spec.env } } : {},
|
|
1497
|
+
encoding: "utf-8",
|
|
1498
|
+
maxBuffer: MAX_BUFFER,
|
|
1499
|
+
timeout: spec.timeoutMs,
|
|
1500
|
+
killSignal: "SIGKILL"
|
|
1501
|
+
},
|
|
468
1502
|
(err, stdout, stderr) => {
|
|
469
|
-
if (err)
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
resolve3(stdout);
|
|
475
|
-
}
|
|
1503
|
+
if (err)
|
|
1504
|
+
reject(
|
|
1505
|
+
new Error(`${spec.label} failed: ${classifyExecError(err, stderr, spec.timeoutMs)}`)
|
|
1506
|
+
);
|
|
1507
|
+
else resolve3(stdout);
|
|
476
1508
|
}
|
|
477
1509
|
);
|
|
478
|
-
if (stdin !== void 0) {
|
|
479
|
-
child.stdin?.
|
|
1510
|
+
if (spec.stdin !== void 0) {
|
|
1511
|
+
child.stdin?.on("error", () => void 0);
|
|
1512
|
+
child.stdin?.end(spec.stdin);
|
|
480
1513
|
}
|
|
481
1514
|
});
|
|
482
1515
|
|
|
1516
|
+
// src/gh.ts
|
|
1517
|
+
var describeEndpoint = (args) => args.find((a) => a === "graphql" || a.includes("/") && !a.startsWith("-")) ?? args[0] ?? "(no endpoint)";
|
|
1518
|
+
var runGhApi = (args, stdin, env) => execFileWithTimeout({
|
|
1519
|
+
command: "gh",
|
|
1520
|
+
args: ["api", ...args],
|
|
1521
|
+
label: `gh api ${describeEndpoint(args)}`,
|
|
1522
|
+
timeoutMs: subprocessTimeoutMs(),
|
|
1523
|
+
env,
|
|
1524
|
+
stdin
|
|
1525
|
+
});
|
|
1526
|
+
|
|
483
1527
|
// src/pr.ts
|
|
1528
|
+
var CANDIDATE_JQ = ".[] | {number: .number, state: .state, headRef: .head.ref, headSha: .head.sha}";
|
|
1529
|
+
var parseCandidates = (stdout) => parseJsonl(stdout);
|
|
1530
|
+
var fetchDirectCandidates = async (repo, headSha, ghApi) => {
|
|
1531
|
+
try {
|
|
1532
|
+
return parseCandidates(
|
|
1533
|
+
await ghApi([`repos/${repo}/commits/${headSha}/pulls`, "--jq", CANDIDATE_JQ])
|
|
1534
|
+
);
|
|
1535
|
+
} catch {
|
|
1536
|
+
return [];
|
|
1537
|
+
}
|
|
1538
|
+
};
|
|
484
1539
|
var fetchPrCandidates = async (repo, headSha, ghApi) => {
|
|
485
|
-
const
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
1540
|
+
const direct = await fetchDirectCandidates(repo, headSha, ghApi);
|
|
1541
|
+
if (direct.length > 0) return direct;
|
|
1542
|
+
const open = parseCandidates(
|
|
1543
|
+
await ghApi([
|
|
1544
|
+
`repos/${repo}/pulls?state=open&per_page=100`,
|
|
1545
|
+
"--paginate",
|
|
1546
|
+
"--jq",
|
|
1547
|
+
CANDIDATE_JQ
|
|
1548
|
+
])
|
|
1549
|
+
);
|
|
1550
|
+
return open.filter((c) => c.headSha === headSha);
|
|
491
1551
|
};
|
|
492
1552
|
var resolvePr = (candidates, headBranch) => {
|
|
493
1553
|
if (candidates.length === 0) return { kind: "none" };
|
|
494
1554
|
const scoped = candidates.length > 1 && headBranch ? candidates.filter((c) => c.headRef === headBranch) : candidates;
|
|
495
|
-
const chosen = scoped[0] ?? candidates[0];
|
|
1555
|
+
const chosen = scoped.find((c) => c.state === "open") ?? scoped[0] ?? candidates[0];
|
|
496
1556
|
if (chosen === void 0) return { kind: "none" };
|
|
497
1557
|
return chosen.state === "open" ? { kind: "open", prNumber: chosen.number } : { kind: "not-open", prNumber: chosen.number, state: chosen.state };
|
|
498
1558
|
};
|
|
@@ -502,10 +1562,112 @@ var fetchDiff = async (repo, prNumber, ghApi) => ghApi([
|
|
|
502
1562
|
"Accept: application/vnd.github.v3.diff"
|
|
503
1563
|
]);
|
|
504
1564
|
|
|
1565
|
+
// src/checkrun.ts
|
|
1566
|
+
var CHECK_RUN_NAME = "Code review";
|
|
1567
|
+
var settled = /* @__PURE__ */ new Set(["success", "neutral", "skipped"]);
|
|
1568
|
+
var runIdFromUrl = (runUrl) => {
|
|
1569
|
+
const m = /\/actions\/runs\/(\d+)\/?$/.exec(runUrl);
|
|
1570
|
+
return m?.[1] ?? null;
|
|
1571
|
+
};
|
|
1572
|
+
var ownedCheck = (checks, runUrl) => {
|
|
1573
|
+
const runId = runIdFromUrl(runUrl);
|
|
1574
|
+
if (runId === null) return null;
|
|
1575
|
+
return checks.find((c) => c.detailsUrl !== null && c.detailsUrl.endsWith(`/runs/${runId}`)) ?? null;
|
|
1576
|
+
};
|
|
1577
|
+
var decideCheckAction = (checks, intent, runUrl) => {
|
|
1578
|
+
const owned = ownedCheck(checks, runUrl);
|
|
1579
|
+
switch (intent) {
|
|
1580
|
+
case "in_progress":
|
|
1581
|
+
return owned !== null ? { kind: "noop", reason: "this run's check is already in progress" } : { kind: "create", status: "in_progress" };
|
|
1582
|
+
case "neutral":
|
|
1583
|
+
if (owned === null) return { kind: "create", status: "completed", conclusion: "neutral" };
|
|
1584
|
+
return owned.status === "completed" && owned.conclusion === "neutral" ? { kind: "noop", reason: "the check already records this completed review" } : { kind: "patch", id: owned.id, status: "completed", conclusion: "neutral" };
|
|
1585
|
+
case "failure":
|
|
1586
|
+
if (owned === null) return { kind: "create", status: "completed", conclusion: "failure" };
|
|
1587
|
+
if (owned.status === "completed" && owned.conclusion !== null && settled.has(owned.conclusion))
|
|
1588
|
+
return { kind: "noop", reason: "a completed review already recorded this head" };
|
|
1589
|
+
return owned.status === "completed" && owned.conclusion === "failure" ? { kind: "noop", reason: "the check already records this failure" } : { kind: "patch", id: owned.id, status: "completed", conclusion: "failure" };
|
|
1590
|
+
case "cancelled":
|
|
1591
|
+
return { kind: "noop", reason: "cancelled is handled via decideCancelledAction" };
|
|
1592
|
+
}
|
|
1593
|
+
};
|
|
1594
|
+
var decideCancelledAction = (checks, runUrl) => {
|
|
1595
|
+
const owned = ownedCheck(checks, runUrl);
|
|
1596
|
+
if (owned === null) return { kind: "noop", reason: "no check was created by this run to settle" };
|
|
1597
|
+
if (owned.status === "completed" && owned.conclusion === "cancelled")
|
|
1598
|
+
return { kind: "noop", reason: "the check already records this cancelled run" };
|
|
1599
|
+
if (owned.status === "completed" && owned.conclusion !== null && settled.has(owned.conclusion))
|
|
1600
|
+
return { kind: "noop", reason: "a completed review already recorded this head" };
|
|
1601
|
+
return { kind: "patch", id: owned.id, status: "completed", conclusion: "cancelled" };
|
|
1602
|
+
};
|
|
1603
|
+
var CHECK_JQ = ".check_runs[] | {id: .id, status: .status, conclusion: .conclusion, detailsUrl: .details_url}";
|
|
1604
|
+
var fetchChecks = async (repo, headSha, ghApi) => parseJsonl(
|
|
1605
|
+
await ghApi([
|
|
1606
|
+
`repos/${repo}/commits/${headSha}/check-runs?check_name=${encodeURIComponent(CHECK_RUN_NAME)}&per_page=100`,
|
|
1607
|
+
"--paginate",
|
|
1608
|
+
"--jq",
|
|
1609
|
+
CHECK_JQ
|
|
1610
|
+
])
|
|
1611
|
+
);
|
|
1612
|
+
var output = (intent, runUrl) => {
|
|
1613
|
+
switch (intent) {
|
|
1614
|
+
case "in_progress":
|
|
1615
|
+
return {
|
|
1616
|
+
title: "Code review in progress",
|
|
1617
|
+
summary: `The review is running \u2014 [see the run](${runUrl}).`
|
|
1618
|
+
};
|
|
1619
|
+
case "neutral":
|
|
1620
|
+
return {
|
|
1621
|
+
title: "Code review complete",
|
|
1622
|
+
summary: `The review was posted \u2014 [see the run](${runUrl}).`
|
|
1623
|
+
};
|
|
1624
|
+
case "failure":
|
|
1625
|
+
return {
|
|
1626
|
+
title: "Code review did not complete",
|
|
1627
|
+
summary: `The review job failed \u2014 [see the run](${runUrl}). Re-request the review; do not treat this round as spent.`
|
|
1628
|
+
};
|
|
1629
|
+
case "cancelled":
|
|
1630
|
+
return {
|
|
1631
|
+
title: "Code review superseded",
|
|
1632
|
+
summary: `This review run was cancelled \u2014 [see the run](${runUrl}). No action needed.`
|
|
1633
|
+
};
|
|
1634
|
+
}
|
|
1635
|
+
};
|
|
1636
|
+
var checkRun = async (input, ghApi = runGhApi) => {
|
|
1637
|
+
const checks = await fetchChecks(input.repo, input.headSha, ghApi);
|
|
1638
|
+
const action = input.intent === "cancelled" ? decideCancelledAction(checks, input.runUrl) : decideCheckAction(checks, input.intent, input.runUrl);
|
|
1639
|
+
if (action.kind === "noop") {
|
|
1640
|
+
process.stderr.write(`code-review check-run: ${action.reason} \u2014 leaving it
|
|
1641
|
+
`);
|
|
1642
|
+
return;
|
|
1643
|
+
}
|
|
1644
|
+
const body = action.kind === "create" ? {
|
|
1645
|
+
name: CHECK_RUN_NAME,
|
|
1646
|
+
head_sha: input.headSha,
|
|
1647
|
+
status: action.status,
|
|
1648
|
+
details_url: input.runUrl,
|
|
1649
|
+
...action.conclusion ? { conclusion: action.conclusion } : {},
|
|
1650
|
+
output: output(input.intent, input.runUrl)
|
|
1651
|
+
} : {
|
|
1652
|
+
status: action.status,
|
|
1653
|
+
conclusion: action.conclusion,
|
|
1654
|
+
details_url: input.runUrl,
|
|
1655
|
+
output: output(input.intent, input.runUrl)
|
|
1656
|
+
};
|
|
1657
|
+
const endpoint = action.kind === "create" ? [`--method`, `POST`, `repos/${input.repo}/check-runs`, `--input`, `-`] : [
|
|
1658
|
+
`--method`,
|
|
1659
|
+
`PATCH`,
|
|
1660
|
+
`repos/${input.repo}/check-runs/${String(action.id)}`,
|
|
1661
|
+
`--input`,
|
|
1662
|
+
`-`
|
|
1663
|
+
];
|
|
1664
|
+
await ghApi(endpoint, JSON.stringify(body));
|
|
1665
|
+
};
|
|
1666
|
+
|
|
505
1667
|
// src/post.ts
|
|
506
1668
|
var DEFAULT_MARKER = "<!-- code-review -->";
|
|
1669
|
+
var EMPTY_MECHANIC_LEAVE_MESSAGE = "The CI-fix pass found no issues and the sticky already reflects a completed full review \u2014 leaving it in place\n";
|
|
507
1670
|
var MAX_SUGGESTION_LINES = 10;
|
|
508
|
-
var REVIEWED_SHA_RE = /<!-- reviewed-sha: ([0-9a-f]{7,40}) -->/;
|
|
509
1671
|
var countSuggestionLines = (text) => text.split("\n").length;
|
|
510
1672
|
var checkLongSuggestions = (comments) => {
|
|
511
1673
|
const longFiles = [];
|
|
@@ -525,13 +1687,6 @@ var checkLongSuggestions = (comments) => {
|
|
|
525
1687
|
});
|
|
526
1688
|
return { comments: adjusted, longFiles };
|
|
527
1689
|
};
|
|
528
|
-
var extractReviewedSha = (commentBody) => REVIEWED_SHA_RE.exec(commentBody)?.[1] ?? null;
|
|
529
|
-
var noticeFindings = (message) => ({
|
|
530
|
-
schema_version: DEFAULT_SCHEMA_VERSION,
|
|
531
|
-
summary: `### \u26A0\uFE0F ${message}`,
|
|
532
|
-
verdict: "comment",
|
|
533
|
-
findings: []
|
|
534
|
-
});
|
|
535
1690
|
var loadFindings = (path) => {
|
|
536
1691
|
let raw;
|
|
537
1692
|
try {
|
|
@@ -578,7 +1733,7 @@ var loadTestReport = (path) => {
|
|
|
578
1733
|
raw = JSON.parse(readFileSync(path, "utf-8"));
|
|
579
1734
|
} catch (err) {
|
|
580
1735
|
process.stderr.write(
|
|
581
|
-
`Warning: could not read test report at ${path}: ${
|
|
1736
|
+
`Warning: could not read test report at ${path}: ${errMsg(err)} \u2014 omitting test panel
|
|
582
1737
|
`
|
|
583
1738
|
);
|
|
584
1739
|
return void 0;
|
|
@@ -593,20 +1748,55 @@ var loadTestReport = (path) => {
|
|
|
593
1748
|
}
|
|
594
1749
|
return decoded.right;
|
|
595
1750
|
};
|
|
596
|
-
var
|
|
597
|
-
const
|
|
598
|
-
|
|
1751
|
+
var parseHtmlUrl = (raw) => {
|
|
1752
|
+
const parsed = tryParseJson(raw);
|
|
1753
|
+
const htmlUrl = parsed.ok ? asRecord(parsed.value)?.["html_url"] : void 0;
|
|
1754
|
+
return typeof htmlUrl === "string" ? htmlUrl : void 0;
|
|
1755
|
+
};
|
|
1756
|
+
var commentPayload = (c) => ({
|
|
1757
|
+
path: c.path,
|
|
1758
|
+
line: c.line,
|
|
1759
|
+
side: c.side,
|
|
1760
|
+
...c.start_line !== void 0 && c.start_side !== void 0 ? { start_line: c.start_line, start_side: c.start_side } : {},
|
|
1761
|
+
body: formatMarkdown(c.body)
|
|
1762
|
+
});
|
|
1763
|
+
var postInlineReview = async (repo, prNumber, headSha, comments, inDiff, stickyUrl, marker, ghApi) => {
|
|
1764
|
+
const pointer = reviewBodyPointer(headSha, stickyUrl, marker);
|
|
1765
|
+
const reviewBody = (withComments) => JSON.stringify({
|
|
1766
|
+
body: pointer,
|
|
599
1767
|
commit_id: headSha,
|
|
600
1768
|
event: "COMMENT",
|
|
601
|
-
comments: comments.map(
|
|
602
|
-
path: c.path,
|
|
603
|
-
line: c.line,
|
|
604
|
-
side: c.side,
|
|
605
|
-
...c.start_line !== void 0 && c.start_side !== void 0 ? { start_line: c.start_line, start_side: c.start_side } : {},
|
|
606
|
-
body: c.body
|
|
607
|
-
}))
|
|
1769
|
+
comments: withComments ? comments.map(commentPayload) : []
|
|
608
1770
|
});
|
|
609
|
-
|
|
1771
|
+
const reviewsEndpoint = [`repos/${repo}/pulls/${String(prNumber)}/reviews`, "--input", "-"];
|
|
1772
|
+
try {
|
|
1773
|
+
const stdout = await ghApi(reviewsEndpoint, reviewBody(true));
|
|
1774
|
+
return { url: parseHtmlUrl(stdout), inlinePosted: comments.length, unposted: [] };
|
|
1775
|
+
} catch (err) {
|
|
1776
|
+
if (comments.length === 0) throw err;
|
|
1777
|
+
process.stderr.write(
|
|
1778
|
+
`Warning: the batched inline review on PR #${String(prNumber)} was rejected (${errMsg(err)}) \u2014 posting the review body-only, then each comment individually to keep the ones GitHub accepts (issue #57)
|
|
1779
|
+
`
|
|
1780
|
+
);
|
|
1781
|
+
const url = parseHtmlUrl(await ghApi(reviewsEndpoint, reviewBody(false)));
|
|
1782
|
+
const commentsEndpoint = [`repos/${repo}/pulls/${String(prNumber)}/comments`, "--input", "-"];
|
|
1783
|
+
const unposted = [];
|
|
1784
|
+
let inlinePosted = 0;
|
|
1785
|
+
for (const [i, c] of comments.entries()) {
|
|
1786
|
+
try {
|
|
1787
|
+
await ghApi(commentsEndpoint, JSON.stringify({ commit_id: headSha, ...commentPayload(c) }));
|
|
1788
|
+
inlinePosted += 1;
|
|
1789
|
+
} catch (e) {
|
|
1790
|
+
const finding = inDiff[i];
|
|
1791
|
+
if (finding) unposted.push(finding);
|
|
1792
|
+
process.stderr.write(
|
|
1793
|
+
`Warning: inline comment on ${c.path}:${String(c.line)} rejected (${errMsg(e)}) \u2014 surfacing that finding in the sticky instead (issue #57)
|
|
1794
|
+
`
|
|
1795
|
+
);
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
return { url, inlinePosted, unposted };
|
|
1799
|
+
}
|
|
610
1800
|
};
|
|
611
1801
|
var findBotComment = async (repo, prNumber, botLogin, marker, ghApi) => {
|
|
612
1802
|
const stdout = await ghApi(
|
|
@@ -626,33 +1816,44 @@ var findBotComment = async (repo, prNumber, botLogin, marker, ghApi) => {
|
|
|
626
1816
|
const parsed = JSON.parse(last);
|
|
627
1817
|
return { id: parsed.id, body: parsed.body };
|
|
628
1818
|
};
|
|
1819
|
+
var parseCommentRef = (raw) => {
|
|
1820
|
+
const parsed = tryParseJson(raw);
|
|
1821
|
+
const rec = parsed.ok ? asRecord(parsed.value) : null;
|
|
1822
|
+
const id = rec?.["id"];
|
|
1823
|
+
const html_url = rec?.["html_url"];
|
|
1824
|
+
return typeof id === "number" && typeof html_url === "string" ? { id, html_url } : null;
|
|
1825
|
+
};
|
|
629
1826
|
var patchComment = async (repo, commentId, body, ghApi) => {
|
|
630
|
-
await ghApi(
|
|
1827
|
+
const stdout = await ghApi(
|
|
631
1828
|
[`repos/${repo}/issues/comments/${String(commentId)}`, "--input", "-"],
|
|
632
1829
|
JSON.stringify({ body })
|
|
633
1830
|
);
|
|
1831
|
+
const htmlUrl = parseHtmlUrl(stdout);
|
|
1832
|
+
return htmlUrl !== void 0 ? { html_url: htmlUrl } : null;
|
|
634
1833
|
};
|
|
635
1834
|
var postComment = async (repo, prNumber, body, ghApi) => {
|
|
636
|
-
await ghApi(
|
|
1835
|
+
const stdout = await ghApi(
|
|
637
1836
|
[`repos/${repo}/issues/${String(prNumber)}/comments`, "--input", "-"],
|
|
638
1837
|
JSON.stringify({ body })
|
|
639
1838
|
);
|
|
1839
|
+
return parseCommentRef(stdout);
|
|
640
1840
|
};
|
|
641
1841
|
var upsertSticky = async (repo, prNumber, existing, body, ghApi) => {
|
|
642
1842
|
if (existing !== null) {
|
|
643
|
-
await patchComment(repo, existing.id, body, ghApi);
|
|
1843
|
+
const patched = await patchComment(repo, existing.id, body, ghApi);
|
|
644
1844
|
process.stderr.write(
|
|
645
1845
|
`Updated sticky comment #${String(existing.id)} on PR #${String(prNumber)}
|
|
646
1846
|
`
|
|
647
1847
|
);
|
|
648
|
-
|
|
649
|
-
await postComment(repo, prNumber, body, ghApi);
|
|
650
|
-
process.stderr.write(`Posted new sticky comment on PR #${String(prNumber)}
|
|
651
|
-
`);
|
|
1848
|
+
return { id: existing.id, url: patched?.html_url };
|
|
652
1849
|
}
|
|
1850
|
+
const posted = await postComment(repo, prNumber, body, ghApi);
|
|
1851
|
+
process.stderr.write(`Posted new sticky comment on PR #${String(prNumber)}
|
|
1852
|
+
`);
|
|
1853
|
+
return posted ? { id: posted.id, url: posted.html_url } : null;
|
|
653
1854
|
};
|
|
654
1855
|
var isBotReview = (r) => typeof r === "object" && r !== null && typeof r.id === "number" && typeof r.state === "string" && typeof r.user?.login === "string";
|
|
655
|
-
var
|
|
1856
|
+
var fetchBotReviews = async (repo, prNumber, botLogin, ghApi) => {
|
|
656
1857
|
const stdout = await ghApi([`repos/${repo}/pulls/${String(prNumber)}/reviews`, "--paginate"]);
|
|
657
1858
|
let reviews;
|
|
658
1859
|
try {
|
|
@@ -661,10 +1862,9 @@ var fetchBotReviewIds = async (repo, prNumber, botLogin, ghApi) => {
|
|
|
661
1862
|
return [];
|
|
662
1863
|
}
|
|
663
1864
|
if (!Array.isArray(reviews)) return [];
|
|
664
|
-
return reviews.filter(
|
|
1865
|
+
return reviews.filter(isBotReview).filter((r) => r.user.login === botLogin && r.state !== "DISMISSED").map((r) => ({ id: r.id }));
|
|
665
1866
|
};
|
|
666
|
-
var
|
|
667
|
-
const ids = await fetchBotReviewIds(repo, prNumber, botLogin, ghApi);
|
|
1867
|
+
var dismissReviews = async (repo, prNumber, ids, ghApi) => {
|
|
668
1868
|
for (const id of ids) {
|
|
669
1869
|
try {
|
|
670
1870
|
await ghApi(
|
|
@@ -679,24 +1879,104 @@ var dismissPriorBotReviews = async (repo, prNumber, botLogin, ghApi) => {
|
|
|
679
1879
|
);
|
|
680
1880
|
} catch (err) {
|
|
681
1881
|
process.stderr.write(
|
|
682
|
-
`Warning: failed to dismiss prior review #${String(id)} on PR #${String(prNumber)}: ${
|
|
1882
|
+
`Warning: failed to dismiss prior review #${String(id)} on PR #${String(prNumber)}: ${errMsg(err)}
|
|
683
1883
|
`
|
|
684
1884
|
);
|
|
685
1885
|
}
|
|
686
1886
|
}
|
|
687
1887
|
};
|
|
688
|
-
var
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
if (
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
1888
|
+
var REVIEW_THREAD_COMMENTS_QUERY = "query($owner:String!,$name:String!,$pr:Int!){repository(owner:$owner,name:$name){pullRequest(number:$pr){reviewThreads(first:100){pageInfo{hasNextPage}nodes{comments(first:100){nodes{id isMinimized author{login}}}}}}}}";
|
|
1889
|
+
var MINIMIZE_COMMENT_MUTATION = "mutation($id:ID!){minimizeComment(input:{subjectId:$id,classifier:OUTDATED}){minimizedComment{isMinimized}}}";
|
|
1890
|
+
var priorBotCommentId = (c, logins) => {
|
|
1891
|
+
if (typeof c !== "object" || c === null) return null;
|
|
1892
|
+
const o = c;
|
|
1893
|
+
const login = o.author?.login;
|
|
1894
|
+
return typeof o.id === "string" && o.isMinimized !== true && typeof login === "string" && logins.includes(login) ? o.id : null;
|
|
1895
|
+
};
|
|
1896
|
+
var priorBotCommentIds = (raw, botLogin) => {
|
|
1897
|
+
let parsed;
|
|
1898
|
+
try {
|
|
1899
|
+
parsed = JSON.parse(raw);
|
|
1900
|
+
} catch {
|
|
1901
|
+
return { ids: [], truncated: false };
|
|
695
1902
|
}
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
1903
|
+
const conn = parsed.data?.repository?.pullRequest?.reviewThreads;
|
|
1904
|
+
const truncated = conn?.pageInfo?.hasNextPage === true;
|
|
1905
|
+
const nodes = conn?.nodes;
|
|
1906
|
+
if (!Array.isArray(nodes)) return { ids: [], truncated };
|
|
1907
|
+
const logins = [botLogin.replace(/\[bot\]$/, ""), botLogin];
|
|
1908
|
+
const ids = nodes.flatMap((t7) => {
|
|
1909
|
+
const cnodes = t7.comments?.nodes;
|
|
1910
|
+
return Array.isArray(cnodes) ? cnodes.map((c) => priorBotCommentId(c, logins)).filter((id) => id !== null) : [];
|
|
1911
|
+
});
|
|
1912
|
+
return { ids, truncated };
|
|
1913
|
+
};
|
|
1914
|
+
var listPriorBotCommentIds = async (repo, prNumber, botLogin, ghApi) => {
|
|
1915
|
+
const slash = repo.indexOf("/");
|
|
1916
|
+
if (slash <= 0) return [];
|
|
1917
|
+
const owner = repo.slice(0, slash);
|
|
1918
|
+
const name = repo.slice(slash + 1);
|
|
1919
|
+
let raw;
|
|
1920
|
+
try {
|
|
1921
|
+
raw = await ghApi([
|
|
1922
|
+
"graphql",
|
|
1923
|
+
"-f",
|
|
1924
|
+
`query=${REVIEW_THREAD_COMMENTS_QUERY}`,
|
|
1925
|
+
"-f",
|
|
1926
|
+
`owner=${owner}`,
|
|
1927
|
+
"-f",
|
|
1928
|
+
`name=${name}`,
|
|
1929
|
+
"-F",
|
|
1930
|
+
`pr=${String(prNumber)}`
|
|
1931
|
+
]);
|
|
1932
|
+
} catch (err) {
|
|
1933
|
+
process.stderr.write(
|
|
1934
|
+
`Warning: could not list review threads to minimize stale comments on PR #${String(prNumber)}: ${errMsg(err)}
|
|
1935
|
+
`
|
|
1936
|
+
);
|
|
1937
|
+
return [];
|
|
1938
|
+
}
|
|
1939
|
+
const { ids, truncated } = priorBotCommentIds(raw, botLogin);
|
|
1940
|
+
if (truncated) {
|
|
1941
|
+
process.stderr.write(
|
|
1942
|
+
`Note: PR #${String(prNumber)} has more than 100 review threads \u2014 only the first 100 were scanned for stale bot comments
|
|
1943
|
+
`
|
|
1944
|
+
);
|
|
1945
|
+
}
|
|
1946
|
+
return ids;
|
|
1947
|
+
};
|
|
1948
|
+
var minimizeComments = async (prNumber, ids, ghApi) => {
|
|
1949
|
+
let minimized = 0;
|
|
1950
|
+
for (const id of ids) {
|
|
1951
|
+
try {
|
|
1952
|
+
await ghApi(["graphql", "-f", `query=${MINIMIZE_COMMENT_MUTATION}`, "-f", `id=${id}`]);
|
|
1953
|
+
minimized += 1;
|
|
1954
|
+
} catch (err) {
|
|
1955
|
+
process.stderr.write(
|
|
1956
|
+
`Warning: failed to minimize a stale review comment on PR #${String(prNumber)}: ${errMsg(err)}
|
|
1957
|
+
`
|
|
1958
|
+
);
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
if (minimized > 0) {
|
|
1962
|
+
process.stderr.write(
|
|
1963
|
+
`Minimized ${String(minimized)} stale inline comment(s) from superseded reviews on PR #${String(prNumber)}
|
|
1964
|
+
`
|
|
1965
|
+
);
|
|
1966
|
+
}
|
|
1967
|
+
};
|
|
1968
|
+
var post = async (input, ghApi = runGhApi) => {
|
|
1969
|
+
const candidates = await fetchPrCandidates(input.repo, input.headSha, ghApi);
|
|
1970
|
+
const resolution = resolvePr(candidates, input.headBranch);
|
|
1971
|
+
if (resolution.kind === "none") {
|
|
1972
|
+
process.stderr.write(`No open PR for ${input.headSha} \u2014 nothing to post
|
|
1973
|
+
`);
|
|
1974
|
+
process.exit(0);
|
|
1975
|
+
}
|
|
1976
|
+
if (resolution.kind === "not-open") {
|
|
1977
|
+
process.stderr.write(
|
|
1978
|
+
`PR #${String(resolution.prNumber)} for ${input.headSha} is not open (state: ${resolution.state}) \u2014 nothing to post
|
|
1979
|
+
`
|
|
700
1980
|
);
|
|
701
1981
|
process.exit(0);
|
|
702
1982
|
}
|
|
@@ -709,25 +1989,74 @@ var post = async (input, ghApi = runGhApi) => {
|
|
|
709
1989
|
DEFAULT_MARKER,
|
|
710
1990
|
ghApi
|
|
711
1991
|
);
|
|
712
|
-
const
|
|
713
|
-
const
|
|
1992
|
+
const existingComplete = existingSticky !== null && parseReviewComplete(existingSticky.body);
|
|
1993
|
+
const wouldBuryCompleted = (incomplete) => incomplete && existingComplete;
|
|
1994
|
+
const priorIsFullReview = (body) => isFullReviewSticky(body) || parseReviewedRoute(body) === null && (parseReviewComplete(body) || parseCompletedAncestor(body));
|
|
1995
|
+
const emptyMechanicWouldBury = (route, incomplete) => route === "mechanic" && !incomplete && findings.findings.length === 0 && existingSticky !== null && priorIsFullReview(existingSticky.body);
|
|
1996
|
+
const priorRounds = existingSticky !== null ? parseRounds(existingSticky.body) : [];
|
|
1997
|
+
const priorSignal = existingSticky === null ? null : parseSignalMarker(existingSticky.body) ?? parseSurfaceSignal(parseFindingsMarker(existingSticky.body));
|
|
1998
|
+
const findingsMarkerFor = (findings2, signal2) => {
|
|
1999
|
+
const pointer = surfacedFindingsPointer(findings2, signal2, input.jsonUrl);
|
|
2000
|
+
return signal2 !== null || priorSignal === null ? pointer : `${pointer}
|
|
2001
|
+
${signalMarker(priorSignal)}`;
|
|
2002
|
+
};
|
|
2003
|
+
const leaveInPlace = (message) => {
|
|
2004
|
+
process.stderr.write(
|
|
2005
|
+
message ?? "Review did not complete and the sticky already reflects a completed review \u2014 leaving it in place\n"
|
|
2006
|
+
);
|
|
2007
|
+
process.exit(0);
|
|
2008
|
+
};
|
|
2009
|
+
const emptyMechanicLeaveOrNote = async (sticky) => {
|
|
2010
|
+
if (existingComplete) leaveInPlace(EMPTY_MECHANIC_LEAVE_MESSAGE);
|
|
2011
|
+
const priorSha = parseReviewedSha(sticky.body);
|
|
2012
|
+
const body = formatMarkdown(
|
|
2013
|
+
noticeBody(
|
|
2014
|
+
`${DEFAULT_MARKER}
|
|
2015
|
+
|
|
2016
|
+
\u26A0\uFE0F **CI-fix pass completed with no findings** for \`${input.headSha.slice(0, 7)}\` \u2014 the completed full review of \`${priorSha ? priorSha.slice(0, 7) : "an earlier commit"}\` is preserved below.`,
|
|
2017
|
+
sticky.body
|
|
2018
|
+
)
|
|
2019
|
+
);
|
|
2020
|
+
await upsertSticky(input.repo, prNumber, sticky, body, ghApi);
|
|
2021
|
+
process.exit(0);
|
|
2022
|
+
};
|
|
714
2023
|
const prices = JSON.parse(readFileSync(input.pricesPath, "utf-8"));
|
|
715
2024
|
const decodedPrices = PriceMapCodec.decode(prices);
|
|
716
2025
|
if (decodedPrices._tag === "Left") {
|
|
717
2026
|
throw new Error(`Price map at ${input.pricesPath} does not match the expected shape`);
|
|
718
2027
|
}
|
|
719
2028
|
const template = readFileSync(input.templatePath, "utf-8");
|
|
720
|
-
const inlineTemplate =
|
|
721
|
-
const renderNotice = (message) =>
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
2029
|
+
const inlineTemplate = readFileSync(input.inlineTemplatePath, "utf-8");
|
|
2030
|
+
const renderNotice = (message) => {
|
|
2031
|
+
const findings2 = incompleteFindings(`### \u26A0\uFE0F ${message}`);
|
|
2032
|
+
return formatMarkdown(
|
|
2033
|
+
render({
|
|
2034
|
+
findings: findings2,
|
|
2035
|
+
envelope: null,
|
|
2036
|
+
incomplete: true,
|
|
2037
|
+
prices: decodedPrices.right,
|
|
2038
|
+
pricesProvided: input.pricesProvided,
|
|
2039
|
+
template,
|
|
2040
|
+
route: input.route,
|
|
2041
|
+
reviewedSha: input.headSha,
|
|
2042
|
+
effort: input.effort,
|
|
2043
|
+
rounds: priorRounds,
|
|
2044
|
+
sameRootNotes: {},
|
|
2045
|
+
roundCount: priorSignal?.round ?? priorRounds.length,
|
|
2046
|
+
convergenceRound: false,
|
|
2047
|
+
runUrl: input.runUrl,
|
|
2048
|
+
jsonUrl: input.jsonUrl,
|
|
2049
|
+
// A notice's own blob stays clean: verdict "error" + a carried "converged" would read as a
|
|
2050
|
+
// stop signal for a run that produced no verdict (issue #141 review r2). The prior signal
|
|
2051
|
+
// survives on the sticky in the compact marker (findingsMarkerFor), and the carried-forward
|
|
2052
|
+
// trajectory (rounds marker) remains the historical record.
|
|
2053
|
+
findingsPointer: findingsMarkerFor(findings2, null),
|
|
2054
|
+
postedAt: input.postedAt
|
|
2055
|
+
})
|
|
2056
|
+
);
|
|
2057
|
+
};
|
|
730
2058
|
if (isEmptyDiff(diff)) {
|
|
2059
|
+
if (wouldBuryCompleted(true)) leaveInPlace();
|
|
731
2060
|
await upsertSticky(
|
|
732
2061
|
input.repo,
|
|
733
2062
|
prNumber,
|
|
@@ -739,6 +2068,7 @@ var post = async (input, ghApi = runGhApi) => {
|
|
|
739
2068
|
}
|
|
740
2069
|
const findingsResult = loadFindings(input.findingsPath);
|
|
741
2070
|
if (findingsResult.kind !== "ok") {
|
|
2071
|
+
if (wouldBuryCompleted(true)) leaveInPlace();
|
|
742
2072
|
await upsertSticky(
|
|
743
2073
|
input.repo,
|
|
744
2074
|
prNumber,
|
|
@@ -751,28 +2081,63 @@ var post = async (input, ghApi = runGhApi) => {
|
|
|
751
2081
|
const findings = findingsResult.findings;
|
|
752
2082
|
const envelope = loadEnvelope(input.envelopePath);
|
|
753
2083
|
const testReport = input.testReportPath ? loadTestReport(input.testReportPath) : void 0;
|
|
2084
|
+
const effectiveRoute = input.route ?? envelope?.route;
|
|
754
2085
|
if (envelope === null) {
|
|
755
|
-
const
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
2086
|
+
const envelopelessIncomplete = isIncompleteFindings(findings);
|
|
2087
|
+
if (wouldBuryCompleted(envelopelessIncomplete)) leaveInPlace();
|
|
2088
|
+
if (emptyMechanicWouldBury(effectiveRoute, envelopelessIncomplete) && existingSticky !== null)
|
|
2089
|
+
await emptyMechanicLeaveOrNote(existingSticky);
|
|
2090
|
+
const body = formatMarkdown(
|
|
2091
|
+
render({
|
|
2092
|
+
findings,
|
|
2093
|
+
envelope: null,
|
|
2094
|
+
incomplete: envelopelessIncomplete,
|
|
2095
|
+
prices: decodedPrices.right,
|
|
2096
|
+
pricesProvided: input.pricesProvided,
|
|
2097
|
+
template,
|
|
2098
|
+
route: effectiveRoute,
|
|
2099
|
+
reviewedSha: input.headSha,
|
|
2100
|
+
effort: input.effort,
|
|
2101
|
+
rounds: priorRounds,
|
|
2102
|
+
sameRootNotes: {},
|
|
2103
|
+
roundCount: priorSignal?.round ?? priorRounds.length,
|
|
2104
|
+
convergenceRound: false,
|
|
2105
|
+
testReport,
|
|
2106
|
+
inlineDisposition: { kind: "no-envelope" },
|
|
2107
|
+
runUrl: input.runUrl,
|
|
2108
|
+
jsonUrl: input.jsonUrl,
|
|
2109
|
+
// Same signal rule as the main path: only a completed-review doc carries the prior signal
|
|
2110
|
+
// in its blob; an error-verdict doc preserves it in the compact marker instead.
|
|
2111
|
+
findingsPointer: findingsMarkerFor(
|
|
2112
|
+
findings,
|
|
2113
|
+
isReviewVerdict(findings.verdict) ? priorSignal : null
|
|
2114
|
+
),
|
|
2115
|
+
postedAt: input.postedAt
|
|
2116
|
+
})
|
|
2117
|
+
);
|
|
2118
|
+
await upsertSticky(input.repo, prNumber, existingSticky, body, ghApi);
|
|
766
2119
|
process.stderr.write(
|
|
767
2120
|
"Result envelope missing or malformed \u2014 posted sticky summary without usage/cost data; no inline review\n"
|
|
768
2121
|
);
|
|
769
2122
|
process.exit(0);
|
|
770
2123
|
}
|
|
771
|
-
const
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
);
|
|
2124
|
+
const thisIncomplete = envelope.incomplete === true || isIncompleteFindings(findings);
|
|
2125
|
+
if (wouldBuryCompleted(thisIncomplete)) leaveInPlace();
|
|
2126
|
+
if (emptyMechanicWouldBury(effectiveRoute, thisIncomplete) && existingSticky !== null)
|
|
2127
|
+
await emptyMechanicLeaveOrNote(existingSticky);
|
|
2128
|
+
const isRound = isConvergenceRound(effectiveRoute, thisIncomplete) && isReviewVerdict(findings.verdict);
|
|
2129
|
+
const sameRootNotes = isRound ? computeSameRootNotes(priorRounds, findings.findings, input.headSha.slice(0, 12)) : {};
|
|
2130
|
+
const {
|
|
2131
|
+
comments: rawComments,
|
|
2132
|
+
strays,
|
|
2133
|
+
inDiff
|
|
2134
|
+
} = buildInlineComments(findings.findings, diff, {
|
|
2135
|
+
inlineTemplate,
|
|
2136
|
+
models: envelope.models.map((m) => m.model),
|
|
2137
|
+
findings,
|
|
2138
|
+
jsonUrl: input.jsonUrl,
|
|
2139
|
+
sameRootNotes
|
|
2140
|
+
});
|
|
776
2141
|
const { comments, longFiles } = checkLongSuggestions(rawComments);
|
|
777
2142
|
for (const wf of longFiles) {
|
|
778
2143
|
process.stderr.write(
|
|
@@ -780,45 +2145,482 @@ var post = async (input, ghApi = runGhApi) => {
|
|
|
780
2145
|
`
|
|
781
2146
|
);
|
|
782
2147
|
}
|
|
783
|
-
|
|
2148
|
+
const botReviews = await fetchBotReviews(input.repo, prNumber, input.botLogin, ghApi);
|
|
2149
|
+
const initialDisposition = comments.length === 0 && strays.length > 0 ? { kind: "none-in-diff" } : void 0;
|
|
2150
|
+
const currentCounts = computeSeverityCounts(findings.findings);
|
|
2151
|
+
const currentCodes = computeCodeCounts(findings.findings, findings.systemic_problems ?? []);
|
|
2152
|
+
const priorLastCodes = priorRounds.length > 0 ? priorRounds[priorRounds.length - 1]?.codes : void 0;
|
|
2153
|
+
const roundNumber = Math.max(priorSignal?.round ?? priorRounds.length, priorRounds.length) + 1;
|
|
2154
|
+
const rounds = isRound ? [
|
|
2155
|
+
...priorRounds,
|
|
2156
|
+
roundRecord(
|
|
2157
|
+
computeRoundCounts(findings),
|
|
2158
|
+
currentCodes,
|
|
2159
|
+
priorLastCodes,
|
|
2160
|
+
input.headSha.slice(0, 12),
|
|
2161
|
+
roundNumber
|
|
2162
|
+
)
|
|
2163
|
+
] : priorRounds;
|
|
2164
|
+
const signal = isRound ? signalForRound(roundNumber, computeRoundCounts(findings), input.convergenceThreshold) : thisIncomplete || !isReviewVerdict(findings.verdict) ? null : priorSignal;
|
|
2165
|
+
const findingsMarker = findingsMarkerFor(findings, signal);
|
|
2166
|
+
const markerForm = findingsMarkerForm(surfaceFindings(findings, signal), input.jsonUrl);
|
|
2167
|
+
if (markerForm === "link") {
|
|
2168
|
+
process.stderr.write(
|
|
2169
|
+
"Warning: the findings-json marker exceeds the embed limit \u2014 degraded to the jsonUrl-link form; a decoding agent must fetch the artifact instead of the embedded JSON\n"
|
|
2170
|
+
);
|
|
2171
|
+
} else if (markerForm === "omitted") {
|
|
2172
|
+
process.stderr.write(
|
|
2173
|
+
"Warning: the findings-json marker exceeds the embed limit and no --json-url was given \u2014 the machine-readable channel is omitted from the posted surfaces\n"
|
|
2174
|
+
);
|
|
2175
|
+
}
|
|
2176
|
+
const commonRenderInput = {
|
|
784
2177
|
findings,
|
|
785
2178
|
envelope,
|
|
2179
|
+
incomplete: thisIncomplete,
|
|
786
2180
|
prices: decodedPrices.right,
|
|
2181
|
+
pricesProvided: input.pricesProvided,
|
|
787
2182
|
template,
|
|
788
|
-
route:
|
|
2183
|
+
route: effectiveRoute,
|
|
789
2184
|
reviewedSha: input.headSha,
|
|
790
2185
|
effort: input.effort,
|
|
791
|
-
testReport
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
2186
|
+
testReport,
|
|
2187
|
+
severityCounts: currentCounts,
|
|
2188
|
+
rounds,
|
|
2189
|
+
sameRootNotes,
|
|
2190
|
+
roundCount: signal?.round ?? priorSignal?.round ?? priorRounds.length,
|
|
2191
|
+
convergenceThreshold: input.convergenceThreshold,
|
|
2192
|
+
convergenceRound: isRound,
|
|
2193
|
+
strays,
|
|
2194
|
+
runUrl: input.runUrl,
|
|
2195
|
+
jsonUrl: input.jsonUrl,
|
|
2196
|
+
findingsPointer: findingsMarker,
|
|
2197
|
+
postedAt: input.postedAt
|
|
2198
|
+
};
|
|
2199
|
+
const longFilesNote = longFiles.length > 0 ? `
|
|
797
2200
|
|
|
798
2201
|
---
|
|
799
2202
|
|
|
800
|
-
> **Note:** ${String(longFiles.length)} suggestion(s) exceeded GitHub's ~10-line inline suggestion limit and were omitted from inline comments
|
|
801
|
-
|
|
2203
|
+
> **Note:** ${String(longFiles.length)} suggestion(s) exceeded GitHub's ~10-line inline suggestion limit and were omitted from the inline comments; the affected findings remain in the review.
|
|
2204
|
+
` : "";
|
|
2205
|
+
const renderBody = (inlineDisposition, reviewUrl2, straysOverride, unanchoredCount2) => formatMarkdown(
|
|
2206
|
+
render({
|
|
2207
|
+
...commonRenderInput,
|
|
2208
|
+
...straysOverride ? { strays: straysOverride } : {},
|
|
2209
|
+
...unanchoredCount2 !== void 0 ? { unanchoredCount: unanchoredCount2 } : {},
|
|
2210
|
+
inlineDisposition,
|
|
2211
|
+
reviewUrl: reviewUrl2
|
|
2212
|
+
}) + longFilesNote
|
|
2213
|
+
);
|
|
2214
|
+
const stickyRef = await upsertSticky(
|
|
2215
|
+
input.repo,
|
|
2216
|
+
prNumber,
|
|
2217
|
+
existingSticky,
|
|
2218
|
+
renderBody(initialDisposition),
|
|
2219
|
+
ghApi
|
|
2220
|
+
);
|
|
2221
|
+
const priorInlineComments = await listPriorBotCommentIds(
|
|
2222
|
+
input.repo,
|
|
2223
|
+
prNumber,
|
|
2224
|
+
input.botLogin,
|
|
2225
|
+
ghApi
|
|
2226
|
+
);
|
|
2227
|
+
const {
|
|
2228
|
+
url: reviewUrl,
|
|
2229
|
+
inlinePosted,
|
|
2230
|
+
unposted
|
|
2231
|
+
} = await postInlineReview(
|
|
2232
|
+
input.repo,
|
|
2233
|
+
prNumber,
|
|
2234
|
+
input.headSha,
|
|
2235
|
+
comments,
|
|
2236
|
+
inDiff,
|
|
2237
|
+
stickyRef?.url,
|
|
2238
|
+
findingsMarker,
|
|
2239
|
+
ghApi
|
|
2240
|
+
);
|
|
2241
|
+
process.stderr.write(
|
|
2242
|
+
`Posted a review with ${String(inlinePosted)} inline comment(s) on PR #${String(prNumber)}
|
|
2243
|
+
`
|
|
2244
|
+
);
|
|
2245
|
+
const priorReviewIds = botReviews.map((r) => r.id);
|
|
2246
|
+
if (priorReviewIds.length > 0) {
|
|
2247
|
+
await dismissReviews(input.repo, prNumber, priorReviewIds, ghApi);
|
|
802
2248
|
}
|
|
803
|
-
|
|
804
|
-
|
|
2249
|
+
await minimizeComments(prNumber, priorInlineComments, ghApi);
|
|
2250
|
+
const unanchoredCount = unposted.length;
|
|
2251
|
+
const finalStrays = unanchoredCount > 0 ? [...unposted, ...strays] : strays;
|
|
2252
|
+
if (stickyRef !== null && (inlinePosted > 0 || unanchoredCount > 0)) {
|
|
2253
|
+
const finalDisposition = inlinePosted > 0 ? { kind: "posted", count: inlinePosted, sha: input.headSha } : { kind: "inline-unavailable" };
|
|
2254
|
+
try {
|
|
2255
|
+
await patchComment(
|
|
2256
|
+
input.repo,
|
|
2257
|
+
stickyRef.id,
|
|
2258
|
+
renderBody(finalDisposition, reviewUrl, finalStrays, unanchoredCount),
|
|
2259
|
+
ghApi
|
|
2260
|
+
);
|
|
2261
|
+
process.stderr.write(
|
|
2262
|
+
`Updated sticky comment #${String(stickyRef.id)} to reflect the review
|
|
2263
|
+
`
|
|
2264
|
+
);
|
|
2265
|
+
} catch (err) {
|
|
2266
|
+
process.stderr.write(
|
|
2267
|
+
`Warning: failed to update the sticky summary after the review: ${errMsg(err)}
|
|
2268
|
+
`
|
|
2269
|
+
);
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
};
|
|
2273
|
+
var noticeBody = (lead, existingBody) => {
|
|
2274
|
+
const carried = existingBody ? carryForwardMarkers(existingBody) : "";
|
|
2275
|
+
return carried ? `${lead}
|
|
2276
|
+
|
|
2277
|
+
${carried}` : lead;
|
|
2278
|
+
};
|
|
2279
|
+
var bodyRefsRun = (body, runUrl) => {
|
|
2280
|
+
const runId = runIdFromUrl(runUrl);
|
|
2281
|
+
return runId === null ? body.includes(runUrl) : new RegExp(`/actions/runs/${runId}(?!\\d)`).test(body);
|
|
2282
|
+
};
|
|
2283
|
+
var announceBody = (headSha, runUrl, existingBody) => noticeBody(
|
|
2284
|
+
`${DEFAULT_MARKER}
|
|
2285
|
+
|
|
2286
|
+
\u{1F504} **Code review in progress** for \`${headSha.slice(0, 7)}\` \u2014 see the [workflow run](${runUrl}) for progress; this comment is updated with the review when it completes.`,
|
|
2287
|
+
existingBody
|
|
2288
|
+
);
|
|
2289
|
+
var announce = async (input, ghApi = runGhApi) => {
|
|
2290
|
+
const candidates = await fetchPrCandidates(input.repo, input.headSha, ghApi);
|
|
2291
|
+
const resolution = resolvePr(candidates, input.headBranch);
|
|
2292
|
+
if (resolution.kind !== "open") {
|
|
2293
|
+
process.stderr.write(
|
|
2294
|
+
`No open PR for ${input.headSha} \u2014 nothing to announce (${resolution.kind})
|
|
2295
|
+
`
|
|
2296
|
+
);
|
|
2297
|
+
return;
|
|
805
2298
|
}
|
|
806
|
-
|
|
807
|
-
|
|
2299
|
+
const existing = await findBotComment(
|
|
2300
|
+
input.repo,
|
|
2301
|
+
resolution.prNumber,
|
|
2302
|
+
input.botLogin,
|
|
2303
|
+
DEFAULT_MARKER,
|
|
2304
|
+
ghApi
|
|
2305
|
+
);
|
|
2306
|
+
if (existing !== null && parseReviewComplete(existing.body) && parseReviewedSha(existing.body) === input.headSha.toLowerCase()) {
|
|
808
2307
|
process.stderr.write(
|
|
809
|
-
`
|
|
2308
|
+
`Sticky already reflects a completed review of ${input.headSha} \u2014 leaving it in place
|
|
810
2309
|
`
|
|
811
2310
|
);
|
|
812
2311
|
return;
|
|
813
2312
|
}
|
|
814
|
-
|
|
815
|
-
|
|
2313
|
+
await upsertSticky(
|
|
2314
|
+
input.repo,
|
|
2315
|
+
resolution.prNumber,
|
|
2316
|
+
existing,
|
|
2317
|
+
announceBody(input.headSha, input.runUrl, existing?.body),
|
|
2318
|
+
ghApi
|
|
2319
|
+
);
|
|
2320
|
+
};
|
|
2321
|
+
var incompleteBody = (headSha, runUrl, existingBody) => noticeBody(
|
|
2322
|
+
`${DEFAULT_MARKER}
|
|
2323
|
+
|
|
2324
|
+
\u26A0\uFE0F **Code review did not complete** for \`${headSha.slice(0, 7)}\` \u2014 the review job failed ([run](${runUrl})). Re-request the review; do not treat this round as spent.`,
|
|
2325
|
+
existingBody
|
|
2326
|
+
);
|
|
2327
|
+
var cancelledBody = (headSha, runUrl, existingBody) => noticeBody(
|
|
2328
|
+
`${DEFAULT_MARKER}
|
|
2329
|
+
|
|
2330
|
+
\u21A9\uFE0F **Code review superseded** for \`${headSha.slice(0, 7)}\` \u2014 this run was cancelled before completing, typically because a newer review run started on this branch. No action needed. [View the cancelled run](${runUrl}) for the record.`,
|
|
2331
|
+
existingBody
|
|
2332
|
+
);
|
|
2333
|
+
var reportIncomplete = async (input, ghApi = runGhApi) => {
|
|
2334
|
+
const candidates = await fetchPrCandidates(input.repo, input.headSha, ghApi);
|
|
2335
|
+
const resolution = resolvePr(candidates, input.headBranch);
|
|
2336
|
+
if (resolution.kind !== "open") {
|
|
816
2337
|
process.stderr.write(
|
|
817
|
-
`
|
|
2338
|
+
`No open PR for ${input.headSha} \u2014 nothing to report (${resolution.kind})
|
|
818
2339
|
`
|
|
819
2340
|
);
|
|
2341
|
+
return;
|
|
2342
|
+
}
|
|
2343
|
+
const existing = await findBotComment(
|
|
2344
|
+
input.repo,
|
|
2345
|
+
resolution.prNumber,
|
|
2346
|
+
input.botLogin,
|
|
2347
|
+
DEFAULT_MARKER,
|
|
2348
|
+
ghApi
|
|
2349
|
+
);
|
|
2350
|
+
if (existing !== null && parseReviewComplete(existing.body)) {
|
|
2351
|
+
process.stderr.write(`Sticky already reflects a completed review \u2014 leaving it in place
|
|
2352
|
+
`);
|
|
2353
|
+
return;
|
|
2354
|
+
}
|
|
2355
|
+
if (existing !== null && !bodyRefsRun(existing.body, input.runUrl)) {
|
|
2356
|
+
process.stderr.write(`Sticky belongs to another run \u2014 leaving it in place
|
|
2357
|
+
`);
|
|
2358
|
+
return;
|
|
2359
|
+
}
|
|
2360
|
+
if (input.cancelled && existing === null) {
|
|
2361
|
+
process.stderr.write(`Cancelled review has no sticky to supersede \u2014 leaving it absent
|
|
2362
|
+
`);
|
|
2363
|
+
return;
|
|
2364
|
+
}
|
|
2365
|
+
await upsertSticky(
|
|
2366
|
+
input.repo,
|
|
2367
|
+
resolution.prNumber,
|
|
2368
|
+
existing,
|
|
2369
|
+
input.cancelled ? cancelledBody(input.headSha, input.runUrl, existing?.body) : incompleteBody(input.headSha, input.runUrl, existing?.body),
|
|
2370
|
+
ghApi
|
|
2371
|
+
);
|
|
2372
|
+
};
|
|
2373
|
+
var DURATION_RE = /^(\d+)(h|m|s)$/;
|
|
2374
|
+
var USD_RE = /^\$(\d+(?:\.\d+)?)$/;
|
|
2375
|
+
var toSeconds = (n, unit) => unit === "h" ? n * 3600 : unit === "m" ? n * 60 : n;
|
|
2376
|
+
var stripTrigger = (body, trigger) => {
|
|
2377
|
+
const trimmed = body.replace(/^\s+/, "");
|
|
2378
|
+
if (!trimmed.startsWith(trigger)) return null;
|
|
2379
|
+
const after = trimmed.slice(trigger.length);
|
|
2380
|
+
return after === "" || /^\s/.test(after) ? after : null;
|
|
2381
|
+
};
|
|
2382
|
+
var scanLeading = (s, acc) => {
|
|
2383
|
+
const m = /^(\s*)(\S+)([\s\S]*)$/.exec(s);
|
|
2384
|
+
if (m === null) return { ...acc, rest: "" };
|
|
2385
|
+
const [, , token = "", tail = ""] = m;
|
|
2386
|
+
const dm = DURATION_RE.exec(token);
|
|
2387
|
+
if (dm && acc.durationSec === null)
|
|
2388
|
+
return scanLeading(tail, {
|
|
2389
|
+
...acc,
|
|
2390
|
+
durationSec: toSeconds(Number.parseInt(dm[1] ?? "", 10), dm[2] ?? "s")
|
|
2391
|
+
});
|
|
2392
|
+
const um = USD_RE.exec(token);
|
|
2393
|
+
if (um && acc.usd === null)
|
|
2394
|
+
return scanLeading(tail, { ...acc, usd: Number.parseFloat(um[1] ?? "") });
|
|
2395
|
+
return { ...acc, rest: s };
|
|
2396
|
+
};
|
|
2397
|
+
var clampDuration = (requested, ceiling) => requested !== null && ceiling !== null && requested > ceiling ? {
|
|
2398
|
+
value: ceiling,
|
|
2399
|
+
notes: [
|
|
2400
|
+
`requested duration ${String(requested)}s exceeds the ${String(ceiling)}s ceiling \u2014 clamped to ${String(ceiling)}s`
|
|
2401
|
+
]
|
|
2402
|
+
} : { value: requested, notes: [] };
|
|
2403
|
+
var clampUsd = (requested, ceiling) => requested !== null && ceiling !== null && requested > ceiling ? {
|
|
2404
|
+
value: ceiling,
|
|
2405
|
+
notes: [
|
|
2406
|
+
`requested $${requested.toFixed(2)} exceeds the $${ceiling.toFixed(2)} ceiling \u2014 clamped to $${ceiling.toFixed(2)}`
|
|
2407
|
+
]
|
|
2408
|
+
} : { value: requested, notes: [] };
|
|
2409
|
+
var capInstructions = (text, maxLen) => text.length > maxLen ? {
|
|
2410
|
+
value: text.slice(0, maxLen),
|
|
2411
|
+
notes: [
|
|
2412
|
+
`instructions truncated from ${String(text.length)} to ${String(maxLen)} characters`
|
|
2413
|
+
]
|
|
2414
|
+
} : { value: text, notes: [] };
|
|
2415
|
+
var parseCommandArgs = (body, options) => {
|
|
2416
|
+
const afterTrigger = stripTrigger(body, options.trigger);
|
|
2417
|
+
if (afterTrigger === null) return { kind: "not-a-command" };
|
|
2418
|
+
const scan = scanLeading(afterTrigger, { durationSec: null, usd: null });
|
|
2419
|
+
const duration = clampDuration(scan.durationSec, options.maxDurationSec);
|
|
2420
|
+
const usd = clampUsd(scan.usd, options.maxUsd);
|
|
2421
|
+
const instructions = capInstructions(scan.rest.trim(), options.maxInstructionsLen);
|
|
2422
|
+
return {
|
|
2423
|
+
kind: "command",
|
|
2424
|
+
args: {
|
|
2425
|
+
durationSec: duration.value,
|
|
2426
|
+
usd: usd.value,
|
|
2427
|
+
instructions: instructions.value,
|
|
2428
|
+
notes: [...duration.notes, ...usd.notes, ...instructions.notes]
|
|
2429
|
+
}
|
|
2430
|
+
};
|
|
2431
|
+
};
|
|
2432
|
+
var PrHeadCodec = t.type({
|
|
2433
|
+
head_sha: t.string,
|
|
2434
|
+
head_ref: t.string,
|
|
2435
|
+
head_repo: t.union([t.string, t.null]),
|
|
2436
|
+
state: t.string
|
|
2437
|
+
});
|
|
2438
|
+
var resolvePrHead = async (repo, prNumber, ghApi) => {
|
|
2439
|
+
const stdout = await ghApi([
|
|
2440
|
+
`repos/${repo}/pulls/${String(prNumber)}`,
|
|
2441
|
+
"--jq",
|
|
2442
|
+
"{head_sha: .head.sha, head_ref: .head.ref, head_repo: .head.repo.full_name, state: .state}"
|
|
2443
|
+
]);
|
|
2444
|
+
const decoded = PrHeadCodec.decode(JSON.parse(stdout));
|
|
2445
|
+
if (decoded._tag === "Left") {
|
|
2446
|
+
throw new Error(`PR head for #${String(prNumber)} did not match the expected shape`);
|
|
2447
|
+
}
|
|
2448
|
+
return decoded.right;
|
|
2449
|
+
};
|
|
2450
|
+
var parseCommand = async (input, ghApi = runGhApi) => {
|
|
2451
|
+
const parse = parseCommandArgs(input.body, input.options);
|
|
2452
|
+
if (parse.kind === "not-a-command") {
|
|
2453
|
+
return {
|
|
2454
|
+
kind: "skip",
|
|
2455
|
+
reason: `comment does not begin with the trigger "${input.options.trigger}"`
|
|
2456
|
+
};
|
|
2457
|
+
}
|
|
2458
|
+
const head = await resolvePrHead(input.repo, input.prNumber, ghApi).catch(
|
|
2459
|
+
(err) => err instanceof Error ? err : new Error(String(err))
|
|
2460
|
+
);
|
|
2461
|
+
if (head instanceof Error) {
|
|
2462
|
+
return {
|
|
2463
|
+
kind: "skip",
|
|
2464
|
+
reason: `could not resolve PR #${String(input.prNumber)}: ${head.message}`
|
|
2465
|
+
};
|
|
2466
|
+
}
|
|
2467
|
+
if (head.state !== "open") {
|
|
2468
|
+
return {
|
|
2469
|
+
kind: "skip",
|
|
2470
|
+
reason: `PR #${String(input.prNumber)} is not open (state: ${head.state})`
|
|
2471
|
+
};
|
|
820
2472
|
}
|
|
2473
|
+
return {
|
|
2474
|
+
kind: "run",
|
|
2475
|
+
headSha: head.head_sha,
|
|
2476
|
+
headBranch: head.head_ref,
|
|
2477
|
+
headRepo: head.head_repo ?? input.repo,
|
|
2478
|
+
args: parse.args
|
|
2479
|
+
};
|
|
2480
|
+
};
|
|
2481
|
+
var safeHeredocDelim = (instructions, randomHex, attemptsLeft = 8) => {
|
|
2482
|
+
const candidate = `GHOUT_${randomHex()}`;
|
|
2483
|
+
if (!instructions.split("\n").includes(candidate)) return candidate;
|
|
2484
|
+
if (attemptsLeft <= 0) throw new Error("could not derive a collision-free heredoc delimiter");
|
|
2485
|
+
return safeHeredocDelim(instructions, randomHex, attemptsLeft - 1);
|
|
2486
|
+
};
|
|
2487
|
+
var renderCommandOutputs = (result, delim) => {
|
|
2488
|
+
if (result.kind === "skip") return "should_run=false\n";
|
|
2489
|
+
const { headSha, headBranch, headRepo, args } = result;
|
|
2490
|
+
return `${[
|
|
2491
|
+
"should_run=true",
|
|
2492
|
+
`head_sha=${headSha}`,
|
|
2493
|
+
`head_branch=${headBranch}`,
|
|
2494
|
+
`head_repo=${headRepo}`,
|
|
2495
|
+
`duration=${args.durationSec === null ? "" : `${String(args.durationSec)}s`}`,
|
|
2496
|
+
`usd=${args.usd === null ? "" : args.usd.toFixed(2)}`,
|
|
2497
|
+
`instructions<<${delim}`,
|
|
2498
|
+
args.instructions,
|
|
2499
|
+
delim
|
|
2500
|
+
].join("\n")}
|
|
2501
|
+
`;
|
|
821
2502
|
};
|
|
2503
|
+
var REACTIONS = [
|
|
2504
|
+
"+1",
|
|
2505
|
+
"-1",
|
|
2506
|
+
"laugh",
|
|
2507
|
+
"confused",
|
|
2508
|
+
"heart",
|
|
2509
|
+
"hooray",
|
|
2510
|
+
"rocket",
|
|
2511
|
+
"eyes"
|
|
2512
|
+
];
|
|
2513
|
+
var isReaction = (s) => REACTIONS.includes(s);
|
|
2514
|
+
var ReactionCodec = t.type({ id: t.number, content: t.string });
|
|
2515
|
+
var reactionsPath = (repo, commentId) => `repos/${repo}/issues/comments/${String(commentId)}/reactions`;
|
|
2516
|
+
var removeReactions = async (repo, commentId, content, ghApi) => {
|
|
2517
|
+
const stdout = await ghApi([
|
|
2518
|
+
reactionsPath(repo, commentId),
|
|
2519
|
+
"--paginate",
|
|
2520
|
+
"--jq",
|
|
2521
|
+
".[] | {id, content}"
|
|
2522
|
+
]);
|
|
2523
|
+
for (const line of stdout.split("\n").filter((l) => l.trim() !== "")) {
|
|
2524
|
+
const parsed = tryParseJson(line);
|
|
2525
|
+
const decoded = parsed.ok ? ReactionCodec.decode(parsed.value) : void 0;
|
|
2526
|
+
if (decoded === void 0 || decoded._tag === "Left") {
|
|
2527
|
+
process.stderr.write("code-review react: could not decode a reaction entry \u2014 skipping\n");
|
|
2528
|
+
continue;
|
|
2529
|
+
}
|
|
2530
|
+
if (decoded.right.content !== content) continue;
|
|
2531
|
+
await ghApi([
|
|
2532
|
+
"--method",
|
|
2533
|
+
"DELETE",
|
|
2534
|
+
`${reactionsPath(repo, commentId)}/${String(decoded.right.id)}`
|
|
2535
|
+
]).catch(
|
|
2536
|
+
(err) => process.stderr.write(
|
|
2537
|
+
`code-review react: could not remove reaction ${String(decoded.right.id)} (${errMsg(err)}) \u2014 skipping
|
|
2538
|
+
`
|
|
2539
|
+
)
|
|
2540
|
+
);
|
|
2541
|
+
}
|
|
2542
|
+
};
|
|
2543
|
+
var react = async (input, ghApi = runGhApi) => {
|
|
2544
|
+
if (input.add !== void 0) {
|
|
2545
|
+
await ghApi([
|
|
2546
|
+
"--method",
|
|
2547
|
+
"POST",
|
|
2548
|
+
reactionsPath(input.repo, input.commentId),
|
|
2549
|
+
"-f",
|
|
2550
|
+
`content=${input.add}`
|
|
2551
|
+
]);
|
|
2552
|
+
}
|
|
2553
|
+
if (input.remove !== void 0) {
|
|
2554
|
+
await removeReactions(input.repo, input.commentId, input.remove, ghApi);
|
|
2555
|
+
}
|
|
2556
|
+
};
|
|
2557
|
+
var RunCodec = t.type({
|
|
2558
|
+
id: t.number,
|
|
2559
|
+
name: t.union([t.string, t.null]),
|
|
2560
|
+
status: t.union([t.string, t.null]),
|
|
2561
|
+
conclusion: t.union([t.string, t.null]),
|
|
2562
|
+
run_number: t.number
|
|
2563
|
+
});
|
|
2564
|
+
var RUN_JQ = ".workflow_runs[] | {id: .id, name: .name, status: .status, conclusion: .conclusion, run_number: .run_number}";
|
|
2565
|
+
var resolveCiRun = async (repo, headSha, workflowName, ghApi) => {
|
|
2566
|
+
const endpoint = `repos/${repo}/actions/runs?head_sha=${headSha}&per_page=100`;
|
|
2567
|
+
const rows = parseJsonl(await ghApi([endpoint, "--paginate", "--jq", RUN_JQ]));
|
|
2568
|
+
const decoded = rows.map((row) => RunCodec.decode(row));
|
|
2569
|
+
const runs = decoded.flatMap((d) => d._tag === "Right" ? [d.right] : []);
|
|
2570
|
+
const dropped = decoded.length - runs.length;
|
|
2571
|
+
if (dropped > 0) {
|
|
2572
|
+
const firstDrift = decoded.find((d) => d._tag === "Left");
|
|
2573
|
+
const detail = firstDrift === void 0 ? "" : ` (${PathReporter.report(firstDrift).join("; ")})`;
|
|
2574
|
+
process.stderr.write(
|
|
2575
|
+
`Warning: ${String(dropped)} of ${String(rows.length)} workflow-run row(s) from ${endpoint} failed to decode${detail} \u2014 excluded from the lookup
|
|
2576
|
+
`
|
|
2577
|
+
);
|
|
2578
|
+
}
|
|
2579
|
+
const latest = runs.filter((r) => r.name === workflowName).reduce(
|
|
2580
|
+
(best, r) => best === null || r.run_number > best.run_number ? r : best,
|
|
2581
|
+
null
|
|
2582
|
+
);
|
|
2583
|
+
return {
|
|
2584
|
+
run: latest === null ? null : { id: latest.id, status: latest.status ?? "unknown", conclusion: latest.conclusion },
|
|
2585
|
+
seenNames: [...new Set(runs.flatMap((r) => r.name === null ? [] : [r.name]))]
|
|
2586
|
+
};
|
|
2587
|
+
};
|
|
2588
|
+
var awaitCiConclusion = async (repo, headSha, options, deps = { ghApi: runGhApi, sleep: defaultSleep, elapsedMs: monotonicElapsed() }) => {
|
|
2589
|
+
const safeResolve = async () => {
|
|
2590
|
+
try {
|
|
2591
|
+
return await resolveCiRun(repo, headSha, options.workflowName, deps.ghApi);
|
|
2592
|
+
} catch (err) {
|
|
2593
|
+
process.stderr.write(
|
|
2594
|
+
`Warning: CI-run lookup for ${headSha} failed (${errMsg(err)}) \u2014 retrying until the timeout
|
|
2595
|
+
`
|
|
2596
|
+
);
|
|
2597
|
+
return { run: null, seenNames: [] };
|
|
2598
|
+
}
|
|
2599
|
+
};
|
|
2600
|
+
const poll = async (lastSeenNames, lastRunId) => {
|
|
2601
|
+
const { run, seenNames } = await safeResolve();
|
|
2602
|
+
if (run !== null && run.status === "completed" && run.conclusion !== null)
|
|
2603
|
+
return { kind: "concluded", conclusion: run.conclusion, runId: run.id };
|
|
2604
|
+
const runId = run === null ? lastRunId : run.id;
|
|
2605
|
+
const names = seenNames.length > 0 ? seenNames : lastSeenNames;
|
|
2606
|
+
if (deps.elapsedMs() >= options.timeoutMs)
|
|
2607
|
+
return { kind: "timed-out", runId, seenNames: names };
|
|
2608
|
+
await deps.sleep(options.pollIntervalMs);
|
|
2609
|
+
return poll(names, runId);
|
|
2610
|
+
};
|
|
2611
|
+
return poll([], null);
|
|
2612
|
+
};
|
|
2613
|
+
var defaultSleep = (ms) => new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
|
|
2614
|
+
var monotonicElapsed = () => {
|
|
2615
|
+
const start = performance.now();
|
|
2616
|
+
return () => performance.now() - start;
|
|
2617
|
+
};
|
|
2618
|
+
var renderCiOutputs = (outcome) => outcome.kind === "concluded" ? `ci_settled=true
|
|
2619
|
+
ci_conclusion=${outcome.conclusion}
|
|
2620
|
+
ci_run_id=${String(outcome.runId)}
|
|
2621
|
+
` : `ci_settled=false
|
|
2622
|
+
ci_run_id=${outcome.runId === null ? "" : String(outcome.runId)}
|
|
2623
|
+
`;
|
|
822
2624
|
var renderOutputs = (result) => {
|
|
823
2625
|
switch (result.kind) {
|
|
824
2626
|
case "skip":
|
|
@@ -827,44 +2629,41 @@ var renderOutputs = (result) => {
|
|
|
827
2629
|
return `pr=${String(result.pr)}
|
|
828
2630
|
conclusion=${result.conclusion}
|
|
829
2631
|
diff_size=${String(result.diffSize)}
|
|
2632
|
+
stacked=${String(result.stacked)}
|
|
830
2633
|
`;
|
|
831
2634
|
}
|
|
832
2635
|
};
|
|
833
|
-
var runGit = (args) =>
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
(err, stdout, stderr) => {
|
|
839
|
-
if (err) {
|
|
840
|
-
const stderrStr = typeof stderr === "string" && stderr.trim() ? stderr.trim() : "";
|
|
841
|
-
const errStr = err instanceof Error ? err.message : "unknown error";
|
|
842
|
-
reject(new Error(`git ${args.join(" ")} failed: ${stderrStr || errStr}`));
|
|
843
|
-
} else {
|
|
844
|
-
resolve3(stdout);
|
|
845
|
-
}
|
|
846
|
-
}
|
|
847
|
-
);
|
|
2636
|
+
var runGit = (args) => execFileWithTimeout({
|
|
2637
|
+
command: "git",
|
|
2638
|
+
args,
|
|
2639
|
+
label: `git ${args.join(" ")}`,
|
|
2640
|
+
timeoutMs: subprocessTimeoutMs()
|
|
848
2641
|
});
|
|
849
2642
|
var PrMetaCodec = t.type({
|
|
850
2643
|
changed_files: t.number,
|
|
851
2644
|
base_sha: t.string,
|
|
2645
|
+
base_ref: t.string,
|
|
852
2646
|
title: t.string,
|
|
853
2647
|
body: t.union([t.string, t.null])
|
|
854
2648
|
});
|
|
855
|
-
var IssueCommentCodec = t.
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
})
|
|
860
|
-
|
|
2649
|
+
var IssueCommentCodec = t.intersection([
|
|
2650
|
+
t.type({
|
|
2651
|
+
id: t.number,
|
|
2652
|
+
body: t.union([t.string, t.null]),
|
|
2653
|
+
user: t.type({ login: t.string })
|
|
2654
|
+
}),
|
|
2655
|
+
t.partial({
|
|
2656
|
+
created_at: t.union([t.string, t.null]),
|
|
2657
|
+
author_association: t.union([t.string, t.null])
|
|
2658
|
+
})
|
|
2659
|
+
]);
|
|
861
2660
|
var JobCodec = t.type({ id: t.number, conclusion: t.union([t.string, t.null]) });
|
|
862
2661
|
var JobsResponseCodec = t.type({ jobs: t.array(JobCodec) });
|
|
863
2662
|
var fetchPrMeta = async (repo, prNumber, ghApi) => {
|
|
864
2663
|
const stdout = await ghApi([
|
|
865
2664
|
`repos/${repo}/pulls/${String(prNumber)}`,
|
|
866
2665
|
"--jq",
|
|
867
|
-
"{changed_files: .changed_files, base_sha: .base.sha, title: .title, body: .body}"
|
|
2666
|
+
"{changed_files: .changed_files, base_sha: .base.sha, base_ref: .base.ref, title: .title, body: .body}"
|
|
868
2667
|
]);
|
|
869
2668
|
const decoded = PrMetaCodec.decode(JSON.parse(stdout));
|
|
870
2669
|
if (decoded._tag === "Left") {
|
|
@@ -879,18 +2678,139 @@ var fetchApiDiff = async (repo, prNumber, ghApi) => {
|
|
|
879
2678
|
return null;
|
|
880
2679
|
}
|
|
881
2680
|
};
|
|
882
|
-
var
|
|
2681
|
+
var fetchFullDiff = async (repo, defaultBranch, headSha, ghApi, gitRun) => {
|
|
883
2682
|
try {
|
|
884
|
-
const
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
2683
|
+
const diff = await ghApi([
|
|
2684
|
+
`repos/${repo}/compare/${defaultBranch}...${headSha}`,
|
|
2685
|
+
"-H",
|
|
2686
|
+
"Accept: application/vnd.github.v3.diff"
|
|
2687
|
+
]);
|
|
2688
|
+
if (diff.length > 0) return diff;
|
|
2689
|
+
process.stderr.write(
|
|
2690
|
+
`compare diff for ${defaultBranch}...${headSha} was empty \u2014 falling back to git diff
|
|
2691
|
+
`
|
|
2692
|
+
);
|
|
2693
|
+
} catch (err) {
|
|
2694
|
+
process.stderr.write(`compare diff fetch failed (${errMsg(err)}) \u2014 falling back to git diff
|
|
2695
|
+
`);
|
|
2696
|
+
}
|
|
2697
|
+
await gitRun(["fetch", "origin", headSha]);
|
|
2698
|
+
const base = (await gitRun(["rev-parse", "HEAD"])).trim();
|
|
2699
|
+
return gitRun(["diff", `${base}...${headSha}`]);
|
|
2700
|
+
};
|
|
2701
|
+
var CommitCodec = t.type({
|
|
2702
|
+
sha: t.string,
|
|
2703
|
+
message: t.string,
|
|
2704
|
+
author: t.union([t.string, t.null]),
|
|
2705
|
+
email: t.union([t.string, t.null])
|
|
2706
|
+
});
|
|
2707
|
+
var COMMIT_JQ = ".commits[] | {sha: .sha, message: .commit.message, author: .commit.author.name, email: .commit.author.email}";
|
|
2708
|
+
var fetchCompareCommits = async (repo, defaultBranch, headSha, ghApi) => {
|
|
2709
|
+
const rows = parseJsonl(
|
|
2710
|
+
await ghApi([
|
|
2711
|
+
`repos/${repo}/compare/${defaultBranch}...${headSha}`,
|
|
2712
|
+
"--paginate",
|
|
2713
|
+
"--jq",
|
|
2714
|
+
COMMIT_JQ
|
|
2715
|
+
])
|
|
2716
|
+
);
|
|
2717
|
+
const decoded = rows.map((row) => CommitCodec.decode(row));
|
|
2718
|
+
const commits = decoded.flatMap((d) => d._tag === "Right" ? [d.right] : []);
|
|
2719
|
+
const dropped = decoded.length - commits.length;
|
|
2720
|
+
if (dropped > 0) {
|
|
2721
|
+
process.stderr.write(
|
|
2722
|
+
`Warning: ${String(dropped)} of ${String(rows.length)} commit row(s) failed to decode \u2014 their messages are NOT in the triage scan though a checkout's git log still exposes them
|
|
2723
|
+
`
|
|
2724
|
+
);
|
|
2725
|
+
}
|
|
2726
|
+
return commits;
|
|
2727
|
+
};
|
|
2728
|
+
var COMMENT_JQ = ".[] | {id: .id, body: .body, user: {login: .user.login}, created_at: .created_at, author_association: .author_association}";
|
|
2729
|
+
var REVIEW_COMMENT_JQ = ".[] | {body: .body, user: {login: .user.login}, created_at: .created_at, author_association: .author_association, path: .path, line: .line}";
|
|
2730
|
+
var REVIEW_JQ = ".[] | {body: .body, user: {login: .user.login}, submitted_at: .submitted_at, author_association: .author_association, state: .state}";
|
|
2731
|
+
var ReviewCommentCodec = t.intersection([
|
|
2732
|
+
t.type({ body: t.union([t.string, t.null]), user: t.type({ login: t.string }) }),
|
|
2733
|
+
t.partial({
|
|
2734
|
+
created_at: t.union([t.string, t.null]),
|
|
2735
|
+
author_association: t.union([t.string, t.null]),
|
|
2736
|
+
path: t.union([t.string, t.null]),
|
|
2737
|
+
line: t.union([t.number, t.null])
|
|
2738
|
+
})
|
|
2739
|
+
]);
|
|
2740
|
+
var ReviewCodec = t.intersection([
|
|
2741
|
+
t.type({ body: t.union([t.string, t.null]), user: t.type({ login: t.string }) }),
|
|
2742
|
+
t.partial({
|
|
2743
|
+
submitted_at: t.union([t.string, t.null]),
|
|
2744
|
+
author_association: t.union([t.string, t.null]),
|
|
2745
|
+
state: t.union([t.string, t.null])
|
|
2746
|
+
})
|
|
2747
|
+
]);
|
|
2748
|
+
var fetchJsonlRows = async (ghApi, endpoint, jq) => {
|
|
2749
|
+
try {
|
|
2750
|
+
return parseJsonl(await ghApi([endpoint, "--paginate", "--jq", jq]));
|
|
2751
|
+
} catch (err) {
|
|
2752
|
+
process.stderr.write(
|
|
2753
|
+
`Warning: could not fetch ${endpoint} (${errMsg(err)}) \u2014 omitting it from the review context
|
|
2754
|
+
`
|
|
2755
|
+
);
|
|
891
2756
|
return null;
|
|
892
2757
|
}
|
|
893
2758
|
};
|
|
2759
|
+
var decodeArrayOrNull = (codec, rows) => {
|
|
2760
|
+
if (rows === null) return null;
|
|
2761
|
+
return rows.flatMap((row) => {
|
|
2762
|
+
const decoded = codec.decode(row);
|
|
2763
|
+
return decoded._tag === "Right" ? [decoded.right] : [];
|
|
2764
|
+
});
|
|
2765
|
+
};
|
|
2766
|
+
var priorReviewFrom = (comments, botLogin) => {
|
|
2767
|
+
const byBot = comments.filter((c) => c.user.login === botLogin);
|
|
2768
|
+
const last = byBot[byBot.length - 1];
|
|
2769
|
+
return last ? { id: last.id, body: last.body } : null;
|
|
2770
|
+
};
|
|
2771
|
+
var MAX_CONVERSATION_COMMENTS = 50;
|
|
2772
|
+
var MAX_CONVERSATION_BODY_CHARS = 4e3;
|
|
2773
|
+
var clip = (body) => {
|
|
2774
|
+
if (body.length <= MAX_CONVERSATION_BODY_CHARS) return body;
|
|
2775
|
+
const cut = body.slice(0, MAX_CONVERSATION_BODY_CHARS);
|
|
2776
|
+
const safe = /[\uD800-\uDBFF]$/.test(cut) ? cut.slice(0, -1) : cut;
|
|
2777
|
+
return `${safe}
|
|
2778
|
+
\u2026 [truncated]`;
|
|
2779
|
+
};
|
|
2780
|
+
var boundedHuman = (items, botLogin, label, project) => {
|
|
2781
|
+
const human = items.filter(
|
|
2782
|
+
(a) => a.user.login !== botLogin && typeof a.body === "string" && a.body.trim() !== ""
|
|
2783
|
+
);
|
|
2784
|
+
const kept = human.slice(-MAX_CONVERSATION_COMMENTS);
|
|
2785
|
+
if (kept.length < human.length) {
|
|
2786
|
+
process.stderr.write(
|
|
2787
|
+
`Note: PR has ${String(human.length)} ${label} \u2014 feeding the review the most recent ${String(MAX_CONVERSATION_COMMENTS)}
|
|
2788
|
+
`
|
|
2789
|
+
);
|
|
2790
|
+
}
|
|
2791
|
+
return kept.map(project);
|
|
2792
|
+
};
|
|
2793
|
+
var issueCommentsFrom = (comments, botLogin) => boundedHuman(comments, botLogin, "discussion comments", (c) => ({
|
|
2794
|
+
author: c.user.login,
|
|
2795
|
+
author_association: c.author_association ?? null,
|
|
2796
|
+
created_at: c.created_at ?? null,
|
|
2797
|
+
body: clip(c.body)
|
|
2798
|
+
}));
|
|
2799
|
+
var reviewCommentsFrom = (comments, botLogin) => boundedHuman(comments, botLogin, "inline review comments", (c) => ({
|
|
2800
|
+
author: c.user.login,
|
|
2801
|
+
author_association: c.author_association ?? null,
|
|
2802
|
+
created_at: c.created_at ?? null,
|
|
2803
|
+
path: c.path ?? null,
|
|
2804
|
+
line: c.line ?? null,
|
|
2805
|
+
body: clip(c.body)
|
|
2806
|
+
}));
|
|
2807
|
+
var reviewsFrom = (reviews, botLogin) => boundedHuman(reviews, botLogin, "review submissions", (r) => ({
|
|
2808
|
+
author: r.user.login,
|
|
2809
|
+
author_association: r.author_association ?? null,
|
|
2810
|
+
submitted_at: r.submitted_at ?? null,
|
|
2811
|
+
state: r.state ?? null,
|
|
2812
|
+
body: clip(r.body)
|
|
2813
|
+
}));
|
|
894
2814
|
var downloadFailingJobLogs = async (repo, runId, outDir, ghApi) => {
|
|
895
2815
|
const stdout = await ghApi([`repos/${repo}/actions/runs/${runId}/jobs`]);
|
|
896
2816
|
const decoded = JobsResponseCodec.decode(JSON.parse(stdout));
|
|
@@ -903,7 +2823,7 @@ var downloadFailingJobLogs = async (repo, runId, outDir, ghApi) => {
|
|
|
903
2823
|
writeFileSync(join(outDir, `job_${String(job.id)}.log`), log);
|
|
904
2824
|
} catch (err) {
|
|
905
2825
|
process.stderr.write(
|
|
906
|
-
`Warning: failed to download logs for job ${String(job.id)}: ${
|
|
2826
|
+
`Warning: failed to download logs for job ${String(job.id)}: ${errMsg(err)} \u2014 continuing with the logs retrieved so far
|
|
907
2827
|
`
|
|
908
2828
|
);
|
|
909
2829
|
}
|
|
@@ -926,8 +2846,10 @@ var gather = async (input, ghApi = runGhApi, gitRun = runGit) => {
|
|
|
926
2846
|
}
|
|
927
2847
|
const prNumber = resolution.prNumber;
|
|
928
2848
|
const meta = await fetchPrMeta(input.repo, prNumber, ghApi);
|
|
929
|
-
const
|
|
930
|
-
const
|
|
2849
|
+
const stacked = meta.base_ref !== input.defaultBranch;
|
|
2850
|
+
const prDiff = await (async () => {
|
|
2851
|
+
const apiDiff = await fetchApiDiff(input.repo, prNumber, ghApi);
|
|
2852
|
+
if (apiDiff !== null && !(apiDiff.length === 0 && meta.changed_files > 0)) return apiDiff;
|
|
931
2853
|
process.stderr.write(
|
|
932
2854
|
`PR diff fetch failed or was empty for ${String(meta.changed_files)} changed files \u2014 falling back to git diff
|
|
933
2855
|
`
|
|
@@ -935,16 +2857,40 @@ var gather = async (input, ghApi = runGhApi, gitRun = runGit) => {
|
|
|
935
2857
|
await gitRun(["fetch", "origin", input.headSha]);
|
|
936
2858
|
return gitRun(["diff", meta.base_sha, input.headSha]);
|
|
937
2859
|
})();
|
|
938
|
-
|
|
2860
|
+
const fullDiff = stacked ? await fetchFullDiff(input.repo, input.defaultBranch, input.headSha, ghApi, gitRun) : prDiff;
|
|
2861
|
+
const commits = await fetchCompareCommits(input.repo, input.defaultBranch, input.headSha, ghApi);
|
|
2862
|
+
writeFileSync(join(input.outDir, "full.diff"), fullDiff);
|
|
2863
|
+
writeFileSync(join(input.outDir, "pr.diff"), prDiff);
|
|
2864
|
+
writeFileSync(join(input.outDir, "commits.json"), JSON.stringify(commits));
|
|
939
2865
|
writeFileSync(
|
|
940
2866
|
join(input.outDir, "pr_context.json"),
|
|
941
2867
|
JSON.stringify({ title: meta.title, body: meta.body })
|
|
942
2868
|
);
|
|
943
|
-
const
|
|
2869
|
+
const [issueRows, reviewCommentRows, reviewRows] = await Promise.all([
|
|
2870
|
+
fetchJsonlRows(ghApi, `repos/${input.repo}/issues/${String(prNumber)}/comments`, COMMENT_JQ),
|
|
2871
|
+
fetchJsonlRows(
|
|
2872
|
+
ghApi,
|
|
2873
|
+
`repos/${input.repo}/pulls/${String(prNumber)}/comments`,
|
|
2874
|
+
REVIEW_COMMENT_JQ
|
|
2875
|
+
),
|
|
2876
|
+
fetchJsonlRows(ghApi, `repos/${input.repo}/pulls/${String(prNumber)}/reviews`, REVIEW_JQ)
|
|
2877
|
+
]);
|
|
2878
|
+
const issueComments = decodeArrayOrNull(IssueCommentCodec, issueRows);
|
|
2879
|
+
const reviewComments = decodeArrayOrNull(ReviewCommentCodec, reviewCommentRows);
|
|
2880
|
+
const reviews = decodeArrayOrNull(ReviewCodec, reviewRows);
|
|
2881
|
+
const prior = issueComments === null ? null : priorReviewFrom(issueComments, input.botLogin);
|
|
944
2882
|
writeFileSync(
|
|
945
2883
|
join(input.outDir, "prior_review.json"),
|
|
946
2884
|
prior === null ? "null" : JSON.stringify(prior)
|
|
947
2885
|
);
|
|
2886
|
+
writeFileSync(
|
|
2887
|
+
join(input.outDir, "pr_conversation.json"),
|
|
2888
|
+
JSON.stringify({
|
|
2889
|
+
issue_comments: issueComments === null ? [] : issueCommentsFrom(issueComments, input.botLogin),
|
|
2890
|
+
review_comments: reviewComments === null ? [] : reviewCommentsFrom(reviewComments, input.botLogin),
|
|
2891
|
+
reviews: reviews === null ? [] : reviewsFrom(reviews, input.botLogin)
|
|
2892
|
+
})
|
|
2893
|
+
);
|
|
948
2894
|
if (input.conclusion === "failure") {
|
|
949
2895
|
await downloadFailingJobLogs(input.repo, input.runId, input.outDir, ghApi);
|
|
950
2896
|
}
|
|
@@ -952,9 +2898,12 @@ var gather = async (input, ghApi = runGhApi, gitRun = runGit) => {
|
|
|
952
2898
|
kind: "gathered",
|
|
953
2899
|
pr: prNumber,
|
|
954
2900
|
conclusion: input.conclusion,
|
|
955
|
-
diffSize: Buffer.byteLength(
|
|
2901
|
+
diffSize: Buffer.byteLength(prDiff, "utf8"),
|
|
2902
|
+
stacked
|
|
956
2903
|
};
|
|
957
2904
|
};
|
|
2905
|
+
|
|
2906
|
+
// src/extract.ts
|
|
958
2907
|
var fieldOf = (raw, key2) => typeof raw === "object" && raw !== null && key2 in raw ? raw[key2] : void 0;
|
|
959
2908
|
var parseNativeForExtraction = (raw) => ({
|
|
960
2909
|
result: fieldOf(raw, "result"),
|
|
@@ -1007,20 +2956,6 @@ var gateCandidate = (kind, rawCandidate) => {
|
|
|
1007
2956
|
const resolution = resolve(kind, candidate);
|
|
1008
2957
|
return resolution.kind === "ok" ? { version: resolution.version, candidate } : null;
|
|
1009
2958
|
};
|
|
1010
|
-
var tryParseJson = (text) => {
|
|
1011
|
-
try {
|
|
1012
|
-
return { ok: true, value: JSON.parse(text) };
|
|
1013
|
-
} catch {
|
|
1014
|
-
return { ok: false };
|
|
1015
|
-
}
|
|
1016
|
-
};
|
|
1017
|
-
var readFileOrNull = (path) => {
|
|
1018
|
-
try {
|
|
1019
|
-
return readFileSync(path, "utf-8");
|
|
1020
|
-
} catch {
|
|
1021
|
-
return null;
|
|
1022
|
-
}
|
|
1023
|
-
};
|
|
1024
2959
|
var candidateFromJsonText = (kind, text) => {
|
|
1025
2960
|
if (text === null) return null;
|
|
1026
2961
|
const parsed = tryParseJson(text);
|
|
@@ -1028,7 +2963,7 @@ var candidateFromJsonText = (kind, text) => {
|
|
|
1028
2963
|
};
|
|
1029
2964
|
var FENCE_OPEN = /^\s*(`{3,})/;
|
|
1030
2965
|
var FENCE_MARKER_ONLY = /^`+$/;
|
|
1031
|
-
var
|
|
2966
|
+
var scanLine2 = (state, line) => {
|
|
1032
2967
|
if (state.openLength === null) {
|
|
1033
2968
|
const opened = FENCE_OPEN.exec(line)?.[1]?.length;
|
|
1034
2969
|
return opened !== void 0 ? { blocks: state.blocks, openLength: opened, buffer: [] } : state;
|
|
@@ -1037,7 +2972,7 @@ var scanLine = (state, line) => {
|
|
|
1037
2972
|
const closes = FENCE_MARKER_ONLY.test(trimmed) && trimmed.length >= state.openLength;
|
|
1038
2973
|
return closes ? { blocks: [...state.blocks, state.buffer.join("\n")], openLength: null, buffer: [] } : { ...state, buffer: [...state.buffer, line] };
|
|
1039
2974
|
};
|
|
1040
|
-
var scanFencedBlocks = (text) => text.split("\n").reduce(
|
|
2975
|
+
var scanFencedBlocks = (text) => text.split("\n").reduce(scanLine2, { blocks: [], openLength: null, buffer: [] }).blocks;
|
|
1041
2976
|
var ladderFailureDiagnostics = (input) => {
|
|
1042
2977
|
const native = parseNativeForExtraction(input.native);
|
|
1043
2978
|
const preview = (s) => {
|
|
@@ -1049,6 +2984,10 @@ var ladderFailureDiagnostics = (input) => {
|
|
|
1049
2984
|
lines.push(
|
|
1050
2985
|
input.agentFilePath === void 0 ? "agent-file rung: no --agent-file given" : `agent-file rung: ${input.agentFilePath} did not validate (or was unreadable)`
|
|
1051
2986
|
);
|
|
2987
|
+
if (input.agentFileFallbackPath !== void 0)
|
|
2988
|
+
lines.push(
|
|
2989
|
+
`last-valid rung: ${input.agentFileFallbackPath} did not validate (or was absent)`
|
|
2990
|
+
);
|
|
1052
2991
|
}
|
|
1053
2992
|
lines.push(
|
|
1054
2993
|
isNullish(native.structuredOutput) ? "structured_output rung: absent (null) \u2014 the CLI's --json-schema likely did not enforce" : "structured_output rung: present but did not validate against the schema"
|
|
@@ -1075,13 +3014,16 @@ var okOutcome = (gated) => ({
|
|
|
1075
3014
|
});
|
|
1076
3015
|
var extractStructured = (input) => {
|
|
1077
3016
|
const native = parseNativeForExtraction(input.native);
|
|
3017
|
+
if (input.kind === "findings") {
|
|
3018
|
+
for (const path of [input.agentFilePath, input.agentFileFallbackPath]) {
|
|
3019
|
+
if (path === void 0) continue;
|
|
3020
|
+
const fromFile = candidateFromJsonText(input.kind, readFileOrNull(path));
|
|
3021
|
+
if (fromFile) return okOutcome(fromFile);
|
|
3022
|
+
}
|
|
3023
|
+
}
|
|
1078
3024
|
if (isErrorEnvelope(native)) {
|
|
1079
3025
|
return { kind: "error-envelope", detail: describeErrorEnvelope(native) };
|
|
1080
3026
|
}
|
|
1081
|
-
if (input.kind === "findings" && input.agentFilePath !== void 0) {
|
|
1082
|
-
const fromFile = candidateFromJsonText(input.kind, readFileOrNull(input.agentFilePath));
|
|
1083
|
-
if (fromFile) return okOutcome(fromFile);
|
|
1084
|
-
}
|
|
1085
3027
|
if (native.structuredOutput !== void 0) {
|
|
1086
3028
|
const fromStructured = gateCandidate(input.kind, native.structuredOutput);
|
|
1087
3029
|
if (fromStructured) return okOutcome(fromStructured);
|
|
@@ -1101,9 +3043,10 @@ var extractStructured = (input) => {
|
|
|
1101
3043
|
};
|
|
1102
3044
|
}
|
|
1103
3045
|
}
|
|
3046
|
+
const fallbackRung = input.agentFileFallbackPath ? ", last-valid snapshot" : "";
|
|
1104
3047
|
return {
|
|
1105
3048
|
kind: "none",
|
|
1106
|
-
detail: `no --agent-file, structured_output, JSON result, or fenced block validated against the ${input.kind} schema`
|
|
3049
|
+
detail: `no --agent-file${fallbackRung}, structured_output, JSON result, or fenced block validated against the ${input.kind} schema`
|
|
1107
3050
|
};
|
|
1108
3051
|
};
|
|
1109
3052
|
|
|
@@ -1140,80 +3083,272 @@ var mapModelUsage = (modelUsage) => Object.entries(modelUsage).map(([model, entr
|
|
|
1140
3083
|
...entry.cacheReadInputTokens !== void 0 ? { cache_read_tokens: entry.cacheReadInputTokens } : {},
|
|
1141
3084
|
...entry.cacheCreationInputTokens !== void 0 ? { cache_write_tokens: entry.cacheCreationInputTokens } : {}
|
|
1142
3085
|
}));
|
|
1143
|
-
var
|
|
1144
|
-
const
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
3086
|
+
var findingsOutcome = (native, agentFilePath, agentFileFallbackPath) => {
|
|
3087
|
+
const ladder = extractStructured({
|
|
3088
|
+
kind: "findings",
|
|
3089
|
+
native,
|
|
3090
|
+
agentFilePath,
|
|
3091
|
+
agentFileFallbackPath
|
|
3092
|
+
});
|
|
3093
|
+
if (ladder.kind !== "ok")
|
|
3094
|
+
return { kind: "telemetry-only", reason: describeLadderFailure(ladder) };
|
|
3095
|
+
const resolution = resolve("findings", ladder.candidate);
|
|
3096
|
+
return resolution.kind === "ok" ? { kind: "ok", version: resolution.version, findings: resolution.value } : {
|
|
3097
|
+
kind: "telemetry-only",
|
|
3098
|
+
reason: "internal error: the extraction ladder validated a candidate the registry then rejected"
|
|
3099
|
+
};
|
|
3100
|
+
};
|
|
3101
|
+
var withMeta = (base, meta) => ({
|
|
3102
|
+
...base,
|
|
3103
|
+
...meta.route ? { route: meta.route } : {},
|
|
3104
|
+
...meta.effort ? { effort: meta.effort } : {}
|
|
3105
|
+
});
|
|
3106
|
+
var resolveTelemetry = (native, meta) => {
|
|
3107
|
+
const fb = (() => {
|
|
3108
|
+
try {
|
|
3109
|
+
return meta.transcriptFallback?.();
|
|
3110
|
+
} catch {
|
|
3111
|
+
return void 0;
|
|
3112
|
+
}
|
|
3113
|
+
})();
|
|
3114
|
+
const wallTurns = fb !== void 0 && fb.durationMs > 0 ? { turns: fb.turns, duration_ms: fb.durationMs } : { turns: native.turns, duration_ms: native.durationMs };
|
|
3115
|
+
return withMeta(
|
|
3116
|
+
{
|
|
3117
|
+
models: native.models.length > 0 ? native.models : fb ? [...fb.models] : native.models,
|
|
3118
|
+
...wallTurns,
|
|
3119
|
+
vendor_cost_usd: native.vendorCostUsd
|
|
3120
|
+
},
|
|
3121
|
+
meta
|
|
3122
|
+
);
|
|
3123
|
+
};
|
|
3124
|
+
var nativeTelemetry = (native, meta) => resolveTelemetry(
|
|
3125
|
+
{
|
|
1152
3126
|
models: mapModelUsage(native.modelUsage),
|
|
1153
3127
|
turns: native.num_turns,
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
3128
|
+
durationMs: native.duration_ms,
|
|
3129
|
+
vendorCostUsd: native.total_cost_usd ?? null
|
|
3130
|
+
},
|
|
3131
|
+
meta
|
|
3132
|
+
);
|
|
3133
|
+
var absentTelemetry = (meta) => resolveTelemetry({ models: [], turns: 0, durationMs: 0, vendorCostUsd: null }, meta);
|
|
3134
|
+
var buildEnvelope = (telemetry, native, agentFilePath, agentFileFallbackPath, seedUnrevised) => {
|
|
3135
|
+
const outcome = findingsOutcome(native, agentFilePath, agentFileFallbackPath);
|
|
3136
|
+
switch (outcome.kind) {
|
|
3137
|
+
case "ok":
|
|
3138
|
+
return { schema_version: outcome.version, findings: outcome.findings, ...telemetry };
|
|
3139
|
+
case "telemetry-only": {
|
|
3140
|
+
const reason = seedUnrevised ? "the review agent did not write a review (its draft is still the pre-seeded sentinel)" : outcome.reason;
|
|
3141
|
+
return {
|
|
3142
|
+
schema_version: DEFAULT_SCHEMA_VERSION,
|
|
3143
|
+
findings: incompleteFindings(`### \u26A0\uFE0F Review did not complete
|
|
3144
|
+
|
|
3145
|
+
${reason}`),
|
|
3146
|
+
incomplete: true,
|
|
3147
|
+
...telemetry
|
|
3148
|
+
};
|
|
3149
|
+
}
|
|
3150
|
+
}
|
|
1161
3151
|
};
|
|
1162
3152
|
var adapt = (adapterName, native, agentFilePath, meta = {}) => {
|
|
1163
3153
|
switch (adapterName) {
|
|
1164
3154
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- exhaustive by design; AdapterName grows (e.g. "opencode") without collapsing this switch to an if
|
|
1165
3155
|
case "claude-code": {
|
|
3156
|
+
if (native === void 0 || native === null)
|
|
3157
|
+
return right(
|
|
3158
|
+
buildEnvelope(
|
|
3159
|
+
absentTelemetry(meta),
|
|
3160
|
+
void 0,
|
|
3161
|
+
agentFilePath,
|
|
3162
|
+
meta.agentFileFallbackPath,
|
|
3163
|
+
meta.seedUnrevised === true
|
|
3164
|
+
)
|
|
3165
|
+
);
|
|
1166
3166
|
const decoded = ClaudeCodeEnvelopeCodec.decode(native);
|
|
1167
|
-
if (decoded._tag === "Left")
|
|
3167
|
+
if (decoded._tag === "Left")
|
|
1168
3168
|
return left("native envelope does not match the Claude Code output shape");
|
|
1169
|
-
|
|
1170
|
-
|
|
3169
|
+
return right(
|
|
3170
|
+
buildEnvelope(
|
|
3171
|
+
nativeTelemetry(decoded.right, meta),
|
|
3172
|
+
native,
|
|
3173
|
+
agentFilePath,
|
|
3174
|
+
meta.agentFileFallbackPath,
|
|
3175
|
+
meta.seedUnrevised === true
|
|
3176
|
+
)
|
|
3177
|
+
);
|
|
3178
|
+
}
|
|
3179
|
+
}
|
|
3180
|
+
};
|
|
3181
|
+
|
|
3182
|
+
// src/settings.ts
|
|
3183
|
+
var composeReviewSettings = (opts) => {
|
|
3184
|
+
const budgetCommand = budgetHookCommand(opts.draftPath, opts.budget);
|
|
3185
|
+
return {
|
|
3186
|
+
hooks: {
|
|
3187
|
+
Stop: [
|
|
3188
|
+
{ hooks: [{ type: "command", command: defaultHookCommand(opts.draftPath, opts.stop) }] }
|
|
3189
|
+
],
|
|
3190
|
+
PreToolUse: [{ hooks: [{ type: "command", command: budgetCommand }] }],
|
|
3191
|
+
PostToolBatch: [{ hooks: [{ type: "command", command: budgetCommand }] }]
|
|
1171
3192
|
}
|
|
3193
|
+
};
|
|
3194
|
+
};
|
|
3195
|
+
|
|
3196
|
+
// src/sandbox.ts
|
|
3197
|
+
var GITHUB_HOSTS = ["api.github.com", "github.com"];
|
|
3198
|
+
var deriveModelHost = (apiBaseUrl) => {
|
|
3199
|
+
const host = URL.canParse(apiBaseUrl) ? new URL(apiBaseUrl).hostname : "";
|
|
3200
|
+
if (host === "") {
|
|
3201
|
+
throw new Error(
|
|
3202
|
+
`could not derive the model host from api_base_url ${JSON.stringify(apiBaseUrl)} \u2014 expected a URL like https://api.deepseek.com/anthropic`
|
|
3203
|
+
);
|
|
1172
3204
|
}
|
|
3205
|
+
return host;
|
|
3206
|
+
};
|
|
3207
|
+
var KNOWN_MODEL_HOST_SUFFIXES = ["anthropic.com", "deepseek.com"];
|
|
3208
|
+
var isKnownModelHost = (host, declared = []) => {
|
|
3209
|
+
const normalize = (h) => h.replace(/\.$/, "").toLowerCase();
|
|
3210
|
+
const target = normalize(host);
|
|
3211
|
+
return declared.some((d) => normalize(d) === target) || KNOWN_MODEL_HOST_SUFFIXES.some((suffix) => target.endsWith(`.${suffix}`));
|
|
3212
|
+
};
|
|
3213
|
+
var parseExtraEndpoints = (extra) => extra.split(/\s+/).map((token) => token.replace(/:\d+$/, "")).filter((token) => token.length > 0);
|
|
3214
|
+
var buildSandboxConfig = (opts) => ({
|
|
3215
|
+
network: {
|
|
3216
|
+
allowedDomains: [
|
|
3217
|
+
.../* @__PURE__ */ new Set([
|
|
3218
|
+
deriveModelHost(opts.apiBaseUrl),
|
|
3219
|
+
...GITHUB_HOSTS,
|
|
3220
|
+
...parseExtraEndpoints(opts.extra ?? "")
|
|
3221
|
+
])
|
|
3222
|
+
],
|
|
3223
|
+
deniedDomains: []
|
|
3224
|
+
},
|
|
3225
|
+
filesystem: { allowRead: [], denyRead: [], allowWrite: ["/"], denyWrite: [] }
|
|
3226
|
+
});
|
|
3227
|
+
|
|
3228
|
+
// src/scope.ts
|
|
3229
|
+
var SCOPE_SEPARATOR_RE = /[\s,;]+/;
|
|
3230
|
+
var parseScope = (raw) => {
|
|
3231
|
+
const trimmed = raw?.trim();
|
|
3232
|
+
if (trimmed === void 0 || trimmed === "") return { kind: "absent" };
|
|
3233
|
+
if (UNSAFE_IN_SUMMARY.test(trimmed)) {
|
|
3234
|
+
return {
|
|
3235
|
+
kind: "invalid",
|
|
3236
|
+
reason: 'scope contains a character (newline, carriage return, backtick, "<", ">", or "|") that would corrupt the review prompt \u2014 use plain language names/tags'
|
|
3237
|
+
};
|
|
3238
|
+
}
|
|
3239
|
+
const languages = Array.from(new Set(trimmed.split(SCOPE_SEPARATOR_RE).filter((t7) => t7 !== "")));
|
|
3240
|
+
return languages.length === 0 ? { kind: "absent" } : { kind: "ok", languages };
|
|
1173
3241
|
};
|
|
1174
3242
|
|
|
1175
3243
|
// src/index.ts
|
|
1176
3244
|
var readJSON = (path) => {
|
|
1177
3245
|
try {
|
|
1178
|
-
return JSON.parse(readFileSync(resolve$1(path), "utf-8"));
|
|
3246
|
+
return JSON.parse(readFileSync(resolve$1(path), "utf-8"));
|
|
3247
|
+
} catch (err) {
|
|
3248
|
+
return fail(`Cannot read ${path}: ${errMsg(err)}`);
|
|
3249
|
+
}
|
|
3250
|
+
};
|
|
3251
|
+
var fail = (msg) => {
|
|
3252
|
+
process.stderr.write(`${msg}
|
|
3253
|
+
`);
|
|
3254
|
+
process.exit(1);
|
|
3255
|
+
};
|
|
3256
|
+
var readJSONOrAbsent = (path) => {
|
|
3257
|
+
const read = (() => {
|
|
3258
|
+
try {
|
|
3259
|
+
return { text: readFileSync(resolve$1(path), "utf-8") };
|
|
3260
|
+
} catch (err) {
|
|
3261
|
+
return { error: errMsg(err) };
|
|
3262
|
+
}
|
|
3263
|
+
})();
|
|
3264
|
+
if ("error" in read) {
|
|
3265
|
+
process.stderr.write(
|
|
3266
|
+
`code-review: native envelope ${path} could not be read (${read.error}) \u2014 proceeding with no native telemetry
|
|
3267
|
+
`
|
|
3268
|
+
);
|
|
3269
|
+
return void 0;
|
|
3270
|
+
}
|
|
3271
|
+
if (read.text.trim() === "") {
|
|
3272
|
+
process.stderr.write(
|
|
3273
|
+
`code-review: native envelope ${path} is empty \u2014 proceeding with no native telemetry
|
|
3274
|
+
`
|
|
3275
|
+
);
|
|
3276
|
+
return void 0;
|
|
3277
|
+
}
|
|
3278
|
+
try {
|
|
3279
|
+
return JSON.parse(read.text);
|
|
1179
3280
|
} catch (err) {
|
|
1180
|
-
|
|
1181
|
-
|
|
3281
|
+
process.stderr.write(
|
|
3282
|
+
`code-review: native envelope ${path} is not valid JSON (${errMsg(err)}) \u2014 proceeding with no native telemetry
|
|
3283
|
+
`
|
|
3284
|
+
);
|
|
3285
|
+
return void 0;
|
|
1182
3286
|
}
|
|
1183
3287
|
};
|
|
1184
|
-
var
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
3288
|
+
var readSandboxConfigForNotice = (path) => {
|
|
3289
|
+
const text = readFileOrNull(resolve$1(path));
|
|
3290
|
+
if (text === null) return void 0;
|
|
3291
|
+
const parsed = tryParseJson(text);
|
|
3292
|
+
if (!parsed.ok) {
|
|
3293
|
+
process.stderr.write(
|
|
3294
|
+
`::warning::${annotationSafe(`code-review notice: ${path} is present but not valid JSON \u2014 omitting the agent allowlist from the notice`)}
|
|
3295
|
+
`
|
|
3296
|
+
);
|
|
3297
|
+
return void 0;
|
|
3298
|
+
}
|
|
3299
|
+
return parsed.value;
|
|
3300
|
+
};
|
|
3301
|
+
var readStdinJSON = () => {
|
|
3302
|
+
if (process.stdin.isTTY) return null;
|
|
3303
|
+
const raw = (() => {
|
|
3304
|
+
try {
|
|
3305
|
+
return readFileSync(0, "utf-8");
|
|
3306
|
+
} catch {
|
|
3307
|
+
return "";
|
|
3308
|
+
}
|
|
3309
|
+
})();
|
|
3310
|
+
if (raw.trim() === "") return null;
|
|
3311
|
+
const parsed = tryParseJson(raw);
|
|
3312
|
+
return parsed.ok ? parsed.value : null;
|
|
1188
3313
|
};
|
|
1189
3314
|
var decode = (either, label) => {
|
|
1190
3315
|
try {
|
|
1191
3316
|
return unsafeUnwrap(either);
|
|
1192
3317
|
} catch {
|
|
1193
|
-
fail(`${label} does not match expected shape`);
|
|
3318
|
+
return fail(`${label} does not match expected shape`);
|
|
1194
3319
|
}
|
|
1195
|
-
throw new Error("unreachable");
|
|
1196
3320
|
};
|
|
1197
3321
|
var unwrapAdapt = (either) => {
|
|
1198
3322
|
try {
|
|
1199
3323
|
if (either._tag === "Left") throw new Error(either.left);
|
|
1200
3324
|
return either.right;
|
|
1201
3325
|
} catch (err) {
|
|
1202
|
-
fail(
|
|
3326
|
+
return fail(errMsg(err));
|
|
1203
3327
|
}
|
|
1204
|
-
|
|
3328
|
+
};
|
|
3329
|
+
var transcriptFallbackFrom = (path) => {
|
|
3330
|
+
const tree = readTranscriptTree(resolve$1(path));
|
|
3331
|
+
if (tree.missing)
|
|
3332
|
+
process.stderr.write(
|
|
3333
|
+
`code-review adapt: transcript ${path} is unreadable \u2014 no telemetry fallback
|
|
3334
|
+
`
|
|
3335
|
+
);
|
|
3336
|
+
const usage = sumTranscriptUsage(tree.entries);
|
|
3337
|
+
return { models: usage.models, turns: usage.turns, durationMs: usage.durationMs };
|
|
1205
3338
|
};
|
|
1206
3339
|
var bundledPath = (...segments) => resolve$1(import.meta.dirname, "..", ...segments);
|
|
1207
3340
|
var packageVersion = JSON.parse(readFileSync(bundledPath("package.json"), "utf-8")).version;
|
|
1208
3341
|
var resolveTemplatePath = (templateArg) => templateArg ? resolve$1(templateArg) : bundledPath("templates", "comment.eta");
|
|
1209
|
-
var
|
|
1210
|
-
|
|
3342
|
+
var resolveInlineTemplatePath = (templateArg) => templateArg ? resolve$1(templateArg) : bundledPath("templates", "inline.eta");
|
|
3343
|
+
var resolvePrices = (pricesArg) => {
|
|
3344
|
+
if (pricesArg) return { kind: "provided", path: resolve$1(pricesArg) };
|
|
1211
3345
|
process.stderr.write(
|
|
1212
|
-
"code-review: no --prices given \u2014
|
|
3346
|
+
"code-review: no --prices given \u2014 cost will be reported as N/A (no price map to recompute from)\n"
|
|
1213
3347
|
);
|
|
1214
|
-
return bundledPath("schema", "prices.example.json");
|
|
3348
|
+
return { kind: "absent", path: bundledPath("schema", "prices.example.json") };
|
|
1215
3349
|
};
|
|
1216
3350
|
var TEST_REPORT_DESCRIPTION = 'Path to a JSON test summary: {"passed": number, "failed": number, "total": number, "failures"?: [{"name": string, "message"?: string}]}';
|
|
3351
|
+
var CONVERGENCE_THRESHOLD_DESCRIPTION = "Advisory convergence tolerance: the weighted-severity score (critical 4 \xB7 major 2 \xB7 minor 1 \xB7 nit 0) at or below which the sticky reads as converged (default: 1 \u2014 unlimited nits plus at most one minor)";
|
|
1217
3352
|
var renderCmd = defineCommand({
|
|
1218
3353
|
meta: {
|
|
1219
3354
|
name: "render",
|
|
@@ -1253,27 +3388,38 @@ var renderCmd = defineCommand({
|
|
|
1253
3388
|
"test-report": {
|
|
1254
3389
|
type: "string",
|
|
1255
3390
|
description: TEST_REPORT_DESCRIPTION
|
|
3391
|
+
},
|
|
3392
|
+
"convergence-threshold": {
|
|
3393
|
+
type: "string",
|
|
3394
|
+
description: CONVERGENCE_THRESHOLD_DESCRIPTION
|
|
1256
3395
|
}
|
|
1257
3396
|
},
|
|
1258
3397
|
run: async ({ args }) => {
|
|
1259
3398
|
const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
|
|
1260
3399
|
const envelope = decode(ResultEnvelopeCodec.decode(readJSON(args.usage)), "envelope");
|
|
1261
3400
|
const templatePath = resolveTemplatePath(args.template);
|
|
1262
|
-
const
|
|
1263
|
-
const prices = decode(PriceMapCodec.decode(readJSON(
|
|
3401
|
+
const priceResolution = resolvePrices(args.prices);
|
|
3402
|
+
const prices = decode(PriceMapCodec.decode(readJSON(priceResolution.path)), "prices");
|
|
1264
3403
|
const template = readFileSync(templatePath, "utf-8");
|
|
1265
3404
|
const testReport = args["test-report"] ? decode(TestSummaryCodec.decode(readJSON(args["test-report"])), "test report") : void 0;
|
|
1266
|
-
const
|
|
3405
|
+
const route = args.route || envelope.route || null;
|
|
3406
|
+
const isRound = isConvergenceRound(route, envelope.incomplete === true || isIncompleteFindings(findings)) && isReviewVerdict(findings.verdict);
|
|
3407
|
+
const counts = computeRoundCounts(findings);
|
|
3408
|
+
const output2 = render({
|
|
1267
3409
|
findings,
|
|
1268
3410
|
envelope,
|
|
1269
3411
|
prices,
|
|
3412
|
+
pricesProvided: priceResolution.kind === "provided",
|
|
1270
3413
|
template,
|
|
1271
3414
|
reviewedSha: args["reviewed-sha"],
|
|
1272
3415
|
route: args.route,
|
|
1273
3416
|
effort: args.effort,
|
|
1274
|
-
testReport
|
|
3417
|
+
testReport,
|
|
3418
|
+
rounds: isRound ? [counts] : [],
|
|
3419
|
+
convergenceThreshold: parseConvergenceThreshold(args["convergence-threshold"]),
|
|
3420
|
+
postedAt: formatUtc(/* @__PURE__ */ new Date())
|
|
1275
3421
|
});
|
|
1276
|
-
process.stdout.write(
|
|
3422
|
+
process.stdout.write(output2);
|
|
1277
3423
|
}
|
|
1278
3424
|
});
|
|
1279
3425
|
var inlineCmd = defineCommand({
|
|
@@ -1294,14 +3440,17 @@ var inlineCmd = defineCommand({
|
|
|
1294
3440
|
},
|
|
1295
3441
|
template: {
|
|
1296
3442
|
type: "string",
|
|
1297
|
-
description: "Path to inline comment Eta template (default:
|
|
3443
|
+
description: "Path to inline comment Eta template (default: bundled templates/inline.eta)"
|
|
1298
3444
|
}
|
|
1299
3445
|
},
|
|
1300
3446
|
run: async ({ args }) => {
|
|
1301
3447
|
const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
|
|
1302
3448
|
const diff = readFileSync(resolve$1(args.diff), "utf-8");
|
|
1303
|
-
const inlineTemplate =
|
|
1304
|
-
const { comments, strays } = buildInlineComments(findings.findings, diff,
|
|
3449
|
+
const inlineTemplate = readFileSync(resolveInlineTemplatePath(args.template), "utf-8");
|
|
3450
|
+
const { comments, strays } = buildInlineComments(findings.findings, diff, {
|
|
3451
|
+
inlineTemplate,
|
|
3452
|
+
findings
|
|
3453
|
+
});
|
|
1305
3454
|
process.stdout.write(
|
|
1306
3455
|
JSON.stringify({ comments, strays, stray_markdown: renderStraysSection(strays) }, null, 2)
|
|
1307
3456
|
);
|
|
@@ -1331,8 +3480,306 @@ var costCmd = defineCommand({
|
|
|
1331
3480
|
process.stdout.write(JSON.stringify(report, null, 2));
|
|
1332
3481
|
}
|
|
1333
3482
|
});
|
|
1334
|
-
var
|
|
1335
|
-
|
|
3483
|
+
var checkCostCmd = defineCommand({
|
|
3484
|
+
meta: {
|
|
3485
|
+
name: "check-cost",
|
|
3486
|
+
description: "Sum real USD spend from a Claude Code transcript tree (main + subagents) against a price map"
|
|
3487
|
+
},
|
|
3488
|
+
args: {
|
|
3489
|
+
transcript: {
|
|
3490
|
+
type: "positional",
|
|
3491
|
+
description: "Path to the session transcript JSONL (the hook's transcript_path)",
|
|
3492
|
+
required: true
|
|
3493
|
+
},
|
|
3494
|
+
prices: {
|
|
3495
|
+
type: "string",
|
|
3496
|
+
description: "Path to price map JSON (default: bundled schema/prices.example.json \u2014 token totals stay real, cost reads as $0)"
|
|
3497
|
+
}
|
|
3498
|
+
},
|
|
3499
|
+
run: async ({ args }) => {
|
|
3500
|
+
const tree = readTranscriptTree(resolve$1(args.transcript));
|
|
3501
|
+
if (tree.missing) {
|
|
3502
|
+
process.stderr.write(
|
|
3503
|
+
`code-review check-cost: transcript ${args.transcript} is unreadable \u2014 reporting zero spend
|
|
3504
|
+
`
|
|
3505
|
+
);
|
|
3506
|
+
}
|
|
3507
|
+
const usage = sumTranscriptUsage(tree.entries);
|
|
3508
|
+
const priceResolution = resolvePrices(args.prices);
|
|
3509
|
+
const prices = decode(PriceMapCodec.decode(readJSON(priceResolution.path)), "prices");
|
|
3510
|
+
const report = computeCost(usage.models, prices);
|
|
3511
|
+
process.stdout.write(
|
|
3512
|
+
`${JSON.stringify(
|
|
3513
|
+
{
|
|
3514
|
+
...report,
|
|
3515
|
+
turns: usage.turns,
|
|
3516
|
+
durationMs: usage.durationMs,
|
|
3517
|
+
transcripts: tree.files,
|
|
3518
|
+
pricesProvided: priceResolution.kind === "provided"
|
|
3519
|
+
},
|
|
3520
|
+
null,
|
|
3521
|
+
2
|
|
3522
|
+
)}
|
|
3523
|
+
`
|
|
3524
|
+
);
|
|
3525
|
+
}
|
|
3526
|
+
});
|
|
3527
|
+
var tryReadPrices = (path) => {
|
|
3528
|
+
try {
|
|
3529
|
+
const decoded = PriceMapCodec.decode(JSON.parse(readFileSync(resolve$1(path), "utf-8")));
|
|
3530
|
+
return decoded._tag === "Right" ? decoded.right : null;
|
|
3531
|
+
} catch {
|
|
3532
|
+
return null;
|
|
3533
|
+
}
|
|
3534
|
+
};
|
|
3535
|
+
var parseBudgetUsd = (raw) => {
|
|
3536
|
+
if (raw === void 0) return null;
|
|
3537
|
+
const n = Number.parseFloat(raw);
|
|
3538
|
+
return Number.isFinite(n) && n >= 0 ? n : null;
|
|
3539
|
+
};
|
|
3540
|
+
var parseConvergenceThreshold = (raw) => {
|
|
3541
|
+
const trimmed = raw?.trim();
|
|
3542
|
+
if (trimmed === void 0 || trimmed === "") return void 0;
|
|
3543
|
+
if (!/^\d+(\.\d+)?$/.test(trimmed)) {
|
|
3544
|
+
fail(`--convergence-threshold must be a non-negative number; got "${trimmed}"`);
|
|
3545
|
+
}
|
|
3546
|
+
const n = Number.parseFloat(trimmed);
|
|
3547
|
+
if (!Number.isFinite(n)) {
|
|
3548
|
+
fail(`--convergence-threshold is too large to be a meaningful tolerance; got "${trimmed}"`);
|
|
3549
|
+
}
|
|
3550
|
+
return n;
|
|
3551
|
+
};
|
|
3552
|
+
var transcriptPathOf = (input) => {
|
|
3553
|
+
const tp = (typeof input === "object" && input !== null ? input : {})["transcript_path"];
|
|
3554
|
+
return typeof tp === "string" ? tp : void 0;
|
|
3555
|
+
};
|
|
3556
|
+
var statMtimeMsOrNull = (path) => {
|
|
3557
|
+
try {
|
|
3558
|
+
return statSync(path).mtimeMs;
|
|
3559
|
+
} catch {
|
|
3560
|
+
return null;
|
|
3561
|
+
}
|
|
3562
|
+
};
|
|
3563
|
+
var statSizeOrNull = (path) => {
|
|
3564
|
+
try {
|
|
3565
|
+
return statSync(path).size;
|
|
3566
|
+
} catch {
|
|
3567
|
+
return null;
|
|
3568
|
+
}
|
|
3569
|
+
};
|
|
3570
|
+
var snapshotIfValid = (draftPath) => {
|
|
3571
|
+
try {
|
|
3572
|
+
const snapPath = lastValidPath(draftPath);
|
|
3573
|
+
const draftMtime = statMtimeMsOrNull(draftPath);
|
|
3574
|
+
if (draftMtime === null) return;
|
|
3575
|
+
const snapMtime = statMtimeMsOrNull(snapPath);
|
|
3576
|
+
if (snapMtime !== null && draftMtime <= snapMtime) return;
|
|
3577
|
+
if (extractStructured({ kind: "findings", native: void 0, agentFilePath: draftPath }).kind === "ok")
|
|
3578
|
+
copyFileSync(draftPath, snapPath);
|
|
3579
|
+
} catch (err) {
|
|
3580
|
+
process.stderr.write(
|
|
3581
|
+
`code-review: could not snapshot the last-valid draft (${errMsg(err)}) \u2014 any prior snapshot is unchanged
|
|
3582
|
+
`
|
|
3583
|
+
);
|
|
3584
|
+
}
|
|
3585
|
+
};
|
|
3586
|
+
var budgetHookCmd = defineCommand({
|
|
3587
|
+
meta: {
|
|
3588
|
+
name: "budget-hook",
|
|
3589
|
+
description: "Self-dispatching Claude Code budget hook: on PostToolBatch steer the agent to converge as spend/wall-clock nears the budget; on PreToolUse deny budget-burning tools under the hard reserve, gate subagent spawns until the main agent has drafted, and run permitted spawns in the background. Reads the hook payload on stdin; degrades to a no-op on error."
|
|
3590
|
+
},
|
|
3591
|
+
args: {
|
|
3592
|
+
draft: {
|
|
3593
|
+
type: "string",
|
|
3594
|
+
description: "Path to the findings draft that is the sole permitted write target under forced convergence",
|
|
3595
|
+
required: true
|
|
3596
|
+
},
|
|
3597
|
+
"budget-usd": {
|
|
3598
|
+
type: "string",
|
|
3599
|
+
description: "Dollar budget for the run; the cost axis is measured against it (needs --prices)"
|
|
3600
|
+
},
|
|
3601
|
+
wall: {
|
|
3602
|
+
type: "string",
|
|
3603
|
+
description: "Wall-clock budget (e.g. 20m, 1200s, 2h); the time axis is measured against it"
|
|
3604
|
+
},
|
|
3605
|
+
prices: {
|
|
3606
|
+
type: "string",
|
|
3607
|
+
description: "Price map JSON to recompute real spend from the transcript (omit to disable the cost axis)"
|
|
3608
|
+
},
|
|
3609
|
+
"reserve-frac": {
|
|
3610
|
+
type: "string",
|
|
3611
|
+
description: "Base wind-down headroom as a fraction of each budget: converge once less than this remains (default: 0.15; the soft steer tier reserves 2\xD7 this)"
|
|
3612
|
+
},
|
|
3613
|
+
"reserve-growth": {
|
|
3614
|
+
type: "string",
|
|
3615
|
+
description: "How much the reserve grows as a budget is spent \u2014 added at full usage, so convergence lands earlier the longer the run has gone (default: 0.25; 0 = flat reserve)"
|
|
3616
|
+
},
|
|
3617
|
+
"reserve-usd": {
|
|
3618
|
+
type: "string",
|
|
3619
|
+
description: "Flat dollar wind-down floor, whichever is larger with --reserve-frac (default: 0.02)"
|
|
3620
|
+
},
|
|
3621
|
+
"reserve-wall": {
|
|
3622
|
+
type: "string",
|
|
3623
|
+
description: "Flat wall-clock wind-down floor (e.g. 2m, 120s), whichever is larger with --reserve-frac (default: 2m)"
|
|
3624
|
+
}
|
|
3625
|
+
},
|
|
3626
|
+
run: async ({ args }) => {
|
|
3627
|
+
try {
|
|
3628
|
+
const draftPath = resolve$1(args.draft);
|
|
3629
|
+
const input = readStdinJSON();
|
|
3630
|
+
const transcriptPath = transcriptPathOf(input);
|
|
3631
|
+
const tree = transcriptPath ? readTranscriptTree(resolve$1(transcriptPath)) : void 0;
|
|
3632
|
+
const usage = tree ? sumTranscriptUsage(tree.entries) : void 0;
|
|
3633
|
+
const prices = args.prices ? tryReadPrices(args.prices) : null;
|
|
3634
|
+
const spentUsd = prices !== null && usage ? computeCost(usage.models, prices).totalCostUSD : null;
|
|
3635
|
+
const wallMs = args.wall ? parseWallMs(args.wall) : null;
|
|
3636
|
+
const output2 = evaluateBudgetHook(input, {
|
|
3637
|
+
spentUsd,
|
|
3638
|
+
budgetUsd: parseBudgetUsd(args["budget-usd"]),
|
|
3639
|
+
elapsedMs: anchoredElapsedMs({
|
|
3640
|
+
deadlineMs: parseEpochSecMs(process.env[DEADLINE_ENV]),
|
|
3641
|
+
wallMs,
|
|
3642
|
+
firstTsMs: usage?.firstTsMs ?? null,
|
|
3643
|
+
nowMs: Date.now()
|
|
3644
|
+
}),
|
|
3645
|
+
wallMs,
|
|
3646
|
+
reserve: {
|
|
3647
|
+
frac: parseFraction(args["reserve-frac"], DEFAULT_RESERVE.frac),
|
|
3648
|
+
growth: parseFraction(args["reserve-growth"], DEFAULT_RESERVE.growth),
|
|
3649
|
+
flatUsd: parseBudgetUsd(args["reserve-usd"]) ?? DEFAULT_RESERVE.flatUsd,
|
|
3650
|
+
flatMs: args["reserve-wall"] ? parseWallMs(args["reserve-wall"]) ?? DEFAULT_RESERVE.flatMs : DEFAULT_RESERVE.flatMs
|
|
3651
|
+
},
|
|
3652
|
+
draftPath,
|
|
3653
|
+
// Lazy: the spawn gate is the only consumer, and the hook fires on every tool event.
|
|
3654
|
+
mainDraftWritten: () => mainHasWrittenDraft(readFileOrNull(draftPath))
|
|
3655
|
+
});
|
|
3656
|
+
if (asRecord(input)?.["hook_event_name"] === "PostToolBatch" && !isSubagentHookInput(input))
|
|
3657
|
+
snapshotIfValid(draftPath);
|
|
3658
|
+
process.stdout.write(`${JSON.stringify(output2)}
|
|
3659
|
+
`);
|
|
3660
|
+
} catch (err) {
|
|
3661
|
+
process.stderr.write(`code-review budget-hook: degrading to no-op \u2014 ${errMsg(err)}
|
|
3662
|
+
`);
|
|
3663
|
+
process.stdout.write("{}\n");
|
|
3664
|
+
}
|
|
3665
|
+
}
|
|
3666
|
+
});
|
|
3667
|
+
var printSettingsCmd = defineCommand({
|
|
3668
|
+
meta: {
|
|
3669
|
+
name: "print-settings",
|
|
3670
|
+
description: "Emit one Claude Code --settings JSON composing the Stop deliverable gate and the budget hooks (PreToolUse convergence + PostToolBatch steer) from one self-dispatching command"
|
|
3671
|
+
},
|
|
3672
|
+
args: {
|
|
3673
|
+
draft: {
|
|
3674
|
+
type: "string",
|
|
3675
|
+
description: "Path to the findings draft the agent must produce \u2014 the Stop gate's target and the only write allowed under forced convergence",
|
|
3676
|
+
required: true
|
|
3677
|
+
},
|
|
3678
|
+
kind: {
|
|
3679
|
+
type: "string",
|
|
3680
|
+
description: "Schema kind for the Stop gate: findings | triage | prices (default: findings)"
|
|
3681
|
+
},
|
|
3682
|
+
schema: {
|
|
3683
|
+
type: "string",
|
|
3684
|
+
description: "Path to a schema file for the Stop gate (wins over --kind)"
|
|
3685
|
+
},
|
|
3686
|
+
"schema-version": {
|
|
3687
|
+
type: "string",
|
|
3688
|
+
description: "Schema major.minor for the Stop gate (default: the draft's declared version)"
|
|
3689
|
+
},
|
|
3690
|
+
"max-nudges": {
|
|
3691
|
+
type: "string",
|
|
3692
|
+
description: "Stop-gate nudge budget before relenting (default: 5)"
|
|
3693
|
+
},
|
|
3694
|
+
counter: {
|
|
3695
|
+
type: "string",
|
|
3696
|
+
description: "Path for the Stop-gate nudge counter (default: <draft>.nudges)"
|
|
3697
|
+
},
|
|
3698
|
+
"budget-usd": {
|
|
3699
|
+
type: "string",
|
|
3700
|
+
description: "Dollar budget the cost axis is measured against (needs --prices)"
|
|
3701
|
+
},
|
|
3702
|
+
wall: {
|
|
3703
|
+
type: "string",
|
|
3704
|
+
description: "Wall-clock budget the time axis is measured against (e.g. 20m, 1200s)"
|
|
3705
|
+
},
|
|
3706
|
+
prices: {
|
|
3707
|
+
type: "string",
|
|
3708
|
+
description: "Price map JSON to recompute real spend from the transcript"
|
|
3709
|
+
},
|
|
3710
|
+
"reserve-frac": {
|
|
3711
|
+
type: "string",
|
|
3712
|
+
description: "Base wind-down headroom as a fraction of each budget (default: 0.15; soft tier is 2\xD7)"
|
|
3713
|
+
},
|
|
3714
|
+
"reserve-growth": {
|
|
3715
|
+
type: "string",
|
|
3716
|
+
description: "How much the reserve grows as a budget is spent, converging earlier the longer the run has gone (default: 0.25; 0 = flat)"
|
|
3717
|
+
},
|
|
3718
|
+
"reserve-usd": {
|
|
3719
|
+
type: "string",
|
|
3720
|
+
description: "Flat dollar wind-down floor, whichever is larger with --reserve-frac (default: 0.02)"
|
|
3721
|
+
},
|
|
3722
|
+
"reserve-wall": {
|
|
3723
|
+
type: "string",
|
|
3724
|
+
description: "Flat wall-clock wind-down floor (e.g. 2m), whichever is larger with --reserve-frac (default: 2m)"
|
|
3725
|
+
}
|
|
3726
|
+
},
|
|
3727
|
+
run: async ({ args }) => {
|
|
3728
|
+
if (args.kind && !["findings", "triage", "prices"].includes(args.kind))
|
|
3729
|
+
fail(`--kind must be one of findings|triage|prices (got '${args.kind}')`);
|
|
3730
|
+
const settings = composeReviewSettings({
|
|
3731
|
+
draftPath: resolve$1(args.draft),
|
|
3732
|
+
stop: {
|
|
3733
|
+
kind: args.kind,
|
|
3734
|
+
schema: args.schema,
|
|
3735
|
+
schemaVersion: args["schema-version"],
|
|
3736
|
+
maxNudges: args["max-nudges"],
|
|
3737
|
+
counter: args.counter
|
|
3738
|
+
},
|
|
3739
|
+
budget: {
|
|
3740
|
+
budgetUsd: args["budget-usd"],
|
|
3741
|
+
wall: args.wall,
|
|
3742
|
+
prices: args.prices,
|
|
3743
|
+
reserveFrac: args["reserve-frac"],
|
|
3744
|
+
reserveGrowth: args["reserve-growth"],
|
|
3745
|
+
reserveUsd: args["reserve-usd"],
|
|
3746
|
+
reserveWall: args["reserve-wall"]
|
|
3747
|
+
}
|
|
3748
|
+
});
|
|
3749
|
+
process.stdout.write(`${JSON.stringify(settings)}
|
|
3750
|
+
`);
|
|
3751
|
+
}
|
|
3752
|
+
});
|
|
3753
|
+
var deadlineCmd = defineCommand({
|
|
3754
|
+
meta: {
|
|
3755
|
+
name: "deadline",
|
|
3756
|
+
description: "Print the run's absolute deadline as Unix epoch seconds (now + --wall) \u2014 exported as CODE_REVIEW_DEADLINE_EPOCH so every budget hook (main and subagents) measures the same remaining wall"
|
|
3757
|
+
},
|
|
3758
|
+
args: {
|
|
3759
|
+
wall: {
|
|
3760
|
+
type: "string",
|
|
3761
|
+
description: "Wall-clock budget for the run (e.g. 24m, 1200s, 2h) \u2014 the deadline is now + this",
|
|
3762
|
+
required: true
|
|
3763
|
+
}
|
|
3764
|
+
},
|
|
3765
|
+
run: async ({ args }) => {
|
|
3766
|
+
const wallMs = parseWallMs(args.wall);
|
|
3767
|
+
if (wallMs === null) {
|
|
3768
|
+
fail(`--wall must be a duration like 24m, 1200s, or 2h (got '${args.wall}')`);
|
|
3769
|
+
} else {
|
|
3770
|
+
process.stdout.write(`${String(deadlineEpochSec(wallMs, Date.now()))}
|
|
3771
|
+
`);
|
|
3772
|
+
}
|
|
3773
|
+
}
|
|
3774
|
+
});
|
|
3775
|
+
var derivedSchemaVersion = (kind, raw) => kind === "findings" ? declaredVersion(raw) : void 0;
|
|
3776
|
+
var printableSchema = (schemaPath) => {
|
|
3777
|
+
const schema = JSON.parse(readFileSync(schemaPath, "utf-8"));
|
|
3778
|
+
const enforcementSchema = Object.fromEntries(
|
|
3779
|
+
Object.entries(schema).filter(([key2]) => key2 !== "$schema")
|
|
3780
|
+
);
|
|
3781
|
+
return JSON.stringify(enforcementSchema, null, 2);
|
|
3782
|
+
};
|
|
1336
3783
|
var validateCmd = defineCommand({
|
|
1337
3784
|
meta: {
|
|
1338
3785
|
name: "validate",
|
|
@@ -1355,6 +3802,10 @@ var validateCmd = defineCommand({
|
|
|
1355
3802
|
"schema-version": {
|
|
1356
3803
|
type: "string",
|
|
1357
3804
|
description: "Schema major.minor version to validate against (default: the document's declared schema_version for findings, or the kind's latest)"
|
|
3805
|
+
},
|
|
3806
|
+
explain: {
|
|
3807
|
+
type: "boolean",
|
|
3808
|
+
description: "On failure, also print the schema after the errors \u2014 its field descriptions are the authoritative spec, so the document can be fixed in one pass instead of by trial and error"
|
|
1358
3809
|
}
|
|
1359
3810
|
},
|
|
1360
3811
|
run: async ({ args }) => {
|
|
@@ -1368,14 +3819,132 @@ var validateCmd = defineCommand({
|
|
|
1368
3819
|
process.stderr.write("\u274C invalid\n");
|
|
1369
3820
|
for (const e of errors) process.stderr.write(` - ${e}
|
|
1370
3821
|
`);
|
|
3822
|
+
if (args.explain) {
|
|
3823
|
+
process.stderr.write(
|
|
3824
|
+
`
|
|
3825
|
+
The ${kind} document must conform to this schema (the field descriptions are the authoritative spec \u2014 match the property names exactly):
|
|
3826
|
+
${printableSchema(schemaPath)}
|
|
3827
|
+
`
|
|
3828
|
+
);
|
|
3829
|
+
}
|
|
1371
3830
|
process.exit(1);
|
|
1372
3831
|
}
|
|
1373
3832
|
}
|
|
1374
3833
|
});
|
|
3834
|
+
var seedDraftCmd = defineCommand({
|
|
3835
|
+
meta: {
|
|
3836
|
+
name: "seed-draft",
|
|
3837
|
+
description: "Initialize $DRAFT before the review runs: write a NON-REVIEW SENTINEL (no recovery path can validate it as a review), and deliver the decoded findings of a prior review OUT-OF-BAND to a read-only context file beside the draft when one exists and still validates (incremental re-review). Skips a prior that never completed (an error-verdict notice) or that a CI-fix mechanic pass produced \u2014 the seed chain is route-aware. Prints the mode to stdout (prior-same|prior-new when prior context was delivered, by whether the prior review examined this same commit; empty-had-prior when a prior review exists but its findings could not be loaded; empty on a first review; none when even the sentinel write failed) and always exits 0"
|
|
3838
|
+
},
|
|
3839
|
+
args: {
|
|
3840
|
+
prior: {
|
|
3841
|
+
type: "string",
|
|
3842
|
+
description: "Path to the prior-review JSON gather staged ({ id, body }, or the literal null); its embedded base64 findings marker is decoded and delivered as re-review context when it validates against the schema"
|
|
3843
|
+
},
|
|
3844
|
+
"head-sha": {
|
|
3845
|
+
type: "string",
|
|
3846
|
+
description: "Current head SHA, compared against the prior review's embedded reviewed-sha to distinguish a same-commit re-review from a new-commit one; an unknown or mismatched prior SHA is treated as a new commit"
|
|
3847
|
+
},
|
|
3848
|
+
out: {
|
|
3849
|
+
type: "string",
|
|
3850
|
+
description: "Path to write the sentinel $DRAFT to (an absolute path outside the worktree)",
|
|
3851
|
+
required: true
|
|
3852
|
+
},
|
|
3853
|
+
kind: {
|
|
3854
|
+
type: "string",
|
|
3855
|
+
description: "Schema kind to validate the prior findings against (default: findings)"
|
|
3856
|
+
},
|
|
3857
|
+
schema: {
|
|
3858
|
+
type: "string",
|
|
3859
|
+
description: "Path to a schema file (wins over --kind/--schema-version)"
|
|
3860
|
+
},
|
|
3861
|
+
"schema-version": {
|
|
3862
|
+
type: "string",
|
|
3863
|
+
description: "Schema major.minor to validate the prior findings against (default: the kind's latest \u2014 an older-shaped prior review then delivers no context)"
|
|
3864
|
+
}
|
|
3865
|
+
},
|
|
3866
|
+
run: async ({ args }) => {
|
|
3867
|
+
const outPath = resolve$1(args.out);
|
|
3868
|
+
const kindArg = args.kind || "findings";
|
|
3869
|
+
const kind = isSchemaKind(kindArg) ? kindArg : "findings";
|
|
3870
|
+
if (kind !== kindArg) {
|
|
3871
|
+
process.stderr.write(
|
|
3872
|
+
`Warning: unknown --kind "${kindArg}" \u2014 validating against "findings"
|
|
3873
|
+
`
|
|
3874
|
+
);
|
|
3875
|
+
}
|
|
3876
|
+
const writeSentinel = () => {
|
|
3877
|
+
try {
|
|
3878
|
+
writeFileSync(outPath, SEED_SENTINEL);
|
|
3879
|
+
process.stderr.write(
|
|
3880
|
+
`Seeded ${outPath} with the non-review sentinel \u2014 the agent must replace it with its review
|
|
3881
|
+
`
|
|
3882
|
+
);
|
|
3883
|
+
return true;
|
|
3884
|
+
} catch (err) {
|
|
3885
|
+
process.stderr.write(
|
|
3886
|
+
`Warning: could not write the seed sentinel to ${outPath} (${errMsg(err)}) \u2014 the agent will create $DRAFT itself
|
|
3887
|
+
`
|
|
3888
|
+
);
|
|
3889
|
+
return false;
|
|
3890
|
+
}
|
|
3891
|
+
};
|
|
3892
|
+
const priorBody = (() => {
|
|
3893
|
+
if (!args.prior) return null;
|
|
3894
|
+
const raw = (() => {
|
|
3895
|
+
try {
|
|
3896
|
+
return JSON.parse(readFileSync(resolve$1(args.prior), "utf-8"));
|
|
3897
|
+
} catch {
|
|
3898
|
+
return null;
|
|
3899
|
+
}
|
|
3900
|
+
})();
|
|
3901
|
+
return typeof raw === "object" && raw !== null && "body" in raw && typeof raw.body === "string" ? raw.body : null;
|
|
3902
|
+
})();
|
|
3903
|
+
const priorFindings = priorBody === null ? null : stripSurfaceFields(parseFindingsMarker(priorBody));
|
|
3904
|
+
const seededFromPrior = priorFindings === null ? false : (() => {
|
|
3905
|
+
try {
|
|
3906
|
+
const schemaPath = args.schema ? resolve$1(args.schema) : schemaPathFor(kind, args["schema-version"]);
|
|
3907
|
+
if (!validateAgainstSchema(priorFindings, schemaPath).valid) return false;
|
|
3908
|
+
const resolution = resolve("findings", priorFindings);
|
|
3909
|
+
if (resolution.kind !== "ok") return false;
|
|
3910
|
+
if (isIncompleteFindings(resolution.value)) return false;
|
|
3911
|
+
if (parseReviewedRoute(priorBody ?? "") !== "full review") return false;
|
|
3912
|
+
writeFileSync(outPath, SEED_SENTINEL);
|
|
3913
|
+
writeFileSync(
|
|
3914
|
+
priorContextPath(outPath),
|
|
3915
|
+
`${JSON.stringify(priorFindings, null, 2)}
|
|
3916
|
+
`
|
|
3917
|
+
);
|
|
3918
|
+
const count = resolution.value.findings.length;
|
|
3919
|
+
process.stderr.write(
|
|
3920
|
+
`Seeded ${outPath} with the sentinel and wrote the prior review (${String(count)} finding(s)) to ${priorContextPath(outPath)} as context
|
|
3921
|
+
`
|
|
3922
|
+
);
|
|
3923
|
+
return true;
|
|
3924
|
+
} catch (err) {
|
|
3925
|
+
process.stderr.write(
|
|
3926
|
+
`Warning: could not seed from the prior review (${errMsg(err)}) \u2014 seeding the sentinel only
|
|
3927
|
+
`
|
|
3928
|
+
);
|
|
3929
|
+
return false;
|
|
3930
|
+
}
|
|
3931
|
+
})();
|
|
3932
|
+
const mode = (() => {
|
|
3933
|
+
if (seededFromPrior) {
|
|
3934
|
+
const priorSha = priorBody === null ? null : parseReviewedSha(priorBody);
|
|
3935
|
+
return args["head-sha"] && priorSha && priorSha === args["head-sha"].toLowerCase() ? "prior-same" : "prior-new";
|
|
3936
|
+
}
|
|
3937
|
+
if (!writeSentinel()) return "none";
|
|
3938
|
+
return priorBody === null ? "empty" : "empty-had-prior";
|
|
3939
|
+
})();
|
|
3940
|
+
process.stdout.write(`${mode}
|
|
3941
|
+
`);
|
|
3942
|
+
}
|
|
3943
|
+
});
|
|
1375
3944
|
var adaptCmd = defineCommand({
|
|
1376
3945
|
meta: {
|
|
1377
3946
|
name: "adapt",
|
|
1378
|
-
description: "Map a native agent-CLI result envelope onto the abstract SPEC
|
|
3947
|
+
description: "Map a native agent-CLI result envelope onto the abstract SPEC envelope"
|
|
1379
3948
|
},
|
|
1380
3949
|
args: {
|
|
1381
3950
|
native: {
|
|
@@ -1392,6 +3961,10 @@ var adaptCmd = defineCommand({
|
|
|
1392
3961
|
type: "string",
|
|
1393
3962
|
description: "Path to a file the agent was told to write its own validated findings JSON to (wins over the native envelope's structured_output/result when it validates)"
|
|
1394
3963
|
},
|
|
3964
|
+
"agent-file-fallback": {
|
|
3965
|
+
type: "string",
|
|
3966
|
+
description: "Path to the last-valid findings snapshot, tried after --agent-file and before the native envelope; recovers the last valid state when a wall-clock kill left --agent-file truncated, so the review posts that instead of a 'did not complete' notice"
|
|
3967
|
+
},
|
|
1395
3968
|
route: {
|
|
1396
3969
|
type: "string",
|
|
1397
3970
|
description: 'Review route label to stamp into the envelope (e.g. "full review" or "mechanic")'
|
|
@@ -1399,25 +3972,74 @@ var adaptCmd = defineCommand({
|
|
|
1399
3972
|
effort: {
|
|
1400
3973
|
type: "string",
|
|
1401
3974
|
description: 'Effort label to stamp into the envelope (e.g. "max" or "low")'
|
|
3975
|
+
},
|
|
3976
|
+
transcript: {
|
|
3977
|
+
type: "string",
|
|
3978
|
+
description: "Path to the session transcript (main .jsonl). Its tree (main + subagents) gives the true wall + turn count the native envelope under-reports, and refills per-model usage when the native envelope is empty (e.g. after a wall-clock kill)"
|
|
1402
3979
|
}
|
|
1403
3980
|
},
|
|
1404
3981
|
run: async ({ args }) => {
|
|
3982
|
+
const agentFile = args["agent-file"];
|
|
3983
|
+
const agentFileSize = agentFile ? statSizeOrNull(agentFile) : null;
|
|
3984
|
+
const seedUnrevised = agentFileSize !== null && agentFileSize <= Buffer.byteLength(SEED_SENTINEL) + 2 && isSeedSentinel(readFileOrNull(agentFile));
|
|
1405
3985
|
const envelope = unwrapAdapt(
|
|
1406
|
-
adapt(requireAdapterName(args.adapter),
|
|
3986
|
+
adapt(requireAdapterName(args.adapter), readJSONOrAbsent(args.native), agentFile, {
|
|
1407
3987
|
route: args.route,
|
|
1408
|
-
effort: args.effort
|
|
3988
|
+
effort: args.effort,
|
|
3989
|
+
agentFileFallbackPath: args["agent-file-fallback"],
|
|
3990
|
+
seedUnrevised,
|
|
3991
|
+
...args.transcript ? {
|
|
3992
|
+
transcriptFallback: () => transcriptFallbackFrom(args.transcript)
|
|
3993
|
+
} : {}
|
|
1409
3994
|
})
|
|
1410
3995
|
);
|
|
1411
3996
|
process.stdout.write(`${JSON.stringify(envelope, null, 2)}
|
|
1412
3997
|
`);
|
|
1413
3998
|
}
|
|
1414
3999
|
});
|
|
4000
|
+
var noticeCmd = defineCommand({
|
|
4001
|
+
meta: {
|
|
4002
|
+
name: "notice",
|
|
4003
|
+
description: "Emit an abstract envelope for a run that produced no completed review (security block, triage error, setup failure, checkout failure, or empty run) \u2014 flagged incomplete so the commenter renders it honestly and won't bury a real review"
|
|
4004
|
+
},
|
|
4005
|
+
args: {
|
|
4006
|
+
kind: {
|
|
4007
|
+
type: "positional",
|
|
4008
|
+
description: `One of: ${NOTICE_KINDS.join(", ")} (an unrecognized kind renders a generic incomplete notice rather than failing \u2014 the pinned CLI is older than the workflow)`,
|
|
4009
|
+
required: true
|
|
4010
|
+
},
|
|
4011
|
+
reasons: {
|
|
4012
|
+
type: "string",
|
|
4013
|
+
description: "security-blocked / triage-error only: the triage's fail-closed reason string (empty/omitted \u21D2 the no-reason wording)"
|
|
4014
|
+
},
|
|
4015
|
+
"sandbox-config": {
|
|
4016
|
+
type: "string",
|
|
4017
|
+
description: "no-output / triage-error only: path to the agent's sandbox-runtime settings (sandbox.json); its network.allowedDomains is named in the notice so an egress-blocked review self-diagnoses. Missing or unreadable \u21D2 the allowlist is omitted (an early failure runs before the jail is set up)."
|
|
4018
|
+
}
|
|
4019
|
+
},
|
|
4020
|
+
// An unrecognized kind degrades to a generic incomplete notice instead of exiting non-zero: a
|
|
4021
|
+
// `notice <kind>` call under the workflow's `set -euo pipefail` must never crash the assemble
|
|
4022
|
+
// step into posting nothing, and an unknown kind almost always means version skew, not a typo.
|
|
4023
|
+
run: ({ args }) => {
|
|
4024
|
+
if (!isNoticeKind(args.kind)) {
|
|
4025
|
+
process.stderr.write(
|
|
4026
|
+
`::warning::code-review notice: unrecognized kind "${annotationSafe(args.kind)}" \u2014 the pinned CLI is older than the workflow calling it; rendering a generic incomplete notice
|
|
4027
|
+
`
|
|
4028
|
+
);
|
|
4029
|
+
process.stdout.write(`${JSON.stringify(buildUnknownNoticeEnvelope(args.kind), null, 2)}
|
|
4030
|
+
`);
|
|
4031
|
+
return;
|
|
4032
|
+
}
|
|
4033
|
+
const namesAllowlist = args.kind === "no-output" || args.kind === "triage-error";
|
|
4034
|
+
const agentAllowlist = namesAllowlist && args["sandbox-config"] ? parseAgentAllowlist(readSandboxConfigForNotice(args["sandbox-config"])) : [];
|
|
4035
|
+
process.stdout.write(
|
|
4036
|
+
`${JSON.stringify(buildNoticeEnvelope(args.kind, args.reasons, agentAllowlist), null, 2)}
|
|
4037
|
+
`
|
|
4038
|
+
);
|
|
4039
|
+
}
|
|
4040
|
+
});
|
|
1415
4041
|
var isExtractSchemaKind = (s) => s === "findings" || s === "triage";
|
|
1416
|
-
var requireExtractSchemaKind = (name) => {
|
|
1417
|
-
if (isExtractSchemaKind(name)) return name;
|
|
1418
|
-
fail(`Unknown kind "${name}" for extract \u2014 expected one of: findings, triage`);
|
|
1419
|
-
throw new Error("unreachable");
|
|
1420
|
-
};
|
|
4042
|
+
var requireExtractSchemaKind = (name) => isExtractSchemaKind(name) ? name : fail(`Unknown kind "${name}" for extract \u2014 expected one of: findings, triage`);
|
|
1421
4043
|
var failClosedTriage = (outcome) => ({
|
|
1422
4044
|
safe: false,
|
|
1423
4045
|
reasons: describeLadderFailure(outcome)
|
|
@@ -1463,32 +4085,87 @@ var extractCmd = defineCommand({
|
|
|
1463
4085
|
${ladderFailureDiagnostics(input)}
|
|
1464
4086
|
`);
|
|
1465
4087
|
}
|
|
1466
|
-
if (kind === "triage") {
|
|
1467
|
-
process.stdout.write(`${JSON.stringify(failClosedTriage(outcome), null, 2)}
|
|
4088
|
+
if (kind === "triage") {
|
|
4089
|
+
process.stdout.write(`${JSON.stringify(failClosedTriage(outcome), null, 2)}
|
|
4090
|
+
`);
|
|
4091
|
+
return;
|
|
4092
|
+
}
|
|
4093
|
+
fail(describeLadderFailure(outcome));
|
|
4094
|
+
}
|
|
4095
|
+
});
|
|
4096
|
+
var withoutPatch = (finding) => {
|
|
4097
|
+
const copy = { ...finding };
|
|
4098
|
+
delete copy.patch;
|
|
4099
|
+
return copy;
|
|
4100
|
+
};
|
|
4101
|
+
var readFileLines = (path) => {
|
|
4102
|
+
try {
|
|
4103
|
+
const rawLines = readFileSync(path, "utf-8").split("\n");
|
|
4104
|
+
return rawLines.length > 0 && rawLines[rawLines.length - 1] === "" ? rawLines.slice(0, -1) : rawLines;
|
|
4105
|
+
} catch {
|
|
4106
|
+
return null;
|
|
4107
|
+
}
|
|
4108
|
+
};
|
|
4109
|
+
var validateFinding = (finding, repoRoot) => {
|
|
4110
|
+
if (finding.patch === void 0) return finding;
|
|
4111
|
+
const lines = readFileLines(resolve$1(repoRoot, finding.path));
|
|
4112
|
+
if (lines === null) {
|
|
4113
|
+
process.stderr.write(
|
|
4114
|
+
`validate-patches: ${finding.path}: could not read file at "${repoRoot}" \u2014 dropping patch
|
|
4115
|
+
`
|
|
4116
|
+
);
|
|
4117
|
+
return withoutPatch(finding);
|
|
4118
|
+
}
|
|
4119
|
+
const result = validatePatch(finding.patch, lines);
|
|
4120
|
+
switch (result.kind) {
|
|
4121
|
+
case "anchored":
|
|
4122
|
+
return { ...finding, start_line: result.startLine, end_line: result.endLine };
|
|
4123
|
+
case "keep":
|
|
4124
|
+
return finding;
|
|
4125
|
+
case "drop":
|
|
4126
|
+
process.stderr.write(
|
|
4127
|
+
`validate-patches: ${finding.path}:${String(finding.start_line)}: ${result.reason} \u2014 dropping patch
|
|
4128
|
+
`
|
|
4129
|
+
);
|
|
4130
|
+
return withoutPatch(finding);
|
|
4131
|
+
}
|
|
4132
|
+
};
|
|
4133
|
+
var validatePatchesCmd = defineCommand({
|
|
4134
|
+
meta: {
|
|
4135
|
+
name: "validate-patches",
|
|
4136
|
+
description: "Validate each finding's patch against the real PR-head tree: align the finding's range and keep the patch when it anchors, keep it unaligned for a pure insertion, or drop it when it doesn't apply"
|
|
4137
|
+
},
|
|
4138
|
+
args: {
|
|
4139
|
+
findings: {
|
|
4140
|
+
type: "positional",
|
|
4141
|
+
description: "Path to findings JSON",
|
|
4142
|
+
required: true
|
|
4143
|
+
},
|
|
4144
|
+
"repo-root": {
|
|
4145
|
+
type: "string",
|
|
4146
|
+
description: "Directory to resolve each finding's path against \u2014 the review job's checked-out, clean PR-head tree (default: .)"
|
|
4147
|
+
}
|
|
4148
|
+
},
|
|
4149
|
+
run: async ({ args }) => {
|
|
4150
|
+
const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
|
|
4151
|
+
const repoRoot = args["repo-root"] ? resolve$1(args["repo-root"]) : process.cwd();
|
|
4152
|
+
const validated = {
|
|
4153
|
+
...findings,
|
|
4154
|
+
findings: findings.findings.map((f) => validateFinding(f, repoRoot))
|
|
4155
|
+
};
|
|
4156
|
+
process.stdout.write(`${JSON.stringify(validated, null, 2)}
|
|
1468
4157
|
`);
|
|
1469
|
-
return;
|
|
1470
|
-
}
|
|
1471
|
-
fail(describeLadderFailure(outcome));
|
|
1472
4158
|
}
|
|
1473
4159
|
});
|
|
1474
|
-
var requireAdapterName = (name) => {
|
|
1475
|
-
if (isAdapterName(name)) return name;
|
|
1476
|
-
fail(`Unknown adapter "${name}" \u2014 supported: claude-code`);
|
|
1477
|
-
throw new Error("unreachable");
|
|
1478
|
-
};
|
|
4160
|
+
var requireAdapterName = (name) => isAdapterName(name) ? name : fail(`Unknown adapter "${name}" \u2014 supported: claude-code`);
|
|
1479
4161
|
var isSchemaKind = (s) => s === "findings" || s === "triage" || s === "prices";
|
|
1480
|
-
var requireSchemaKind = (name) => {
|
|
1481
|
-
if (isSchemaKind(name)) return name;
|
|
1482
|
-
fail(`Unknown schema "${name}" \u2014 expected one of: findings, triage, prices`);
|
|
1483
|
-
throw new Error("unreachable");
|
|
1484
|
-
};
|
|
4162
|
+
var requireSchemaKind = (name) => isSchemaKind(name) ? name : fail(`Unknown schema "${name}" \u2014 expected one of: findings, triage, prices`);
|
|
1485
4163
|
var requireSchemaPath = (kind, version) => {
|
|
1486
4164
|
try {
|
|
1487
4165
|
return schemaPathFor(kind, version);
|
|
1488
4166
|
} catch (err) {
|
|
1489
|
-
fail(
|
|
4167
|
+
return fail(errMsg(err));
|
|
1490
4168
|
}
|
|
1491
|
-
throw new Error("unreachable");
|
|
1492
4169
|
};
|
|
1493
4170
|
var printSchemaCmd = defineCommand({
|
|
1494
4171
|
meta: {
|
|
@@ -1509,18 +4186,105 @@ var printSchemaCmd = defineCommand({
|
|
|
1509
4186
|
run: async ({ args }) => {
|
|
1510
4187
|
const schemaKind = requireSchemaKind(args.name);
|
|
1511
4188
|
const schemaPath = requireSchemaPath(schemaKind, args["schema-version"]);
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
4189
|
+
process.stdout.write(`${printableSchema(schemaPath)}
|
|
4190
|
+
`);
|
|
4191
|
+
}
|
|
4192
|
+
});
|
|
4193
|
+
var MAX_NUDGES_DEFAULT = 5;
|
|
4194
|
+
var drainStdin = () => {
|
|
4195
|
+
if (process.stdin.isTTY) return;
|
|
4196
|
+
try {
|
|
4197
|
+
readFileSync(0);
|
|
4198
|
+
} catch {
|
|
4199
|
+
}
|
|
4200
|
+
};
|
|
4201
|
+
var requireMaxNudges = (raw) => {
|
|
4202
|
+
if (raw === void 0) return MAX_NUDGES_DEFAULT;
|
|
4203
|
+
if (!/^\d+$/.test(raw)) {
|
|
4204
|
+
fail(`--max-nudges must be a non-negative integer; got "${raw}"`);
|
|
4205
|
+
}
|
|
4206
|
+
const n = Number.parseInt(raw, 10);
|
|
4207
|
+
if (n < 1) {
|
|
4208
|
+
fail(`--max-nudges must be >= 1 \u2014 a gate that never blocks must be omitted, not set to ${raw}`);
|
|
4209
|
+
}
|
|
4210
|
+
return n;
|
|
4211
|
+
};
|
|
4212
|
+
var stopGateCmd = defineCommand({
|
|
4213
|
+
meta: {
|
|
4214
|
+
name: "stop-gate",
|
|
4215
|
+
description: "Claude Code Stop-hook gate: refuse to let the agent end its turn until --draft validates against the schema (bounded by --max-nudges). With --print-settings, emit the --settings JSON that wires this as the Stop hook."
|
|
4216
|
+
},
|
|
4217
|
+
args: {
|
|
4218
|
+
draft: {
|
|
4219
|
+
type: "string",
|
|
4220
|
+
description: "Path to the findings document the agent must produce and keep valid",
|
|
4221
|
+
required: true
|
|
4222
|
+
},
|
|
4223
|
+
kind: {
|
|
4224
|
+
type: "string",
|
|
4225
|
+
description: "Schema kind to validate against: findings | triage | prices (default: findings)"
|
|
4226
|
+
},
|
|
4227
|
+
schema: { type: "string", description: "Path to a schema file (wins over --kind)" },
|
|
4228
|
+
"schema-version": {
|
|
4229
|
+
type: "string",
|
|
4230
|
+
description: "Schema major.minor to validate against (default: the draft's declared version)"
|
|
4231
|
+
},
|
|
4232
|
+
"max-nudges": {
|
|
4233
|
+
type: "string",
|
|
4234
|
+
description: `Times to block before relenting so the step fails downstream as before (default: ${String(MAX_NUDGES_DEFAULT)})`
|
|
4235
|
+
},
|
|
4236
|
+
counter: {
|
|
4237
|
+
type: "string",
|
|
4238
|
+
description: "Path for the nudge counter (default: <draft>.nudges)"
|
|
4239
|
+
},
|
|
4240
|
+
"print-settings": {
|
|
4241
|
+
type: "boolean",
|
|
4242
|
+
description: "Print the Stop-hook settings JSON that wires this gate, then exit"
|
|
4243
|
+
}
|
|
4244
|
+
},
|
|
4245
|
+
run: async ({ args }) => {
|
|
4246
|
+
const draftPath = resolve$1(args.draft);
|
|
4247
|
+
if (args["print-settings"]) {
|
|
4248
|
+
const command = defaultHookCommand(draftPath, {
|
|
4249
|
+
kind: args.kind,
|
|
4250
|
+
schema: args.schema,
|
|
4251
|
+
schemaVersion: args["schema-version"],
|
|
4252
|
+
maxNudges: args["max-nudges"],
|
|
4253
|
+
counter: args.counter
|
|
4254
|
+
});
|
|
4255
|
+
process.stdout.write(`${JSON.stringify(stopHookSettings(command))}
|
|
4256
|
+
`);
|
|
4257
|
+
return;
|
|
4258
|
+
}
|
|
4259
|
+
drainStdin();
|
|
4260
|
+
const kind = requireSchemaKind(args.kind || "findings");
|
|
4261
|
+
const maxNudges = requireMaxNudges(args["max-nudges"]);
|
|
4262
|
+
const counterPath = args.counter ? resolve$1(args.counter) : `${draftPath}.nudges`;
|
|
4263
|
+
const state = draftState(
|
|
4264
|
+
draftPath,
|
|
4265
|
+
(parsed) => args.schema ? resolve$1(args.schema) : schemaPathFor(kind, args["schema-version"] || derivedSchemaVersion(kind, parsed))
|
|
1515
4266
|
);
|
|
1516
|
-
|
|
4267
|
+
const nudges = readNudges(counterPath);
|
|
4268
|
+
const decision = decideGate(state, nudges, maxNudges, draftPath, kind);
|
|
4269
|
+
if (decision.kind === "block") {
|
|
4270
|
+
try {
|
|
4271
|
+
bumpNudges(counterPath, nudges);
|
|
4272
|
+
} catch (err) {
|
|
4273
|
+
process.stderr.write(
|
|
4274
|
+
`stop-gate: cannot persist nudge counter at ${counterPath} \u2192 allowing to avoid an unbounded block loop: ${errMsg(err)}
|
|
4275
|
+
`
|
|
4276
|
+
);
|
|
4277
|
+
return;
|
|
4278
|
+
}
|
|
4279
|
+
process.stdout.write(`${JSON.stringify({ decision: "block", reason: decision.reason })}
|
|
1517
4280
|
`);
|
|
4281
|
+
}
|
|
1518
4282
|
}
|
|
1519
4283
|
});
|
|
1520
4284
|
var gatherCmd = defineCommand({
|
|
1521
4285
|
meta: {
|
|
1522
4286
|
name: "gather",
|
|
1523
|
-
description: "Resolve the PR from the CI head SHA and gather review inputs (diff with git-diff fallback, PR context, prior bot review, failing-job logs) as files for the review agent"
|
|
4287
|
+
description: "Resolve the PR from the CI head SHA and gather review inputs (diff with git-diff fallback, PR context, prior bot review, untrusted PR conversation to triage, failing-job logs) as files for the review agent"
|
|
1524
4288
|
},
|
|
1525
4289
|
args: {
|
|
1526
4290
|
repo: { type: "string", description: "Repository (owner/name)", required: true },
|
|
@@ -1533,6 +4297,11 @@ var gatherCmd = defineCommand({
|
|
|
1533
4297
|
type: "string",
|
|
1534
4298
|
description: "Head branch to disambiguate the PR when multiple share a commit"
|
|
1535
4299
|
},
|
|
4300
|
+
"default-branch": {
|
|
4301
|
+
type: "string",
|
|
4302
|
+
description: "The repo's default branch \u2014 the trusted base the review checks out, and the reference for the full (triage) diff; a PR based on any other branch is treated as stacked",
|
|
4303
|
+
required: true
|
|
4304
|
+
},
|
|
1536
4305
|
"run-id": {
|
|
1537
4306
|
type: "string",
|
|
1538
4307
|
description: "CI run id (from workflow_run.id); its failing jobs' logs are downloaded on failure",
|
|
@@ -1557,6 +4326,7 @@ var gatherCmd = defineCommand({
|
|
|
1557
4326
|
repo: args.repo,
|
|
1558
4327
|
headSha: args["head-sha"],
|
|
1559
4328
|
headBranch: args["head-branch"],
|
|
4329
|
+
defaultBranch: args["default-branch"],
|
|
1560
4330
|
runId: args["run-id"],
|
|
1561
4331
|
conclusion: args.conclusion,
|
|
1562
4332
|
botLogin: args["bot-login"] || "github-actions[bot]",
|
|
@@ -1601,7 +4371,7 @@ var postCmd = defineCommand({
|
|
|
1601
4371
|
},
|
|
1602
4372
|
"inline-template": {
|
|
1603
4373
|
type: "string",
|
|
1604
|
-
description: "Path to inline comment Eta template (default:
|
|
4374
|
+
description: "Path to inline comment Eta template (default: bundled templates/inline.eta)"
|
|
1605
4375
|
},
|
|
1606
4376
|
route: {
|
|
1607
4377
|
type: "string",
|
|
@@ -1622,47 +4392,457 @@ var postCmd = defineCommand({
|
|
|
1622
4392
|
"test-report": {
|
|
1623
4393
|
type: "string",
|
|
1624
4394
|
description: TEST_REPORT_DESCRIPTION
|
|
4395
|
+
},
|
|
4396
|
+
"run-url": {
|
|
4397
|
+
type: "string",
|
|
4398
|
+
description: "Workflow run URL (transcript/traces), rendered as a link in the LLM Disclosure aside"
|
|
4399
|
+
},
|
|
4400
|
+
"json-url": {
|
|
4401
|
+
type: "string",
|
|
4402
|
+
description: "URL to the machine-readable findings JSON artifact, pointed at from the sticky and each inline comment"
|
|
4403
|
+
},
|
|
4404
|
+
"convergence-threshold": {
|
|
4405
|
+
type: "string",
|
|
4406
|
+
description: CONVERGENCE_THRESHOLD_DESCRIPTION
|
|
1625
4407
|
}
|
|
1626
4408
|
},
|
|
1627
4409
|
run: async ({ args }) => {
|
|
4410
|
+
const priceResolution = resolvePrices(args.prices);
|
|
1628
4411
|
await post({
|
|
1629
4412
|
repo: args.repo,
|
|
1630
4413
|
headSha: args["head-sha"],
|
|
1631
4414
|
botLogin: args["bot-login"] || "github-actions[bot]",
|
|
1632
4415
|
findingsPath: args.findings,
|
|
1633
4416
|
envelopePath: args.usage,
|
|
1634
|
-
pricesPath:
|
|
4417
|
+
pricesPath: priceResolution.path,
|
|
4418
|
+
pricesProvided: priceResolution.kind === "provided",
|
|
1635
4419
|
templatePath: resolveTemplatePath(args.template),
|
|
1636
|
-
inlineTemplatePath:
|
|
4420
|
+
inlineTemplatePath: resolveInlineTemplatePath(args["inline-template"]),
|
|
1637
4421
|
route: args.route,
|
|
1638
4422
|
headBranch: args["head-branch"],
|
|
1639
4423
|
effort: args.effort,
|
|
1640
|
-
testReportPath: args["test-report"]
|
|
4424
|
+
testReportPath: args["test-report"],
|
|
4425
|
+
runUrl: args["run-url"],
|
|
4426
|
+
jsonUrl: args["json-url"],
|
|
4427
|
+
convergenceThreshold: parseConvergenceThreshold(args["convergence-threshold"]),
|
|
4428
|
+
postedAt: formatUtc(/* @__PURE__ */ new Date())
|
|
4429
|
+
});
|
|
4430
|
+
}
|
|
4431
|
+
});
|
|
4432
|
+
var announceCmd = defineCommand({
|
|
4433
|
+
meta: {
|
|
4434
|
+
name: "announce",
|
|
4435
|
+
description: "Post (or update) the sticky the moment a review starts \u2014 an in-progress placeholder linking the run \u2014 so a workflow_run review, which runs from the default branch and is otherwise invisible on the PR, is visibly under way. Preserves a prior sticky's embedded findings + reviewed-sha markers so the re-review seed survives the swap."
|
|
4436
|
+
},
|
|
4437
|
+
args: {
|
|
4438
|
+
"head-sha": {
|
|
4439
|
+
type: "string",
|
|
4440
|
+
description: "Trusted head SHA to resolve the PR (from workflow_run.head_sha)",
|
|
4441
|
+
required: true
|
|
4442
|
+
},
|
|
4443
|
+
repo: {
|
|
4444
|
+
type: "string",
|
|
4445
|
+
description: "Repository (owner/name)",
|
|
4446
|
+
required: true
|
|
4447
|
+
},
|
|
4448
|
+
"run-url": {
|
|
4449
|
+
type: "string",
|
|
4450
|
+
description: "Workflow run URL the placeholder links to",
|
|
4451
|
+
required: true
|
|
4452
|
+
},
|
|
4453
|
+
"bot-login": {
|
|
4454
|
+
type: "string",
|
|
4455
|
+
description: "Bot login to trust for the sticky comment upsert (default: github-actions[bot])"
|
|
4456
|
+
},
|
|
4457
|
+
"head-branch": {
|
|
4458
|
+
type: "string",
|
|
4459
|
+
description: "Head branch to disambiguate the PR when multiple share a commit"
|
|
4460
|
+
}
|
|
4461
|
+
},
|
|
4462
|
+
run: async ({ args }) => {
|
|
4463
|
+
await announce({
|
|
4464
|
+
repo: args.repo,
|
|
4465
|
+
headSha: args["head-sha"],
|
|
4466
|
+
botLogin: args["bot-login"] || "github-actions[bot]",
|
|
4467
|
+
runUrl: args["run-url"],
|
|
4468
|
+
headBranch: args["head-branch"]
|
|
4469
|
+
}).catch(
|
|
4470
|
+
(err) => (
|
|
4471
|
+
// `::warning::` so a persistently broken announce shows up in the run's annotations, not only
|
|
4472
|
+
// buried in the step log — `announce` otherwise reports success while having posted nothing.
|
|
4473
|
+
process.stderr.write(
|
|
4474
|
+
`::warning::code-review announce: could not post the in-progress sticky (${annotationSafe(errMsg(err))}) \u2014 continuing (cosmetic)
|
|
4475
|
+
`
|
|
4476
|
+
)
|
|
4477
|
+
)
|
|
4478
|
+
);
|
|
4479
|
+
}
|
|
4480
|
+
});
|
|
4481
|
+
var isCheckIntent = (s) => s === "in_progress" || s === "neutral" || s === "failure" || s === "cancelled";
|
|
4482
|
+
var checkRunCmd = defineCommand({
|
|
4483
|
+
meta: {
|
|
4484
|
+
name: "check-run",
|
|
4485
|
+
description: "Upsert the native 'Code review' check-run on the head SHA \u2014 the attribution surface that appears in the PR's own checks list and (writing to the base repo) works for fork PRs too. `in_progress` at review start, `neutral` when the review completes, `failure` when it didn't, `cancelled` when a cancelled review settles its own check (matched by details_url, so it never touches a superseding run's check). Forward-only: `failure`/`cancelled` never overwrite a completed review."
|
|
4486
|
+
},
|
|
4487
|
+
args: {
|
|
4488
|
+
repo: { type: "string", description: "Repository (owner/name)", required: true },
|
|
4489
|
+
"head-sha": {
|
|
4490
|
+
type: "string",
|
|
4491
|
+
description: "Head SHA the check-run is anchored to",
|
|
4492
|
+
required: true
|
|
4493
|
+
},
|
|
4494
|
+
status: {
|
|
4495
|
+
type: "positional",
|
|
4496
|
+
description: "One of: in_progress, neutral, failure, cancelled",
|
|
4497
|
+
required: true
|
|
4498
|
+
},
|
|
4499
|
+
"run-url": {
|
|
4500
|
+
type: "string",
|
|
4501
|
+
description: "Workflow run URL the check-run's details link to (also the ownership key for `cancelled`)",
|
|
4502
|
+
required: true
|
|
4503
|
+
}
|
|
4504
|
+
},
|
|
4505
|
+
run: async ({ args }) => {
|
|
4506
|
+
if (!isCheckIntent(args.status)) {
|
|
4507
|
+
process.stderr.write(
|
|
4508
|
+
`::warning::code-review check-run: unrecognized status "${annotationSafe(args.status)}" \u2014 expected in_progress, neutral, failure, or cancelled; skipping
|
|
4509
|
+
`
|
|
4510
|
+
);
|
|
4511
|
+
return;
|
|
4512
|
+
}
|
|
4513
|
+
await checkRun({
|
|
4514
|
+
repo: args.repo,
|
|
4515
|
+
headSha: args["head-sha"],
|
|
4516
|
+
intent: args.status,
|
|
4517
|
+
runUrl: args["run-url"]
|
|
4518
|
+
}).catch(
|
|
4519
|
+
(err) => process.stderr.write(
|
|
4520
|
+
`::warning::code-review check-run: could not upsert the check-run (${annotationSafe(errMsg(err))}) \u2014 continuing (attribution aid)
|
|
4521
|
+
`
|
|
4522
|
+
)
|
|
4523
|
+
);
|
|
4524
|
+
}
|
|
4525
|
+
});
|
|
4526
|
+
var reportIncompleteCmd = defineCommand({
|
|
4527
|
+
meta: {
|
|
4528
|
+
name: "report-incomplete",
|
|
4529
|
+
description: "Post (or update) the sticky when a review job hard-failed and posted nothing \u2014 an attributed 'did not complete' notice linking the run, telling the reader to re-request; with --cancelled, the informational 'superseded \u2014 no action needed' notice instead (issue #139). Never buries a completed review, and never overwrites a superseding run's live in-progress placeholder."
|
|
4530
|
+
},
|
|
4531
|
+
args: {
|
|
4532
|
+
repo: { type: "string", description: "Repository (owner/name)", required: true },
|
|
4533
|
+
"head-sha": {
|
|
4534
|
+
type: "string",
|
|
4535
|
+
description: "Trusted head SHA to resolve the PR (from workflow_run.head_sha)",
|
|
4536
|
+
required: true
|
|
4537
|
+
},
|
|
4538
|
+
"run-url": {
|
|
4539
|
+
type: "string",
|
|
4540
|
+
description: "Workflow run URL the notice links to",
|
|
4541
|
+
required: true
|
|
4542
|
+
},
|
|
4543
|
+
"bot-login": {
|
|
4544
|
+
type: "string",
|
|
4545
|
+
description: "Bot login to trust for the sticky comment upsert (default: github-actions[bot])"
|
|
4546
|
+
},
|
|
4547
|
+
"head-branch": {
|
|
4548
|
+
type: "string",
|
|
4549
|
+
description: "Head branch to disambiguate the PR when multiple share a commit"
|
|
4550
|
+
},
|
|
4551
|
+
cancelled: {
|
|
4552
|
+
type: "boolean",
|
|
4553
|
+
description: "This run was CANCELLED before completing (typically superseded by a newer run on the same branch) \u2014 post the informational 'superseded' notice, not the failure notice (issue #139)"
|
|
4554
|
+
}
|
|
4555
|
+
},
|
|
4556
|
+
run: async ({ args }) => {
|
|
4557
|
+
await reportIncomplete({
|
|
4558
|
+
repo: args.repo,
|
|
4559
|
+
headSha: args["head-sha"],
|
|
4560
|
+
botLogin: args["bot-login"] || "github-actions[bot]",
|
|
4561
|
+
runUrl: args["run-url"],
|
|
4562
|
+
headBranch: args["head-branch"],
|
|
4563
|
+
cancelled: args.cancelled
|
|
4564
|
+
}).catch(
|
|
4565
|
+
(err) => process.stderr.write(
|
|
4566
|
+
`::warning::code-review report-incomplete: could not post the notice (${annotationSafe(errMsg(err))}) \u2014 continuing
|
|
4567
|
+
`
|
|
4568
|
+
)
|
|
4569
|
+
);
|
|
4570
|
+
}
|
|
4571
|
+
});
|
|
4572
|
+
var requireCeilingSec = (raw) => {
|
|
4573
|
+
if (raw === void 0) return null;
|
|
4574
|
+
const ms = parseWallMs(raw);
|
|
4575
|
+
if (ms === null)
|
|
4576
|
+
return fail(`--max-duration must be a duration like 60m, 3600s, or 1h (got "${raw}")`);
|
|
4577
|
+
return Math.floor(ms / 1e3);
|
|
4578
|
+
};
|
|
4579
|
+
var requireCeilingUsd = (raw) => {
|
|
4580
|
+
if (raw === void 0) return null;
|
|
4581
|
+
const n = Number.parseFloat(raw.replace(/^\$/, ""));
|
|
4582
|
+
if (!Number.isFinite(n) || n < 0) fail(`--max-usd must be a non-negative number (got "${raw}")`);
|
|
4583
|
+
return n;
|
|
4584
|
+
};
|
|
4585
|
+
var requireMaxInstructions = (raw) => {
|
|
4586
|
+
if (raw === void 0) return 4e3;
|
|
4587
|
+
if (!/^\d+$/.test(raw)) fail(`--max-instructions must be a non-negative integer (got "${raw}")`);
|
|
4588
|
+
return Number.parseInt(raw, 10);
|
|
4589
|
+
};
|
|
4590
|
+
var requirePositiveInt = (raw, flag) => {
|
|
4591
|
+
const n = Number.parseInt(raw, 10);
|
|
4592
|
+
return Number.isInteger(n) && n > 0 && /^\d+$/.test(raw) ? n : fail(`${flag} must be a positive integer; got "${raw}"`);
|
|
4593
|
+
};
|
|
4594
|
+
var requireWallMs = (raw, flag, fallback) => {
|
|
4595
|
+
const ms = parseWallMs(raw || fallback);
|
|
4596
|
+
return ms === null ? fail(`${flag} must be a duration like 30m, 15s, or 1h (got "${raw ?? ""}")`) : ms;
|
|
4597
|
+
};
|
|
4598
|
+
var parseCommandCmd = defineCommand({
|
|
4599
|
+
meta: {
|
|
4600
|
+
name: "parse-command",
|
|
4601
|
+
description: `Resolve a PR's head (SHA/branch/repo) from its NUMBER via the API and parse a ChatOps trigger comment ("/code-review [24m] [$1.00] <instructions>") into $GITHUB_OUTPUT lines. The untrusted comment is parsed here in type-safe code, never in workflow bash, and the head is resolved from the trusted PR number, never from the comment text. Emits should_run=false (and nothing else) when the comment is not the trigger, the PR is closed, or resolution fails.`
|
|
4602
|
+
},
|
|
4603
|
+
args: {
|
|
4604
|
+
repo: { type: "string", description: "Repository (owner/name)", required: true },
|
|
4605
|
+
pr: {
|
|
4606
|
+
type: "string",
|
|
4607
|
+
description: "PR number (from github.event.issue.number \u2014 trusted event data)",
|
|
4608
|
+
required: true
|
|
4609
|
+
},
|
|
4610
|
+
"comment-body": {
|
|
4611
|
+
type: "string",
|
|
4612
|
+
description: "The comment body to parse (default: the CODE_REVIEW_COMMENT_BODY env var \u2014 the safe way to pass untrusted text without shell interpolation)"
|
|
4613
|
+
},
|
|
4614
|
+
trigger: {
|
|
4615
|
+
type: "string",
|
|
4616
|
+
description: 'Trigger token the comment must begin with (default: "/code-review")'
|
|
4617
|
+
},
|
|
4618
|
+
"max-duration": {
|
|
4619
|
+
type: "string",
|
|
4620
|
+
description: "Ceiling the requested duration is clamped to (e.g. 60m); omit for no clamp \u2014 a comment could then request an unbounded wall, so set this"
|
|
4621
|
+
},
|
|
4622
|
+
"max-usd": {
|
|
4623
|
+
type: "string",
|
|
4624
|
+
description: "Ceiling the requested USD budget is clamped to (e.g. 5); omit for no clamp"
|
|
4625
|
+
},
|
|
4626
|
+
"max-instructions": {
|
|
4627
|
+
type: "string",
|
|
4628
|
+
description: "Max characters of free-form instructions kept (default: 4000)"
|
|
4629
|
+
}
|
|
4630
|
+
},
|
|
4631
|
+
run: async ({ args }) => {
|
|
4632
|
+
const body = args["comment-body"] || process.env["CODE_REVIEW_COMMENT_BODY"] || "";
|
|
4633
|
+
const result = await parseCommand({
|
|
4634
|
+
repo: args.repo,
|
|
4635
|
+
prNumber: requirePositiveInt(args.pr, "--pr"),
|
|
4636
|
+
body,
|
|
4637
|
+
options: {
|
|
4638
|
+
trigger: args.trigger || "/code-review",
|
|
4639
|
+
maxDurationSec: requireCeilingSec(args["max-duration"]),
|
|
4640
|
+
maxUsd: requireCeilingUsd(args["max-usd"]),
|
|
4641
|
+
maxInstructionsLen: requireMaxInstructions(args["max-instructions"])
|
|
4642
|
+
}
|
|
4643
|
+
});
|
|
4644
|
+
if (result.kind === "skip") {
|
|
4645
|
+
process.stderr.write(`code-review parse-command: not running \u2014 ${result.reason}
|
|
4646
|
+
`);
|
|
4647
|
+
process.stdout.write(renderCommandOutputs(result, "UNUSED"));
|
|
4648
|
+
return;
|
|
4649
|
+
}
|
|
4650
|
+
for (const note of result.args.notes)
|
|
4651
|
+
process.stderr.write(`code-review parse-command: ${note}
|
|
4652
|
+
`);
|
|
4653
|
+
const delim = safeHeredocDelim(result.args.instructions, () => randomBytes(16).toString("hex"));
|
|
4654
|
+
process.stdout.write(renderCommandOutputs(result, delim));
|
|
4655
|
+
}
|
|
4656
|
+
});
|
|
4657
|
+
var requireReaction = (name) => isReaction(name) ? name : fail(`Unknown reaction "${name}" \u2014 one of: ${REACTIONS.join(", ")}`);
|
|
4658
|
+
var reactCmd = defineCommand({
|
|
4659
|
+
meta: {
|
|
4660
|
+
name: "react",
|
|
4661
|
+
description: "Add and/or remove a GitHub reaction on a PR/issue comment \u2014 the ChatOps acknowledgement (\u{1F440} on receipt, swapped to \u{1F680} on completion). Cosmetic: warns and exits 0 on any API error so a reaction never fails the job."
|
|
4662
|
+
},
|
|
4663
|
+
args: {
|
|
4664
|
+
repo: { type: "string", description: "Repository (owner/name)", required: true },
|
|
4665
|
+
"comment-id": {
|
|
4666
|
+
type: "string",
|
|
4667
|
+
description: "Comment id to react on (github.event.comment.id)",
|
|
4668
|
+
required: true
|
|
4669
|
+
},
|
|
4670
|
+
add: { type: "string", description: `Reaction to add: ${REACTIONS.join(" | ")}` },
|
|
4671
|
+
remove: {
|
|
4672
|
+
type: "string",
|
|
4673
|
+
description: "Reaction (of the token owner) to remove after adding \u2014 for the \u{1F440}\u2192\u{1F680} swap"
|
|
4674
|
+
}
|
|
4675
|
+
},
|
|
4676
|
+
run: async ({ args }) => {
|
|
4677
|
+
const commentId = requirePositiveInt(args["comment-id"], "--comment-id");
|
|
4678
|
+
const add = args.add ? requireReaction(args.add) : void 0;
|
|
4679
|
+
const remove = args.remove ? requireReaction(args.remove) : void 0;
|
|
4680
|
+
if (add === void 0 && remove === void 0)
|
|
4681
|
+
fail("react: nothing to do \u2014 pass --add and/or --remove");
|
|
4682
|
+
await react({ repo: args.repo, commentId, add, remove }).catch(
|
|
4683
|
+
(err) => process.stderr.write(
|
|
4684
|
+
`code-review react: reaction update failed (${errMsg(err)}) \u2014 continuing (reactions are cosmetic)
|
|
4685
|
+
`
|
|
4686
|
+
)
|
|
4687
|
+
);
|
|
4688
|
+
}
|
|
4689
|
+
});
|
|
4690
|
+
var awaitCiCmd = defineCommand({
|
|
4691
|
+
meta: {
|
|
4692
|
+
name: "await-ci",
|
|
4693
|
+
description: "Wait for the PR head's CI workflow run to conclude, then emit its REAL conclusion + run id to $GITHUB_OUTPUT (ci_settled, ci_conclusion, ci_run_id). The on-demand comment trigger uses this so it routes on the same CI result the CI-completion trigger would \u2014 success \u2192 full review, failure \u2192 mechanic with that run's logs \u2014 instead of reviewing blind. Polls until the run completes or the timeout elapses; ci_settled=false \u21D2 no conclusive result (caller should decline to review, not guess)."
|
|
4694
|
+
},
|
|
4695
|
+
args: {
|
|
4696
|
+
repo: { type: "string", description: "Repository (owner/name)", required: true },
|
|
4697
|
+
"head-sha": {
|
|
4698
|
+
type: "string",
|
|
4699
|
+
description: "PR head SHA to find the CI run for (resolved from the trusted PR number)",
|
|
4700
|
+
required: true
|
|
4701
|
+
},
|
|
4702
|
+
"ci-workflow": {
|
|
4703
|
+
type: "string",
|
|
4704
|
+
description: 'CI workflow name to wait for \u2014 the name: of your CI workflow (default: "CI")'
|
|
4705
|
+
},
|
|
4706
|
+
timeout: {
|
|
4707
|
+
type: "string",
|
|
4708
|
+
description: "Give up waiting after this wall (default: 30m)"
|
|
4709
|
+
},
|
|
4710
|
+
"poll-interval": {
|
|
4711
|
+
type: "string",
|
|
4712
|
+
description: "How often to re-check the run status (default: 15s)"
|
|
4713
|
+
}
|
|
4714
|
+
},
|
|
4715
|
+
run: async ({ args }) => {
|
|
4716
|
+
const workflowName = args["ci-workflow"] || "CI";
|
|
4717
|
+
const outcome = await awaitCiConclusion(args.repo, args["head-sha"], {
|
|
4718
|
+
workflowName,
|
|
4719
|
+
pollIntervalMs: requireWallMs(args["poll-interval"], "--poll-interval", "15s"),
|
|
4720
|
+
timeoutMs: requireWallMs(args.timeout, "--timeout", "30m")
|
|
1641
4721
|
});
|
|
4722
|
+
process.stderr.write(
|
|
4723
|
+
outcome.kind === "concluded" ? `code-review await-ci: CI run ${String(outcome.runId)} ("${workflowName}") concluded "${outcome.conclusion}"
|
|
4724
|
+
` : `code-review await-ci: no run named "${workflowName}" concluded before the timeout \u2014 not reviewing.${outcome.seenNames.length > 0 ? ` Workflow names seen for this head SHA: ${outcome.seenNames.join(", ")} \u2014 check --ci-workflow matches one.` : " No workflow runs were seen for this head SHA at all."}
|
|
4725
|
+
`
|
|
4726
|
+
);
|
|
4727
|
+
process.stdout.write(renderCiOutputs(outcome));
|
|
4728
|
+
}
|
|
4729
|
+
});
|
|
4730
|
+
var checkScopeCmd = defineCommand({
|
|
4731
|
+
meta: {
|
|
4732
|
+
name: "check-scope",
|
|
4733
|
+
description: "Validate + normalize the workflow's `scope` input \u2014 the languages/inputs the project accepts (issue #139). Prints the normalized space-separated language list for splicing into the review prompt, or nothing when the scope is empty (the reviewer then infers it from the README's first paragraph). Fails loudly on a malformed value, so a config typo never silently corrupts the prompt it is spliced into."
|
|
4734
|
+
},
|
|
4735
|
+
args: {
|
|
4736
|
+
scope: {
|
|
4737
|
+
type: "string",
|
|
4738
|
+
description: 'The raw scope value \u2014 whitespace/comma/semicolon-separated language names (e.g. "C C++"); empty \u21D2 absent'
|
|
4739
|
+
}
|
|
4740
|
+
},
|
|
4741
|
+
run: ({ args }) => {
|
|
4742
|
+
const parsed = parseScope(args.scope);
|
|
4743
|
+
switch (parsed.kind) {
|
|
4744
|
+
case "absent":
|
|
4745
|
+
return;
|
|
4746
|
+
case "invalid":
|
|
4747
|
+
return fail(`check-scope: ${parsed.reason}`);
|
|
4748
|
+
case "ok":
|
|
4749
|
+
process.stdout.write(`${parsed.languages.join(" ")}
|
|
4750
|
+
`);
|
|
4751
|
+
}
|
|
4752
|
+
}
|
|
4753
|
+
});
|
|
4754
|
+
var sandboxConfigCmd = defineCommand({
|
|
4755
|
+
meta: {
|
|
4756
|
+
name: "sandbox-config",
|
|
4757
|
+
description: "Emit the sandbox-runtime (srt) settings that jail the untrusted review agent's egress: allow the model host (derived from api_base_url), the GitHub API/host, and the consumer's extra_endpoints; deny all else; filesystem isolation off"
|
|
4758
|
+
},
|
|
4759
|
+
args: {
|
|
4760
|
+
"api-base-url": {
|
|
4761
|
+
type: "string",
|
|
4762
|
+
description: "The model endpoint the CLI dials (ANTHROPIC_BASE_URL) \u2014 its host is allowlisted",
|
|
4763
|
+
required: true
|
|
4764
|
+
},
|
|
4765
|
+
extra: {
|
|
4766
|
+
type: "string",
|
|
4767
|
+
description: "Whitespace-separated host[:port] list of extra domains to allow (the extra_endpoints input)"
|
|
4768
|
+
},
|
|
4769
|
+
out: {
|
|
4770
|
+
type: "string",
|
|
4771
|
+
description: "Write the settings JSON here instead of stdout"
|
|
4772
|
+
},
|
|
4773
|
+
"known-model-host": {
|
|
4774
|
+
type: "string",
|
|
4775
|
+
description: "Additional model HOST(S) to treat as known (space-separated bare hostnames, not URLs) \u2014 the consumer's declared host(s) for a provider outside the built-in set; a derived host outside the built-ins and this list warns (or fails under --strict-host)"
|
|
4776
|
+
},
|
|
4777
|
+
"strict-host": {
|
|
4778
|
+
type: "boolean",
|
|
4779
|
+
description: "Fail (instead of warning) when the model host derived from api_base_url is not a well-known or declared host \u2014 closes the fail-open-on-typo gap where a mistyped api_base_url would still be allowlisted and sent the key"
|
|
4780
|
+
}
|
|
4781
|
+
},
|
|
4782
|
+
run: ({ args }) => {
|
|
4783
|
+
const modelHost = deriveModelHost(args["api-base-url"]);
|
|
4784
|
+
const declaredHosts = [
|
|
4785
|
+
...args["known-model-host"] ? parseExtraEndpoints(args["known-model-host"]) : [],
|
|
4786
|
+
...args.extra ? parseExtraEndpoints(args.extra) : []
|
|
4787
|
+
];
|
|
4788
|
+
if (!isKnownModelHost(modelHost, declaredHosts)) {
|
|
4789
|
+
const message = `code-review sandbox-config: derived model host "${modelHost}" is not a well-known or declared host \u2014 the jail will allow egress to it and send MODEL_API_KEY there; verify api_base_url is correct`;
|
|
4790
|
+
if (args["strict-host"]) {
|
|
4791
|
+
process.stderr.write(`::error::${annotationSafe(message)}
|
|
4792
|
+
`);
|
|
4793
|
+
process.exit(1);
|
|
4794
|
+
}
|
|
4795
|
+
process.stderr.write(`::warning::${annotationSafe(message)}
|
|
4796
|
+
`);
|
|
4797
|
+
}
|
|
4798
|
+
const config = buildSandboxConfig({ apiBaseUrl: args["api-base-url"], extra: args.extra });
|
|
4799
|
+
const json = `${JSON.stringify(config, null, 2)}
|
|
4800
|
+
`;
|
|
4801
|
+
if (args.out) {
|
|
4802
|
+
writeFileSync(resolve$1(args.out), json);
|
|
4803
|
+
} else {
|
|
4804
|
+
process.stdout.write(json);
|
|
4805
|
+
}
|
|
1642
4806
|
}
|
|
1643
4807
|
});
|
|
1644
4808
|
var main = defineCommand({
|
|
1645
4809
|
meta: {
|
|
1646
4810
|
name: "code-review",
|
|
1647
4811
|
version: packageVersion,
|
|
1648
|
-
description: "Deterministic commenter for agentic PR review
|
|
4812
|
+
description: "Deterministic commenter for agentic PR review"
|
|
1649
4813
|
},
|
|
1650
4814
|
subCommands: {
|
|
1651
4815
|
gather: gatherCmd,
|
|
4816
|
+
"parse-command": parseCommandCmd,
|
|
4817
|
+
react: reactCmd,
|
|
4818
|
+
"await-ci": awaitCiCmd,
|
|
1652
4819
|
render: renderCmd,
|
|
1653
4820
|
inline: inlineCmd,
|
|
1654
4821
|
post: postCmd,
|
|
4822
|
+
announce: announceCmd,
|
|
4823
|
+
"check-run": checkRunCmd,
|
|
4824
|
+
"check-scope": checkScopeCmd,
|
|
4825
|
+
"report-incomplete": reportIncompleteCmd,
|
|
1655
4826
|
cost: costCmd,
|
|
4827
|
+
"check-cost": checkCostCmd,
|
|
1656
4828
|
validate: validateCmd,
|
|
4829
|
+
"seed-draft": seedDraftCmd,
|
|
1657
4830
|
adapt: adaptCmd,
|
|
4831
|
+
notice: noticeCmd,
|
|
1658
4832
|
extract: extractCmd,
|
|
1659
|
-
"
|
|
4833
|
+
"validate-patches": validatePatchesCmd,
|
|
4834
|
+
"print-schema": printSchemaCmd,
|
|
4835
|
+
"stop-gate": stopGateCmd,
|
|
4836
|
+
"budget-hook": budgetHookCmd,
|
|
4837
|
+
"print-settings": printSettingsCmd,
|
|
4838
|
+
deadline: deadlineCmd,
|
|
4839
|
+
"sandbox-config": sandboxConfigCmd
|
|
1660
4840
|
}
|
|
1661
4841
|
});
|
|
1662
4842
|
if (!process.env["VITEST"]) {
|
|
1663
4843
|
await runMain(main);
|
|
1664
4844
|
}
|
|
1665
4845
|
|
|
1666
|
-
export { main };
|
|
4846
|
+
export { main, snapshotIfValid };
|
|
1667
4847
|
//# sourceMappingURL=index.js.map
|
|
1668
4848
|
//# sourceMappingURL=index.js.map
|