@jphutchins/code-review 0.1.0-alpha.3 → 0.1.0-alpha.30
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 +37 -7
- package/dist/index.js +2544 -301
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/schema/VERSIONING.md +3 -1
- package/schema/findings.schema.json +30 -13
- package/schema/v0.2/findings.schema.json +88 -0
- package/templates/comment.eta +74 -46
- package/templates/inline.eta +28 -5
package/dist/index.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
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, statSync, copyFileSync, 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';
|
|
6
7
|
import parseDiff from 'parse-diff';
|
|
7
8
|
import { Ajv2020 } from 'ajv/dist/2020.js';
|
|
8
9
|
import _addFormats from 'ajv-formats';
|
|
9
10
|
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';
|
|
11
14
|
|
|
12
15
|
// src/cost.ts
|
|
13
16
|
var defaultWarn = (message) => {
|
|
@@ -53,44 +56,224 @@ var computeCost = (models, prices, warn = defaultWarn) => {
|
|
|
53
56
|
};
|
|
54
57
|
};
|
|
55
58
|
|
|
59
|
+
// src/patch.ts
|
|
60
|
+
var HUNK_HEADER_RE = /^@@ -(\d+)(?:,\d+)? \+\d+(?:,\d+)? @@/;
|
|
61
|
+
var hunkOldStart = (line) => {
|
|
62
|
+
const raw = HUNK_HEADER_RE.exec(line)?.[1];
|
|
63
|
+
return raw !== void 0 ? Number(raw) : null;
|
|
64
|
+
};
|
|
65
|
+
var classifyBodyLine = (line) => {
|
|
66
|
+
if (line.startsWith(" ")) return { kind: "context", text: line.slice(1) };
|
|
67
|
+
if (line.startsWith("-")) return { kind: "removed", text: line.slice(1) };
|
|
68
|
+
if (line.startsWith("+")) return { kind: "added", text: line.slice(1) };
|
|
69
|
+
return null;
|
|
70
|
+
};
|
|
71
|
+
var trimmedMiddle = (body) => {
|
|
72
|
+
const first = body.findIndex((l) => l.kind !== "context");
|
|
73
|
+
if (first === -1) return [];
|
|
74
|
+
const last = body.findLastIndex((l) => l.kind !== "context");
|
|
75
|
+
return body.slice(first, last + 1);
|
|
76
|
+
};
|
|
77
|
+
var isContiguousChange = (middle) => {
|
|
78
|
+
if (middle.some((l) => l.kind === "context")) return false;
|
|
79
|
+
const firstAdded = middle.findIndex((l) => l.kind === "added");
|
|
80
|
+
if (firstAdded === -1) return true;
|
|
81
|
+
return middle.slice(0, firstAdded).every((l) => l.kind === "removed") && middle.slice(firstAdded).every((l) => l.kind === "added");
|
|
82
|
+
};
|
|
83
|
+
var removedRange = (body, oldStart) => body.reduce(
|
|
84
|
+
(acc, line) => line.kind === "added" ? acc : {
|
|
85
|
+
lineNumber: acc.lineNumber + 1,
|
|
86
|
+
firstRemoved: line.kind === "removed" && acc.firstRemoved === null ? acc.lineNumber : acc.firstRemoved,
|
|
87
|
+
lastRemoved: line.kind === "removed" ? acc.lineNumber : acc.lastRemoved
|
|
88
|
+
},
|
|
89
|
+
{ lineNumber: oldStart, firstRemoved: null, lastRemoved: null }
|
|
90
|
+
);
|
|
91
|
+
var drop = (reason) => ({
|
|
92
|
+
kind: "drop",
|
|
93
|
+
reason
|
|
94
|
+
});
|
|
95
|
+
var keep = (reason) => ({
|
|
96
|
+
kind: "keep",
|
|
97
|
+
reason
|
|
98
|
+
});
|
|
99
|
+
var parseHunk = (patch) => {
|
|
100
|
+
const rawLines = patch.split("\n");
|
|
101
|
+
const lines = rawLines.length > 0 && rawLines[rawLines.length - 1] === "" ? rawLines.slice(0, -1) : rawLines;
|
|
102
|
+
const headerHits = lines.reduce(
|
|
103
|
+
(acc, line, index) => {
|
|
104
|
+
const oldStart = hunkOldStart(line);
|
|
105
|
+
return oldStart !== null ? [...acc, { index, oldStart }] : acc;
|
|
106
|
+
},
|
|
107
|
+
[]
|
|
108
|
+
);
|
|
109
|
+
if (headerHits.length !== 1) {
|
|
110
|
+
return drop(`expected exactly one hunk, got ${String(headerHits.length)}`);
|
|
111
|
+
}
|
|
112
|
+
const hit = headerHits[0];
|
|
113
|
+
if (hit === void 0) return drop("malformed hunk header");
|
|
114
|
+
const bodyRaw = lines.slice(hit.index + 1).filter((line) => !line.startsWith("\\"));
|
|
115
|
+
const classified = bodyRaw.map(classifyBodyLine);
|
|
116
|
+
if (classified.some((line) => line === null)) return drop("malformed hunk body line");
|
|
117
|
+
const body = classified.filter((line) => line !== null);
|
|
118
|
+
return { kind: "ok", oldStart: hit.oldStart, body };
|
|
119
|
+
};
|
|
120
|
+
var validatePatch = (patch, fileLines) => {
|
|
121
|
+
const parsed = parseHunk(patch);
|
|
122
|
+
if (parsed.kind === "drop") return parsed;
|
|
123
|
+
const { oldStart, body } = parsed;
|
|
124
|
+
const oldSideTexts = body.filter((l) => l.kind !== "added").map((l) => l.text);
|
|
125
|
+
const expected = fileLines.slice(oldStart - 1, oldStart - 1 + oldSideTexts.length);
|
|
126
|
+
const oldSideMatches = expected.length === oldSideTexts.length && expected.every((line, i) => line === oldSideTexts[i]);
|
|
127
|
+
if (!oldSideMatches) {
|
|
128
|
+
return drop(
|
|
129
|
+
`patch context does not match the file at lines ${String(oldStart)}..${String(oldStart + oldSideTexts.length - 1)}`
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
if (!isContiguousChange(trimmedMiddle(body))) {
|
|
133
|
+
return drop("change is not a single contiguous block");
|
|
134
|
+
}
|
|
135
|
+
const removedCount = body.filter((l) => l.kind === "removed").length;
|
|
136
|
+
const addedCount = body.filter((l) => l.kind === "added").length;
|
|
137
|
+
if (removedCount === 0 && addedCount === 0) return drop("hunk contains no changes");
|
|
138
|
+
if (removedCount === 0) {
|
|
139
|
+
return keep("pure insertion applies cleanly but has no removed range to anchor a suggestion");
|
|
140
|
+
}
|
|
141
|
+
const { firstRemoved, lastRemoved } = removedRange(body, oldStart);
|
|
142
|
+
if (firstRemoved === null || lastRemoved === null) return drop("malformed hunk body");
|
|
143
|
+
return { kind: "anchored", startLine: firstRemoved, endLine: lastRemoved };
|
|
144
|
+
};
|
|
145
|
+
var patchToSuggestion = (patch) => {
|
|
146
|
+
const parsed = parseHunk(patch);
|
|
147
|
+
if (parsed.kind === "drop") return parsed;
|
|
148
|
+
const { body } = parsed;
|
|
149
|
+
if (!isContiguousChange(trimmedMiddle(body))) {
|
|
150
|
+
return drop("change is not a single contiguous block");
|
|
151
|
+
}
|
|
152
|
+
const removedCount = body.filter((l) => l.kind === "removed").length;
|
|
153
|
+
const addedLines = body.filter((l) => l.kind === "added");
|
|
154
|
+
if (removedCount === 0 && addedLines.length === 0) return drop("hunk contains no changes");
|
|
155
|
+
if (removedCount === 0) return drop("pure insertion can't be expressed as a suggestion");
|
|
156
|
+
return addedLines.map((l) => l.text).join("\n");
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// src/surface.ts
|
|
160
|
+
var severityEmoji = (s) => {
|
|
161
|
+
switch (s) {
|
|
162
|
+
case "critical":
|
|
163
|
+
return "\u{1F534}";
|
|
164
|
+
case "major":
|
|
165
|
+
return "\u{1F7E0}";
|
|
166
|
+
case "minor":
|
|
167
|
+
return "\u{1F535}";
|
|
168
|
+
case "nit":
|
|
169
|
+
return "\u26AA";
|
|
170
|
+
default:
|
|
171
|
+
return "\u2753";
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
var EMBED_LIMIT = 4e4;
|
|
175
|
+
var AGENTS_STOP_DIRECTIVE = "<!-- AGENTS: STOP \u2014 do not parse the prose below; decode this findings JSON and read schema_version first. -->";
|
|
176
|
+
var encodeMarker = (document, jsonUrl, limit) => {
|
|
177
|
+
const b64 = Buffer.from(JSON.stringify(document), "utf-8").toString("base64");
|
|
178
|
+
const marker = b64.length <= limit ? `<!-- code-review:findings-json;base64 ${b64} -->` : jsonUrl ? `<!-- code-review:findings-json ${jsonUrl} -->` : "";
|
|
179
|
+
return marker ? `${AGENTS_STOP_DIRECTIVE}
|
|
180
|
+
${marker}` : "";
|
|
181
|
+
};
|
|
182
|
+
var findingsPointer = (findings, jsonUrl, limit = EMBED_LIMIT) => encodeMarker(findings, jsonUrl, limit);
|
|
183
|
+
var findingPointer = (finding, schemaVersion, jsonUrl, limit = EMBED_LIMIT) => encodeMarker({ schema_version: schemaVersion, findings: [finding] }, jsonUrl, limit);
|
|
184
|
+
var ZERO_SHA = "0000000000000000000000000000000000000000";
|
|
185
|
+
var parseReviewedSha = (body) => {
|
|
186
|
+
const sha = /<!-- reviewed-sha: ([0-9a-fA-F]{40}) -->/.exec(body)?.[1]?.toLowerCase();
|
|
187
|
+
return sha && sha !== ZERO_SHA ? sha : null;
|
|
188
|
+
};
|
|
189
|
+
var REVIEW_COMPLETE_MARKER = "<!-- review-complete -->";
|
|
190
|
+
var parseReviewComplete = (body) => body.includes(REVIEW_COMPLETE_MARKER);
|
|
191
|
+
var parseFindingsMarker = (body) => {
|
|
192
|
+
const match = /<!-- code-review:findings-json;base64 ([A-Za-z0-9+/=]+) -->/.exec(body);
|
|
193
|
+
const b64 = match?.[1];
|
|
194
|
+
if (b64 === void 0) return null;
|
|
195
|
+
try {
|
|
196
|
+
return JSON.parse(Buffer.from(b64, "base64").toString("utf-8"));
|
|
197
|
+
} catch {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
var carryForwardMarkers = (body) => {
|
|
202
|
+
const findings = /<!-- code-review:findings-json[^>]*-->/.exec(body)?.[0];
|
|
203
|
+
const reviewedSha = /<!-- reviewed-sha: [0-9a-fA-F]{40} -->/.exec(body)?.[0];
|
|
204
|
+
const findingsBlock = findings ? `${AGENTS_STOP_DIRECTIVE}
|
|
205
|
+
${findings}` : void 0;
|
|
206
|
+
return [findingsBlock, reviewedSha].filter((m) => m !== void 0).join("\n\n");
|
|
207
|
+
};
|
|
208
|
+
var escapeFence = (text) => text.replace(/```/g, "`` ` ``");
|
|
209
|
+
var projectPatch = (patch) => {
|
|
210
|
+
if (patch === void 0) return { kind: "none" };
|
|
211
|
+
const lowered = patchToSuggestion(patch);
|
|
212
|
+
return typeof lowered === "string" ? { kind: "suggestion", text: escapeFence(lowered) } : { kind: "patch", raw: escapeFence(patch) };
|
|
213
|
+
};
|
|
214
|
+
var formatConfidence = (n) => n.toFixed(2);
|
|
215
|
+
var reviewBodyPointer = (headSha, stickyUrl, marker) => {
|
|
216
|
+
const sha7 = headSha.slice(0, 7);
|
|
217
|
+
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.`;
|
|
218
|
+
return marker ? `${marker}
|
|
219
|
+
|
|
220
|
+
${linkLine}` : linkLine;
|
|
221
|
+
};
|
|
222
|
+
|
|
56
223
|
// src/render.ts
|
|
57
|
-
var escapeBackticks = (text) => text.replace(/```/g, "`` ` ``");
|
|
58
224
|
var escapePipes = (text) => text.replace(/\|/g, "\\|");
|
|
59
225
|
var escapeCodeBackticks = (text) => text.replace(/`/g, "-");
|
|
60
226
|
var sanitizeFinding = (f) => ({
|
|
61
227
|
...f,
|
|
62
228
|
title: escapePipes(f.title),
|
|
63
229
|
path: escapeCodeBackticks(f.path),
|
|
64
|
-
|
|
230
|
+
patchProjection: projectPatch(f.patch)
|
|
65
231
|
});
|
|
232
|
+
var emptySeverityCounts = () => ({
|
|
233
|
+
critical: 0,
|
|
234
|
+
major: 0,
|
|
235
|
+
minor: 0,
|
|
236
|
+
nit: 0
|
|
237
|
+
});
|
|
238
|
+
var computeSeverityCounts = (findings) => findings.reduce(
|
|
239
|
+
(acc, f) => f.severity in acc ? { ...acc, [f.severity]: acc[f.severity] + 1 } : acc,
|
|
240
|
+
emptySeverityCounts()
|
|
241
|
+
);
|
|
66
242
|
var render = (input) => {
|
|
67
243
|
const eta = new Eta({ autoTrim: false });
|
|
68
244
|
const usageAvailable = input.envelope !== null;
|
|
245
|
+
const hasUsage = input.envelope !== null && input.envelope.models.length > 0;
|
|
246
|
+
const incomplete = input.incomplete ?? input.envelope?.incomplete ?? false;
|
|
69
247
|
const costReport = input.envelope ? computeCost(input.envelope.models, input.prices) : null;
|
|
248
|
+
const pricesProvided = input.pricesProvided ?? true;
|
|
70
249
|
const route = input.route ?? input.envelope?.route ?? null;
|
|
71
250
|
const effort = input.effort ?? input.envelope?.effort ?? null;
|
|
72
251
|
const modelNames = input.envelope ? input.envelope.models.map((m) => m.model).join(", ") : "";
|
|
73
|
-
const findings = input.findings.findings.map(sanitizeFinding);
|
|
74
|
-
const safeFindings = { ...input.findings, findings };
|
|
75
|
-
const uniqueFiles = [...new Set(findings.map((f) => f.path))];
|
|
76
252
|
return eta.renderString(input.template, {
|
|
77
|
-
findings:
|
|
253
|
+
findings: input.findings,
|
|
78
254
|
envelope: input.envelope,
|
|
79
255
|
usageAvailable,
|
|
256
|
+
hasUsage,
|
|
257
|
+
incomplete,
|
|
80
258
|
costReport,
|
|
259
|
+
pricesProvided,
|
|
81
260
|
route,
|
|
82
261
|
effort,
|
|
83
262
|
modelNames,
|
|
84
263
|
testReport: input.testReport ?? null,
|
|
85
264
|
reviewedSha: input.reviewedSha ?? "0000000000000000000000000000000000000000",
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
265
|
+
postedAt: input.postedAt ?? "",
|
|
266
|
+
severityCounts: input.severityCounts ?? computeSeverityCounts(input.findings.findings),
|
|
267
|
+
strays: (input.strays ?? []).map(sanitizeFinding),
|
|
268
|
+
unanchoredCount: input.unanchoredCount ?? 0,
|
|
269
|
+
inlineDisposition: input.inlineDisposition ?? null,
|
|
270
|
+
runUrl: input.runUrl ?? null,
|
|
271
|
+
jsonUrl: input.jsonUrl ?? null,
|
|
272
|
+
findingsPointer: input.findingsPointer ?? findingsPointer(input.findings, input.jsonUrl),
|
|
273
|
+
reviewUrl: input.reviewUrl ?? null,
|
|
92
274
|
formatTokens: (n) => Number.isFinite(n) && n >= 0 ? n.toLocaleString("en-US") : "\u2014",
|
|
93
|
-
|
|
275
|
+
// N/A (never a false $0.00) when no real price map was provided — real tokens, no rates to price them.
|
|
276
|
+
formatCost: (n) => !pricesProvided ? "N/A" : Number.isFinite(n) ? n > 0 && n.toFixed(2) === "0.00" ? "<$0.01" : `$${n.toFixed(2)}` : "\u2014",
|
|
94
277
|
formatDuration: (ms) => {
|
|
95
278
|
if (!Number.isFinite(ms) || ms < 0) return "\u2014";
|
|
96
279
|
const s = Math.round(ms / 1e3);
|
|
@@ -108,20 +291,8 @@ var render = (input) => {
|
|
|
108
291
|
return `\u2753 ${v}`;
|
|
109
292
|
}
|
|
110
293
|
},
|
|
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
|
-
}
|
|
294
|
+
severityEmoji,
|
|
295
|
+
formatConfidence
|
|
125
296
|
});
|
|
126
297
|
};
|
|
127
298
|
var key = (path, line) => `${path}:${String(line)}`;
|
|
@@ -178,33 +349,33 @@ var partitionFindings = (findings, index) => {
|
|
|
178
349
|
};
|
|
179
350
|
|
|
180
351
|
// 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, {
|
|
352
|
+
var formatModels = (models) => models.length > 0 ? models.map((m) => `\`${m}\``).join("/") : "an AI model";
|
|
353
|
+
var renderCommentBody = (f, eta, template, modelsText, jsonUrl, pointer) => (
|
|
354
|
+
// Eta.renderString returns string | Promise<string>; with autoTrim:false it's always sync.
|
|
355
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
|
356
|
+
eta.renderString(template, {
|
|
194
357
|
...f,
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
358
|
+
patchProjection: projectPatch(f.patch),
|
|
359
|
+
severityEmoji,
|
|
360
|
+
formatConfidence,
|
|
361
|
+
modelsText,
|
|
362
|
+
jsonUrl: jsonUrl ?? null,
|
|
363
|
+
findingsPointer: pointer
|
|
364
|
+
})
|
|
365
|
+
);
|
|
366
|
+
var buildInlineComments = (findings, diff, context) => {
|
|
367
|
+
const { inlineTemplate, models = [], jsonUrl, findings: fullFindings } = context;
|
|
199
368
|
const index = indexDiff(diff);
|
|
200
369
|
const { inDiff, strays } = partitionFindings(findings, index);
|
|
201
|
-
const eta =
|
|
370
|
+
const eta = new Eta({ autoTrim: false });
|
|
371
|
+
const modelsText = formatModels(models);
|
|
202
372
|
const comments = inDiff.map((f) => {
|
|
373
|
+
const pointer = fullFindings ? findingPointer(f, fullFindings.schema_version, jsonUrl) : "";
|
|
203
374
|
const comment = {
|
|
204
375
|
path: f.path,
|
|
205
376
|
line: f.end_line,
|
|
206
377
|
side: defaultSide(f.side),
|
|
207
|
-
body:
|
|
378
|
+
body: renderCommentBody(f, eta, inlineTemplate, modelsText, jsonUrl, pointer)
|
|
208
379
|
};
|
|
209
380
|
if (f.start_line < f.end_line) {
|
|
210
381
|
return {
|
|
@@ -215,7 +386,7 @@ var buildInlineComments = (findings, diff, inlineTemplate) => {
|
|
|
215
386
|
}
|
|
216
387
|
return comment;
|
|
217
388
|
});
|
|
218
|
-
return { comments, strays };
|
|
389
|
+
return { comments, strays, inDiff };
|
|
219
390
|
};
|
|
220
391
|
var renderStraysSection = (strays) => {
|
|
221
392
|
if (strays.length === 0) return "";
|
|
@@ -231,6 +402,128 @@ var renderStraysSection = (strays) => {
|
|
|
231
402
|
...items
|
|
232
403
|
].join("\n");
|
|
233
404
|
};
|
|
405
|
+
var asRecord = (u) => typeof u === "object" && u !== null && !Array.isArray(u) ? u : null;
|
|
406
|
+
var errMsg = (e) => e instanceof Error ? e.message : String(e);
|
|
407
|
+
var tryParseJson = (text) => {
|
|
408
|
+
try {
|
|
409
|
+
return { ok: true, value: JSON.parse(text) };
|
|
410
|
+
} catch {
|
|
411
|
+
return { ok: false };
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
var readFileOrNull = (path) => {
|
|
415
|
+
try {
|
|
416
|
+
return readFileSync(path, "utf-8");
|
|
417
|
+
} catch {
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
// src/transcript.ts
|
|
423
|
+
var numField = (rec, key2) => {
|
|
424
|
+
const v = rec[key2];
|
|
425
|
+
return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : 0;
|
|
426
|
+
};
|
|
427
|
+
var messageUsage = (entry) => {
|
|
428
|
+
const rec = asRecord(entry);
|
|
429
|
+
if (rec === null || rec["type"] !== "assistant") return null;
|
|
430
|
+
const msg = asRecord(rec["message"]);
|
|
431
|
+
if (msg === null) return null;
|
|
432
|
+
const model = msg["model"];
|
|
433
|
+
const usage = asRecord(msg["usage"]);
|
|
434
|
+
if (typeof model !== "string" || usage === null) return null;
|
|
435
|
+
const id = msg["id"];
|
|
436
|
+
return {
|
|
437
|
+
id: typeof id === "string" ? id : null,
|
|
438
|
+
model,
|
|
439
|
+
input: numField(usage, "input_tokens"),
|
|
440
|
+
output: numField(usage, "output_tokens"),
|
|
441
|
+
cacheRead: numField(usage, "cache_read_input_tokens"),
|
|
442
|
+
cacheWrite: numField(usage, "cache_creation_input_tokens")
|
|
443
|
+
};
|
|
444
|
+
};
|
|
445
|
+
var tsMsOf = (entry) => {
|
|
446
|
+
const rec = asRecord(entry);
|
|
447
|
+
const ts = rec?.["timestamp"];
|
|
448
|
+
if (typeof ts !== "string") return null;
|
|
449
|
+
const ms = Date.parse(ts);
|
|
450
|
+
return Number.isNaN(ms) ? null : ms;
|
|
451
|
+
};
|
|
452
|
+
var EMPTY_TOTALS = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
453
|
+
var parseJsonl = (text) => text.split("\n").filter((line) => line.trim() !== "").flatMap((line) => {
|
|
454
|
+
try {
|
|
455
|
+
return [JSON.parse(line)];
|
|
456
|
+
} catch {
|
|
457
|
+
return [];
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
var sumTranscriptUsage = (entries) => {
|
|
461
|
+
const summed = entries.reduce(
|
|
462
|
+
(acc, entry) => {
|
|
463
|
+
const u = messageUsage(entry);
|
|
464
|
+
if (u === null) return acc;
|
|
465
|
+
if (u.id !== null && acc.seen.has(u.id)) return acc;
|
|
466
|
+
if (u.id !== null) acc.seen.add(u.id);
|
|
467
|
+
const prev = acc.totals.get(u.model) ?? EMPTY_TOTALS;
|
|
468
|
+
acc.totals.set(u.model, {
|
|
469
|
+
input: prev.input + u.input,
|
|
470
|
+
output: prev.output + u.output,
|
|
471
|
+
cacheRead: prev.cacheRead + u.cacheRead,
|
|
472
|
+
cacheWrite: prev.cacheWrite + u.cacheWrite
|
|
473
|
+
});
|
|
474
|
+
return { totals: acc.totals, turns: acc.turns + 1, seen: acc.seen };
|
|
475
|
+
},
|
|
476
|
+
{ totals: /* @__PURE__ */ new Map(), turns: 0, seen: /* @__PURE__ */ new Set() }
|
|
477
|
+
);
|
|
478
|
+
const models = [...summed.totals].map(([model, t7]) => ({
|
|
479
|
+
model,
|
|
480
|
+
input_tokens: t7.input,
|
|
481
|
+
output_tokens: t7.output,
|
|
482
|
+
cache_read_tokens: t7.cacheRead,
|
|
483
|
+
cache_write_tokens: t7.cacheWrite
|
|
484
|
+
}));
|
|
485
|
+
const bounds = entries.reduce(
|
|
486
|
+
(acc, entry) => {
|
|
487
|
+
const ms = tsMsOf(entry);
|
|
488
|
+
if (ms === null) return acc;
|
|
489
|
+
return {
|
|
490
|
+
min: acc.min === null || ms < acc.min ? ms : acc.min,
|
|
491
|
+
max: acc.max === null || ms > acc.max ? ms : acc.max
|
|
492
|
+
};
|
|
493
|
+
},
|
|
494
|
+
{ min: null, max: null }
|
|
495
|
+
);
|
|
496
|
+
return {
|
|
497
|
+
models,
|
|
498
|
+
turns: summed.turns,
|
|
499
|
+
durationMs: bounds.min !== null && bounds.max !== null ? bounds.max - bounds.min : 0,
|
|
500
|
+
firstTsMs: bounds.min,
|
|
501
|
+
lastTsMs: bounds.max
|
|
502
|
+
};
|
|
503
|
+
};
|
|
504
|
+
var subagentFiles = (mainPath) => {
|
|
505
|
+
const dir = join(dirname(mainPath), basename(mainPath, ".jsonl"), "subagents");
|
|
506
|
+
try {
|
|
507
|
+
return readdirSync(dir).filter((name) => name.endsWith(".jsonl")).map((name) => join(dir, name));
|
|
508
|
+
} catch {
|
|
509
|
+
return [];
|
|
510
|
+
}
|
|
511
|
+
};
|
|
512
|
+
var readTranscriptTree = (mainPath) => {
|
|
513
|
+
const mainText = readFileOrNull(mainPath);
|
|
514
|
+
const mainEntries = mainText === null ? [] : parseJsonl(mainText);
|
|
515
|
+
const inlineSidechains = mainEntries.some((e) => asRecord(e)?.["isSidechain"] === true);
|
|
516
|
+
const siblings = inlineSidechains ? [] : subagentFiles(mainPath);
|
|
517
|
+
const siblingReads = siblings.flatMap((path) => {
|
|
518
|
+
const text = readFileOrNull(path);
|
|
519
|
+
return text === null ? [] : [{ path, entries: parseJsonl(text) }];
|
|
520
|
+
});
|
|
521
|
+
return {
|
|
522
|
+
entries: [...mainEntries, ...siblingReads.flatMap((r) => r.entries)],
|
|
523
|
+
files: [...mainText === null ? [] : [mainPath], ...siblingReads.map((r) => r.path)],
|
|
524
|
+
missing: mainText === null
|
|
525
|
+
};
|
|
526
|
+
};
|
|
234
527
|
var SeverityCodec = t.union([
|
|
235
528
|
t.literal("critical"),
|
|
236
529
|
t.literal("major"),
|
|
@@ -258,14 +551,16 @@ var FindingShape = t.intersection([
|
|
|
258
551
|
end_line: LineNumber,
|
|
259
552
|
severity: SeverityCodec,
|
|
260
553
|
title: t.string,
|
|
261
|
-
|
|
554
|
+
description: t.string,
|
|
555
|
+
reasoning: t.string,
|
|
556
|
+
confidence: Confidence
|
|
262
557
|
}),
|
|
263
558
|
t.partial({
|
|
264
559
|
side: SideCodec,
|
|
265
|
-
suggestion: t.union([t.string, t.null]),
|
|
266
|
-
confidence: Confidence,
|
|
267
560
|
code: t.string,
|
|
268
|
-
code_url: t.string
|
|
561
|
+
code_url: t.string,
|
|
562
|
+
recommendation: t.string,
|
|
563
|
+
patch: t.string
|
|
269
564
|
})
|
|
270
565
|
]);
|
|
271
566
|
var EndGeStart = t.refinement(
|
|
@@ -313,7 +608,12 @@ var ResultEnvelopeCodec = t.intersection([
|
|
|
313
608
|
t.partial({
|
|
314
609
|
vendor_cost_usd: t.union([t.number, t.null]),
|
|
315
610
|
route: t.string,
|
|
316
|
-
effort: t.string
|
|
611
|
+
effort: t.string,
|
|
612
|
+
// The run produced a notice rather than a completed review (security-gate block, agent kill, no
|
|
613
|
+
// recoverable findings). An empty `findings` array alone can't say this — a genuine clean review
|
|
614
|
+
// is also empty — so the render suppresses "clean review" and the sticky precedence guard refuses
|
|
615
|
+
// to bury a completed review under it. Absent ⇒ a completed review.
|
|
616
|
+
incomplete: t.boolean
|
|
317
617
|
})
|
|
318
618
|
]);
|
|
319
619
|
var ModelPricesCodec = t.type({
|
|
@@ -341,7 +641,13 @@ var TestSummaryCodec = t.intersection([
|
|
|
341
641
|
failures: t.array(TestFailureCodec)
|
|
342
642
|
})
|
|
343
643
|
]);
|
|
344
|
-
var DEFAULT_SCHEMA_VERSION = "0.
|
|
644
|
+
var DEFAULT_SCHEMA_VERSION = "0.4.0";
|
|
645
|
+
var noticeFindings = (summary) => ({
|
|
646
|
+
schema_version: DEFAULT_SCHEMA_VERSION,
|
|
647
|
+
summary,
|
|
648
|
+
verdict: "comment",
|
|
649
|
+
findings: []
|
|
650
|
+
});
|
|
345
651
|
|
|
346
652
|
// src/validate.ts
|
|
347
653
|
var addFormats = _addFormats;
|
|
@@ -377,10 +683,326 @@ var unsafeUnwrap = (decoded) => {
|
|
|
377
683
|
if (decoded._tag === "Right") return decoded.right;
|
|
378
684
|
throw new Error("io-ts decode failed \u2014 data does not match expected shape");
|
|
379
685
|
};
|
|
686
|
+
|
|
687
|
+
// src/stop-gate.ts
|
|
688
|
+
var whatsWrong = (state, draftPath, kind) => {
|
|
689
|
+
switch (state.kind) {
|
|
690
|
+
case "missing":
|
|
691
|
+
return `${draftPath} does not exist yet`;
|
|
692
|
+
case "unreadable":
|
|
693
|
+
return `${draftPath} could not be read: ${state.error}`;
|
|
694
|
+
case "invalid":
|
|
695
|
+
return `${draftPath} does not validate against the ${kind} schema:
|
|
696
|
+
${state.errors.map((e) => ` - ${e}`).join("\n")}`;
|
|
697
|
+
}
|
|
698
|
+
};
|
|
699
|
+
var decideGate = (state, nudges, maxNudges, draftPath, kind) => {
|
|
700
|
+
if (state.kind === "valid") return { kind: "allow" };
|
|
701
|
+
if (nudges >= maxNudges) return { kind: "allow" };
|
|
702
|
+
return {
|
|
703
|
+
kind: "block",
|
|
704
|
+
reason: [
|
|
705
|
+
`This review is not complete \u2014 ${whatsWrong(state, draftPath, kind)}`,
|
|
706
|
+
`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.`,
|
|
707
|
+
`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).`
|
|
708
|
+
].join("\n")
|
|
709
|
+
};
|
|
710
|
+
};
|
|
711
|
+
var draftState = (draftPath, resolveSchema) => {
|
|
712
|
+
let raw;
|
|
713
|
+
try {
|
|
714
|
+
raw = readFileSync(draftPath, "utf-8");
|
|
715
|
+
} catch (err) {
|
|
716
|
+
if (err instanceof Error && err.code === "ENOENT") {
|
|
717
|
+
return { kind: "missing" };
|
|
718
|
+
}
|
|
719
|
+
return { kind: "unreadable", error: errMsg(err) };
|
|
720
|
+
}
|
|
721
|
+
let parsed;
|
|
722
|
+
try {
|
|
723
|
+
parsed = JSON.parse(raw);
|
|
724
|
+
} catch (err) {
|
|
725
|
+
return {
|
|
726
|
+
kind: "invalid",
|
|
727
|
+
errors: [`not valid JSON: ${errMsg(err)}`]
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
let schemaPath;
|
|
731
|
+
try {
|
|
732
|
+
schemaPath = resolveSchema(parsed);
|
|
733
|
+
} catch (err) {
|
|
734
|
+
return { kind: "invalid", errors: [errMsg(err)] };
|
|
735
|
+
}
|
|
736
|
+
try {
|
|
737
|
+
const { valid, errors } = validateAgainstSchema(parsed, schemaPath);
|
|
738
|
+
return valid ? { kind: "valid" } : { kind: "invalid", errors };
|
|
739
|
+
} catch (err) {
|
|
740
|
+
return { kind: "invalid", errors: [errMsg(err)] };
|
|
741
|
+
}
|
|
742
|
+
};
|
|
743
|
+
var readNudges = (counterPath) => {
|
|
744
|
+
try {
|
|
745
|
+
const n = Number.parseInt(readFileSync(counterPath, "utf-8").trim(), 10);
|
|
746
|
+
return Number.isInteger(n) && n >= 0 ? n : 0;
|
|
747
|
+
} catch {
|
|
748
|
+
return 0;
|
|
749
|
+
}
|
|
750
|
+
};
|
|
751
|
+
var bumpNudges = (counterPath, current) => {
|
|
752
|
+
writeFileSync(counterPath, `${String(current + 1)}
|
|
753
|
+
`);
|
|
754
|
+
};
|
|
755
|
+
var shellQuote = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
756
|
+
var defaultHookCommand = (draftPath, opts) => [
|
|
757
|
+
"code-review stop-gate --draft",
|
|
758
|
+
shellQuote(draftPath),
|
|
759
|
+
...opts.kind ? ["--kind", shellQuote(opts.kind)] : [],
|
|
760
|
+
...opts.schema ? ["--schema", shellQuote(opts.schema)] : [],
|
|
761
|
+
...opts.schemaVersion ? ["--schema-version", shellQuote(opts.schemaVersion)] : [],
|
|
762
|
+
...opts.maxNudges ? ["--max-nudges", shellQuote(opts.maxNudges)] : [],
|
|
763
|
+
...opts.counter ? ["--counter", shellQuote(opts.counter)] : []
|
|
764
|
+
].join(" ");
|
|
765
|
+
var stopHookSettings = (command) => ({
|
|
766
|
+
hooks: { Stop: [{ hooks: [{ type: "command", command }] }] }
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
// src/budget.ts
|
|
770
|
+
var DEADLINE_ENV = "CODE_REVIEW_DEADLINE_EPOCH";
|
|
771
|
+
var DEFAULT_RESERVE = {
|
|
772
|
+
frac: 0.15,
|
|
773
|
+
growth: 0.25,
|
|
774
|
+
flatUsd: 0.02,
|
|
775
|
+
flatMs: 12e4
|
|
776
|
+
};
|
|
777
|
+
var SOFT_MULTIPLE = 2;
|
|
778
|
+
var costAxis = (i) => i.spentUsd !== null && i.budgetUsd !== null && i.budgetUsd > 0 ? { used: i.spentUsd, limit: i.budgetUsd, flat: i.reserve.flatUsd } : null;
|
|
779
|
+
var timeAxis = (i) => i.elapsedMs !== null && i.wallMs !== null && i.wallMs > 0 ? { used: i.elapsedMs, limit: i.wallMs, flat: i.reserve.flatMs } : null;
|
|
780
|
+
var axisSeverity = (a, reserve) => {
|
|
781
|
+
const usedFrac = Math.min(1, Math.max(0, a.used / a.limit));
|
|
782
|
+
const effFrac = reserve.frac + reserve.growth * usedFrac;
|
|
783
|
+
const hardReserve = Math.max(a.flat, effFrac * a.limit);
|
|
784
|
+
const remaining = a.limit - a.used;
|
|
785
|
+
if (remaining <= hardReserve) return 2;
|
|
786
|
+
if (remaining <= SOFT_MULTIPLE * hardReserve) return 1;
|
|
787
|
+
return 0;
|
|
788
|
+
};
|
|
789
|
+
var decideBudget = (i) => {
|
|
790
|
+
const worst = [costAxis(i), timeAxis(i)].filter((a) => a !== null).reduce((max, a) => Math.max(max, axisSeverity(a, i.reserve)), 0);
|
|
791
|
+
return worst === 2 ? { kind: "hard" } : worst === 1 ? { kind: "soft" } : { kind: "ok" };
|
|
792
|
+
};
|
|
793
|
+
var pct = (n) => `${String(Math.round(n * 100))}%`;
|
|
794
|
+
var money = (n) => `$${n.toFixed(2)}`;
|
|
795
|
+
var mins = (ms) => `${(ms / 6e4).toFixed(1)}m`;
|
|
796
|
+
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)}`;
|
|
797
|
+
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`;
|
|
798
|
+
var directive = (phase, draftPath, isSubagent) => {
|
|
799
|
+
if (isSubagent)
|
|
800
|
+
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.`;
|
|
801
|
+
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.`;
|
|
802
|
+
};
|
|
803
|
+
var budgetMessage = (i, phase, draftPath, isSubagent) => {
|
|
804
|
+
const status = [spendClause(i), timeClause(i)].filter((c) => c !== null).join(" \xB7 ");
|
|
805
|
+
return `Budget check \u2014 ${status}. ${directive(phase, draftPath, isSubagent)}`;
|
|
806
|
+
};
|
|
807
|
+
var invokesCodeReviewValidate = (toolInput) => {
|
|
808
|
+
const cmd = asRecord(toolInput)?.["command"];
|
|
809
|
+
return typeof cmd === "string" && /\bcode-review\s+validate(?![\w-])/.test(cmd);
|
|
810
|
+
};
|
|
811
|
+
var SPAWN_TOOLS = /* @__PURE__ */ new Set(["Agent", "Task"]);
|
|
812
|
+
var WEB_TOOLS = /* @__PURE__ */ new Set(["WebFetch", "WebSearch"]);
|
|
813
|
+
var blockedDuringConvergence = (toolName, toolInput) => {
|
|
814
|
+
if (SPAWN_TOOLS.has(toolName) || WEB_TOOLS.has(toolName)) return true;
|
|
815
|
+
if (toolName === "Bash") return !invokesCodeReviewValidate(toolInput);
|
|
816
|
+
return false;
|
|
817
|
+
};
|
|
818
|
+
var escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
819
|
+
var WRITE_TOOLS = /* @__PURE__ */ new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
|
|
820
|
+
var writesToDraft = (toolName, toolInput, draftPath) => {
|
|
821
|
+
const rec = asRecord(toolInput);
|
|
822
|
+
const targets = [draftPath, basename(draftPath), "$DRAFT", "${DRAFT}"];
|
|
823
|
+
if (WRITE_TOOLS.has(toolName)) {
|
|
824
|
+
const fp = rec?.["file_path"] ?? rec?.["notebook_path"];
|
|
825
|
+
if (typeof fp === "string" && targets.some((t7) => fp === t7 || basename(fp) === basename(t7)))
|
|
826
|
+
return true;
|
|
827
|
+
}
|
|
828
|
+
if (toolName === "Bash") {
|
|
829
|
+
const cmd = rec?.["command"];
|
|
830
|
+
if (typeof cmd !== "string") return false;
|
|
831
|
+
const alt = targets.map(escapeRegExp).join("|");
|
|
832
|
+
const end = "(?=$|[\\s|&;)])";
|
|
833
|
+
const redirect = new RegExp(`>>?\\|?\\s*(['"]?)(?:${alt})\\1${end}`);
|
|
834
|
+
const teeArg = new RegExp(`\\btee\\b(?:\\s+-{1,2}\\S+)*\\s+(['"]?)(?:${alt})\\1${end}`);
|
|
835
|
+
return redirect.test(cmd) || teeArg.test(cmd);
|
|
836
|
+
}
|
|
837
|
+
return false;
|
|
838
|
+
};
|
|
839
|
+
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.`;
|
|
840
|
+
var seedMarkerPath = (draftPath) => `${draftPath}.seed`;
|
|
841
|
+
var lastValidPath = (draftPath) => {
|
|
842
|
+
const ext = extname(draftPath);
|
|
843
|
+
return join(dirname(draftPath), `${basename(draftPath, ext)}.last-valid${ext}`);
|
|
844
|
+
};
|
|
845
|
+
var mainHasWrittenDraft = (draftMtimeMs, seedMarkerMtimeMs) => draftMtimeMs !== null && (seedMarkerMtimeMs === null || draftMtimeMs > seedMarkerMtimeMs);
|
|
846
|
+
var spawnFloorMessage = (draftPath) => `Write your own first-pass findings to ${draftPath} before spawning subagents \u2014 a review must never depend on subagents alone, and a pre-seeded draft does not count until you have revised it yourself this run. 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.`;
|
|
847
|
+
var forceBackgroundSpawn = (toolInput) => ({
|
|
848
|
+
hookSpecificOutput: {
|
|
849
|
+
hookEventName: "PreToolUse",
|
|
850
|
+
permissionDecision: "allow",
|
|
851
|
+
updatedInput: { ...asRecord(toolInput) ?? {}, run_in_background: true }
|
|
852
|
+
}
|
|
853
|
+
});
|
|
854
|
+
var denyPreTool = (reason) => ({
|
|
855
|
+
hookSpecificOutput: {
|
|
856
|
+
hookEventName: "PreToolUse",
|
|
857
|
+
permissionDecision: "deny",
|
|
858
|
+
permissionDecisionReason: reason
|
|
859
|
+
}
|
|
860
|
+
});
|
|
861
|
+
var isSubagentHookInput = (input) => {
|
|
862
|
+
const agentId = asRecord(input)?.["agent_id"];
|
|
863
|
+
return typeof agentId === "string" && agentId.length > 0;
|
|
864
|
+
};
|
|
865
|
+
var evaluateBudgetHook = (input, params) => {
|
|
866
|
+
const rec = asRecord(input);
|
|
867
|
+
const inputs = {
|
|
868
|
+
spentUsd: params.spentUsd,
|
|
869
|
+
budgetUsd: params.budgetUsd,
|
|
870
|
+
elapsedMs: params.elapsedMs,
|
|
871
|
+
wallMs: params.wallMs,
|
|
872
|
+
reserve: params.reserve
|
|
873
|
+
};
|
|
874
|
+
const phase = decideBudget(inputs);
|
|
875
|
+
const isSubagent = isSubagentHookInput(input);
|
|
876
|
+
switch (rec?.["hook_event_name"]) {
|
|
877
|
+
case "PostToolBatch":
|
|
878
|
+
return phase.kind === "ok" ? {} : {
|
|
879
|
+
hookSpecificOutput: {
|
|
880
|
+
hookEventName: "PostToolBatch",
|
|
881
|
+
additionalContext: budgetMessage(inputs, phase, params.draftPath, isSubagent)
|
|
882
|
+
}
|
|
883
|
+
};
|
|
884
|
+
case "PreToolUse": {
|
|
885
|
+
const toolName = rec["tool_name"];
|
|
886
|
+
if (typeof toolName !== "string") return {};
|
|
887
|
+
if (isSubagent && writesToDraft(toolName, rec["tool_input"], params.draftPath))
|
|
888
|
+
return denyPreTool(singleWriterMessage(params.draftPath));
|
|
889
|
+
if (phase.kind === "hard" && blockedDuringConvergence(toolName, rec["tool_input"]))
|
|
890
|
+
return denyPreTool(budgetMessage(inputs, phase, params.draftPath, isSubagent));
|
|
891
|
+
if (SPAWN_TOOLS.has(toolName)) {
|
|
892
|
+
if (!isSubagent && !params.mainDraftWritten)
|
|
893
|
+
return denyPreTool(spawnFloorMessage(params.draftPath));
|
|
894
|
+
return forceBackgroundSpawn(rec["tool_input"]);
|
|
895
|
+
}
|
|
896
|
+
return {};
|
|
897
|
+
}
|
|
898
|
+
default:
|
|
899
|
+
return {};
|
|
900
|
+
}
|
|
901
|
+
};
|
|
902
|
+
var parseWallMs = (raw) => {
|
|
903
|
+
const m = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/.exec(raw.trim());
|
|
904
|
+
if (m === null) return null;
|
|
905
|
+
const [, num = "", unit = "s"] = m;
|
|
906
|
+
const n = Number.parseFloat(num);
|
|
907
|
+
if (!Number.isFinite(n)) return null;
|
|
908
|
+
switch (unit) {
|
|
909
|
+
case "ms":
|
|
910
|
+
return n;
|
|
911
|
+
case "s":
|
|
912
|
+
return n * 1e3;
|
|
913
|
+
case "m":
|
|
914
|
+
return n * 6e4;
|
|
915
|
+
default:
|
|
916
|
+
return n * 36e5;
|
|
917
|
+
}
|
|
918
|
+
};
|
|
919
|
+
var parseEpochSecMs = (raw) => {
|
|
920
|
+
if (raw === void 0) return null;
|
|
921
|
+
const t7 = raw.trim();
|
|
922
|
+
if (!/^\d+$/.test(t7)) return null;
|
|
923
|
+
const n = Number.parseInt(t7, 10);
|
|
924
|
+
return Number.isFinite(n) && n > 0 ? n * 1e3 : null;
|
|
925
|
+
};
|
|
926
|
+
var anchoredElapsedMs = (src) => {
|
|
927
|
+
if (src.deadlineMs !== null && src.wallMs !== null)
|
|
928
|
+
return Math.max(0, src.wallMs - (src.deadlineMs - src.nowMs));
|
|
929
|
+
if (src.firstTsMs !== null) return Math.max(0, src.nowMs - src.firstTsMs);
|
|
930
|
+
return null;
|
|
931
|
+
};
|
|
932
|
+
var deadlineEpochSec = (wallMs, nowMs) => Math.floor(nowMs / 1e3) + Math.ceil(wallMs / 1e3);
|
|
933
|
+
var parseFraction = (raw, fallback) => {
|
|
934
|
+
if (raw === void 0) return fallback;
|
|
935
|
+
const n = Number.parseFloat(raw);
|
|
936
|
+
return Number.isFinite(n) && n >= 0 && n <= 1 ? n : fallback;
|
|
937
|
+
};
|
|
938
|
+
var budgetHookCommand = (draftPath, opts) => [
|
|
939
|
+
"code-review budget-hook --draft",
|
|
940
|
+
shellQuote(draftPath),
|
|
941
|
+
...opts.budgetUsd ? ["--budget-usd", shellQuote(opts.budgetUsd)] : [],
|
|
942
|
+
...opts.wall ? ["--wall", shellQuote(opts.wall)] : [],
|
|
943
|
+
...opts.prices ? ["--prices", shellQuote(opts.prices)] : [],
|
|
944
|
+
...opts.reserveFrac ? ["--reserve-frac", shellQuote(opts.reserveFrac)] : [],
|
|
945
|
+
...opts.reserveGrowth ? ["--reserve-growth", shellQuote(opts.reserveGrowth)] : [],
|
|
946
|
+
...opts.reserveUsd ? ["--reserve-usd", shellQuote(opts.reserveUsd)] : [],
|
|
947
|
+
...opts.reserveWall ? ["--reserve-wall", shellQuote(opts.reserveWall)] : []
|
|
948
|
+
].join(" ");
|
|
949
|
+
|
|
950
|
+
// src/format.ts
|
|
951
|
+
var FENCE_RE = /^\s*```/;
|
|
952
|
+
var scanLine = (state, line) => {
|
|
953
|
+
if (FENCE_RE.test(line)) {
|
|
954
|
+
return { lines: [...state.lines, line], inFence: !state.inFence, blankRun: 0 };
|
|
955
|
+
}
|
|
956
|
+
if (state.inFence) {
|
|
957
|
+
return { lines: [...state.lines, line], inFence: true, blankRun: 0 };
|
|
958
|
+
}
|
|
959
|
+
const trimmed = line.replace(/[ \t]+$/, "");
|
|
960
|
+
if (trimmed !== "") {
|
|
961
|
+
return { lines: [...state.lines, trimmed], inFence: false, blankRun: 0 };
|
|
962
|
+
}
|
|
963
|
+
const blankRun = state.blankRun + 1;
|
|
964
|
+
return blankRun === 1 ? { lines: [...state.lines, ""], inFence: false, blankRun } : { ...state, blankRun };
|
|
965
|
+
};
|
|
966
|
+
var formatMarkdown = (md) => {
|
|
967
|
+
const { lines } = md.split("\n").reduce(scanLine, { lines: [], inFence: false, blankRun: 0 });
|
|
968
|
+
return `${lines.join("\n").replace(/\n+$/, "")}
|
|
969
|
+
`;
|
|
970
|
+
};
|
|
971
|
+
var pad2 = (n) => String(n).padStart(2, "0");
|
|
972
|
+
var formatUtc = (d) => `${String(d.getUTCFullYear())}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())} ${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())} UTC`;
|
|
973
|
+
|
|
974
|
+
// src/notice.ts
|
|
975
|
+
var isNoticeKind = (s) => s === "security-blocked" || s === "setup-failed" || s === "checkout-failed" || s === "no-output";
|
|
976
|
+
var blockquote = (text) => text.replaceAll("\n", "\n> ");
|
|
977
|
+
var noticeSummary = (kind, reasons) => {
|
|
978
|
+
switch (kind) {
|
|
979
|
+
case "security-blocked":
|
|
980
|
+
return reasons !== void 0 && reasons.trim() !== "" ? `### \u{1F6D1} Code review skipped by the security gate
|
|
981
|
+
|
|
982
|
+
The diff was flagged as unsafe to apply and execute:
|
|
983
|
+
|
|
984
|
+
> ${blockquote(reasons)}` : "### \u{1F6D1} Code review skipped by the security gate\n\nThe security triage returned an unsafe verdict without a reason. See workflow logs.";
|
|
985
|
+
case "setup-failed":
|
|
986
|
+
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.";
|
|
987
|
+
case "checkout-failed":
|
|
988
|
+
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.";
|
|
989
|
+
case "no-output":
|
|
990
|
+
return "### \u26A0\uFE0F Review did not complete\n\nThe diff passed triage but the review produced no output. See workflow logs.";
|
|
991
|
+
}
|
|
992
|
+
};
|
|
993
|
+
var buildNoticeEnvelope = (kind, reasons) => ({
|
|
994
|
+
schema_version: DEFAULT_SCHEMA_VERSION,
|
|
995
|
+
findings: noticeFindings(noticeSummary(kind, reasons)),
|
|
996
|
+
models: [],
|
|
997
|
+
turns: 0,
|
|
998
|
+
duration_ms: 0,
|
|
999
|
+
vendor_cost_usd: null,
|
|
1000
|
+
incomplete: true
|
|
1001
|
+
});
|
|
380
1002
|
var identity = (decoded) => decoded;
|
|
381
1003
|
var findingsTable = [
|
|
382
1004
|
{
|
|
383
|
-
minor: "0.
|
|
1005
|
+
minor: "0.4",
|
|
384
1006
|
defaultVersion: DEFAULT_SCHEMA_VERSION,
|
|
385
1007
|
schemaFile: "findings.schema.json",
|
|
386
1008
|
codec: FindingsCodec,
|
|
@@ -481,18 +1103,27 @@ var runGhApi = (args, stdin, env) => new Promise((resolve3, reject) => {
|
|
|
481
1103
|
});
|
|
482
1104
|
|
|
483
1105
|
// src/pr.ts
|
|
1106
|
+
var CANDIDATE_JQ = ".[] | {number: .number, state: .state, headRef: .head.ref, headSha: .head.sha}";
|
|
1107
|
+
var parseCandidates = (stdout) => parseJsonl(stdout);
|
|
484
1108
|
var fetchPrCandidates = async (repo, headSha, ghApi) => {
|
|
485
|
-
const
|
|
486
|
-
`repos/${repo}/commits/${headSha}/pulls`,
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
1109
|
+
const direct = parseCandidates(
|
|
1110
|
+
await ghApi([`repos/${repo}/commits/${headSha}/pulls`, "--jq", CANDIDATE_JQ])
|
|
1111
|
+
);
|
|
1112
|
+
if (direct.length > 0) return direct;
|
|
1113
|
+
const open = parseCandidates(
|
|
1114
|
+
await ghApi([
|
|
1115
|
+
`repos/${repo}/pulls?state=open&per_page=100`,
|
|
1116
|
+
"--paginate",
|
|
1117
|
+
"--jq",
|
|
1118
|
+
CANDIDATE_JQ
|
|
1119
|
+
])
|
|
1120
|
+
);
|
|
1121
|
+
return open.filter((c) => c.headSha === headSha);
|
|
491
1122
|
};
|
|
492
1123
|
var resolvePr = (candidates, headBranch) => {
|
|
493
1124
|
if (candidates.length === 0) return { kind: "none" };
|
|
494
1125
|
const scoped = candidates.length > 1 && headBranch ? candidates.filter((c) => c.headRef === headBranch) : candidates;
|
|
495
|
-
const chosen = scoped[0] ?? candidates[0];
|
|
1126
|
+
const chosen = scoped.find((c) => c.state === "open") ?? scoped[0] ?? candidates[0];
|
|
496
1127
|
if (chosen === void 0) return { kind: "none" };
|
|
497
1128
|
return chosen.state === "open" ? { kind: "open", prNumber: chosen.number } : { kind: "not-open", prNumber: chosen.number, state: chosen.state };
|
|
498
1129
|
};
|
|
@@ -505,7 +1136,6 @@ var fetchDiff = async (repo, prNumber, ghApi) => ghApi([
|
|
|
505
1136
|
// src/post.ts
|
|
506
1137
|
var DEFAULT_MARKER = "<!-- code-review -->";
|
|
507
1138
|
var MAX_SUGGESTION_LINES = 10;
|
|
508
|
-
var REVIEWED_SHA_RE = /<!-- reviewed-sha: ([0-9a-f]{7,40}) -->/;
|
|
509
1139
|
var countSuggestionLines = (text) => text.split("\n").length;
|
|
510
1140
|
var checkLongSuggestions = (comments) => {
|
|
511
1141
|
const longFiles = [];
|
|
@@ -525,13 +1155,6 @@ var checkLongSuggestions = (comments) => {
|
|
|
525
1155
|
});
|
|
526
1156
|
return { comments: adjusted, longFiles };
|
|
527
1157
|
};
|
|
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
1158
|
var loadFindings = (path) => {
|
|
536
1159
|
let raw;
|
|
537
1160
|
try {
|
|
@@ -578,7 +1201,7 @@ var loadTestReport = (path) => {
|
|
|
578
1201
|
raw = JSON.parse(readFileSync(path, "utf-8"));
|
|
579
1202
|
} catch (err) {
|
|
580
1203
|
process.stderr.write(
|
|
581
|
-
`Warning: could not read test report at ${path}: ${
|
|
1204
|
+
`Warning: could not read test report at ${path}: ${errMsg(err)} \u2014 omitting test panel
|
|
582
1205
|
`
|
|
583
1206
|
);
|
|
584
1207
|
return void 0;
|
|
@@ -593,20 +1216,55 @@ var loadTestReport = (path) => {
|
|
|
593
1216
|
}
|
|
594
1217
|
return decoded.right;
|
|
595
1218
|
};
|
|
596
|
-
var
|
|
597
|
-
const
|
|
598
|
-
|
|
1219
|
+
var parseHtmlUrl = (raw) => {
|
|
1220
|
+
const parsed = tryParseJson(raw);
|
|
1221
|
+
const htmlUrl = parsed.ok ? asRecord(parsed.value)?.["html_url"] : void 0;
|
|
1222
|
+
return typeof htmlUrl === "string" ? htmlUrl : void 0;
|
|
1223
|
+
};
|
|
1224
|
+
var commentPayload = (c) => ({
|
|
1225
|
+
path: c.path,
|
|
1226
|
+
line: c.line,
|
|
1227
|
+
side: c.side,
|
|
1228
|
+
...c.start_line !== void 0 && c.start_side !== void 0 ? { start_line: c.start_line, start_side: c.start_side } : {},
|
|
1229
|
+
body: formatMarkdown(c.body)
|
|
1230
|
+
});
|
|
1231
|
+
var postInlineReview = async (repo, prNumber, headSha, comments, inDiff, stickyUrl, marker, ghApi) => {
|
|
1232
|
+
const pointer = reviewBodyPointer(headSha, stickyUrl, marker);
|
|
1233
|
+
const reviewBody = (withComments) => JSON.stringify({
|
|
1234
|
+
body: pointer,
|
|
599
1235
|
commit_id: headSha,
|
|
600
1236
|
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
|
-
}))
|
|
1237
|
+
comments: withComments ? comments.map(commentPayload) : []
|
|
608
1238
|
});
|
|
609
|
-
|
|
1239
|
+
const reviewsEndpoint = [`repos/${repo}/pulls/${String(prNumber)}/reviews`, "--input", "-"];
|
|
1240
|
+
try {
|
|
1241
|
+
const stdout = await ghApi(reviewsEndpoint, reviewBody(true));
|
|
1242
|
+
return { url: parseHtmlUrl(stdout), inlinePosted: comments.length, unposted: [] };
|
|
1243
|
+
} catch (err) {
|
|
1244
|
+
if (comments.length === 0) throw err;
|
|
1245
|
+
process.stderr.write(
|
|
1246
|
+
`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)
|
|
1247
|
+
`
|
|
1248
|
+
);
|
|
1249
|
+
const url = parseHtmlUrl(await ghApi(reviewsEndpoint, reviewBody(false)));
|
|
1250
|
+
const commentsEndpoint = [`repos/${repo}/pulls/${String(prNumber)}/comments`, "--input", "-"];
|
|
1251
|
+
const unposted = [];
|
|
1252
|
+
let inlinePosted = 0;
|
|
1253
|
+
for (const [i, c] of comments.entries()) {
|
|
1254
|
+
try {
|
|
1255
|
+
await ghApi(commentsEndpoint, JSON.stringify({ commit_id: headSha, ...commentPayload(c) }));
|
|
1256
|
+
inlinePosted += 1;
|
|
1257
|
+
} catch (e) {
|
|
1258
|
+
const finding = inDiff[i];
|
|
1259
|
+
if (finding) unposted.push(finding);
|
|
1260
|
+
process.stderr.write(
|
|
1261
|
+
`Warning: inline comment on ${c.path}:${String(c.line)} rejected (${errMsg(e)}) \u2014 surfacing that finding in the sticky instead (issue #57)
|
|
1262
|
+
`
|
|
1263
|
+
);
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
return { url, inlinePosted, unposted };
|
|
1267
|
+
}
|
|
610
1268
|
};
|
|
611
1269
|
var findBotComment = async (repo, prNumber, botLogin, marker, ghApi) => {
|
|
612
1270
|
const stdout = await ghApi(
|
|
@@ -626,33 +1284,44 @@ var findBotComment = async (repo, prNumber, botLogin, marker, ghApi) => {
|
|
|
626
1284
|
const parsed = JSON.parse(last);
|
|
627
1285
|
return { id: parsed.id, body: parsed.body };
|
|
628
1286
|
};
|
|
1287
|
+
var parseCommentRef = (raw) => {
|
|
1288
|
+
const parsed = tryParseJson(raw);
|
|
1289
|
+
const rec = parsed.ok ? asRecord(parsed.value) : null;
|
|
1290
|
+
const id = rec?.["id"];
|
|
1291
|
+
const html_url = rec?.["html_url"];
|
|
1292
|
+
return typeof id === "number" && typeof html_url === "string" ? { id, html_url } : null;
|
|
1293
|
+
};
|
|
629
1294
|
var patchComment = async (repo, commentId, body, ghApi) => {
|
|
630
|
-
await ghApi(
|
|
1295
|
+
const stdout = await ghApi(
|
|
631
1296
|
[`repos/${repo}/issues/comments/${String(commentId)}`, "--input", "-"],
|
|
632
1297
|
JSON.stringify({ body })
|
|
633
1298
|
);
|
|
1299
|
+
const htmlUrl = parseHtmlUrl(stdout);
|
|
1300
|
+
return htmlUrl !== void 0 ? { html_url: htmlUrl } : null;
|
|
634
1301
|
};
|
|
635
1302
|
var postComment = async (repo, prNumber, body, ghApi) => {
|
|
636
|
-
await ghApi(
|
|
1303
|
+
const stdout = await ghApi(
|
|
637
1304
|
[`repos/${repo}/issues/${String(prNumber)}/comments`, "--input", "-"],
|
|
638
1305
|
JSON.stringify({ body })
|
|
639
1306
|
);
|
|
1307
|
+
return parseCommentRef(stdout);
|
|
640
1308
|
};
|
|
641
1309
|
var upsertSticky = async (repo, prNumber, existing, body, ghApi) => {
|
|
642
1310
|
if (existing !== null) {
|
|
643
|
-
await patchComment(repo, existing.id, body, ghApi);
|
|
1311
|
+
const patched = await patchComment(repo, existing.id, body, ghApi);
|
|
644
1312
|
process.stderr.write(
|
|
645
1313
|
`Updated sticky comment #${String(existing.id)} on PR #${String(prNumber)}
|
|
646
1314
|
`
|
|
647
1315
|
);
|
|
648
|
-
|
|
649
|
-
await postComment(repo, prNumber, body, ghApi);
|
|
650
|
-
process.stderr.write(`Posted new sticky comment on PR #${String(prNumber)}
|
|
651
|
-
`);
|
|
1316
|
+
return { id: existing.id, url: patched?.html_url };
|
|
652
1317
|
}
|
|
1318
|
+
const posted = await postComment(repo, prNumber, body, ghApi);
|
|
1319
|
+
process.stderr.write(`Posted new sticky comment on PR #${String(prNumber)}
|
|
1320
|
+
`);
|
|
1321
|
+
return posted ? { id: posted.id, url: posted.html_url } : null;
|
|
653
1322
|
};
|
|
654
1323
|
var isBotReview = (r) => typeof r === "object" && r !== null && typeof r.id === "number" && typeof r.state === "string" && typeof r.user?.login === "string";
|
|
655
|
-
var
|
|
1324
|
+
var fetchBotReviews = async (repo, prNumber, botLogin, ghApi) => {
|
|
656
1325
|
const stdout = await ghApi([`repos/${repo}/pulls/${String(prNumber)}/reviews`, "--paginate"]);
|
|
657
1326
|
let reviews;
|
|
658
1327
|
try {
|
|
@@ -661,10 +1330,9 @@ var fetchBotReviewIds = async (repo, prNumber, botLogin, ghApi) => {
|
|
|
661
1330
|
return [];
|
|
662
1331
|
}
|
|
663
1332
|
if (!Array.isArray(reviews)) return [];
|
|
664
|
-
return reviews.filter(
|
|
1333
|
+
return reviews.filter(isBotReview).filter((r) => r.user.login === botLogin && r.state !== "DISMISSED").map((r) => ({ id: r.id }));
|
|
665
1334
|
};
|
|
666
|
-
var
|
|
667
|
-
const ids = await fetchBotReviewIds(repo, prNumber, botLogin, ghApi);
|
|
1335
|
+
var dismissReviews = async (repo, prNumber, ids, ghApi) => {
|
|
668
1336
|
for (const id of ids) {
|
|
669
1337
|
try {
|
|
670
1338
|
await ghApi(
|
|
@@ -679,11 +1347,91 @@ var dismissPriorBotReviews = async (repo, prNumber, botLogin, ghApi) => {
|
|
|
679
1347
|
);
|
|
680
1348
|
} catch (err) {
|
|
681
1349
|
process.stderr.write(
|
|
682
|
-
`Warning: failed to dismiss prior review #${String(id)} on PR #${String(prNumber)}: ${
|
|
1350
|
+
`Warning: failed to dismiss prior review #${String(id)} on PR #${String(prNumber)}: ${errMsg(err)}
|
|
1351
|
+
`
|
|
1352
|
+
);
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
};
|
|
1356
|
+
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}}}}}}}}";
|
|
1357
|
+
var MINIMIZE_COMMENT_MUTATION = "mutation($id:ID!){minimizeComment(input:{subjectId:$id,classifier:OUTDATED}){minimizedComment{isMinimized}}}";
|
|
1358
|
+
var priorBotCommentId = (c, logins) => {
|
|
1359
|
+
if (typeof c !== "object" || c === null) return null;
|
|
1360
|
+
const o = c;
|
|
1361
|
+
const login = o.author?.login;
|
|
1362
|
+
return typeof o.id === "string" && o.isMinimized !== true && typeof login === "string" && logins.includes(login) ? o.id : null;
|
|
1363
|
+
};
|
|
1364
|
+
var priorBotCommentIds = (raw, botLogin) => {
|
|
1365
|
+
let parsed;
|
|
1366
|
+
try {
|
|
1367
|
+
parsed = JSON.parse(raw);
|
|
1368
|
+
} catch {
|
|
1369
|
+
return { ids: [], truncated: false };
|
|
1370
|
+
}
|
|
1371
|
+
const conn = parsed.data?.repository?.pullRequest?.reviewThreads;
|
|
1372
|
+
const truncated = conn?.pageInfo?.hasNextPage === true;
|
|
1373
|
+
const nodes = conn?.nodes;
|
|
1374
|
+
if (!Array.isArray(nodes)) return { ids: [], truncated };
|
|
1375
|
+
const logins = [botLogin.replace(/\[bot\]$/, ""), botLogin];
|
|
1376
|
+
const ids = nodes.flatMap((t7) => {
|
|
1377
|
+
const cnodes = t7.comments?.nodes;
|
|
1378
|
+
return Array.isArray(cnodes) ? cnodes.map((c) => priorBotCommentId(c, logins)).filter((id) => id !== null) : [];
|
|
1379
|
+
});
|
|
1380
|
+
return { ids, truncated };
|
|
1381
|
+
};
|
|
1382
|
+
var listPriorBotCommentIds = async (repo, prNumber, botLogin, ghApi) => {
|
|
1383
|
+
const slash = repo.indexOf("/");
|
|
1384
|
+
if (slash <= 0) return [];
|
|
1385
|
+
const owner = repo.slice(0, slash);
|
|
1386
|
+
const name = repo.slice(slash + 1);
|
|
1387
|
+
let raw;
|
|
1388
|
+
try {
|
|
1389
|
+
raw = await ghApi([
|
|
1390
|
+
"graphql",
|
|
1391
|
+
"-f",
|
|
1392
|
+
`query=${REVIEW_THREAD_COMMENTS_QUERY}`,
|
|
1393
|
+
"-f",
|
|
1394
|
+
`owner=${owner}`,
|
|
1395
|
+
"-f",
|
|
1396
|
+
`name=${name}`,
|
|
1397
|
+
"-F",
|
|
1398
|
+
`pr=${String(prNumber)}`
|
|
1399
|
+
]);
|
|
1400
|
+
} catch (err) {
|
|
1401
|
+
process.stderr.write(
|
|
1402
|
+
`Warning: could not list review threads to minimize stale comments on PR #${String(prNumber)}: ${errMsg(err)}
|
|
1403
|
+
`
|
|
1404
|
+
);
|
|
1405
|
+
return [];
|
|
1406
|
+
}
|
|
1407
|
+
const { ids, truncated } = priorBotCommentIds(raw, botLogin);
|
|
1408
|
+
if (truncated) {
|
|
1409
|
+
process.stderr.write(
|
|
1410
|
+
`Note: PR #${String(prNumber)} has more than 100 review threads \u2014 only the first 100 were scanned for stale bot comments
|
|
1411
|
+
`
|
|
1412
|
+
);
|
|
1413
|
+
}
|
|
1414
|
+
return ids;
|
|
1415
|
+
};
|
|
1416
|
+
var minimizeComments = async (prNumber, ids, ghApi) => {
|
|
1417
|
+
let minimized = 0;
|
|
1418
|
+
for (const id of ids) {
|
|
1419
|
+
try {
|
|
1420
|
+
await ghApi(["graphql", "-f", `query=${MINIMIZE_COMMENT_MUTATION}`, "-f", `id=${id}`]);
|
|
1421
|
+
minimized += 1;
|
|
1422
|
+
} catch (err) {
|
|
1423
|
+
process.stderr.write(
|
|
1424
|
+
`Warning: failed to minimize a stale review comment on PR #${String(prNumber)}: ${errMsg(err)}
|
|
683
1425
|
`
|
|
684
1426
|
);
|
|
685
1427
|
}
|
|
686
1428
|
}
|
|
1429
|
+
if (minimized > 0) {
|
|
1430
|
+
process.stderr.write(
|
|
1431
|
+
`Minimized ${String(minimized)} stale inline comment(s) from superseded reviews on PR #${String(prNumber)}
|
|
1432
|
+
`
|
|
1433
|
+
);
|
|
1434
|
+
}
|
|
687
1435
|
};
|
|
688
1436
|
var post = async (input, ghApi = runGhApi) => {
|
|
689
1437
|
const candidates = await fetchPrCandidates(input.repo, input.headSha, ghApi);
|
|
@@ -709,25 +1457,40 @@ var post = async (input, ghApi = runGhApi) => {
|
|
|
709
1457
|
DEFAULT_MARKER,
|
|
710
1458
|
ghApi
|
|
711
1459
|
);
|
|
712
|
-
const
|
|
713
|
-
const
|
|
1460
|
+
const existingComplete = existingSticky !== null && parseReviewComplete(existingSticky.body);
|
|
1461
|
+
const wouldBuryCompleted = (incomplete) => incomplete && existingComplete;
|
|
1462
|
+
const leaveInPlace = () => {
|
|
1463
|
+
process.stderr.write(
|
|
1464
|
+
`Review did not complete and the sticky already reflects a completed review \u2014 leaving it in place
|
|
1465
|
+
`
|
|
1466
|
+
);
|
|
1467
|
+
process.exit(0);
|
|
1468
|
+
};
|
|
714
1469
|
const prices = JSON.parse(readFileSync(input.pricesPath, "utf-8"));
|
|
715
1470
|
const decodedPrices = PriceMapCodec.decode(prices);
|
|
716
1471
|
if (decodedPrices._tag === "Left") {
|
|
717
1472
|
throw new Error(`Price map at ${input.pricesPath} does not match the expected shape`);
|
|
718
1473
|
}
|
|
719
1474
|
const template = readFileSync(input.templatePath, "utf-8");
|
|
720
|
-
const inlineTemplate =
|
|
721
|
-
const renderNotice = (message) =>
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
1475
|
+
const inlineTemplate = readFileSync(input.inlineTemplatePath, "utf-8");
|
|
1476
|
+
const renderNotice = (message) => formatMarkdown(
|
|
1477
|
+
render({
|
|
1478
|
+
findings: noticeFindings(`### \u26A0\uFE0F ${message}`),
|
|
1479
|
+
envelope: null,
|
|
1480
|
+
incomplete: true,
|
|
1481
|
+
prices: decodedPrices.right,
|
|
1482
|
+
pricesProvided: input.pricesProvided,
|
|
1483
|
+
template,
|
|
1484
|
+
route: input.route,
|
|
1485
|
+
reviewedSha: input.headSha,
|
|
1486
|
+
effort: input.effort,
|
|
1487
|
+
runUrl: input.runUrl,
|
|
1488
|
+
jsonUrl: input.jsonUrl,
|
|
1489
|
+
postedAt: input.postedAt
|
|
1490
|
+
})
|
|
1491
|
+
);
|
|
730
1492
|
if (isEmptyDiff(diff)) {
|
|
1493
|
+
if (wouldBuryCompleted(true)) leaveInPlace();
|
|
731
1494
|
await upsertSticky(
|
|
732
1495
|
input.repo,
|
|
733
1496
|
prNumber,
|
|
@@ -739,6 +1502,7 @@ var post = async (input, ghApi = runGhApi) => {
|
|
|
739
1502
|
}
|
|
740
1503
|
const findingsResult = loadFindings(input.findingsPath);
|
|
741
1504
|
if (findingsResult.kind !== "ok") {
|
|
1505
|
+
if (wouldBuryCompleted(true)) leaveInPlace();
|
|
742
1506
|
await upsertSticky(
|
|
743
1507
|
input.repo,
|
|
744
1508
|
prNumber,
|
|
@@ -752,27 +1516,42 @@ var post = async (input, ghApi = runGhApi) => {
|
|
|
752
1516
|
const envelope = loadEnvelope(input.envelopePath);
|
|
753
1517
|
const testReport = input.testReportPath ? loadTestReport(input.testReportPath) : void 0;
|
|
754
1518
|
if (envelope === null) {
|
|
755
|
-
const
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
1519
|
+
const body = formatMarkdown(
|
|
1520
|
+
render({
|
|
1521
|
+
findings,
|
|
1522
|
+
envelope: null,
|
|
1523
|
+
prices: decodedPrices.right,
|
|
1524
|
+
pricesProvided: input.pricesProvided,
|
|
1525
|
+
template,
|
|
1526
|
+
route: input.route,
|
|
1527
|
+
reviewedSha: input.headSha,
|
|
1528
|
+
effort: input.effort,
|
|
1529
|
+
testReport,
|
|
1530
|
+
inlineDisposition: { kind: "no-envelope" },
|
|
1531
|
+
runUrl: input.runUrl,
|
|
1532
|
+
jsonUrl: input.jsonUrl,
|
|
1533
|
+
postedAt: input.postedAt
|
|
1534
|
+
})
|
|
1535
|
+
);
|
|
1536
|
+
await upsertSticky(input.repo, prNumber, existingSticky, body, ghApi);
|
|
766
1537
|
process.stderr.write(
|
|
767
1538
|
"Result envelope missing or malformed \u2014 posted sticky summary without usage/cost data; no inline review\n"
|
|
768
1539
|
);
|
|
769
1540
|
process.exit(0);
|
|
770
1541
|
}
|
|
771
|
-
const
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
1542
|
+
const thisIncomplete = envelope.incomplete === true;
|
|
1543
|
+
if (wouldBuryCompleted(thisIncomplete)) leaveInPlace();
|
|
1544
|
+
const findingsMarker = findingsPointer(findings, input.jsonUrl);
|
|
1545
|
+
const {
|
|
1546
|
+
comments: rawComments,
|
|
1547
|
+
strays,
|
|
1548
|
+
inDiff
|
|
1549
|
+
} = buildInlineComments(findings.findings, diff, {
|
|
1550
|
+
inlineTemplate,
|
|
1551
|
+
models: envelope.models.map((m) => m.model),
|
|
1552
|
+
findings,
|
|
1553
|
+
jsonUrl: input.jsonUrl
|
|
1554
|
+
});
|
|
776
1555
|
const { comments, longFiles } = checkLongSuggestions(rawComments);
|
|
777
1556
|
for (const wf of longFiles) {
|
|
778
1557
|
process.stderr.write(
|
|
@@ -780,45 +1559,392 @@ var post = async (input, ghApi = runGhApi) => {
|
|
|
780
1559
|
`
|
|
781
1560
|
);
|
|
782
1561
|
}
|
|
783
|
-
|
|
1562
|
+
const botReviews = await fetchBotReviews(input.repo, prNumber, input.botLogin, ghApi);
|
|
1563
|
+
const initialDisposition = comments.length === 0 && strays.length > 0 ? { kind: "none-in-diff" } : void 0;
|
|
1564
|
+
const commonRenderInput = {
|
|
784
1565
|
findings,
|
|
785
1566
|
envelope,
|
|
1567
|
+
incomplete: thisIncomplete,
|
|
786
1568
|
prices: decodedPrices.right,
|
|
1569
|
+
pricesProvided: input.pricesProvided,
|
|
787
1570
|
template,
|
|
788
1571
|
route: input.route,
|
|
789
1572
|
reviewedSha: input.headSha,
|
|
790
1573
|
effort: input.effort,
|
|
791
|
-
testReport
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
1574
|
+
testReport,
|
|
1575
|
+
severityCounts: computeSeverityCounts(findings.findings),
|
|
1576
|
+
strays,
|
|
1577
|
+
runUrl: input.runUrl,
|
|
1578
|
+
jsonUrl: input.jsonUrl,
|
|
1579
|
+
findingsPointer: findingsMarker,
|
|
1580
|
+
postedAt: input.postedAt
|
|
1581
|
+
};
|
|
1582
|
+
const longFilesNote = longFiles.length > 0 ? `
|
|
797
1583
|
|
|
798
1584
|
---
|
|
799
1585
|
|
|
800
|
-
> **Note:** ${String(longFiles.length)} suggestion(s) exceeded GitHub's ~10-line inline suggestion limit and were omitted from inline comments
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
1586
|
+
> **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.
|
|
1587
|
+
` : "";
|
|
1588
|
+
const renderBody = (inlineDisposition, reviewUrl2, straysOverride, unanchoredCount2) => formatMarkdown(
|
|
1589
|
+
render({
|
|
1590
|
+
...commonRenderInput,
|
|
1591
|
+
...straysOverride ? { strays: straysOverride } : {},
|
|
1592
|
+
...unanchoredCount2 !== void 0 ? { unanchoredCount: unanchoredCount2 } : {},
|
|
1593
|
+
inlineDisposition,
|
|
1594
|
+
reviewUrl: reviewUrl2
|
|
1595
|
+
}) + longFilesNote
|
|
1596
|
+
);
|
|
1597
|
+
const stickyRef = await upsertSticky(
|
|
1598
|
+
input.repo,
|
|
1599
|
+
prNumber,
|
|
1600
|
+
existingSticky,
|
|
1601
|
+
renderBody(initialDisposition),
|
|
1602
|
+
ghApi
|
|
1603
|
+
);
|
|
1604
|
+
const priorInlineComments = await listPriorBotCommentIds(
|
|
1605
|
+
input.repo,
|
|
1606
|
+
prNumber,
|
|
1607
|
+
input.botLogin,
|
|
1608
|
+
ghApi
|
|
1609
|
+
);
|
|
1610
|
+
const {
|
|
1611
|
+
url: reviewUrl,
|
|
1612
|
+
inlinePosted,
|
|
1613
|
+
unposted
|
|
1614
|
+
} = await postInlineReview(
|
|
1615
|
+
input.repo,
|
|
1616
|
+
prNumber,
|
|
1617
|
+
input.headSha,
|
|
1618
|
+
comments,
|
|
1619
|
+
inDiff,
|
|
1620
|
+
stickyRef?.url,
|
|
1621
|
+
findingsMarker,
|
|
1622
|
+
ghApi
|
|
1623
|
+
);
|
|
1624
|
+
process.stderr.write(
|
|
1625
|
+
`Posted a review with ${String(inlinePosted)} inline comment(s) on PR #${String(prNumber)}
|
|
1626
|
+
`
|
|
1627
|
+
);
|
|
1628
|
+
const priorReviewIds = botReviews.map((r) => r.id);
|
|
1629
|
+
if (priorReviewIds.length > 0) {
|
|
1630
|
+
await dismissReviews(input.repo, prNumber, priorReviewIds, ghApi);
|
|
1631
|
+
}
|
|
1632
|
+
await minimizeComments(prNumber, priorInlineComments, ghApi);
|
|
1633
|
+
const unanchoredCount = unposted.length;
|
|
1634
|
+
const finalStrays = unanchoredCount > 0 ? [...unposted, ...strays] : strays;
|
|
1635
|
+
if (stickyRef !== null && (inlinePosted > 0 || unanchoredCount > 0)) {
|
|
1636
|
+
const finalDisposition = inlinePosted > 0 ? { kind: "posted", count: inlinePosted, sha: input.headSha } : { kind: "inline-unavailable" };
|
|
1637
|
+
try {
|
|
1638
|
+
await patchComment(
|
|
1639
|
+
input.repo,
|
|
1640
|
+
stickyRef.id,
|
|
1641
|
+
renderBody(finalDisposition, reviewUrl, finalStrays, unanchoredCount),
|
|
1642
|
+
ghApi
|
|
1643
|
+
);
|
|
1644
|
+
process.stderr.write(
|
|
1645
|
+
`Updated sticky comment #${String(stickyRef.id)} to reflect the review
|
|
1646
|
+
`
|
|
1647
|
+
);
|
|
1648
|
+
} catch (err) {
|
|
1649
|
+
process.stderr.write(
|
|
1650
|
+
`Warning: failed to update the sticky summary after the review: ${errMsg(err)}
|
|
1651
|
+
`
|
|
1652
|
+
);
|
|
1653
|
+
}
|
|
805
1654
|
}
|
|
806
|
-
|
|
807
|
-
|
|
1655
|
+
};
|
|
1656
|
+
var announceBody = (headSha, runUrl, existingBody) => {
|
|
1657
|
+
const notice = `${DEFAULT_MARKER}
|
|
1658
|
+
|
|
1659
|
+
\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.`;
|
|
1660
|
+
const carried = existingBody ? carryForwardMarkers(existingBody) : "";
|
|
1661
|
+
return carried ? `${notice}
|
|
1662
|
+
|
|
1663
|
+
${carried}` : notice;
|
|
1664
|
+
};
|
|
1665
|
+
var announce = async (input, ghApi = runGhApi) => {
|
|
1666
|
+
const candidates = await fetchPrCandidates(input.repo, input.headSha, ghApi);
|
|
1667
|
+
const resolution = resolvePr(candidates, input.headBranch);
|
|
1668
|
+
if (resolution.kind !== "open") {
|
|
808
1669
|
process.stderr.write(
|
|
809
|
-
`
|
|
1670
|
+
`No open PR for ${input.headSha} \u2014 nothing to announce (${resolution.kind})
|
|
810
1671
|
`
|
|
811
1672
|
);
|
|
812
1673
|
return;
|
|
813
1674
|
}
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
1675
|
+
const existing = await findBotComment(
|
|
1676
|
+
input.repo,
|
|
1677
|
+
resolution.prNumber,
|
|
1678
|
+
input.botLogin,
|
|
1679
|
+
DEFAULT_MARKER,
|
|
1680
|
+
ghApi
|
|
1681
|
+
);
|
|
1682
|
+
if (existing !== null && parseReviewComplete(existing.body) && parseReviewedSha(existing.body) === input.headSha.toLowerCase()) {
|
|
1683
|
+
process.stderr.write(
|
|
1684
|
+
`Sticky already reflects a completed review of ${input.headSha} \u2014 leaving it in place
|
|
1685
|
+
`
|
|
1686
|
+
);
|
|
1687
|
+
return;
|
|
1688
|
+
}
|
|
1689
|
+
await upsertSticky(
|
|
1690
|
+
input.repo,
|
|
1691
|
+
resolution.prNumber,
|
|
1692
|
+
existing,
|
|
1693
|
+
announceBody(input.headSha, input.runUrl, existing?.body),
|
|
1694
|
+
ghApi
|
|
1695
|
+
);
|
|
1696
|
+
};
|
|
1697
|
+
var DURATION_RE = /^(\d+)(h|m|s)$/;
|
|
1698
|
+
var USD_RE = /^\$(\d+(?:\.\d+)?)$/;
|
|
1699
|
+
var toSeconds = (n, unit) => unit === "h" ? n * 3600 : unit === "m" ? n * 60 : n;
|
|
1700
|
+
var stripTrigger = (body, trigger) => {
|
|
1701
|
+
const trimmed = body.replace(/^\s+/, "");
|
|
1702
|
+
if (!trimmed.startsWith(trigger)) return null;
|
|
1703
|
+
const after = trimmed.slice(trigger.length);
|
|
1704
|
+
return after === "" || /^\s/.test(after) ? after : null;
|
|
1705
|
+
};
|
|
1706
|
+
var scanLeading = (s, acc) => {
|
|
1707
|
+
const m = /^(\s*)(\S+)([\s\S]*)$/.exec(s);
|
|
1708
|
+
if (m === null) return { ...acc, rest: "" };
|
|
1709
|
+
const [, , token = "", tail = ""] = m;
|
|
1710
|
+
const dm = DURATION_RE.exec(token);
|
|
1711
|
+
if (dm && acc.durationSec === null)
|
|
1712
|
+
return scanLeading(tail, {
|
|
1713
|
+
...acc,
|
|
1714
|
+
durationSec: toSeconds(Number.parseInt(dm[1] ?? "", 10), dm[2] ?? "s")
|
|
1715
|
+
});
|
|
1716
|
+
const um = USD_RE.exec(token);
|
|
1717
|
+
if (um && acc.usd === null)
|
|
1718
|
+
return scanLeading(tail, { ...acc, usd: Number.parseFloat(um[1] ?? "") });
|
|
1719
|
+
return { ...acc, rest: s };
|
|
1720
|
+
};
|
|
1721
|
+
var clampDuration = (requested, ceiling) => requested !== null && ceiling !== null && requested > ceiling ? {
|
|
1722
|
+
value: ceiling,
|
|
1723
|
+
notes: [
|
|
1724
|
+
`requested duration ${String(requested)}s exceeds the ${String(ceiling)}s ceiling \u2014 clamped to ${String(ceiling)}s`
|
|
1725
|
+
]
|
|
1726
|
+
} : { value: requested, notes: [] };
|
|
1727
|
+
var clampUsd = (requested, ceiling) => requested !== null && ceiling !== null && requested > ceiling ? {
|
|
1728
|
+
value: ceiling,
|
|
1729
|
+
notes: [
|
|
1730
|
+
`requested $${requested.toFixed(2)} exceeds the $${ceiling.toFixed(2)} ceiling \u2014 clamped to $${ceiling.toFixed(2)}`
|
|
1731
|
+
]
|
|
1732
|
+
} : { value: requested, notes: [] };
|
|
1733
|
+
var capInstructions = (text, maxLen) => text.length > maxLen ? {
|
|
1734
|
+
value: text.slice(0, maxLen),
|
|
1735
|
+
notes: [
|
|
1736
|
+
`instructions truncated from ${String(text.length)} to ${String(maxLen)} characters`
|
|
1737
|
+
]
|
|
1738
|
+
} : { value: text, notes: [] };
|
|
1739
|
+
var parseCommandArgs = (body, options) => {
|
|
1740
|
+
const afterTrigger = stripTrigger(body, options.trigger);
|
|
1741
|
+
if (afterTrigger === null) return { kind: "not-a-command" };
|
|
1742
|
+
const scan = scanLeading(afterTrigger, { durationSec: null, usd: null });
|
|
1743
|
+
const duration = clampDuration(scan.durationSec, options.maxDurationSec);
|
|
1744
|
+
const usd = clampUsd(scan.usd, options.maxUsd);
|
|
1745
|
+
const instructions = capInstructions(scan.rest.trim(), options.maxInstructionsLen);
|
|
1746
|
+
return {
|
|
1747
|
+
kind: "command",
|
|
1748
|
+
args: {
|
|
1749
|
+
durationSec: duration.value,
|
|
1750
|
+
usd: usd.value,
|
|
1751
|
+
instructions: instructions.value,
|
|
1752
|
+
notes: [...duration.notes, ...usd.notes, ...instructions.notes]
|
|
1753
|
+
}
|
|
1754
|
+
};
|
|
1755
|
+
};
|
|
1756
|
+
var PrHeadCodec = t.type({
|
|
1757
|
+
head_sha: t.string,
|
|
1758
|
+
head_ref: t.string,
|
|
1759
|
+
head_repo: t.union([t.string, t.null]),
|
|
1760
|
+
state: t.string
|
|
1761
|
+
});
|
|
1762
|
+
var resolvePrHead = async (repo, prNumber, ghApi) => {
|
|
1763
|
+
const stdout = await ghApi([
|
|
1764
|
+
`repos/${repo}/pulls/${String(prNumber)}`,
|
|
1765
|
+
"--jq",
|
|
1766
|
+
"{head_sha: .head.sha, head_ref: .head.ref, head_repo: .head.repo.full_name, state: .state}"
|
|
1767
|
+
]);
|
|
1768
|
+
const decoded = PrHeadCodec.decode(JSON.parse(stdout));
|
|
1769
|
+
if (decoded._tag === "Left") {
|
|
1770
|
+
throw new Error(`PR head for #${String(prNumber)} did not match the expected shape`);
|
|
1771
|
+
}
|
|
1772
|
+
return decoded.right;
|
|
1773
|
+
};
|
|
1774
|
+
var parseCommand = async (input, ghApi = runGhApi) => {
|
|
1775
|
+
const parse = parseCommandArgs(input.body, input.options);
|
|
1776
|
+
if (parse.kind === "not-a-command") {
|
|
1777
|
+
return {
|
|
1778
|
+
kind: "skip",
|
|
1779
|
+
reason: `comment does not begin with the trigger "${input.options.trigger}"`
|
|
1780
|
+
};
|
|
1781
|
+
}
|
|
1782
|
+
const head = await resolvePrHead(input.repo, input.prNumber, ghApi).catch(
|
|
1783
|
+
(err) => err instanceof Error ? err : new Error(String(err))
|
|
1784
|
+
);
|
|
1785
|
+
if (head instanceof Error) {
|
|
1786
|
+
return {
|
|
1787
|
+
kind: "skip",
|
|
1788
|
+
reason: `could not resolve PR #${String(input.prNumber)}: ${head.message}`
|
|
1789
|
+
};
|
|
1790
|
+
}
|
|
1791
|
+
if (head.state !== "open") {
|
|
1792
|
+
return {
|
|
1793
|
+
kind: "skip",
|
|
1794
|
+
reason: `PR #${String(input.prNumber)} is not open (state: ${head.state})`
|
|
1795
|
+
};
|
|
1796
|
+
}
|
|
1797
|
+
return {
|
|
1798
|
+
kind: "run",
|
|
1799
|
+
headSha: head.head_sha,
|
|
1800
|
+
headBranch: head.head_ref,
|
|
1801
|
+
headRepo: head.head_repo ?? input.repo,
|
|
1802
|
+
args: parse.args
|
|
1803
|
+
};
|
|
1804
|
+
};
|
|
1805
|
+
var safeHeredocDelim = (instructions, randomHex, attemptsLeft = 8) => {
|
|
1806
|
+
const candidate = `GHOUT_${randomHex()}`;
|
|
1807
|
+
if (!instructions.split("\n").includes(candidate)) return candidate;
|
|
1808
|
+
if (attemptsLeft <= 0) throw new Error("could not derive a collision-free heredoc delimiter");
|
|
1809
|
+
return safeHeredocDelim(instructions, randomHex, attemptsLeft - 1);
|
|
1810
|
+
};
|
|
1811
|
+
var renderCommandOutputs = (result, delim) => {
|
|
1812
|
+
if (result.kind === "skip") return "should_run=false\n";
|
|
1813
|
+
const { headSha, headBranch, headRepo, args } = result;
|
|
1814
|
+
return `${[
|
|
1815
|
+
"should_run=true",
|
|
1816
|
+
`head_sha=${headSha}`,
|
|
1817
|
+
`head_branch=${headBranch}`,
|
|
1818
|
+
`head_repo=${headRepo}`,
|
|
1819
|
+
`duration=${args.durationSec === null ? "" : `${String(args.durationSec)}s`}`,
|
|
1820
|
+
`usd=${args.usd === null ? "" : args.usd.toFixed(2)}`,
|
|
1821
|
+
`instructions<<${delim}`,
|
|
1822
|
+
args.instructions,
|
|
1823
|
+
delim
|
|
1824
|
+
].join("\n")}
|
|
1825
|
+
`;
|
|
1826
|
+
};
|
|
1827
|
+
var REACTIONS = [
|
|
1828
|
+
"+1",
|
|
1829
|
+
"-1",
|
|
1830
|
+
"laugh",
|
|
1831
|
+
"confused",
|
|
1832
|
+
"heart",
|
|
1833
|
+
"hooray",
|
|
1834
|
+
"rocket",
|
|
1835
|
+
"eyes"
|
|
1836
|
+
];
|
|
1837
|
+
var isReaction = (s) => REACTIONS.includes(s);
|
|
1838
|
+
var ReactionCodec = t.type({ id: t.number, content: t.string });
|
|
1839
|
+
var reactionsPath = (repo, commentId) => `repos/${repo}/issues/comments/${String(commentId)}/reactions`;
|
|
1840
|
+
var removeReactions = async (repo, commentId, content, ghApi) => {
|
|
1841
|
+
const stdout = await ghApi([
|
|
1842
|
+
reactionsPath(repo, commentId),
|
|
1843
|
+
"--paginate",
|
|
1844
|
+
"--jq",
|
|
1845
|
+
".[] | {id, content}"
|
|
1846
|
+
]);
|
|
1847
|
+
for (const line of stdout.split("\n").filter((l) => l.trim() !== "")) {
|
|
1848
|
+
const parsed = tryParseJson(line);
|
|
1849
|
+
const decoded = parsed.ok ? ReactionCodec.decode(parsed.value) : void 0;
|
|
1850
|
+
if (decoded === void 0 || decoded._tag === "Left") {
|
|
1851
|
+
process.stderr.write("code-review react: could not decode a reaction entry \u2014 skipping\n");
|
|
1852
|
+
continue;
|
|
1853
|
+
}
|
|
1854
|
+
if (decoded.right.content !== content) continue;
|
|
1855
|
+
await ghApi([
|
|
1856
|
+
"--method",
|
|
1857
|
+
"DELETE",
|
|
1858
|
+
`${reactionsPath(repo, commentId)}/${String(decoded.right.id)}`
|
|
1859
|
+
]).catch(
|
|
1860
|
+
(err) => process.stderr.write(
|
|
1861
|
+
`code-review react: could not remove reaction ${String(decoded.right.id)} (${errMsg(err)}) \u2014 skipping
|
|
1862
|
+
`
|
|
1863
|
+
)
|
|
1864
|
+
);
|
|
1865
|
+
}
|
|
1866
|
+
};
|
|
1867
|
+
var react = async (input, ghApi = runGhApi) => {
|
|
1868
|
+
if (input.add !== void 0) {
|
|
1869
|
+
await ghApi([
|
|
1870
|
+
"--method",
|
|
1871
|
+
"POST",
|
|
1872
|
+
reactionsPath(input.repo, input.commentId),
|
|
1873
|
+
"-f",
|
|
1874
|
+
`content=${input.add}`
|
|
1875
|
+
]);
|
|
1876
|
+
}
|
|
1877
|
+
if (input.remove !== void 0) {
|
|
1878
|
+
await removeReactions(input.repo, input.commentId, input.remove, ghApi);
|
|
1879
|
+
}
|
|
1880
|
+
};
|
|
1881
|
+
var RunCodec = t.type({
|
|
1882
|
+
id: t.number,
|
|
1883
|
+
name: t.union([t.string, t.null]),
|
|
1884
|
+
status: t.union([t.string, t.null]),
|
|
1885
|
+
conclusion: t.union([t.string, t.null]),
|
|
1886
|
+
run_number: t.number
|
|
1887
|
+
});
|
|
1888
|
+
var RUN_JQ = ".workflow_runs[] | {id: .id, name: .name, status: .status, conclusion: .conclusion, run_number: .run_number}";
|
|
1889
|
+
var resolveCiRun = async (repo, headSha, workflowName, ghApi) => {
|
|
1890
|
+
const endpoint = `repos/${repo}/actions/runs?head_sha=${headSha}&per_page=100`;
|
|
1891
|
+
const rows = parseJsonl(await ghApi([endpoint, "--paginate", "--jq", RUN_JQ]));
|
|
1892
|
+
const decoded = rows.map((row) => RunCodec.decode(row));
|
|
1893
|
+
const runs = decoded.flatMap((d) => d._tag === "Right" ? [d.right] : []);
|
|
1894
|
+
const dropped = decoded.length - runs.length;
|
|
1895
|
+
if (dropped > 0) {
|
|
1896
|
+
const firstDrift = decoded.find((d) => d._tag === "Left");
|
|
1897
|
+
const detail = firstDrift === void 0 ? "" : ` (${PathReporter.report(firstDrift).join("; ")})`;
|
|
1898
|
+
process.stderr.write(
|
|
1899
|
+
`Warning: ${String(dropped)} of ${String(rows.length)} workflow-run row(s) from ${endpoint} failed to decode${detail} \u2014 excluded from the lookup
|
|
1900
|
+
`
|
|
819
1901
|
);
|
|
820
1902
|
}
|
|
1903
|
+
const latest = runs.filter((r) => r.name === workflowName).reduce(
|
|
1904
|
+
(best, r) => best === null || r.run_number > best.run_number ? r : best,
|
|
1905
|
+
null
|
|
1906
|
+
);
|
|
1907
|
+
return {
|
|
1908
|
+
run: latest === null ? null : { id: latest.id, status: latest.status ?? "unknown", conclusion: latest.conclusion },
|
|
1909
|
+
seenNames: [...new Set(runs.flatMap((r) => r.name === null ? [] : [r.name]))]
|
|
1910
|
+
};
|
|
821
1911
|
};
|
|
1912
|
+
var awaitCiConclusion = async (repo, headSha, options, deps = { ghApi: runGhApi, sleep: defaultSleep, elapsedMs: monotonicElapsed() }) => {
|
|
1913
|
+
const safeResolve = async () => {
|
|
1914
|
+
try {
|
|
1915
|
+
return await resolveCiRun(repo, headSha, options.workflowName, deps.ghApi);
|
|
1916
|
+
} catch (err) {
|
|
1917
|
+
process.stderr.write(
|
|
1918
|
+
`Warning: CI-run lookup for ${headSha} failed (${errMsg(err)}) \u2014 retrying until the timeout
|
|
1919
|
+
`
|
|
1920
|
+
);
|
|
1921
|
+
return { run: null, seenNames: [] };
|
|
1922
|
+
}
|
|
1923
|
+
};
|
|
1924
|
+
const poll = async (lastSeenNames, lastRunId) => {
|
|
1925
|
+
const { run, seenNames } = await safeResolve();
|
|
1926
|
+
if (run !== null && run.status === "completed" && run.conclusion !== null)
|
|
1927
|
+
return { kind: "concluded", conclusion: run.conclusion, runId: run.id };
|
|
1928
|
+
const runId = run === null ? lastRunId : run.id;
|
|
1929
|
+
const names = seenNames.length > 0 ? seenNames : lastSeenNames;
|
|
1930
|
+
if (deps.elapsedMs() >= options.timeoutMs)
|
|
1931
|
+
return { kind: "timed-out", runId, seenNames: names };
|
|
1932
|
+
await deps.sleep(options.pollIntervalMs);
|
|
1933
|
+
return poll(names, runId);
|
|
1934
|
+
};
|
|
1935
|
+
return poll([], null);
|
|
1936
|
+
};
|
|
1937
|
+
var defaultSleep = (ms) => new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
|
|
1938
|
+
var monotonicElapsed = () => {
|
|
1939
|
+
const start = performance.now();
|
|
1940
|
+
return () => performance.now() - start;
|
|
1941
|
+
};
|
|
1942
|
+
var renderCiOutputs = (outcome) => outcome.kind === "concluded" ? `ci_settled=true
|
|
1943
|
+
ci_conclusion=${outcome.conclusion}
|
|
1944
|
+
ci_run_id=${String(outcome.runId)}
|
|
1945
|
+
` : `ci_settled=false
|
|
1946
|
+
ci_run_id=${outcome.runId === null ? "" : String(outcome.runId)}
|
|
1947
|
+
`;
|
|
822
1948
|
var renderOutputs = (result) => {
|
|
823
1949
|
switch (result.kind) {
|
|
824
1950
|
case "skip":
|
|
@@ -827,6 +1953,7 @@ var renderOutputs = (result) => {
|
|
|
827
1953
|
return `pr=${String(result.pr)}
|
|
828
1954
|
conclusion=${result.conclusion}
|
|
829
1955
|
diff_size=${String(result.diffSize)}
|
|
1956
|
+
stacked=${String(result.stacked)}
|
|
830
1957
|
`;
|
|
831
1958
|
}
|
|
832
1959
|
};
|
|
@@ -849,22 +1976,28 @@ var runGit = (args) => new Promise((resolve3, reject) => {
|
|
|
849
1976
|
var PrMetaCodec = t.type({
|
|
850
1977
|
changed_files: t.number,
|
|
851
1978
|
base_sha: t.string,
|
|
1979
|
+
base_ref: t.string,
|
|
852
1980
|
title: t.string,
|
|
853
1981
|
body: t.union([t.string, t.null])
|
|
854
1982
|
});
|
|
855
|
-
var IssueCommentCodec = t.
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
})
|
|
860
|
-
|
|
1983
|
+
var IssueCommentCodec = t.intersection([
|
|
1984
|
+
t.type({
|
|
1985
|
+
id: t.number,
|
|
1986
|
+
body: t.union([t.string, t.null]),
|
|
1987
|
+
user: t.type({ login: t.string })
|
|
1988
|
+
}),
|
|
1989
|
+
t.partial({
|
|
1990
|
+
created_at: t.union([t.string, t.null]),
|
|
1991
|
+
author_association: t.union([t.string, t.null])
|
|
1992
|
+
})
|
|
1993
|
+
]);
|
|
861
1994
|
var JobCodec = t.type({ id: t.number, conclusion: t.union([t.string, t.null]) });
|
|
862
1995
|
var JobsResponseCodec = t.type({ jobs: t.array(JobCodec) });
|
|
863
1996
|
var fetchPrMeta = async (repo, prNumber, ghApi) => {
|
|
864
1997
|
const stdout = await ghApi([
|
|
865
1998
|
`repos/${repo}/pulls/${String(prNumber)}`,
|
|
866
1999
|
"--jq",
|
|
867
|
-
"{changed_files: .changed_files, base_sha: .base.sha, title: .title, body: .body}"
|
|
2000
|
+
"{changed_files: .changed_files, base_sha: .base.sha, base_ref: .base.ref, title: .title, body: .body}"
|
|
868
2001
|
]);
|
|
869
2002
|
const decoded = PrMetaCodec.decode(JSON.parse(stdout));
|
|
870
2003
|
if (decoded._tag === "Left") {
|
|
@@ -879,18 +2012,139 @@ var fetchApiDiff = async (repo, prNumber, ghApi) => {
|
|
|
879
2012
|
return null;
|
|
880
2013
|
}
|
|
881
2014
|
};
|
|
882
|
-
var
|
|
2015
|
+
var fetchFullDiff = async (repo, defaultBranch, headSha, ghApi, gitRun) => {
|
|
883
2016
|
try {
|
|
884
|
-
const
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
2017
|
+
const diff = await ghApi([
|
|
2018
|
+
`repos/${repo}/compare/${defaultBranch}...${headSha}`,
|
|
2019
|
+
"-H",
|
|
2020
|
+
"Accept: application/vnd.github.v3.diff"
|
|
2021
|
+
]);
|
|
2022
|
+
if (diff.length > 0) return diff;
|
|
2023
|
+
process.stderr.write(
|
|
2024
|
+
`compare diff for ${defaultBranch}...${headSha} was empty \u2014 falling back to git diff
|
|
2025
|
+
`
|
|
2026
|
+
);
|
|
2027
|
+
} catch (err) {
|
|
2028
|
+
process.stderr.write(`compare diff fetch failed (${errMsg(err)}) \u2014 falling back to git diff
|
|
2029
|
+
`);
|
|
2030
|
+
}
|
|
2031
|
+
await gitRun(["fetch", "origin", headSha]);
|
|
2032
|
+
const base = (await gitRun(["rev-parse", "HEAD"])).trim();
|
|
2033
|
+
return gitRun(["diff", `${base}...${headSha}`]);
|
|
2034
|
+
};
|
|
2035
|
+
var CommitCodec = t.type({
|
|
2036
|
+
sha: t.string,
|
|
2037
|
+
message: t.string,
|
|
2038
|
+
author: t.union([t.string, t.null]),
|
|
2039
|
+
email: t.union([t.string, t.null])
|
|
2040
|
+
});
|
|
2041
|
+
var COMMIT_JQ = ".commits[] | {sha: .sha, message: .commit.message, author: .commit.author.name, email: .commit.author.email}";
|
|
2042
|
+
var fetchCompareCommits = async (repo, defaultBranch, headSha, ghApi) => {
|
|
2043
|
+
const rows = parseJsonl(
|
|
2044
|
+
await ghApi([
|
|
2045
|
+
`repos/${repo}/compare/${defaultBranch}...${headSha}`,
|
|
2046
|
+
"--paginate",
|
|
2047
|
+
"--jq",
|
|
2048
|
+
COMMIT_JQ
|
|
2049
|
+
])
|
|
2050
|
+
);
|
|
2051
|
+
const decoded = rows.map((row) => CommitCodec.decode(row));
|
|
2052
|
+
const commits = decoded.flatMap((d) => d._tag === "Right" ? [d.right] : []);
|
|
2053
|
+
const dropped = decoded.length - commits.length;
|
|
2054
|
+
if (dropped > 0) {
|
|
2055
|
+
process.stderr.write(
|
|
2056
|
+
`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
|
|
2057
|
+
`
|
|
2058
|
+
);
|
|
2059
|
+
}
|
|
2060
|
+
return commits;
|
|
2061
|
+
};
|
|
2062
|
+
var COMMENT_JQ = ".[] | {id: .id, body: .body, user: {login: .user.login}, created_at: .created_at, author_association: .author_association}";
|
|
2063
|
+
var REVIEW_COMMENT_JQ = ".[] | {body: .body, user: {login: .user.login}, created_at: .created_at, author_association: .author_association, path: .path, line: .line}";
|
|
2064
|
+
var REVIEW_JQ = ".[] | {body: .body, user: {login: .user.login}, submitted_at: .submitted_at, author_association: .author_association, state: .state}";
|
|
2065
|
+
var ReviewCommentCodec = t.intersection([
|
|
2066
|
+
t.type({ body: t.union([t.string, t.null]), user: t.type({ login: t.string }) }),
|
|
2067
|
+
t.partial({
|
|
2068
|
+
created_at: t.union([t.string, t.null]),
|
|
2069
|
+
author_association: t.union([t.string, t.null]),
|
|
2070
|
+
path: t.union([t.string, t.null]),
|
|
2071
|
+
line: t.union([t.number, t.null])
|
|
2072
|
+
})
|
|
2073
|
+
]);
|
|
2074
|
+
var ReviewCodec = t.intersection([
|
|
2075
|
+
t.type({ body: t.union([t.string, t.null]), user: t.type({ login: t.string }) }),
|
|
2076
|
+
t.partial({
|
|
2077
|
+
submitted_at: t.union([t.string, t.null]),
|
|
2078
|
+
author_association: t.union([t.string, t.null]),
|
|
2079
|
+
state: t.union([t.string, t.null])
|
|
2080
|
+
})
|
|
2081
|
+
]);
|
|
2082
|
+
var fetchJsonlRows = async (ghApi, endpoint, jq) => {
|
|
2083
|
+
try {
|
|
2084
|
+
return parseJsonl(await ghApi([endpoint, "--paginate", "--jq", jq]));
|
|
2085
|
+
} catch (err) {
|
|
2086
|
+
process.stderr.write(
|
|
2087
|
+
`Warning: could not fetch ${endpoint} (${errMsg(err)}) \u2014 omitting it from the review context
|
|
2088
|
+
`
|
|
2089
|
+
);
|
|
891
2090
|
return null;
|
|
892
2091
|
}
|
|
893
2092
|
};
|
|
2093
|
+
var decodeArrayOrNull = (codec, rows) => {
|
|
2094
|
+
if (rows === null) return null;
|
|
2095
|
+
return rows.flatMap((row) => {
|
|
2096
|
+
const decoded = codec.decode(row);
|
|
2097
|
+
return decoded._tag === "Right" ? [decoded.right] : [];
|
|
2098
|
+
});
|
|
2099
|
+
};
|
|
2100
|
+
var priorReviewFrom = (comments, botLogin) => {
|
|
2101
|
+
const byBot = comments.filter((c) => c.user.login === botLogin);
|
|
2102
|
+
const last = byBot[byBot.length - 1];
|
|
2103
|
+
return last ? { id: last.id, body: last.body } : null;
|
|
2104
|
+
};
|
|
2105
|
+
var MAX_CONVERSATION_COMMENTS = 50;
|
|
2106
|
+
var MAX_CONVERSATION_BODY_CHARS = 4e3;
|
|
2107
|
+
var clip = (body) => {
|
|
2108
|
+
if (body.length <= MAX_CONVERSATION_BODY_CHARS) return body;
|
|
2109
|
+
const cut = body.slice(0, MAX_CONVERSATION_BODY_CHARS);
|
|
2110
|
+
const safe = /[\uD800-\uDBFF]$/.test(cut) ? cut.slice(0, -1) : cut;
|
|
2111
|
+
return `${safe}
|
|
2112
|
+
\u2026 [truncated]`;
|
|
2113
|
+
};
|
|
2114
|
+
var boundedHuman = (items, botLogin, label, project) => {
|
|
2115
|
+
const human = items.filter(
|
|
2116
|
+
(a) => a.user.login !== botLogin && typeof a.body === "string" && a.body.trim() !== ""
|
|
2117
|
+
);
|
|
2118
|
+
const kept = human.slice(-MAX_CONVERSATION_COMMENTS);
|
|
2119
|
+
if (kept.length < human.length) {
|
|
2120
|
+
process.stderr.write(
|
|
2121
|
+
`Note: PR has ${String(human.length)} ${label} \u2014 feeding the review the most recent ${String(MAX_CONVERSATION_COMMENTS)}
|
|
2122
|
+
`
|
|
2123
|
+
);
|
|
2124
|
+
}
|
|
2125
|
+
return kept.map(project);
|
|
2126
|
+
};
|
|
2127
|
+
var issueCommentsFrom = (comments, botLogin) => boundedHuman(comments, botLogin, "discussion comments", (c) => ({
|
|
2128
|
+
author: c.user.login,
|
|
2129
|
+
author_association: c.author_association ?? null,
|
|
2130
|
+
created_at: c.created_at ?? null,
|
|
2131
|
+
body: clip(c.body)
|
|
2132
|
+
}));
|
|
2133
|
+
var reviewCommentsFrom = (comments, botLogin) => boundedHuman(comments, botLogin, "inline review comments", (c) => ({
|
|
2134
|
+
author: c.user.login,
|
|
2135
|
+
author_association: c.author_association ?? null,
|
|
2136
|
+
created_at: c.created_at ?? null,
|
|
2137
|
+
path: c.path ?? null,
|
|
2138
|
+
line: c.line ?? null,
|
|
2139
|
+
body: clip(c.body)
|
|
2140
|
+
}));
|
|
2141
|
+
var reviewsFrom = (reviews, botLogin) => boundedHuman(reviews, botLogin, "review submissions", (r) => ({
|
|
2142
|
+
author: r.user.login,
|
|
2143
|
+
author_association: r.author_association ?? null,
|
|
2144
|
+
submitted_at: r.submitted_at ?? null,
|
|
2145
|
+
state: r.state ?? null,
|
|
2146
|
+
body: clip(r.body)
|
|
2147
|
+
}));
|
|
894
2148
|
var downloadFailingJobLogs = async (repo, runId, outDir, ghApi) => {
|
|
895
2149
|
const stdout = await ghApi([`repos/${repo}/actions/runs/${runId}/jobs`]);
|
|
896
2150
|
const decoded = JobsResponseCodec.decode(JSON.parse(stdout));
|
|
@@ -903,7 +2157,7 @@ var downloadFailingJobLogs = async (repo, runId, outDir, ghApi) => {
|
|
|
903
2157
|
writeFileSync(join(outDir, `job_${String(job.id)}.log`), log);
|
|
904
2158
|
} catch (err) {
|
|
905
2159
|
process.stderr.write(
|
|
906
|
-
`Warning: failed to download logs for job ${String(job.id)}: ${
|
|
2160
|
+
`Warning: failed to download logs for job ${String(job.id)}: ${errMsg(err)} \u2014 continuing with the logs retrieved so far
|
|
907
2161
|
`
|
|
908
2162
|
);
|
|
909
2163
|
}
|
|
@@ -926,8 +2180,10 @@ var gather = async (input, ghApi = runGhApi, gitRun = runGit) => {
|
|
|
926
2180
|
}
|
|
927
2181
|
const prNumber = resolution.prNumber;
|
|
928
2182
|
const meta = await fetchPrMeta(input.repo, prNumber, ghApi);
|
|
929
|
-
const
|
|
930
|
-
const
|
|
2183
|
+
const stacked = meta.base_ref !== input.defaultBranch;
|
|
2184
|
+
const prDiff = await (async () => {
|
|
2185
|
+
const apiDiff = await fetchApiDiff(input.repo, prNumber, ghApi);
|
|
2186
|
+
if (apiDiff !== null && !(apiDiff.length === 0 && meta.changed_files > 0)) return apiDiff;
|
|
931
2187
|
process.stderr.write(
|
|
932
2188
|
`PR diff fetch failed or was empty for ${String(meta.changed_files)} changed files \u2014 falling back to git diff
|
|
933
2189
|
`
|
|
@@ -935,16 +2191,40 @@ var gather = async (input, ghApi = runGhApi, gitRun = runGit) => {
|
|
|
935
2191
|
await gitRun(["fetch", "origin", input.headSha]);
|
|
936
2192
|
return gitRun(["diff", meta.base_sha, input.headSha]);
|
|
937
2193
|
})();
|
|
938
|
-
|
|
2194
|
+
const fullDiff = stacked ? await fetchFullDiff(input.repo, input.defaultBranch, input.headSha, ghApi, gitRun) : prDiff;
|
|
2195
|
+
const commits = await fetchCompareCommits(input.repo, input.defaultBranch, input.headSha, ghApi);
|
|
2196
|
+
writeFileSync(join(input.outDir, "full.diff"), fullDiff);
|
|
2197
|
+
writeFileSync(join(input.outDir, "pr.diff"), prDiff);
|
|
2198
|
+
writeFileSync(join(input.outDir, "commits.json"), JSON.stringify(commits));
|
|
939
2199
|
writeFileSync(
|
|
940
2200
|
join(input.outDir, "pr_context.json"),
|
|
941
2201
|
JSON.stringify({ title: meta.title, body: meta.body })
|
|
942
2202
|
);
|
|
943
|
-
const
|
|
2203
|
+
const [issueRows, reviewCommentRows, reviewRows] = await Promise.all([
|
|
2204
|
+
fetchJsonlRows(ghApi, `repos/${input.repo}/issues/${String(prNumber)}/comments`, COMMENT_JQ),
|
|
2205
|
+
fetchJsonlRows(
|
|
2206
|
+
ghApi,
|
|
2207
|
+
`repos/${input.repo}/pulls/${String(prNumber)}/comments`,
|
|
2208
|
+
REVIEW_COMMENT_JQ
|
|
2209
|
+
),
|
|
2210
|
+
fetchJsonlRows(ghApi, `repos/${input.repo}/pulls/${String(prNumber)}/reviews`, REVIEW_JQ)
|
|
2211
|
+
]);
|
|
2212
|
+
const issueComments = decodeArrayOrNull(IssueCommentCodec, issueRows);
|
|
2213
|
+
const reviewComments = decodeArrayOrNull(ReviewCommentCodec, reviewCommentRows);
|
|
2214
|
+
const reviews = decodeArrayOrNull(ReviewCodec, reviewRows);
|
|
2215
|
+
const prior = issueComments === null ? null : priorReviewFrom(issueComments, input.botLogin);
|
|
944
2216
|
writeFileSync(
|
|
945
2217
|
join(input.outDir, "prior_review.json"),
|
|
946
2218
|
prior === null ? "null" : JSON.stringify(prior)
|
|
947
2219
|
);
|
|
2220
|
+
writeFileSync(
|
|
2221
|
+
join(input.outDir, "pr_conversation.json"),
|
|
2222
|
+
JSON.stringify({
|
|
2223
|
+
issue_comments: issueComments === null ? [] : issueCommentsFrom(issueComments, input.botLogin),
|
|
2224
|
+
review_comments: reviewComments === null ? [] : reviewCommentsFrom(reviewComments, input.botLogin),
|
|
2225
|
+
reviews: reviews === null ? [] : reviewsFrom(reviews, input.botLogin)
|
|
2226
|
+
})
|
|
2227
|
+
);
|
|
948
2228
|
if (input.conclusion === "failure") {
|
|
949
2229
|
await downloadFailingJobLogs(input.repo, input.runId, input.outDir, ghApi);
|
|
950
2230
|
}
|
|
@@ -952,9 +2232,12 @@ var gather = async (input, ghApi = runGhApi, gitRun = runGit) => {
|
|
|
952
2232
|
kind: "gathered",
|
|
953
2233
|
pr: prNumber,
|
|
954
2234
|
conclusion: input.conclusion,
|
|
955
|
-
diffSize: Buffer.byteLength(
|
|
2235
|
+
diffSize: Buffer.byteLength(prDiff, "utf8"),
|
|
2236
|
+
stacked
|
|
956
2237
|
};
|
|
957
2238
|
};
|
|
2239
|
+
|
|
2240
|
+
// src/extract.ts
|
|
958
2241
|
var fieldOf = (raw, key2) => typeof raw === "object" && raw !== null && key2 in raw ? raw[key2] : void 0;
|
|
959
2242
|
var parseNativeForExtraction = (raw) => ({
|
|
960
2243
|
result: fieldOf(raw, "result"),
|
|
@@ -1007,20 +2290,6 @@ var gateCandidate = (kind, rawCandidate) => {
|
|
|
1007
2290
|
const resolution = resolve(kind, candidate);
|
|
1008
2291
|
return resolution.kind === "ok" ? { version: resolution.version, candidate } : null;
|
|
1009
2292
|
};
|
|
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
2293
|
var candidateFromJsonText = (kind, text) => {
|
|
1025
2294
|
if (text === null) return null;
|
|
1026
2295
|
const parsed = tryParseJson(text);
|
|
@@ -1028,7 +2297,7 @@ var candidateFromJsonText = (kind, text) => {
|
|
|
1028
2297
|
};
|
|
1029
2298
|
var FENCE_OPEN = /^\s*(`{3,})/;
|
|
1030
2299
|
var FENCE_MARKER_ONLY = /^`+$/;
|
|
1031
|
-
var
|
|
2300
|
+
var scanLine2 = (state, line) => {
|
|
1032
2301
|
if (state.openLength === null) {
|
|
1033
2302
|
const opened = FENCE_OPEN.exec(line)?.[1]?.length;
|
|
1034
2303
|
return opened !== void 0 ? { blocks: state.blocks, openLength: opened, buffer: [] } : state;
|
|
@@ -1037,7 +2306,31 @@ var scanLine = (state, line) => {
|
|
|
1037
2306
|
const closes = FENCE_MARKER_ONLY.test(trimmed) && trimmed.length >= state.openLength;
|
|
1038
2307
|
return closes ? { blocks: [...state.blocks, state.buffer.join("\n")], openLength: null, buffer: [] } : { ...state, buffer: [...state.buffer, line] };
|
|
1039
2308
|
};
|
|
1040
|
-
var scanFencedBlocks = (text) => text.split("\n").reduce(
|
|
2309
|
+
var scanFencedBlocks = (text) => text.split("\n").reduce(scanLine2, { blocks: [], openLength: null, buffer: [] }).blocks;
|
|
2310
|
+
var ladderFailureDiagnostics = (input) => {
|
|
2311
|
+
const native = parseNativeForExtraction(input.native);
|
|
2312
|
+
const preview = (s) => {
|
|
2313
|
+
const flat = s.replace(/\s+/g, " ").trim();
|
|
2314
|
+
return flat.length > 200 ? `${flat.slice(0, 200)}\u2026` : flat;
|
|
2315
|
+
};
|
|
2316
|
+
const lines = [];
|
|
2317
|
+
if (input.kind === "findings") {
|
|
2318
|
+
lines.push(
|
|
2319
|
+
input.agentFilePath === void 0 ? "agent-file rung: no --agent-file given" : `agent-file rung: ${input.agentFilePath} did not validate (or was unreadable)`
|
|
2320
|
+
);
|
|
2321
|
+
if (input.agentFileFallbackPath !== void 0)
|
|
2322
|
+
lines.push(
|
|
2323
|
+
`last-valid rung: ${input.agentFileFallbackPath} did not validate (or was absent)`
|
|
2324
|
+
);
|
|
2325
|
+
}
|
|
2326
|
+
lines.push(
|
|
2327
|
+
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"
|
|
2328
|
+
);
|
|
2329
|
+
lines.push(
|
|
2330
|
+
typeof native.result === "string" ? `result rung: ${String(native.result.length)} chars, ${String(scanFencedBlocks(native.result).length)} fenced JSON block(s), none validated; preview: ${preview(native.result)}` : "result rung: absent or not a string"
|
|
2331
|
+
);
|
|
2332
|
+
return lines.join("\n");
|
|
2333
|
+
};
|
|
1041
2334
|
var describeLadderFailure = (outcome) => {
|
|
1042
2335
|
switch (outcome.kind) {
|
|
1043
2336
|
case "error-envelope":
|
|
@@ -1055,13 +2348,16 @@ var okOutcome = (gated) => ({
|
|
|
1055
2348
|
});
|
|
1056
2349
|
var extractStructured = (input) => {
|
|
1057
2350
|
const native = parseNativeForExtraction(input.native);
|
|
2351
|
+
if (input.kind === "findings") {
|
|
2352
|
+
for (const path of [input.agentFilePath, input.agentFileFallbackPath]) {
|
|
2353
|
+
if (path === void 0) continue;
|
|
2354
|
+
const fromFile = candidateFromJsonText(input.kind, readFileOrNull(path));
|
|
2355
|
+
if (fromFile) return okOutcome(fromFile);
|
|
2356
|
+
}
|
|
2357
|
+
}
|
|
1058
2358
|
if (isErrorEnvelope(native)) {
|
|
1059
2359
|
return { kind: "error-envelope", detail: describeErrorEnvelope(native) };
|
|
1060
2360
|
}
|
|
1061
|
-
if (input.kind === "findings" && input.agentFilePath !== void 0) {
|
|
1062
|
-
const fromFile = candidateFromJsonText(input.kind, readFileOrNull(input.agentFilePath));
|
|
1063
|
-
if (fromFile) return okOutcome(fromFile);
|
|
1064
|
-
}
|
|
1065
2361
|
if (native.structuredOutput !== void 0) {
|
|
1066
2362
|
const fromStructured = gateCandidate(input.kind, native.structuredOutput);
|
|
1067
2363
|
if (fromStructured) return okOutcome(fromStructured);
|
|
@@ -1081,9 +2377,10 @@ var extractStructured = (input) => {
|
|
|
1081
2377
|
};
|
|
1082
2378
|
}
|
|
1083
2379
|
}
|
|
2380
|
+
const fallbackRung = input.agentFileFallbackPath ? ", last-valid snapshot" : "";
|
|
1084
2381
|
return {
|
|
1085
2382
|
kind: "none",
|
|
1086
|
-
detail: `no --agent-file, structured_output, JSON result, or fenced block validated against the ${input.kind} schema`
|
|
2383
|
+
detail: `no --agent-file${fallbackRung}, structured_output, JSON result, or fenced block validated against the ${input.kind} schema`
|
|
1087
2384
|
};
|
|
1088
2385
|
};
|
|
1089
2386
|
|
|
@@ -1120,45 +2417,118 @@ var mapModelUsage = (modelUsage) => Object.entries(modelUsage).map(([model, entr
|
|
|
1120
2417
|
...entry.cacheReadInputTokens !== void 0 ? { cache_read_tokens: entry.cacheReadInputTokens } : {},
|
|
1121
2418
|
...entry.cacheCreationInputTokens !== void 0 ? { cache_write_tokens: entry.cacheCreationInputTokens } : {}
|
|
1122
2419
|
}));
|
|
1123
|
-
var
|
|
1124
|
-
const
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
2420
|
+
var findingsOutcome = (native, agentFilePath, agentFileFallbackPath) => {
|
|
2421
|
+
const ladder = extractStructured({
|
|
2422
|
+
kind: "findings",
|
|
2423
|
+
native,
|
|
2424
|
+
agentFilePath,
|
|
2425
|
+
agentFileFallbackPath
|
|
2426
|
+
});
|
|
2427
|
+
if (ladder.kind !== "ok")
|
|
2428
|
+
return { kind: "telemetry-only", reason: describeLadderFailure(ladder) };
|
|
2429
|
+
const resolution = resolve("findings", ladder.candidate);
|
|
2430
|
+
return resolution.kind === "ok" ? { kind: "ok", version: resolution.version, findings: resolution.value } : {
|
|
2431
|
+
kind: "telemetry-only",
|
|
2432
|
+
reason: "internal error: the extraction ladder validated a candidate the registry then rejected"
|
|
2433
|
+
};
|
|
2434
|
+
};
|
|
2435
|
+
var withMeta = (base, meta) => ({
|
|
2436
|
+
...base,
|
|
2437
|
+
...meta.route ? { route: meta.route } : {},
|
|
2438
|
+
...meta.effort ? { effort: meta.effort } : {}
|
|
2439
|
+
});
|
|
2440
|
+
var resolveTelemetry = (native, meta) => {
|
|
2441
|
+
const fb = (() => {
|
|
2442
|
+
try {
|
|
2443
|
+
return meta.transcriptFallback?.();
|
|
2444
|
+
} catch {
|
|
2445
|
+
return void 0;
|
|
2446
|
+
}
|
|
2447
|
+
})();
|
|
2448
|
+
const wallTurns = fb !== void 0 && fb.durationMs > 0 ? { turns: fb.turns, duration_ms: fb.durationMs } : { turns: native.turns, duration_ms: native.durationMs };
|
|
2449
|
+
return withMeta(
|
|
2450
|
+
{
|
|
2451
|
+
models: native.models.length > 0 ? native.models : fb ? [...fb.models] : native.models,
|
|
2452
|
+
...wallTurns,
|
|
2453
|
+
vendor_cost_usd: native.vendorCostUsd
|
|
2454
|
+
},
|
|
2455
|
+
meta
|
|
2456
|
+
);
|
|
2457
|
+
};
|
|
2458
|
+
var nativeTelemetry = (native, meta) => resolveTelemetry(
|
|
2459
|
+
{
|
|
1132
2460
|
models: mapModelUsage(native.modelUsage),
|
|
1133
2461
|
turns: native.num_turns,
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
2462
|
+
durationMs: native.duration_ms,
|
|
2463
|
+
vendorCostUsd: native.total_cost_usd ?? null
|
|
2464
|
+
},
|
|
2465
|
+
meta
|
|
2466
|
+
);
|
|
2467
|
+
var absentTelemetry = (meta) => resolveTelemetry({ models: [], turns: 0, durationMs: 0, vendorCostUsd: null }, meta);
|
|
2468
|
+
var buildEnvelope = (telemetry, native, agentFilePath, agentFileFallbackPath) => {
|
|
2469
|
+
const outcome = findingsOutcome(native, agentFilePath, agentFileFallbackPath);
|
|
2470
|
+
switch (outcome.kind) {
|
|
2471
|
+
case "ok":
|
|
2472
|
+
return { schema_version: outcome.version, findings: outcome.findings, ...telemetry };
|
|
2473
|
+
case "telemetry-only":
|
|
2474
|
+
return {
|
|
2475
|
+
schema_version: DEFAULT_SCHEMA_VERSION,
|
|
2476
|
+
findings: noticeFindings(`### \u26A0\uFE0F Review did not complete
|
|
2477
|
+
|
|
2478
|
+
${outcome.reason}`),
|
|
2479
|
+
incomplete: true,
|
|
2480
|
+
...telemetry
|
|
2481
|
+
};
|
|
2482
|
+
}
|
|
1141
2483
|
};
|
|
1142
2484
|
var adapt = (adapterName, native, agentFilePath, meta = {}) => {
|
|
1143
2485
|
switch (adapterName) {
|
|
1144
2486
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- exhaustive by design; AdapterName grows (e.g. "opencode") without collapsing this switch to an if
|
|
1145
2487
|
case "claude-code": {
|
|
2488
|
+
if (native === void 0 || native === null)
|
|
2489
|
+
return right(
|
|
2490
|
+
buildEnvelope(
|
|
2491
|
+
absentTelemetry(meta),
|
|
2492
|
+
void 0,
|
|
2493
|
+
agentFilePath,
|
|
2494
|
+
meta.agentFileFallbackPath
|
|
2495
|
+
)
|
|
2496
|
+
);
|
|
1146
2497
|
const decoded = ClaudeCodeEnvelopeCodec.decode(native);
|
|
1147
|
-
if (decoded._tag === "Left")
|
|
2498
|
+
if (decoded._tag === "Left")
|
|
1148
2499
|
return left("native envelope does not match the Claude Code output shape");
|
|
1149
|
-
|
|
1150
|
-
|
|
2500
|
+
return right(
|
|
2501
|
+
buildEnvelope(
|
|
2502
|
+
nativeTelemetry(decoded.right, meta),
|
|
2503
|
+
native,
|
|
2504
|
+
agentFilePath,
|
|
2505
|
+
meta.agentFileFallbackPath
|
|
2506
|
+
)
|
|
2507
|
+
);
|
|
1151
2508
|
}
|
|
1152
2509
|
}
|
|
1153
2510
|
};
|
|
1154
2511
|
|
|
2512
|
+
// src/settings.ts
|
|
2513
|
+
var composeReviewSettings = (opts) => {
|
|
2514
|
+
const budgetCommand = budgetHookCommand(opts.draftPath, opts.budget);
|
|
2515
|
+
return {
|
|
2516
|
+
hooks: {
|
|
2517
|
+
Stop: [
|
|
2518
|
+
{ hooks: [{ type: "command", command: defaultHookCommand(opts.draftPath, opts.stop) }] }
|
|
2519
|
+
],
|
|
2520
|
+
PreToolUse: [{ hooks: [{ type: "command", command: budgetCommand }] }],
|
|
2521
|
+
PostToolBatch: [{ hooks: [{ type: "command", command: budgetCommand }] }]
|
|
2522
|
+
}
|
|
2523
|
+
};
|
|
2524
|
+
};
|
|
2525
|
+
|
|
1155
2526
|
// src/index.ts
|
|
1156
2527
|
var readJSON = (path) => {
|
|
1157
2528
|
try {
|
|
1158
2529
|
return JSON.parse(readFileSync(resolve$1(path), "utf-8"));
|
|
1159
2530
|
} catch (err) {
|
|
1160
|
-
fail(`Cannot read ${path}: ${
|
|
1161
|
-
throw new Error("unreachable", { cause: err });
|
|
2531
|
+
return fail(`Cannot read ${path}: ${errMsg(err)}`);
|
|
1162
2532
|
}
|
|
1163
2533
|
};
|
|
1164
2534
|
var fail = (msg) => {
|
|
@@ -1166,32 +2536,86 @@ var fail = (msg) => {
|
|
|
1166
2536
|
`);
|
|
1167
2537
|
process.exit(1);
|
|
1168
2538
|
};
|
|
2539
|
+
var readJSONOrAbsent = (path) => {
|
|
2540
|
+
const read = (() => {
|
|
2541
|
+
try {
|
|
2542
|
+
return { text: readFileSync(resolve$1(path), "utf-8") };
|
|
2543
|
+
} catch (err) {
|
|
2544
|
+
return { error: errMsg(err) };
|
|
2545
|
+
}
|
|
2546
|
+
})();
|
|
2547
|
+
if ("error" in read) {
|
|
2548
|
+
process.stderr.write(
|
|
2549
|
+
`code-review: native envelope ${path} could not be read (${read.error}) \u2014 proceeding with no native telemetry
|
|
2550
|
+
`
|
|
2551
|
+
);
|
|
2552
|
+
return void 0;
|
|
2553
|
+
}
|
|
2554
|
+
if (read.text.trim() === "") {
|
|
2555
|
+
process.stderr.write(
|
|
2556
|
+
`code-review: native envelope ${path} is empty \u2014 proceeding with no native telemetry
|
|
2557
|
+
`
|
|
2558
|
+
);
|
|
2559
|
+
return void 0;
|
|
2560
|
+
}
|
|
2561
|
+
try {
|
|
2562
|
+
return JSON.parse(read.text);
|
|
2563
|
+
} catch (err) {
|
|
2564
|
+
process.stderr.write(
|
|
2565
|
+
`code-review: native envelope ${path} is not valid JSON (${errMsg(err)}) \u2014 proceeding with no native telemetry
|
|
2566
|
+
`
|
|
2567
|
+
);
|
|
2568
|
+
return void 0;
|
|
2569
|
+
}
|
|
2570
|
+
};
|
|
2571
|
+
var readStdinJSON = () => {
|
|
2572
|
+
if (process.stdin.isTTY) return null;
|
|
2573
|
+
const raw = (() => {
|
|
2574
|
+
try {
|
|
2575
|
+
return readFileSync(0, "utf-8");
|
|
2576
|
+
} catch {
|
|
2577
|
+
return "";
|
|
2578
|
+
}
|
|
2579
|
+
})();
|
|
2580
|
+
if (raw.trim() === "") return null;
|
|
2581
|
+
const parsed = tryParseJson(raw);
|
|
2582
|
+
return parsed.ok ? parsed.value : null;
|
|
2583
|
+
};
|
|
1169
2584
|
var decode = (either, label) => {
|
|
1170
2585
|
try {
|
|
1171
2586
|
return unsafeUnwrap(either);
|
|
1172
2587
|
} catch {
|
|
1173
|
-
fail(`${label} does not match expected shape`);
|
|
2588
|
+
return fail(`${label} does not match expected shape`);
|
|
1174
2589
|
}
|
|
1175
|
-
throw new Error("unreachable");
|
|
1176
2590
|
};
|
|
1177
2591
|
var unwrapAdapt = (either) => {
|
|
1178
2592
|
try {
|
|
1179
2593
|
if (either._tag === "Left") throw new Error(either.left);
|
|
1180
2594
|
return either.right;
|
|
1181
2595
|
} catch (err) {
|
|
1182
|
-
fail(
|
|
2596
|
+
return fail(errMsg(err));
|
|
1183
2597
|
}
|
|
1184
|
-
|
|
2598
|
+
};
|
|
2599
|
+
var transcriptFallbackFrom = (path) => {
|
|
2600
|
+
const tree = readTranscriptTree(resolve$1(path));
|
|
2601
|
+
if (tree.missing)
|
|
2602
|
+
process.stderr.write(
|
|
2603
|
+
`code-review adapt: transcript ${path} is unreadable \u2014 no telemetry fallback
|
|
2604
|
+
`
|
|
2605
|
+
);
|
|
2606
|
+
const usage = sumTranscriptUsage(tree.entries);
|
|
2607
|
+
return { models: usage.models, turns: usage.turns, durationMs: usage.durationMs };
|
|
1185
2608
|
};
|
|
1186
2609
|
var bundledPath = (...segments) => resolve$1(import.meta.dirname, "..", ...segments);
|
|
1187
2610
|
var packageVersion = JSON.parse(readFileSync(bundledPath("package.json"), "utf-8")).version;
|
|
1188
2611
|
var resolveTemplatePath = (templateArg) => templateArg ? resolve$1(templateArg) : bundledPath("templates", "comment.eta");
|
|
1189
|
-
var
|
|
1190
|
-
|
|
2612
|
+
var resolveInlineTemplatePath = (templateArg) => templateArg ? resolve$1(templateArg) : bundledPath("templates", "inline.eta");
|
|
2613
|
+
var resolvePrices = (pricesArg) => {
|
|
2614
|
+
if (pricesArg) return { kind: "provided", path: resolve$1(pricesArg) };
|
|
1191
2615
|
process.stderr.write(
|
|
1192
|
-
"code-review: no --prices given \u2014
|
|
2616
|
+
"code-review: no --prices given \u2014 cost will be reported as N/A (no price map to recompute from)\n"
|
|
1193
2617
|
);
|
|
1194
|
-
return bundledPath("schema", "prices.example.json");
|
|
2618
|
+
return { kind: "absent", path: bundledPath("schema", "prices.example.json") };
|
|
1195
2619
|
};
|
|
1196
2620
|
var TEST_REPORT_DESCRIPTION = 'Path to a JSON test summary: {"passed": number, "failed": number, "total": number, "failures"?: [{"name": string, "message"?: string}]}';
|
|
1197
2621
|
var renderCmd = defineCommand({
|
|
@@ -1239,19 +2663,21 @@ var renderCmd = defineCommand({
|
|
|
1239
2663
|
const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
|
|
1240
2664
|
const envelope = decode(ResultEnvelopeCodec.decode(readJSON(args.usage)), "envelope");
|
|
1241
2665
|
const templatePath = resolveTemplatePath(args.template);
|
|
1242
|
-
const
|
|
1243
|
-
const prices = decode(PriceMapCodec.decode(readJSON(
|
|
2666
|
+
const priceResolution = resolvePrices(args.prices);
|
|
2667
|
+
const prices = decode(PriceMapCodec.decode(readJSON(priceResolution.path)), "prices");
|
|
1244
2668
|
const template = readFileSync(templatePath, "utf-8");
|
|
1245
2669
|
const testReport = args["test-report"] ? decode(TestSummaryCodec.decode(readJSON(args["test-report"])), "test report") : void 0;
|
|
1246
2670
|
const output = render({
|
|
1247
2671
|
findings,
|
|
1248
2672
|
envelope,
|
|
1249
2673
|
prices,
|
|
2674
|
+
pricesProvided: priceResolution.kind === "provided",
|
|
1250
2675
|
template,
|
|
1251
2676
|
reviewedSha: args["reviewed-sha"],
|
|
1252
2677
|
route: args.route,
|
|
1253
2678
|
effort: args.effort,
|
|
1254
|
-
testReport
|
|
2679
|
+
testReport,
|
|
2680
|
+
postedAt: formatUtc(/* @__PURE__ */ new Date())
|
|
1255
2681
|
});
|
|
1256
2682
|
process.stdout.write(output);
|
|
1257
2683
|
}
|
|
@@ -1274,14 +2700,17 @@ var inlineCmd = defineCommand({
|
|
|
1274
2700
|
},
|
|
1275
2701
|
template: {
|
|
1276
2702
|
type: "string",
|
|
1277
|
-
description: "Path to inline comment Eta template (default:
|
|
2703
|
+
description: "Path to inline comment Eta template (default: bundled templates/inline.eta)"
|
|
1278
2704
|
}
|
|
1279
2705
|
},
|
|
1280
2706
|
run: async ({ args }) => {
|
|
1281
2707
|
const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
|
|
1282
2708
|
const diff = readFileSync(resolve$1(args.diff), "utf-8");
|
|
1283
|
-
const inlineTemplate =
|
|
1284
|
-
const { comments, strays } = buildInlineComments(findings.findings, diff,
|
|
2709
|
+
const inlineTemplate = readFileSync(resolveInlineTemplatePath(args.template), "utf-8");
|
|
2710
|
+
const { comments, strays } = buildInlineComments(findings.findings, diff, {
|
|
2711
|
+
inlineTemplate,
|
|
2712
|
+
findings
|
|
2713
|
+
});
|
|
1285
2714
|
process.stdout.write(
|
|
1286
2715
|
JSON.stringify({ comments, strays, stray_markdown: renderStraysSection(strays) }, null, 2)
|
|
1287
2716
|
);
|
|
@@ -1311,67 +2740,475 @@ var costCmd = defineCommand({
|
|
|
1311
2740
|
process.stdout.write(JSON.stringify(report, null, 2));
|
|
1312
2741
|
}
|
|
1313
2742
|
});
|
|
1314
|
-
var
|
|
1315
|
-
var derivedSchemaVersion = (kind, raw) => kind === "findings" ? declaredSchemaVersion(raw) : void 0;
|
|
1316
|
-
var validateCmd = defineCommand({
|
|
2743
|
+
var checkCostCmd = defineCommand({
|
|
1317
2744
|
meta: {
|
|
1318
|
-
name: "
|
|
1319
|
-
description: "
|
|
2745
|
+
name: "check-cost",
|
|
2746
|
+
description: "Sum real USD spend from a Claude Code transcript tree (main + subagents) against a price map"
|
|
1320
2747
|
},
|
|
1321
2748
|
args: {
|
|
1322
|
-
|
|
2749
|
+
transcript: {
|
|
1323
2750
|
type: "positional",
|
|
1324
|
-
description: "Path to the
|
|
2751
|
+
description: "Path to the session transcript JSONL (the hook's transcript_path)",
|
|
1325
2752
|
required: true
|
|
1326
2753
|
},
|
|
1327
|
-
|
|
1328
|
-
type: "string",
|
|
1329
|
-
description: "Schema kind to validate against: findings | triage | prices (default: findings)"
|
|
1330
|
-
},
|
|
1331
|
-
schema: {
|
|
1332
|
-
type: "string",
|
|
1333
|
-
description: "Path to a schema file (wins over --kind; default: the bundled schema derived from --kind, --schema-version, the document's declared schema_version, or the bundled latest)"
|
|
1334
|
-
},
|
|
1335
|
-
"schema-version": {
|
|
2754
|
+
prices: {
|
|
1336
2755
|
type: "string",
|
|
1337
|
-
description: "
|
|
2756
|
+
description: "Path to price map JSON (default: bundled schema/prices.example.json \u2014 token totals stay real, cost reads as $0)"
|
|
1338
2757
|
}
|
|
1339
2758
|
},
|
|
1340
2759
|
run: async ({ args }) => {
|
|
1341
|
-
const
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
} else {
|
|
1348
|
-
process.stderr.write("\u274C invalid\n");
|
|
1349
|
-
for (const e of errors) process.stderr.write(` - ${e}
|
|
1350
|
-
`);
|
|
1351
|
-
process.exit(1);
|
|
2760
|
+
const tree = readTranscriptTree(resolve$1(args.transcript));
|
|
2761
|
+
if (tree.missing) {
|
|
2762
|
+
process.stderr.write(
|
|
2763
|
+
`code-review check-cost: transcript ${args.transcript} is unreadable \u2014 reporting zero spend
|
|
2764
|
+
`
|
|
2765
|
+
);
|
|
1352
2766
|
}
|
|
2767
|
+
const usage = sumTranscriptUsage(tree.entries);
|
|
2768
|
+
const priceResolution = resolvePrices(args.prices);
|
|
2769
|
+
const prices = decode(PriceMapCodec.decode(readJSON(priceResolution.path)), "prices");
|
|
2770
|
+
const report = computeCost(usage.models, prices);
|
|
2771
|
+
process.stdout.write(
|
|
2772
|
+
`${JSON.stringify(
|
|
2773
|
+
{
|
|
2774
|
+
...report,
|
|
2775
|
+
turns: usage.turns,
|
|
2776
|
+
durationMs: usage.durationMs,
|
|
2777
|
+
transcripts: tree.files,
|
|
2778
|
+
pricesProvided: priceResolution.kind === "provided"
|
|
2779
|
+
},
|
|
2780
|
+
null,
|
|
2781
|
+
2
|
|
2782
|
+
)}
|
|
2783
|
+
`
|
|
2784
|
+
);
|
|
1353
2785
|
}
|
|
1354
2786
|
});
|
|
1355
|
-
var
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
}
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
2787
|
+
var tryReadPrices = (path) => {
|
|
2788
|
+
try {
|
|
2789
|
+
const decoded = PriceMapCodec.decode(JSON.parse(readFileSync(resolve$1(path), "utf-8")));
|
|
2790
|
+
return decoded._tag === "Right" ? decoded.right : null;
|
|
2791
|
+
} catch {
|
|
2792
|
+
return null;
|
|
2793
|
+
}
|
|
2794
|
+
};
|
|
2795
|
+
var parseBudgetUsd = (raw) => {
|
|
2796
|
+
if (raw === void 0) return null;
|
|
2797
|
+
const n = Number.parseFloat(raw);
|
|
2798
|
+
return Number.isFinite(n) && n >= 0 ? n : null;
|
|
2799
|
+
};
|
|
2800
|
+
var mtimeMsOf = (path) => {
|
|
2801
|
+
try {
|
|
2802
|
+
return statSync(path).mtimeMs;
|
|
2803
|
+
} catch {
|
|
2804
|
+
return null;
|
|
2805
|
+
}
|
|
2806
|
+
};
|
|
2807
|
+
var transcriptPathOf = (input) => {
|
|
2808
|
+
const tp = (typeof input === "object" && input !== null ? input : {})["transcript_path"];
|
|
2809
|
+
return typeof tp === "string" ? tp : void 0;
|
|
2810
|
+
};
|
|
2811
|
+
var snapshotIfValid = (draftPath) => {
|
|
2812
|
+
try {
|
|
2813
|
+
if (extractStructured({ kind: "findings", native: void 0, agentFilePath: draftPath }).kind === "ok")
|
|
2814
|
+
copyFileSync(draftPath, lastValidPath(draftPath));
|
|
2815
|
+
} catch (err) {
|
|
2816
|
+
process.stderr.write(
|
|
2817
|
+
`code-review: could not snapshot the last-valid draft (${errMsg(err)}) \u2014 any prior snapshot is unchanged
|
|
2818
|
+
`
|
|
2819
|
+
);
|
|
2820
|
+
}
|
|
2821
|
+
};
|
|
2822
|
+
var budgetHookCmd = defineCommand({
|
|
2823
|
+
meta: {
|
|
2824
|
+
name: "budget-hook",
|
|
2825
|
+
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."
|
|
2826
|
+
},
|
|
2827
|
+
args: {
|
|
2828
|
+
draft: {
|
|
2829
|
+
type: "string",
|
|
2830
|
+
description: "Path to the findings draft that is the sole permitted write target under forced convergence",
|
|
2831
|
+
required: true
|
|
2832
|
+
},
|
|
2833
|
+
"budget-usd": {
|
|
2834
|
+
type: "string",
|
|
2835
|
+
description: "Dollar budget for the run; the cost axis is measured against it (needs --prices)"
|
|
2836
|
+
},
|
|
2837
|
+
wall: {
|
|
2838
|
+
type: "string",
|
|
2839
|
+
description: "Wall-clock budget (e.g. 20m, 1200s, 2h); the time axis is measured against it"
|
|
2840
|
+
},
|
|
2841
|
+
prices: {
|
|
2842
|
+
type: "string",
|
|
2843
|
+
description: "Price map JSON to recompute real spend from the transcript (omit to disable the cost axis)"
|
|
2844
|
+
},
|
|
2845
|
+
"reserve-frac": {
|
|
2846
|
+
type: "string",
|
|
2847
|
+
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)"
|
|
2848
|
+
},
|
|
2849
|
+
"reserve-growth": {
|
|
2850
|
+
type: "string",
|
|
2851
|
+
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)"
|
|
2852
|
+
},
|
|
2853
|
+
"reserve-usd": {
|
|
2854
|
+
type: "string",
|
|
2855
|
+
description: "Flat dollar wind-down floor, whichever is larger with --reserve-frac (default: 0.02)"
|
|
2856
|
+
},
|
|
2857
|
+
"reserve-wall": {
|
|
2858
|
+
type: "string",
|
|
2859
|
+
description: "Flat wall-clock wind-down floor (e.g. 2m, 120s), whichever is larger with --reserve-frac (default: 2m)"
|
|
2860
|
+
}
|
|
2861
|
+
},
|
|
2862
|
+
run: async ({ args }) => {
|
|
2863
|
+
try {
|
|
2864
|
+
const draftPath = resolve$1(args.draft);
|
|
2865
|
+
const input = readStdinJSON();
|
|
2866
|
+
const transcriptPath = transcriptPathOf(input);
|
|
2867
|
+
const tree = transcriptPath ? readTranscriptTree(resolve$1(transcriptPath)) : void 0;
|
|
2868
|
+
const usage = tree ? sumTranscriptUsage(tree.entries) : void 0;
|
|
2869
|
+
const prices = args.prices ? tryReadPrices(args.prices) : null;
|
|
2870
|
+
const spentUsd = prices !== null && usage ? computeCost(usage.models, prices).totalCostUSD : null;
|
|
2871
|
+
const wallMs = args.wall ? parseWallMs(args.wall) : null;
|
|
2872
|
+
const output = evaluateBudgetHook(input, {
|
|
2873
|
+
spentUsd,
|
|
2874
|
+
budgetUsd: parseBudgetUsd(args["budget-usd"]),
|
|
2875
|
+
elapsedMs: anchoredElapsedMs({
|
|
2876
|
+
deadlineMs: parseEpochSecMs(process.env[DEADLINE_ENV]),
|
|
2877
|
+
wallMs,
|
|
2878
|
+
firstTsMs: usage?.firstTsMs ?? null,
|
|
2879
|
+
nowMs: Date.now()
|
|
2880
|
+
}),
|
|
2881
|
+
wallMs,
|
|
2882
|
+
reserve: {
|
|
2883
|
+
frac: parseFraction(args["reserve-frac"], DEFAULT_RESERVE.frac),
|
|
2884
|
+
growth: parseFraction(args["reserve-growth"], DEFAULT_RESERVE.growth),
|
|
2885
|
+
flatUsd: parseBudgetUsd(args["reserve-usd"]) ?? DEFAULT_RESERVE.flatUsd,
|
|
2886
|
+
flatMs: args["reserve-wall"] ? parseWallMs(args["reserve-wall"]) ?? DEFAULT_RESERVE.flatMs : DEFAULT_RESERVE.flatMs
|
|
2887
|
+
},
|
|
2888
|
+
draftPath,
|
|
2889
|
+
mainDraftWritten: mainHasWrittenDraft(
|
|
2890
|
+
mtimeMsOf(draftPath),
|
|
2891
|
+
mtimeMsOf(seedMarkerPath(draftPath))
|
|
2892
|
+
)
|
|
2893
|
+
});
|
|
2894
|
+
if (asRecord(input)?.["hook_event_name"] === "PostToolBatch" && !isSubagentHookInput(input))
|
|
2895
|
+
snapshotIfValid(draftPath);
|
|
2896
|
+
process.stdout.write(`${JSON.stringify(output)}
|
|
2897
|
+
`);
|
|
2898
|
+
} catch (err) {
|
|
2899
|
+
process.stderr.write(`code-review budget-hook: degrading to no-op \u2014 ${errMsg(err)}
|
|
2900
|
+
`);
|
|
2901
|
+
process.stdout.write("{}\n");
|
|
2902
|
+
}
|
|
2903
|
+
}
|
|
2904
|
+
});
|
|
2905
|
+
var printSettingsCmd = defineCommand({
|
|
2906
|
+
meta: {
|
|
2907
|
+
name: "print-settings",
|
|
2908
|
+
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"
|
|
2909
|
+
},
|
|
2910
|
+
args: {
|
|
2911
|
+
draft: {
|
|
2912
|
+
type: "string",
|
|
2913
|
+
description: "Path to the findings draft the agent must produce \u2014 the Stop gate's target and the only write allowed under forced convergence",
|
|
2914
|
+
required: true
|
|
2915
|
+
},
|
|
2916
|
+
kind: {
|
|
2917
|
+
type: "string",
|
|
2918
|
+
description: "Schema kind for the Stop gate: findings | triage | prices (default: findings)"
|
|
2919
|
+
},
|
|
2920
|
+
schema: {
|
|
2921
|
+
type: "string",
|
|
2922
|
+
description: "Path to a schema file for the Stop gate (wins over --kind)"
|
|
2923
|
+
},
|
|
2924
|
+
"schema-version": {
|
|
2925
|
+
type: "string",
|
|
2926
|
+
description: "Schema major.minor for the Stop gate (default: the draft's declared version)"
|
|
2927
|
+
},
|
|
2928
|
+
"max-nudges": {
|
|
2929
|
+
type: "string",
|
|
2930
|
+
description: "Stop-gate nudge budget before relenting (default: 5)"
|
|
2931
|
+
},
|
|
2932
|
+
counter: {
|
|
2933
|
+
type: "string",
|
|
2934
|
+
description: "Path for the Stop-gate nudge counter (default: <draft>.nudges)"
|
|
2935
|
+
},
|
|
2936
|
+
"budget-usd": {
|
|
2937
|
+
type: "string",
|
|
2938
|
+
description: "Dollar budget the cost axis is measured against (needs --prices)"
|
|
2939
|
+
},
|
|
2940
|
+
wall: {
|
|
2941
|
+
type: "string",
|
|
2942
|
+
description: "Wall-clock budget the time axis is measured against (e.g. 20m, 1200s)"
|
|
2943
|
+
},
|
|
2944
|
+
prices: {
|
|
2945
|
+
type: "string",
|
|
2946
|
+
description: "Price map JSON to recompute real spend from the transcript"
|
|
2947
|
+
},
|
|
2948
|
+
"reserve-frac": {
|
|
2949
|
+
type: "string",
|
|
2950
|
+
description: "Base wind-down headroom as a fraction of each budget (default: 0.15; soft tier is 2\xD7)"
|
|
2951
|
+
},
|
|
2952
|
+
"reserve-growth": {
|
|
2953
|
+
type: "string",
|
|
2954
|
+
description: "How much the reserve grows as a budget is spent, converging earlier the longer the run has gone (default: 0.25; 0 = flat)"
|
|
2955
|
+
},
|
|
2956
|
+
"reserve-usd": {
|
|
2957
|
+
type: "string",
|
|
2958
|
+
description: "Flat dollar wind-down floor, whichever is larger with --reserve-frac (default: 0.02)"
|
|
2959
|
+
},
|
|
2960
|
+
"reserve-wall": {
|
|
2961
|
+
type: "string",
|
|
2962
|
+
description: "Flat wall-clock wind-down floor (e.g. 2m), whichever is larger with --reserve-frac (default: 2m)"
|
|
2963
|
+
}
|
|
2964
|
+
},
|
|
2965
|
+
run: async ({ args }) => {
|
|
2966
|
+
if (args.kind && !["findings", "triage", "prices"].includes(args.kind))
|
|
2967
|
+
fail(`--kind must be one of findings|triage|prices (got '${args.kind}')`);
|
|
2968
|
+
const settings = composeReviewSettings({
|
|
2969
|
+
draftPath: resolve$1(args.draft),
|
|
2970
|
+
stop: {
|
|
2971
|
+
kind: args.kind,
|
|
2972
|
+
schema: args.schema,
|
|
2973
|
+
schemaVersion: args["schema-version"],
|
|
2974
|
+
maxNudges: args["max-nudges"],
|
|
2975
|
+
counter: args.counter
|
|
2976
|
+
},
|
|
2977
|
+
budget: {
|
|
2978
|
+
budgetUsd: args["budget-usd"],
|
|
2979
|
+
wall: args.wall,
|
|
2980
|
+
prices: args.prices,
|
|
2981
|
+
reserveFrac: args["reserve-frac"],
|
|
2982
|
+
reserveGrowth: args["reserve-growth"],
|
|
2983
|
+
reserveUsd: args["reserve-usd"],
|
|
2984
|
+
reserveWall: args["reserve-wall"]
|
|
2985
|
+
}
|
|
2986
|
+
});
|
|
2987
|
+
process.stdout.write(`${JSON.stringify(settings)}
|
|
2988
|
+
`);
|
|
2989
|
+
}
|
|
2990
|
+
});
|
|
2991
|
+
var deadlineCmd = defineCommand({
|
|
2992
|
+
meta: {
|
|
2993
|
+
name: "deadline",
|
|
2994
|
+
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"
|
|
2995
|
+
},
|
|
2996
|
+
args: {
|
|
2997
|
+
wall: {
|
|
2998
|
+
type: "string",
|
|
2999
|
+
description: "Wall-clock budget for the run (e.g. 24m, 1200s, 2h) \u2014 the deadline is now + this",
|
|
3000
|
+
required: true
|
|
3001
|
+
}
|
|
3002
|
+
},
|
|
3003
|
+
run: async ({ args }) => {
|
|
3004
|
+
const wallMs = parseWallMs(args.wall);
|
|
3005
|
+
if (wallMs === null) {
|
|
3006
|
+
fail(`--wall must be a duration like 24m, 1200s, or 2h (got '${args.wall}')`);
|
|
3007
|
+
} else {
|
|
3008
|
+
process.stdout.write(`${String(deadlineEpochSec(wallMs, Date.now()))}
|
|
3009
|
+
`);
|
|
3010
|
+
}
|
|
3011
|
+
}
|
|
3012
|
+
});
|
|
3013
|
+
var derivedSchemaVersion = (kind, raw) => kind === "findings" ? declaredVersion(raw) : void 0;
|
|
3014
|
+
var printableSchema = (schemaPath) => {
|
|
3015
|
+
const schema = JSON.parse(readFileSync(schemaPath, "utf-8"));
|
|
3016
|
+
const enforcementSchema = Object.fromEntries(
|
|
3017
|
+
Object.entries(schema).filter(([key2]) => key2 !== "$schema")
|
|
3018
|
+
);
|
|
3019
|
+
return JSON.stringify(enforcementSchema, null, 2);
|
|
3020
|
+
};
|
|
3021
|
+
var validateCmd = defineCommand({
|
|
3022
|
+
meta: {
|
|
3023
|
+
name: "validate",
|
|
3024
|
+
description: "Validate a findings/triage/prices JSON document against the canonical schema"
|
|
3025
|
+
},
|
|
3026
|
+
args: {
|
|
3027
|
+
document: {
|
|
3028
|
+
type: "positional",
|
|
3029
|
+
description: "Path to the JSON document to validate (of the given --kind)",
|
|
3030
|
+
required: true
|
|
3031
|
+
},
|
|
3032
|
+
kind: {
|
|
3033
|
+
type: "string",
|
|
3034
|
+
description: "Schema kind to validate against: findings | triage | prices (default: findings)"
|
|
3035
|
+
},
|
|
3036
|
+
schema: {
|
|
3037
|
+
type: "string",
|
|
3038
|
+
description: "Path to a schema file (wins over --kind; default: the bundled schema derived from --kind, --schema-version, the document's declared schema_version, or the bundled latest)"
|
|
3039
|
+
},
|
|
3040
|
+
"schema-version": {
|
|
3041
|
+
type: "string",
|
|
3042
|
+
description: "Schema major.minor version to validate against (default: the document's declared schema_version for findings, or the kind's latest)"
|
|
3043
|
+
},
|
|
3044
|
+
explain: {
|
|
3045
|
+
type: "boolean",
|
|
3046
|
+
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"
|
|
3047
|
+
}
|
|
3048
|
+
},
|
|
3049
|
+
run: async ({ args }) => {
|
|
3050
|
+
const kind = requireSchemaKind(args.kind || "findings");
|
|
3051
|
+
const documentRaw = readJSON(args.document);
|
|
3052
|
+
const schemaPath = args.schema ? resolve$1(args.schema) : requireSchemaPath(kind, args["schema-version"] || derivedSchemaVersion(kind, documentRaw));
|
|
3053
|
+
const { valid, errors } = validateAgainstSchema(documentRaw, schemaPath);
|
|
3054
|
+
if (valid) {
|
|
3055
|
+
process.stdout.write("\u2705 valid\n");
|
|
3056
|
+
} else {
|
|
3057
|
+
process.stderr.write("\u274C invalid\n");
|
|
3058
|
+
for (const e of errors) process.stderr.write(` - ${e}
|
|
3059
|
+
`);
|
|
3060
|
+
if (args.explain) {
|
|
3061
|
+
process.stderr.write(
|
|
3062
|
+
`
|
|
3063
|
+
The ${kind} document must conform to this schema (the field descriptions are the authoritative spec \u2014 match the property names exactly):
|
|
3064
|
+
${printableSchema(schemaPath)}
|
|
3065
|
+
`
|
|
3066
|
+
);
|
|
3067
|
+
}
|
|
3068
|
+
process.exit(1);
|
|
3069
|
+
}
|
|
3070
|
+
}
|
|
3071
|
+
});
|
|
3072
|
+
var seedDraftCmd = defineCommand({
|
|
3073
|
+
meta: {
|
|
3074
|
+
name: "seed-draft",
|
|
3075
|
+
description: "Write a valid findings $DRAFT before the review runs: the decoded findings from a prior review when one exists and still validates (incremental re-review), else an empty-but-valid scaffold \u2014 so a valid draft exists from turn 0. Also drops a sidecar marker beside the seed so the budget hook can tell the untouched seed from a draft the agent wrote itself. Prints the mode to stdout (prior-same|prior-new when seeded, 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 scaffold write failed) and always exits 0"
|
|
3076
|
+
},
|
|
3077
|
+
args: {
|
|
3078
|
+
prior: {
|
|
3079
|
+
type: "string",
|
|
3080
|
+
description: "Path to the prior-review JSON gather staged ({ id, body }, or the literal null); its embedded base64 findings marker is decoded and becomes the seed when it validates against the schema"
|
|
3081
|
+
},
|
|
3082
|
+
"head-sha": {
|
|
3083
|
+
type: "string",
|
|
3084
|
+
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"
|
|
3085
|
+
},
|
|
3086
|
+
out: {
|
|
3087
|
+
type: "string",
|
|
3088
|
+
description: "Path to write the seed $DRAFT to (an absolute path outside the worktree)",
|
|
3089
|
+
required: true
|
|
3090
|
+
},
|
|
3091
|
+
kind: {
|
|
3092
|
+
type: "string",
|
|
3093
|
+
description: "Schema kind to validate the prior findings against (default: findings)"
|
|
3094
|
+
},
|
|
3095
|
+
schema: {
|
|
3096
|
+
type: "string",
|
|
3097
|
+
description: "Path to a schema file (wins over --kind/--schema-version)"
|
|
3098
|
+
},
|
|
3099
|
+
"schema-version": {
|
|
3100
|
+
type: "string",
|
|
3101
|
+
description: "Schema major.minor to validate the prior findings against (default: the kind's latest \u2014 an older-shaped prior review then falls back to the empty scaffold)"
|
|
3102
|
+
}
|
|
3103
|
+
},
|
|
3104
|
+
run: async ({ args }) => {
|
|
3105
|
+
const outPath = resolve$1(args.out);
|
|
3106
|
+
const kindArg = args.kind || "findings";
|
|
3107
|
+
const kind = isSchemaKind(kindArg) ? kindArg : "findings";
|
|
3108
|
+
if (kind !== kindArg) {
|
|
3109
|
+
process.stderr.write(
|
|
3110
|
+
`Warning: unknown --kind "${kindArg}" \u2014 validating against "findings"
|
|
3111
|
+
`
|
|
3112
|
+
);
|
|
3113
|
+
}
|
|
3114
|
+
const writeSeedMarker = () => {
|
|
3115
|
+
try {
|
|
3116
|
+
writeFileSync(seedMarkerPath(outPath), "code-review seed marker\n");
|
|
3117
|
+
} catch (err) {
|
|
3118
|
+
process.stderr.write(
|
|
3119
|
+
`Warning: could not write the seed marker beside ${outPath} (${errMsg(err)}) \u2014 the seeded draft will count as agent-written
|
|
3120
|
+
`
|
|
3121
|
+
);
|
|
3122
|
+
}
|
|
3123
|
+
};
|
|
3124
|
+
const writeScaffold = () => {
|
|
3125
|
+
try {
|
|
3126
|
+
writeFileSync(outPath, `${JSON.stringify(noticeFindings(""), null, 2)}
|
|
3127
|
+
`);
|
|
3128
|
+
writeSeedMarker();
|
|
3129
|
+
process.stderr.write(
|
|
3130
|
+
`Seeded ${outPath} with an empty valid scaffold \u2014 no decodable prior findings to build on
|
|
3131
|
+
`
|
|
3132
|
+
);
|
|
3133
|
+
return true;
|
|
3134
|
+
} catch (err) {
|
|
3135
|
+
process.stderr.write(
|
|
3136
|
+
`Warning: could not write the seed scaffold to ${outPath} (${errMsg(err)}) \u2014 the agent will create $DRAFT itself
|
|
3137
|
+
`
|
|
3138
|
+
);
|
|
3139
|
+
return false;
|
|
3140
|
+
}
|
|
3141
|
+
};
|
|
3142
|
+
const priorBody = (() => {
|
|
3143
|
+
if (!args.prior) return null;
|
|
3144
|
+
const raw = (() => {
|
|
3145
|
+
try {
|
|
3146
|
+
return JSON.parse(readFileSync(resolve$1(args.prior), "utf-8"));
|
|
3147
|
+
} catch {
|
|
3148
|
+
return null;
|
|
3149
|
+
}
|
|
3150
|
+
})();
|
|
3151
|
+
return typeof raw === "object" && raw !== null && "body" in raw && typeof raw.body === "string" ? raw.body : null;
|
|
3152
|
+
})();
|
|
3153
|
+
const priorFindings = priorBody === null ? null : parseFindingsMarker(priorBody);
|
|
3154
|
+
const seededFromPrior = priorFindings === null ? false : (() => {
|
|
3155
|
+
try {
|
|
3156
|
+
const schemaPath = args.schema ? resolve$1(args.schema) : schemaPathFor(kind, args["schema-version"]);
|
|
3157
|
+
if (!validateAgainstSchema(priorFindings, schemaPath).valid) return false;
|
|
3158
|
+
writeFileSync(outPath, `${JSON.stringify(priorFindings, null, 2)}
|
|
3159
|
+
`);
|
|
3160
|
+
writeSeedMarker();
|
|
3161
|
+
const priorList = priorFindings.findings;
|
|
3162
|
+
const count = Array.isArray(priorList) ? priorList.length : 0;
|
|
3163
|
+
process.stderr.write(
|
|
3164
|
+
`Seeded ${outPath} from the prior review (${String(count)} finding(s))
|
|
3165
|
+
`
|
|
3166
|
+
);
|
|
3167
|
+
return true;
|
|
3168
|
+
} catch (err) {
|
|
3169
|
+
process.stderr.write(
|
|
3170
|
+
`Warning: could not seed from the prior review (${errMsg(err)}) \u2014 falling back to the empty scaffold
|
|
3171
|
+
`
|
|
3172
|
+
);
|
|
3173
|
+
return false;
|
|
3174
|
+
}
|
|
3175
|
+
})();
|
|
3176
|
+
const mode = (() => {
|
|
3177
|
+
if (seededFromPrior) {
|
|
3178
|
+
const priorSha = priorBody === null ? null : parseReviewedSha(priorBody);
|
|
3179
|
+
return args["head-sha"] && priorSha && priorSha === args["head-sha"].toLowerCase() ? "prior-same" : "prior-new";
|
|
3180
|
+
}
|
|
3181
|
+
if (!writeScaffold()) return "none";
|
|
3182
|
+
return priorBody === null ? "empty" : "empty-had-prior";
|
|
3183
|
+
})();
|
|
3184
|
+
process.stdout.write(`${mode}
|
|
3185
|
+
`);
|
|
3186
|
+
}
|
|
3187
|
+
});
|
|
3188
|
+
var adaptCmd = defineCommand({
|
|
3189
|
+
meta: {
|
|
3190
|
+
name: "adapt",
|
|
3191
|
+
description: "Map a native agent-CLI result envelope onto the abstract SPEC envelope"
|
|
3192
|
+
},
|
|
3193
|
+
args: {
|
|
3194
|
+
native: {
|
|
3195
|
+
type: "positional",
|
|
3196
|
+
description: "Path to the native result envelope JSON (from the agent CLI)",
|
|
3197
|
+
required: true
|
|
3198
|
+
},
|
|
3199
|
+
adapter: {
|
|
3200
|
+
type: "string",
|
|
3201
|
+
description: 'Adapter to use (currently: "claude-code")',
|
|
3202
|
+
required: true
|
|
3203
|
+
},
|
|
3204
|
+
"agent-file": {
|
|
3205
|
+
type: "string",
|
|
1373
3206
|
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)"
|
|
1374
3207
|
},
|
|
3208
|
+
"agent-file-fallback": {
|
|
3209
|
+
type: "string",
|
|
3210
|
+
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"
|
|
3211
|
+
},
|
|
1375
3212
|
route: {
|
|
1376
3213
|
type: "string",
|
|
1377
3214
|
description: 'Review route label to stamp into the envelope (e.g. "full review" or "mechanic")'
|
|
@@ -1379,25 +3216,53 @@ var adaptCmd = defineCommand({
|
|
|
1379
3216
|
effort: {
|
|
1380
3217
|
type: "string",
|
|
1381
3218
|
description: 'Effort label to stamp into the envelope (e.g. "max" or "low")'
|
|
3219
|
+
},
|
|
3220
|
+
transcript: {
|
|
3221
|
+
type: "string",
|
|
3222
|
+
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)"
|
|
1382
3223
|
}
|
|
1383
3224
|
},
|
|
1384
3225
|
run: async ({ args }) => {
|
|
1385
3226
|
const envelope = unwrapAdapt(
|
|
1386
|
-
adapt(requireAdapterName(args.adapter),
|
|
3227
|
+
adapt(requireAdapterName(args.adapter), readJSONOrAbsent(args.native), args["agent-file"], {
|
|
1387
3228
|
route: args.route,
|
|
1388
|
-
effort: args.effort
|
|
3229
|
+
effort: args.effort,
|
|
3230
|
+
agentFileFallbackPath: args["agent-file-fallback"],
|
|
3231
|
+
...args.transcript ? {
|
|
3232
|
+
transcriptFallback: () => transcriptFallbackFrom(args.transcript)
|
|
3233
|
+
} : {}
|
|
1389
3234
|
})
|
|
1390
3235
|
);
|
|
1391
3236
|
process.stdout.write(`${JSON.stringify(envelope, null, 2)}
|
|
1392
3237
|
`);
|
|
1393
3238
|
}
|
|
1394
3239
|
});
|
|
3240
|
+
var noticeCmd = defineCommand({
|
|
3241
|
+
meta: {
|
|
3242
|
+
name: "notice",
|
|
3243
|
+
description: "Emit an abstract envelope for a run that produced no completed review (security block, setup failure, unapplied diff, or empty run) \u2014 flagged incomplete so the commenter renders it honestly and won't bury a real review"
|
|
3244
|
+
},
|
|
3245
|
+
args: {
|
|
3246
|
+
kind: {
|
|
3247
|
+
type: "positional",
|
|
3248
|
+
description: "One of: security-blocked, setup-failed, checkout-failed, no-output",
|
|
3249
|
+
required: true
|
|
3250
|
+
},
|
|
3251
|
+
reasons: {
|
|
3252
|
+
type: "string",
|
|
3253
|
+
description: "security-blocked only: the triage's fail-closed reason string (empty/omitted \u21D2 the no-reason wording)"
|
|
3254
|
+
}
|
|
3255
|
+
},
|
|
3256
|
+
run: ({ args }) => {
|
|
3257
|
+
const kind = isNoticeKind(args.kind) ? args.kind : fail(
|
|
3258
|
+
`Unknown notice kind "${args.kind}" \u2014 expected one of: security-blocked, setup-failed, checkout-failed, no-output`
|
|
3259
|
+
);
|
|
3260
|
+
process.stdout.write(`${JSON.stringify(buildNoticeEnvelope(kind, args.reasons), null, 2)}
|
|
3261
|
+
`);
|
|
3262
|
+
}
|
|
3263
|
+
});
|
|
1395
3264
|
var isExtractSchemaKind = (s) => s === "findings" || s === "triage";
|
|
1396
|
-
var requireExtractSchemaKind = (name) => {
|
|
1397
|
-
if (isExtractSchemaKind(name)) return name;
|
|
1398
|
-
fail(`Unknown kind "${name}" for extract \u2014 expected one of: findings, triage`);
|
|
1399
|
-
throw new Error("unreachable");
|
|
1400
|
-
};
|
|
3265
|
+
var requireExtractSchemaKind = (name) => isExtractSchemaKind(name) ? name : fail(`Unknown kind "${name}" for extract \u2014 expected one of: findings, triage`);
|
|
1401
3266
|
var failClosedTriage = (outcome) => ({
|
|
1402
3267
|
safe: false,
|
|
1403
3268
|
reasons: describeLadderFailure(outcome)
|
|
@@ -1431,16 +3296,18 @@ var extractCmd = defineCommand({
|
|
|
1431
3296
|
run: async ({ args }) => {
|
|
1432
3297
|
requireAdapterName(args.adapter);
|
|
1433
3298
|
const kind = requireExtractSchemaKind(args.kind);
|
|
1434
|
-
const
|
|
1435
|
-
|
|
1436
|
-
native: readJSON(args.native),
|
|
1437
|
-
agentFilePath: args["agent-file"]
|
|
1438
|
-
});
|
|
3299
|
+
const input = { kind, native: readJSON(args.native), agentFilePath: args["agent-file"] };
|
|
3300
|
+
const outcome = extractStructured(input);
|
|
1439
3301
|
if (outcome.kind === "ok") {
|
|
1440
3302
|
process.stdout.write(`${JSON.stringify(outcome.candidate, null, 2)}
|
|
1441
3303
|
`);
|
|
1442
3304
|
return;
|
|
1443
3305
|
}
|
|
3306
|
+
if (outcome.kind === "none" || outcome.kind === "ambiguous") {
|
|
3307
|
+
process.stderr.write(`extract: recovery failed \u2014
|
|
3308
|
+
${ladderFailureDiagnostics(input)}
|
|
3309
|
+
`);
|
|
3310
|
+
}
|
|
1444
3311
|
if (kind === "triage") {
|
|
1445
3312
|
process.stdout.write(`${JSON.stringify(failClosedTriage(outcome), null, 2)}
|
|
1446
3313
|
`);
|
|
@@ -1449,24 +3316,79 @@ var extractCmd = defineCommand({
|
|
|
1449
3316
|
fail(describeLadderFailure(outcome));
|
|
1450
3317
|
}
|
|
1451
3318
|
});
|
|
1452
|
-
var
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
3319
|
+
var withoutPatch = (finding) => {
|
|
3320
|
+
const copy = { ...finding };
|
|
3321
|
+
delete copy.patch;
|
|
3322
|
+
return copy;
|
|
1456
3323
|
};
|
|
1457
|
-
var
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
3324
|
+
var readFileLines = (path) => {
|
|
3325
|
+
try {
|
|
3326
|
+
const rawLines = readFileSync(path, "utf-8").split("\n");
|
|
3327
|
+
return rawLines.length > 0 && rawLines[rawLines.length - 1] === "" ? rawLines.slice(0, -1) : rawLines;
|
|
3328
|
+
} catch {
|
|
3329
|
+
return null;
|
|
3330
|
+
}
|
|
1462
3331
|
};
|
|
3332
|
+
var validateFinding = (finding, repoRoot) => {
|
|
3333
|
+
if (finding.patch === void 0) return finding;
|
|
3334
|
+
const lines = readFileLines(resolve$1(repoRoot, finding.path));
|
|
3335
|
+
if (lines === null) {
|
|
3336
|
+
process.stderr.write(
|
|
3337
|
+
`validate-patches: ${finding.path}: could not read file at "${repoRoot}" \u2014 dropping patch
|
|
3338
|
+
`
|
|
3339
|
+
);
|
|
3340
|
+
return withoutPatch(finding);
|
|
3341
|
+
}
|
|
3342
|
+
const result = validatePatch(finding.patch, lines);
|
|
3343
|
+
switch (result.kind) {
|
|
3344
|
+
case "anchored":
|
|
3345
|
+
return { ...finding, start_line: result.startLine, end_line: result.endLine };
|
|
3346
|
+
case "keep":
|
|
3347
|
+
return finding;
|
|
3348
|
+
case "drop":
|
|
3349
|
+
process.stderr.write(
|
|
3350
|
+
`validate-patches: ${finding.path}:${String(finding.start_line)}: ${result.reason} \u2014 dropping patch
|
|
3351
|
+
`
|
|
3352
|
+
);
|
|
3353
|
+
return withoutPatch(finding);
|
|
3354
|
+
}
|
|
3355
|
+
};
|
|
3356
|
+
var validatePatchesCmd = defineCommand({
|
|
3357
|
+
meta: {
|
|
3358
|
+
name: "validate-patches",
|
|
3359
|
+
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"
|
|
3360
|
+
},
|
|
3361
|
+
args: {
|
|
3362
|
+
findings: {
|
|
3363
|
+
type: "positional",
|
|
3364
|
+
description: "Path to findings JSON",
|
|
3365
|
+
required: true
|
|
3366
|
+
},
|
|
3367
|
+
"repo-root": {
|
|
3368
|
+
type: "string",
|
|
3369
|
+
description: "Directory to resolve each finding's path against \u2014 the review job's checked-out, clean PR-head tree (default: .)"
|
|
3370
|
+
}
|
|
3371
|
+
},
|
|
3372
|
+
run: async ({ args }) => {
|
|
3373
|
+
const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
|
|
3374
|
+
const repoRoot = args["repo-root"] ? resolve$1(args["repo-root"]) : process.cwd();
|
|
3375
|
+
const validated = {
|
|
3376
|
+
...findings,
|
|
3377
|
+
findings: findings.findings.map((f) => validateFinding(f, repoRoot))
|
|
3378
|
+
};
|
|
3379
|
+
process.stdout.write(`${JSON.stringify(validated, null, 2)}
|
|
3380
|
+
`);
|
|
3381
|
+
}
|
|
3382
|
+
});
|
|
3383
|
+
var requireAdapterName = (name) => isAdapterName(name) ? name : fail(`Unknown adapter "${name}" \u2014 supported: claude-code`);
|
|
3384
|
+
var isSchemaKind = (s) => s === "findings" || s === "triage" || s === "prices";
|
|
3385
|
+
var requireSchemaKind = (name) => isSchemaKind(name) ? name : fail(`Unknown schema "${name}" \u2014 expected one of: findings, triage, prices`);
|
|
1463
3386
|
var requireSchemaPath = (kind, version) => {
|
|
1464
3387
|
try {
|
|
1465
3388
|
return schemaPathFor(kind, version);
|
|
1466
3389
|
} catch (err) {
|
|
1467
|
-
fail(
|
|
3390
|
+
return fail(errMsg(err));
|
|
1468
3391
|
}
|
|
1469
|
-
throw new Error("unreachable");
|
|
1470
3392
|
};
|
|
1471
3393
|
var printSchemaCmd = defineCommand({
|
|
1472
3394
|
meta: {
|
|
@@ -1487,18 +3409,105 @@ var printSchemaCmd = defineCommand({
|
|
|
1487
3409
|
run: async ({ args }) => {
|
|
1488
3410
|
const schemaKind = requireSchemaKind(args.name);
|
|
1489
3411
|
const schemaPath = requireSchemaPath(schemaKind, args["schema-version"]);
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
3412
|
+
process.stdout.write(`${printableSchema(schemaPath)}
|
|
3413
|
+
`);
|
|
3414
|
+
}
|
|
3415
|
+
});
|
|
3416
|
+
var MAX_NUDGES_DEFAULT = 5;
|
|
3417
|
+
var drainStdin = () => {
|
|
3418
|
+
if (process.stdin.isTTY) return;
|
|
3419
|
+
try {
|
|
3420
|
+
readFileSync(0);
|
|
3421
|
+
} catch {
|
|
3422
|
+
}
|
|
3423
|
+
};
|
|
3424
|
+
var requireMaxNudges = (raw) => {
|
|
3425
|
+
if (raw === void 0) return MAX_NUDGES_DEFAULT;
|
|
3426
|
+
if (!/^\d+$/.test(raw)) {
|
|
3427
|
+
fail(`--max-nudges must be a non-negative integer; got "${raw}"`);
|
|
3428
|
+
}
|
|
3429
|
+
const n = Number.parseInt(raw, 10);
|
|
3430
|
+
if (n < 1) {
|
|
3431
|
+
fail(`--max-nudges must be >= 1 \u2014 a gate that never blocks must be omitted, not set to ${raw}`);
|
|
3432
|
+
}
|
|
3433
|
+
return n;
|
|
3434
|
+
};
|
|
3435
|
+
var stopGateCmd = defineCommand({
|
|
3436
|
+
meta: {
|
|
3437
|
+
name: "stop-gate",
|
|
3438
|
+
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."
|
|
3439
|
+
},
|
|
3440
|
+
args: {
|
|
3441
|
+
draft: {
|
|
3442
|
+
type: "string",
|
|
3443
|
+
description: "Path to the findings document the agent must produce and keep valid",
|
|
3444
|
+
required: true
|
|
3445
|
+
},
|
|
3446
|
+
kind: {
|
|
3447
|
+
type: "string",
|
|
3448
|
+
description: "Schema kind to validate against: findings | triage | prices (default: findings)"
|
|
3449
|
+
},
|
|
3450
|
+
schema: { type: "string", description: "Path to a schema file (wins over --kind)" },
|
|
3451
|
+
"schema-version": {
|
|
3452
|
+
type: "string",
|
|
3453
|
+
description: "Schema major.minor to validate against (default: the draft's declared version)"
|
|
3454
|
+
},
|
|
3455
|
+
"max-nudges": {
|
|
3456
|
+
type: "string",
|
|
3457
|
+
description: `Times to block before relenting so the step fails downstream as before (default: ${String(MAX_NUDGES_DEFAULT)})`
|
|
3458
|
+
},
|
|
3459
|
+
counter: {
|
|
3460
|
+
type: "string",
|
|
3461
|
+
description: "Path for the nudge counter (default: <draft>.nudges)"
|
|
3462
|
+
},
|
|
3463
|
+
"print-settings": {
|
|
3464
|
+
type: "boolean",
|
|
3465
|
+
description: "Print the Stop-hook settings JSON that wires this gate, then exit"
|
|
3466
|
+
}
|
|
3467
|
+
},
|
|
3468
|
+
run: async ({ args }) => {
|
|
3469
|
+
const draftPath = resolve$1(args.draft);
|
|
3470
|
+
if (args["print-settings"]) {
|
|
3471
|
+
const command = defaultHookCommand(draftPath, {
|
|
3472
|
+
kind: args.kind,
|
|
3473
|
+
schema: args.schema,
|
|
3474
|
+
schemaVersion: args["schema-version"],
|
|
3475
|
+
maxNudges: args["max-nudges"],
|
|
3476
|
+
counter: args.counter
|
|
3477
|
+
});
|
|
3478
|
+
process.stdout.write(`${JSON.stringify(stopHookSettings(command))}
|
|
3479
|
+
`);
|
|
3480
|
+
return;
|
|
3481
|
+
}
|
|
3482
|
+
drainStdin();
|
|
3483
|
+
const kind = requireSchemaKind(args.kind || "findings");
|
|
3484
|
+
const maxNudges = requireMaxNudges(args["max-nudges"]);
|
|
3485
|
+
const counterPath = args.counter ? resolve$1(args.counter) : `${draftPath}.nudges`;
|
|
3486
|
+
const state = draftState(
|
|
3487
|
+
draftPath,
|
|
3488
|
+
(parsed) => args.schema ? resolve$1(args.schema) : schemaPathFor(kind, args["schema-version"] || derivedSchemaVersion(kind, parsed))
|
|
1493
3489
|
);
|
|
1494
|
-
|
|
3490
|
+
const nudges = readNudges(counterPath);
|
|
3491
|
+
const decision = decideGate(state, nudges, maxNudges, draftPath, kind);
|
|
3492
|
+
if (decision.kind === "block") {
|
|
3493
|
+
try {
|
|
3494
|
+
bumpNudges(counterPath, nudges);
|
|
3495
|
+
} catch (err) {
|
|
3496
|
+
process.stderr.write(
|
|
3497
|
+
`stop-gate: cannot persist nudge counter at ${counterPath} \u2192 allowing to avoid an unbounded block loop: ${errMsg(err)}
|
|
3498
|
+
`
|
|
3499
|
+
);
|
|
3500
|
+
return;
|
|
3501
|
+
}
|
|
3502
|
+
process.stdout.write(`${JSON.stringify({ decision: "block", reason: decision.reason })}
|
|
1495
3503
|
`);
|
|
3504
|
+
}
|
|
1496
3505
|
}
|
|
1497
3506
|
});
|
|
1498
3507
|
var gatherCmd = defineCommand({
|
|
1499
3508
|
meta: {
|
|
1500
3509
|
name: "gather",
|
|
1501
|
-
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"
|
|
3510
|
+
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"
|
|
1502
3511
|
},
|
|
1503
3512
|
args: {
|
|
1504
3513
|
repo: { type: "string", description: "Repository (owner/name)", required: true },
|
|
@@ -1511,6 +3520,11 @@ var gatherCmd = defineCommand({
|
|
|
1511
3520
|
type: "string",
|
|
1512
3521
|
description: "Head branch to disambiguate the PR when multiple share a commit"
|
|
1513
3522
|
},
|
|
3523
|
+
"default-branch": {
|
|
3524
|
+
type: "string",
|
|
3525
|
+
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",
|
|
3526
|
+
required: true
|
|
3527
|
+
},
|
|
1514
3528
|
"run-id": {
|
|
1515
3529
|
type: "string",
|
|
1516
3530
|
description: "CI run id (from workflow_run.id); its failing jobs' logs are downloaded on failure",
|
|
@@ -1535,6 +3549,7 @@ var gatherCmd = defineCommand({
|
|
|
1535
3549
|
repo: args.repo,
|
|
1536
3550
|
headSha: args["head-sha"],
|
|
1537
3551
|
headBranch: args["head-branch"],
|
|
3552
|
+
defaultBranch: args["default-branch"],
|
|
1538
3553
|
runId: args["run-id"],
|
|
1539
3554
|
conclusion: args.conclusion,
|
|
1540
3555
|
botLogin: args["bot-login"] || "github-actions[bot]",
|
|
@@ -1579,7 +3594,7 @@ var postCmd = defineCommand({
|
|
|
1579
3594
|
},
|
|
1580
3595
|
"inline-template": {
|
|
1581
3596
|
type: "string",
|
|
1582
|
-
description: "Path to inline comment Eta template (default:
|
|
3597
|
+
description: "Path to inline comment Eta template (default: bundled templates/inline.eta)"
|
|
1583
3598
|
},
|
|
1584
3599
|
route: {
|
|
1585
3600
|
type: "string",
|
|
@@ -1600,47 +3615,275 @@ var postCmd = defineCommand({
|
|
|
1600
3615
|
"test-report": {
|
|
1601
3616
|
type: "string",
|
|
1602
3617
|
description: TEST_REPORT_DESCRIPTION
|
|
3618
|
+
},
|
|
3619
|
+
"run-url": {
|
|
3620
|
+
type: "string",
|
|
3621
|
+
description: "Workflow run URL (transcript/traces), rendered as a link in the LLM Disclosure aside"
|
|
3622
|
+
},
|
|
3623
|
+
"json-url": {
|
|
3624
|
+
type: "string",
|
|
3625
|
+
description: "URL to the machine-readable findings JSON artifact, pointed at from the sticky and each inline comment"
|
|
1603
3626
|
}
|
|
1604
3627
|
},
|
|
1605
3628
|
run: async ({ args }) => {
|
|
3629
|
+
const priceResolution = resolvePrices(args.prices);
|
|
1606
3630
|
await post({
|
|
1607
3631
|
repo: args.repo,
|
|
1608
3632
|
headSha: args["head-sha"],
|
|
1609
3633
|
botLogin: args["bot-login"] || "github-actions[bot]",
|
|
1610
3634
|
findingsPath: args.findings,
|
|
1611
3635
|
envelopePath: args.usage,
|
|
1612
|
-
pricesPath:
|
|
3636
|
+
pricesPath: priceResolution.path,
|
|
3637
|
+
pricesProvided: priceResolution.kind === "provided",
|
|
1613
3638
|
templatePath: resolveTemplatePath(args.template),
|
|
1614
|
-
inlineTemplatePath:
|
|
3639
|
+
inlineTemplatePath: resolveInlineTemplatePath(args["inline-template"]),
|
|
1615
3640
|
route: args.route,
|
|
1616
3641
|
headBranch: args["head-branch"],
|
|
1617
3642
|
effort: args.effort,
|
|
1618
|
-
testReportPath: args["test-report"]
|
|
3643
|
+
testReportPath: args["test-report"],
|
|
3644
|
+
runUrl: args["run-url"],
|
|
3645
|
+
jsonUrl: args["json-url"],
|
|
3646
|
+
postedAt: formatUtc(/* @__PURE__ */ new Date())
|
|
3647
|
+
});
|
|
3648
|
+
}
|
|
3649
|
+
});
|
|
3650
|
+
var announceCmd = defineCommand({
|
|
3651
|
+
meta: {
|
|
3652
|
+
name: "announce",
|
|
3653
|
+
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."
|
|
3654
|
+
},
|
|
3655
|
+
args: {
|
|
3656
|
+
"head-sha": {
|
|
3657
|
+
type: "string",
|
|
3658
|
+
description: "Trusted head SHA to resolve the PR (from workflow_run.head_sha)",
|
|
3659
|
+
required: true
|
|
3660
|
+
},
|
|
3661
|
+
repo: {
|
|
3662
|
+
type: "string",
|
|
3663
|
+
description: "Repository (owner/name)",
|
|
3664
|
+
required: true
|
|
3665
|
+
},
|
|
3666
|
+
"run-url": {
|
|
3667
|
+
type: "string",
|
|
3668
|
+
description: "Workflow run URL the placeholder links to",
|
|
3669
|
+
required: true
|
|
3670
|
+
},
|
|
3671
|
+
"bot-login": {
|
|
3672
|
+
type: "string",
|
|
3673
|
+
description: "Bot login to trust for the sticky comment upsert (default: github-actions[bot])"
|
|
3674
|
+
},
|
|
3675
|
+
"head-branch": {
|
|
3676
|
+
type: "string",
|
|
3677
|
+
description: "Head branch to disambiguate the PR when multiple share a commit"
|
|
3678
|
+
}
|
|
3679
|
+
},
|
|
3680
|
+
run: async ({ args }) => {
|
|
3681
|
+
await announce({
|
|
3682
|
+
repo: args.repo,
|
|
3683
|
+
headSha: args["head-sha"],
|
|
3684
|
+
botLogin: args["bot-login"] || "github-actions[bot]",
|
|
3685
|
+
runUrl: args["run-url"],
|
|
3686
|
+
headBranch: args["head-branch"]
|
|
3687
|
+
}).catch(
|
|
3688
|
+
(err) => process.stderr.write(
|
|
3689
|
+
`code-review announce: could not post the in-progress sticky (${errMsg(err)}) \u2014 continuing (cosmetic)
|
|
3690
|
+
`
|
|
3691
|
+
)
|
|
3692
|
+
);
|
|
3693
|
+
}
|
|
3694
|
+
});
|
|
3695
|
+
var requireCeilingSec = (raw) => {
|
|
3696
|
+
if (raw === void 0) return null;
|
|
3697
|
+
const ms = parseWallMs(raw);
|
|
3698
|
+
if (ms === null)
|
|
3699
|
+
return fail(`--max-duration must be a duration like 60m, 3600s, or 1h (got "${raw}")`);
|
|
3700
|
+
return Math.floor(ms / 1e3);
|
|
3701
|
+
};
|
|
3702
|
+
var requireCeilingUsd = (raw) => {
|
|
3703
|
+
if (raw === void 0) return null;
|
|
3704
|
+
const n = Number.parseFloat(raw.replace(/^\$/, ""));
|
|
3705
|
+
if (!Number.isFinite(n) || n < 0) fail(`--max-usd must be a non-negative number (got "${raw}")`);
|
|
3706
|
+
return n;
|
|
3707
|
+
};
|
|
3708
|
+
var requireMaxInstructions = (raw) => {
|
|
3709
|
+
if (raw === void 0) return 4e3;
|
|
3710
|
+
if (!/^\d+$/.test(raw)) fail(`--max-instructions must be a non-negative integer (got "${raw}")`);
|
|
3711
|
+
return Number.parseInt(raw, 10);
|
|
3712
|
+
};
|
|
3713
|
+
var requirePositiveInt = (raw, flag) => {
|
|
3714
|
+
const n = Number.parseInt(raw, 10);
|
|
3715
|
+
return Number.isInteger(n) && n > 0 && /^\d+$/.test(raw) ? n : fail(`${flag} must be a positive integer; got "${raw}"`);
|
|
3716
|
+
};
|
|
3717
|
+
var requireWallMs = (raw, flag, fallback) => {
|
|
3718
|
+
const ms = parseWallMs(raw || fallback);
|
|
3719
|
+
return ms === null ? fail(`${flag} must be a duration like 30m, 15s, or 1h (got "${raw ?? ""}")`) : ms;
|
|
3720
|
+
};
|
|
3721
|
+
var parseCommandCmd = defineCommand({
|
|
3722
|
+
meta: {
|
|
3723
|
+
name: "parse-command",
|
|
3724
|
+
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.`
|
|
3725
|
+
},
|
|
3726
|
+
args: {
|
|
3727
|
+
repo: { type: "string", description: "Repository (owner/name)", required: true },
|
|
3728
|
+
pr: {
|
|
3729
|
+
type: "string",
|
|
3730
|
+
description: "PR number (from github.event.issue.number \u2014 trusted event data)",
|
|
3731
|
+
required: true
|
|
3732
|
+
},
|
|
3733
|
+
"comment-body": {
|
|
3734
|
+
type: "string",
|
|
3735
|
+
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)"
|
|
3736
|
+
},
|
|
3737
|
+
trigger: {
|
|
3738
|
+
type: "string",
|
|
3739
|
+
description: 'Trigger token the comment must begin with (default: "/code-review")'
|
|
3740
|
+
},
|
|
3741
|
+
"max-duration": {
|
|
3742
|
+
type: "string",
|
|
3743
|
+
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"
|
|
3744
|
+
},
|
|
3745
|
+
"max-usd": {
|
|
3746
|
+
type: "string",
|
|
3747
|
+
description: "Ceiling the requested USD budget is clamped to (e.g. 5); omit for no clamp"
|
|
3748
|
+
},
|
|
3749
|
+
"max-instructions": {
|
|
3750
|
+
type: "string",
|
|
3751
|
+
description: "Max characters of free-form instructions kept (default: 4000)"
|
|
3752
|
+
}
|
|
3753
|
+
},
|
|
3754
|
+
run: async ({ args }) => {
|
|
3755
|
+
const body = args["comment-body"] || process.env["CODE_REVIEW_COMMENT_BODY"] || "";
|
|
3756
|
+
const result = await parseCommand({
|
|
3757
|
+
repo: args.repo,
|
|
3758
|
+
prNumber: requirePositiveInt(args.pr, "--pr"),
|
|
3759
|
+
body,
|
|
3760
|
+
options: {
|
|
3761
|
+
trigger: args.trigger || "/code-review",
|
|
3762
|
+
maxDurationSec: requireCeilingSec(args["max-duration"]),
|
|
3763
|
+
maxUsd: requireCeilingUsd(args["max-usd"]),
|
|
3764
|
+
maxInstructionsLen: requireMaxInstructions(args["max-instructions"])
|
|
3765
|
+
}
|
|
3766
|
+
});
|
|
3767
|
+
if (result.kind === "skip") {
|
|
3768
|
+
process.stderr.write(`code-review parse-command: not running \u2014 ${result.reason}
|
|
3769
|
+
`);
|
|
3770
|
+
process.stdout.write(renderCommandOutputs(result, "UNUSED"));
|
|
3771
|
+
return;
|
|
3772
|
+
}
|
|
3773
|
+
for (const note of result.args.notes)
|
|
3774
|
+
process.stderr.write(`code-review parse-command: ${note}
|
|
3775
|
+
`);
|
|
3776
|
+
const delim = safeHeredocDelim(result.args.instructions, () => randomBytes(16).toString("hex"));
|
|
3777
|
+
process.stdout.write(renderCommandOutputs(result, delim));
|
|
3778
|
+
}
|
|
3779
|
+
});
|
|
3780
|
+
var requireReaction = (name) => isReaction(name) ? name : fail(`Unknown reaction "${name}" \u2014 one of: ${REACTIONS.join(", ")}`);
|
|
3781
|
+
var reactCmd = defineCommand({
|
|
3782
|
+
meta: {
|
|
3783
|
+
name: "react",
|
|
3784
|
+
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."
|
|
3785
|
+
},
|
|
3786
|
+
args: {
|
|
3787
|
+
repo: { type: "string", description: "Repository (owner/name)", required: true },
|
|
3788
|
+
"comment-id": {
|
|
3789
|
+
type: "string",
|
|
3790
|
+
description: "Comment id to react on (github.event.comment.id)",
|
|
3791
|
+
required: true
|
|
3792
|
+
},
|
|
3793
|
+
add: { type: "string", description: `Reaction to add: ${REACTIONS.join(" | ")}` },
|
|
3794
|
+
remove: {
|
|
3795
|
+
type: "string",
|
|
3796
|
+
description: "Reaction (of the token owner) to remove after adding \u2014 for the \u{1F440}\u2192\u{1F680} swap"
|
|
3797
|
+
}
|
|
3798
|
+
},
|
|
3799
|
+
run: async ({ args }) => {
|
|
3800
|
+
const commentId = requirePositiveInt(args["comment-id"], "--comment-id");
|
|
3801
|
+
const add = args.add ? requireReaction(args.add) : void 0;
|
|
3802
|
+
const remove = args.remove ? requireReaction(args.remove) : void 0;
|
|
3803
|
+
if (add === void 0 && remove === void 0)
|
|
3804
|
+
fail("react: nothing to do \u2014 pass --add and/or --remove");
|
|
3805
|
+
await react({ repo: args.repo, commentId, add, remove }).catch(
|
|
3806
|
+
(err) => process.stderr.write(
|
|
3807
|
+
`code-review react: reaction update failed (${errMsg(err)}) \u2014 continuing (reactions are cosmetic)
|
|
3808
|
+
`
|
|
3809
|
+
)
|
|
3810
|
+
);
|
|
3811
|
+
}
|
|
3812
|
+
});
|
|
3813
|
+
var awaitCiCmd = defineCommand({
|
|
3814
|
+
meta: {
|
|
3815
|
+
name: "await-ci",
|
|
3816
|
+
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)."
|
|
3817
|
+
},
|
|
3818
|
+
args: {
|
|
3819
|
+
repo: { type: "string", description: "Repository (owner/name)", required: true },
|
|
3820
|
+
"head-sha": {
|
|
3821
|
+
type: "string",
|
|
3822
|
+
description: "PR head SHA to find the CI run for (resolved from the trusted PR number)",
|
|
3823
|
+
required: true
|
|
3824
|
+
},
|
|
3825
|
+
"ci-workflow": {
|
|
3826
|
+
type: "string",
|
|
3827
|
+
description: 'CI workflow name to wait for \u2014 the name: of your CI workflow (default: "CI")'
|
|
3828
|
+
},
|
|
3829
|
+
timeout: {
|
|
3830
|
+
type: "string",
|
|
3831
|
+
description: "Give up waiting after this wall (default: 30m)"
|
|
3832
|
+
},
|
|
3833
|
+
"poll-interval": {
|
|
3834
|
+
type: "string",
|
|
3835
|
+
description: "How often to re-check the run status (default: 15s)"
|
|
3836
|
+
}
|
|
3837
|
+
},
|
|
3838
|
+
run: async ({ args }) => {
|
|
3839
|
+
const workflowName = args["ci-workflow"] || "CI";
|
|
3840
|
+
const outcome = await awaitCiConclusion(args.repo, args["head-sha"], {
|
|
3841
|
+
workflowName,
|
|
3842
|
+
pollIntervalMs: requireWallMs(args["poll-interval"], "--poll-interval", "15s"),
|
|
3843
|
+
timeoutMs: requireWallMs(args.timeout, "--timeout", "30m")
|
|
1619
3844
|
});
|
|
3845
|
+
process.stderr.write(
|
|
3846
|
+
outcome.kind === "concluded" ? `code-review await-ci: CI run ${String(outcome.runId)} ("${workflowName}") concluded "${outcome.conclusion}"
|
|
3847
|
+
` : `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."}
|
|
3848
|
+
`
|
|
3849
|
+
);
|
|
3850
|
+
process.stdout.write(renderCiOutputs(outcome));
|
|
1620
3851
|
}
|
|
1621
3852
|
});
|
|
1622
3853
|
var main = defineCommand({
|
|
1623
3854
|
meta: {
|
|
1624
3855
|
name: "code-review",
|
|
1625
3856
|
version: packageVersion,
|
|
1626
|
-
description: "Deterministic commenter for agentic PR review
|
|
3857
|
+
description: "Deterministic commenter for agentic PR review"
|
|
1627
3858
|
},
|
|
1628
3859
|
subCommands: {
|
|
1629
3860
|
gather: gatherCmd,
|
|
3861
|
+
"parse-command": parseCommandCmd,
|
|
3862
|
+
react: reactCmd,
|
|
3863
|
+
"await-ci": awaitCiCmd,
|
|
1630
3864
|
render: renderCmd,
|
|
1631
3865
|
inline: inlineCmd,
|
|
1632
3866
|
post: postCmd,
|
|
3867
|
+
announce: announceCmd,
|
|
1633
3868
|
cost: costCmd,
|
|
3869
|
+
"check-cost": checkCostCmd,
|
|
1634
3870
|
validate: validateCmd,
|
|
3871
|
+
"seed-draft": seedDraftCmd,
|
|
1635
3872
|
adapt: adaptCmd,
|
|
3873
|
+
notice: noticeCmd,
|
|
1636
3874
|
extract: extractCmd,
|
|
1637
|
-
"
|
|
3875
|
+
"validate-patches": validatePatchesCmd,
|
|
3876
|
+
"print-schema": printSchemaCmd,
|
|
3877
|
+
"stop-gate": stopGateCmd,
|
|
3878
|
+
"budget-hook": budgetHookCmd,
|
|
3879
|
+
"print-settings": printSettingsCmd,
|
|
3880
|
+
deadline: deadlineCmd
|
|
1638
3881
|
}
|
|
1639
3882
|
});
|
|
1640
3883
|
if (!process.env["VITEST"]) {
|
|
1641
3884
|
await runMain(main);
|
|
1642
3885
|
}
|
|
1643
3886
|
|
|
1644
|
-
export { main };
|
|
3887
|
+
export { main, snapshotIfValid };
|
|
1645
3888
|
//# sourceMappingURL=index.js.map
|
|
1646
3889
|
//# sourceMappingURL=index.js.map
|