@mrkaran/hodor 0.7.2 → 0.7.4
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 +7 -4
- package/dist/{chunk-YKB3BRJM.js → chunk-AFEJ4DRL.js} +1217 -275
- package/dist/chunk-AFEJ4DRL.js.map +1 -0
- package/dist/cli.js +84 -48
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +17 -2
- package/dist/index.js +2 -5
- package/package.json +1 -1
- package/dist/chunk-AMUK6GDX.js +0 -23
- package/dist/chunk-AMUK6GDX.js.map +0 -1
- package/dist/chunk-DALI4QRT.js +0 -674
- package/dist/chunk-DALI4QRT.js.map +0 -1
- package/dist/chunk-YKB3BRJM.js.map +0 -1
- package/dist/codequality-DTJK2LGF.js +0 -42
- package/dist/codequality-DTJK2LGF.js.map +0 -1
- package/dist/gitlab-JSVU4YFQ.js +0 -35
- package/dist/gitlab-JSVU4YFQ.js.map +0 -1
|
@@ -1,25 +1,724 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
1
|
+
// src/utils/logger.ts
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
var currentLevel = "warn";
|
|
4
|
+
var LEVELS = {
|
|
5
|
+
debug: 0,
|
|
6
|
+
info: 1,
|
|
7
|
+
warn: 2,
|
|
8
|
+
error: 3
|
|
9
|
+
};
|
|
10
|
+
function setLogLevel(level) {
|
|
11
|
+
currentLevel = level;
|
|
12
|
+
}
|
|
13
|
+
function shouldLog(level) {
|
|
14
|
+
return LEVELS[level] >= LEVELS[currentLevel];
|
|
15
|
+
}
|
|
16
|
+
function timestamp() {
|
|
17
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
18
|
+
}
|
|
19
|
+
var logger = {
|
|
20
|
+
debug(msg) {
|
|
21
|
+
if (shouldLog("debug")) {
|
|
22
|
+
process.stderr.write(`${chalk.gray(timestamp())} ${chalk.gray("DEBUG")} ${msg}
|
|
23
|
+
`);
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
info(msg) {
|
|
27
|
+
if (shouldLog("info")) {
|
|
28
|
+
process.stderr.write(`${chalk.gray(timestamp())} ${chalk.blue("INFO")} ${msg}
|
|
29
|
+
`);
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
warn(msg) {
|
|
33
|
+
if (shouldLog("warn")) {
|
|
34
|
+
process.stderr.write(`${chalk.gray(timestamp())} ${chalk.yellow("WARN")} ${msg}
|
|
35
|
+
`);
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
error(msg) {
|
|
39
|
+
if (shouldLog("error")) {
|
|
40
|
+
process.stderr.write(`${chalk.gray(timestamp())} ${chalk.red("ERROR")} ${msg}
|
|
41
|
+
`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// src/render.ts
|
|
47
|
+
var HODOR_REVIEW_MARKER = "<!-- hodor-review -->";
|
|
48
|
+
var HODOR_SUMMARY_MARKER = "<!-- hodor:summary:v1 -->";
|
|
49
|
+
function renderMarkdown(review) {
|
|
50
|
+
const lines = [HODOR_REVIEW_MARKER];
|
|
51
|
+
const critical = [];
|
|
52
|
+
const important = [];
|
|
53
|
+
const minor = [];
|
|
54
|
+
for (const f of review.findings) {
|
|
55
|
+
const p = f.priority;
|
|
56
|
+
if (p <= 1) critical.push(f);
|
|
57
|
+
else if (p === 2) important.push(f);
|
|
58
|
+
else minor.push(f);
|
|
59
|
+
}
|
|
60
|
+
lines.push("### Issues Found");
|
|
61
|
+
lines.push("");
|
|
62
|
+
if (review.findings.length === 0) {
|
|
63
|
+
lines.push("No issues found.");
|
|
64
|
+
lines.push("");
|
|
65
|
+
}
|
|
66
|
+
if (critical.length > 0) {
|
|
67
|
+
lines.push("**Critical (P0/P1)**");
|
|
68
|
+
for (const f of critical) {
|
|
69
|
+
lines.push(formatFinding(f));
|
|
70
|
+
}
|
|
71
|
+
lines.push("");
|
|
72
|
+
}
|
|
73
|
+
if (important.length > 0) {
|
|
74
|
+
lines.push("**Important (P2)**");
|
|
75
|
+
for (const f of important) {
|
|
76
|
+
lines.push(formatFinding(f));
|
|
77
|
+
}
|
|
78
|
+
lines.push("");
|
|
79
|
+
}
|
|
80
|
+
if (minor.length > 0) {
|
|
81
|
+
lines.push("**Minor (P3)**");
|
|
82
|
+
for (const f of minor) {
|
|
83
|
+
lines.push(formatFinding(f));
|
|
84
|
+
}
|
|
85
|
+
lines.push("");
|
|
86
|
+
}
|
|
87
|
+
lines.push("### Summary");
|
|
88
|
+
lines.push(
|
|
89
|
+
`Total issues: ${critical.length} critical, ${important.length} important, ${minor.length} minor.`
|
|
90
|
+
);
|
|
91
|
+
lines.push("");
|
|
92
|
+
lines.push("### Overall Verdict");
|
|
93
|
+
const isCorrect = review.overall_correctness === "patch is correct";
|
|
94
|
+
lines.push(
|
|
95
|
+
`**Status**: ${isCorrect ? "Patch is correct" : "Patch has blocking issues"}`
|
|
96
|
+
);
|
|
97
|
+
lines.push("");
|
|
98
|
+
if (review.overall_explanation) {
|
|
99
|
+
lines.push(`**Explanation**: ${review.overall_explanation}`);
|
|
100
|
+
}
|
|
101
|
+
return lines.join("\n").trimEnd() + "\n";
|
|
102
|
+
}
|
|
103
|
+
function renderSummaryMarkdown(review, options = {}) {
|
|
104
|
+
const lines = [HODOR_REVIEW_MARKER, HODOR_SUMMARY_MARKER];
|
|
105
|
+
lines.push("", "### Hodor review");
|
|
106
|
+
const openFindings = options.openFindings ?? review.findings;
|
|
107
|
+
const fallbackFindings = options.fallbackFindings ?? review.findings;
|
|
108
|
+
const counts = { blocking: 0, important: 0, minor: 0 };
|
|
109
|
+
for (const finding of openFindings) {
|
|
110
|
+
if (finding.priority <= 1) counts.blocking++;
|
|
111
|
+
else if (finding.priority === 2) counts.important++;
|
|
112
|
+
else counts.minor++;
|
|
113
|
+
}
|
|
114
|
+
lines.push("");
|
|
115
|
+
lines.push(
|
|
116
|
+
`**Open findings:** ${counts.blocking} blocking \xB7 ${counts.important} important \xB7 ${counts.minor} minor`
|
|
117
|
+
);
|
|
118
|
+
if (options.inlineCreated != null || options.inlineDeduplicated != null) {
|
|
119
|
+
lines.push("");
|
|
120
|
+
lines.push(
|
|
121
|
+
`**Inline delivery:** ${options.inlineCreated ?? 0} new \xB7 ${options.inlineDeduplicated ?? 0} already open`
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
const scope = options.reviewMode ? `${options.reviewMode} review` : "Latest review";
|
|
125
|
+
lines.push("");
|
|
126
|
+
lines.push(`**${scope}:** ${review.overall_explanation}`);
|
|
127
|
+
if (fallbackFindings.length > 0) {
|
|
128
|
+
lines.push("");
|
|
129
|
+
lines.push(`### ${options.fallbackHeading ?? "Findings"}`);
|
|
130
|
+
for (const finding of fallbackFindings) {
|
|
131
|
+
lines.push(formatFinding(finding));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return lines.join("\n").trimEnd() + "\n";
|
|
135
|
+
}
|
|
136
|
+
function formatFinding(f) {
|
|
137
|
+
const loc = ` (\`${formatLocation(f.code_location)}\`)`;
|
|
138
|
+
const title = `- **${f.title}**${loc}`;
|
|
139
|
+
const body = ` - ${f.body}`;
|
|
140
|
+
return `${title}
|
|
141
|
+
${body}`;
|
|
142
|
+
}
|
|
143
|
+
function formatLocation(loc) {
|
|
144
|
+
let filePath = loc.absolute_file_path;
|
|
145
|
+
const buildsMatch = filePath.match(/\/builds\/[^/]+\/[^/]+\/(.+)/);
|
|
146
|
+
if (buildsMatch) {
|
|
147
|
+
filePath = buildsMatch[1];
|
|
148
|
+
} else if (filePath.includes("/workspace/")) {
|
|
149
|
+
filePath = filePath.slice(filePath.indexOf("/workspace/") + "/workspace/".length);
|
|
150
|
+
} else {
|
|
151
|
+
filePath = filePath.replace(/^.*\/hodor-review-[^/]+\//, "");
|
|
152
|
+
}
|
|
153
|
+
const { start, end } = loc.line_range;
|
|
154
|
+
return start === end ? `${filePath}:${start}` : `${filePath}:${start}-${end}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// src/utils/exec.ts
|
|
158
|
+
import { execFile, spawn } from "child_process";
|
|
159
|
+
import { accessSync, constants } from "fs";
|
|
160
|
+
import { delimiter, join } from "path";
|
|
161
|
+
import { promisify } from "util";
|
|
162
|
+
var execFileAsync = promisify(execFile);
|
|
163
|
+
async function exec(cmd, args, opts) {
|
|
164
|
+
if (typeof opts?.input === "string") {
|
|
165
|
+
return new Promise((resolve4, reject) => {
|
|
166
|
+
const child = spawn(cmd, args, {
|
|
167
|
+
cwd: opts.cwd,
|
|
168
|
+
env: opts.env ?? process.env,
|
|
169
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
170
|
+
});
|
|
171
|
+
let stdout2 = "";
|
|
172
|
+
let stderr2 = "";
|
|
173
|
+
child.stdout.on("data", (chunk) => {
|
|
174
|
+
stdout2 += chunk.toString();
|
|
175
|
+
});
|
|
176
|
+
child.stderr.on("data", (chunk) => {
|
|
177
|
+
stderr2 += chunk.toString();
|
|
178
|
+
});
|
|
179
|
+
child.on("error", (error) => {
|
|
180
|
+
reject(error);
|
|
181
|
+
});
|
|
182
|
+
child.on("close", (code, signal) => {
|
|
183
|
+
if (code === 0) {
|
|
184
|
+
resolve4({ stdout: stdout2, stderr: stderr2 });
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const parts = [`Command failed: ${cmd} ${args.join(" ")}`];
|
|
188
|
+
if (stderr2.trim()) {
|
|
189
|
+
parts.push(`stderr:
|
|
190
|
+
${stderr2.trim()}`);
|
|
191
|
+
}
|
|
192
|
+
if (stdout2.trim()) {
|
|
193
|
+
parts.push(`stdout:
|
|
194
|
+
${stdout2.trim()}`);
|
|
195
|
+
}
|
|
196
|
+
if (signal) {
|
|
197
|
+
parts.push(`signal: ${signal}`);
|
|
198
|
+
}
|
|
199
|
+
const error = new Error(parts.join("\n"));
|
|
200
|
+
reject(error);
|
|
201
|
+
});
|
|
202
|
+
child.stdin.write(opts.input);
|
|
203
|
+
child.stdin.end();
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
const { stdout, stderr } = await execFileAsync(cmd, args, {
|
|
207
|
+
cwd: opts?.cwd,
|
|
208
|
+
env: opts?.env ?? process.env,
|
|
209
|
+
maxBuffer: 50 * 1024 * 1024
|
|
210
|
+
// 50MB
|
|
211
|
+
});
|
|
212
|
+
return { stdout, stderr };
|
|
213
|
+
}
|
|
214
|
+
async function execJson(cmd, args, opts) {
|
|
215
|
+
const { stdout } = await exec(cmd, args, opts);
|
|
216
|
+
return JSON.parse(stdout.trim());
|
|
217
|
+
}
|
|
218
|
+
function commandOnPath(cmd) {
|
|
219
|
+
return (process.env.PATH ?? "").split(delimiter).filter((dir) => dir !== "").some((dir) => {
|
|
220
|
+
try {
|
|
221
|
+
accessSync(join(dir, cmd), constants.X_OK);
|
|
222
|
+
return true;
|
|
223
|
+
} catch {
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// src/gitlab.ts
|
|
230
|
+
var DEFAULT_GITLAB_HOST = "gitlab.com";
|
|
231
|
+
var HODOR_NOTE_PREFIX_RE = /^\s*<!--\s*hodor[-:]/;
|
|
232
|
+
var HODOR_CACHE_MARKER_RE = /<!--\s*hodor:cache:v1:[A-Za-z0-9_-]+\s*-->\s*/g;
|
|
233
|
+
var HODOR_SHA_PREFIX_RE = /^\s*<!--\s*hodor:sha:[a-f0-9]{40}\s*-->/i;
|
|
234
|
+
function isHodorNote(body, marker = HODOR_REVIEW_MARKER) {
|
|
235
|
+
if (typeof body !== "string") return false;
|
|
236
|
+
if (body.trimStart().startsWith(marker)) return true;
|
|
237
|
+
if (marker === HODOR_REVIEW_MARKER && HODOR_NOTE_PREFIX_RE.test(body)) {
|
|
238
|
+
return body.includes(HODOR_REVIEW_MARKER);
|
|
239
|
+
}
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
function parseGlabPaginatedJson(raw) {
|
|
243
|
+
const trimmed = raw.trim();
|
|
244
|
+
if (!trimmed) return [];
|
|
245
|
+
const chunks = [];
|
|
246
|
+
let depth = 0;
|
|
247
|
+
let inString = false;
|
|
248
|
+
let escaped = false;
|
|
249
|
+
let start = -1;
|
|
250
|
+
for (let i = 0; i < trimmed.length; i++) {
|
|
251
|
+
const ch = trimmed[i];
|
|
252
|
+
if (escaped) {
|
|
253
|
+
escaped = false;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (ch === "\\" && inString) {
|
|
257
|
+
escaped = true;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (ch === '"') {
|
|
261
|
+
inString = !inString;
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (inString) continue;
|
|
265
|
+
if (ch === "[") {
|
|
266
|
+
if (depth === 0) start = i;
|
|
267
|
+
depth++;
|
|
268
|
+
} else if (ch === "]") {
|
|
269
|
+
depth--;
|
|
270
|
+
if (depth === 0 && start >= 0) {
|
|
271
|
+
chunks.push(trimmed.slice(start, i + 1));
|
|
272
|
+
start = -1;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
const results = [];
|
|
277
|
+
for (const chunk of chunks) {
|
|
278
|
+
try {
|
|
279
|
+
const parsed = JSON.parse(chunk);
|
|
280
|
+
if (Array.isArray(parsed)) results.push(...parsed);
|
|
281
|
+
} catch (err) {
|
|
282
|
+
logger.warn(
|
|
283
|
+
`Skipping malformed glab pagination chunk: ${err instanceof Error ? err.message : err}`
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return results;
|
|
288
|
+
}
|
|
289
|
+
var GitLabAPIError = class extends Error {
|
|
290
|
+
constructor(message) {
|
|
291
|
+
super(message);
|
|
292
|
+
this.name = "GitLabAPIError";
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
function normalizeBaseUrl(host) {
|
|
296
|
+
const candidate = host || process.env.GITLAB_HOST || process.env.CI_SERVER_URL || DEFAULT_GITLAB_HOST;
|
|
297
|
+
const trimmed = candidate.trim() || DEFAULT_GITLAB_HOST;
|
|
298
|
+
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
|
|
299
|
+
return trimmed.replace(/\/+$/, "");
|
|
300
|
+
}
|
|
301
|
+
return `https://${trimmed}`.replace(/\/+$/, "");
|
|
302
|
+
}
|
|
303
|
+
function encodedProjectPath(owner, repo) {
|
|
304
|
+
const projectPath = [owner.replace(/^\/+|\/+$/g, ""), repo.replace(/^\/+|\/+$/g, "")].filter(Boolean).join("/");
|
|
305
|
+
return encodeURIComponent(projectPath);
|
|
306
|
+
}
|
|
307
|
+
function glabEnv(host) {
|
|
308
|
+
const env = { ...process.env };
|
|
309
|
+
const baseUrl = normalizeBaseUrl(host);
|
|
310
|
+
const hostname = baseUrl.replace(/^https?:\/\//, "");
|
|
311
|
+
env.GITLAB_HOST = hostname;
|
|
312
|
+
return env;
|
|
313
|
+
}
|
|
314
|
+
async function fetchGitlabMrInfo(owner, repo, mrNumber, host, options) {
|
|
315
|
+
const encoded = encodedProjectPath(owner, repo);
|
|
316
|
+
const env = glabEnv(host);
|
|
317
|
+
let mrData;
|
|
318
|
+
try {
|
|
319
|
+
mrData = await execJson(
|
|
320
|
+
"glab",
|
|
321
|
+
["api", `projects/${encoded}/merge_requests/${mrNumber}`],
|
|
322
|
+
{ env }
|
|
323
|
+
);
|
|
324
|
+
} catch (err) {
|
|
325
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
326
|
+
throw new GitLabAPIError(`Failed to fetch MR !${mrNumber}: ${msg}`);
|
|
327
|
+
}
|
|
328
|
+
const metadata = {
|
|
329
|
+
title: mrData.title,
|
|
330
|
+
description: mrData.description ?? "",
|
|
331
|
+
source_branch: mrData.source_branch,
|
|
332
|
+
target_branch: mrData.target_branch,
|
|
333
|
+
changes_count: mrData.changes_count,
|
|
334
|
+
labels: mrData.labels,
|
|
335
|
+
author: mrData.author,
|
|
336
|
+
pipeline: mrData.pipeline,
|
|
337
|
+
state: mrData.state
|
|
338
|
+
};
|
|
339
|
+
if (options?.includeComments) {
|
|
340
|
+
try {
|
|
341
|
+
const { stdout: rawNotes } = await exec(
|
|
342
|
+
"glab",
|
|
343
|
+
["api", `projects/${encoded}/merge_requests/${mrNumber}/notes`, "--paginate"],
|
|
344
|
+
{ env }
|
|
345
|
+
);
|
|
346
|
+
const notes = parseGlabPaginatedJson(rawNotes);
|
|
347
|
+
metadata.Notes = notes.map((n) => ({
|
|
348
|
+
body: n.body ?? "",
|
|
349
|
+
author: n.author,
|
|
350
|
+
created_at: n.created_at,
|
|
351
|
+
updated_at: n.updated_at,
|
|
352
|
+
system: n.system
|
|
353
|
+
}));
|
|
354
|
+
} catch (err) {
|
|
355
|
+
logger.warn(`Failed to fetch MR notes: ${err instanceof Error ? err.message : err}`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return metadata;
|
|
359
|
+
}
|
|
360
|
+
async function postGitlabMrComment(owner, repo, mrNumber, body, host) {
|
|
361
|
+
const encoded = encodedProjectPath(owner, repo);
|
|
362
|
+
const env = glabEnv(host);
|
|
363
|
+
try {
|
|
364
|
+
await exec(
|
|
365
|
+
"glab",
|
|
366
|
+
[
|
|
367
|
+
"api",
|
|
368
|
+
`projects/${encoded}/merge_requests/${mrNumber}/notes`,
|
|
369
|
+
"--method",
|
|
370
|
+
"POST",
|
|
371
|
+
"-H",
|
|
372
|
+
"Content-Type: application/json",
|
|
373
|
+
"--input",
|
|
374
|
+
"-"
|
|
375
|
+
],
|
|
376
|
+
{ env, input: JSON.stringify({ body }) }
|
|
377
|
+
);
|
|
378
|
+
} catch (err) {
|
|
379
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
380
|
+
throw new GitLabAPIError(`Failed to post comment to MR !${mrNumber}: ${msg}`);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
async function upsertGitlabMrSummary(owner, repo, mrNumber, body, host) {
|
|
384
|
+
const encoded = encodedProjectPath(owner, repo);
|
|
385
|
+
const env = glabEnv(host);
|
|
386
|
+
try {
|
|
387
|
+
const [currentUser, notesResult] = await Promise.all([
|
|
388
|
+
execJson("glab", ["api", "user"], { env }),
|
|
389
|
+
exec(
|
|
390
|
+
"glab",
|
|
391
|
+
[
|
|
392
|
+
"api",
|
|
393
|
+
`projects/${encoded}/merge_requests/${mrNumber}/notes?per_page=100`,
|
|
394
|
+
"--paginate"
|
|
395
|
+
],
|
|
396
|
+
{ env }
|
|
397
|
+
)
|
|
398
|
+
]);
|
|
399
|
+
const username = currentUser.username;
|
|
400
|
+
if (typeof username !== "string" || !username) {
|
|
401
|
+
throw new Error("authenticated GitLab user has no username");
|
|
402
|
+
}
|
|
403
|
+
const candidates = parseGlabPaginatedJson(notesResult.stdout).filter((note) => {
|
|
404
|
+
const noteBody = note.body;
|
|
405
|
+
const author = note.author;
|
|
406
|
+
const authorUsername = author && typeof author === "object" ? author.username : void 0;
|
|
407
|
+
if (typeof noteBody !== "string" || authorUsername !== username || note.system === true || note.type != null || note.position != null) {
|
|
408
|
+
return false;
|
|
409
|
+
}
|
|
410
|
+
return noteBody.includes(HODOR_SUMMARY_MARKER) || HODOR_SHA_PREFIX_RE.test(noteBody) && noteBody.includes(HODOR_REVIEW_MARKER);
|
|
411
|
+
}).sort((a, b) => {
|
|
412
|
+
const aTime = Date.parse(String(a.updated_at ?? a.created_at ?? ""));
|
|
413
|
+
const bTime = Date.parse(String(b.updated_at ?? b.created_at ?? ""));
|
|
414
|
+
return (Number.isFinite(bTime) ? bTime : 0) - (Number.isFinite(aTime) ? aTime : 0);
|
|
415
|
+
});
|
|
416
|
+
const noteId = candidates[0]?.id;
|
|
417
|
+
if (typeof noteId !== "number" && typeof noteId !== "string") {
|
|
418
|
+
await postGitlabMrComment(owner, repo, mrNumber, body, host);
|
|
419
|
+
return "created";
|
|
420
|
+
}
|
|
421
|
+
await exec(
|
|
422
|
+
"glab",
|
|
423
|
+
[
|
|
424
|
+
"api",
|
|
425
|
+
`projects/${encoded}/merge_requests/${mrNumber}/notes/${noteId}`,
|
|
426
|
+
"--method",
|
|
427
|
+
"PUT",
|
|
428
|
+
"-H",
|
|
429
|
+
"Content-Type: application/json",
|
|
430
|
+
"--input",
|
|
431
|
+
"-"
|
|
432
|
+
],
|
|
433
|
+
{ env, input: JSON.stringify({ body }) }
|
|
434
|
+
);
|
|
435
|
+
return "updated";
|
|
436
|
+
} catch (err) {
|
|
437
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
438
|
+
throw new GitLabAPIError(`Failed to upsert summary for MR !${mrNumber}: ${msg}`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
function summarizeGitlabNotes(notes, maxEntries = 5) {
|
|
442
|
+
return summarizeNotes(notes, maxEntries, (note) => !isHodorNote(note.body));
|
|
443
|
+
}
|
|
444
|
+
function summarizeHodorNotes(notes, maxEntries = 5) {
|
|
445
|
+
return summarizeNotes(notes, maxEntries, (note) => isHodorNote(note.body));
|
|
446
|
+
}
|
|
447
|
+
function summarizeNotes(notes, maxEntries, include) {
|
|
448
|
+
if (!notes || notes.length === 0) return "";
|
|
449
|
+
const trivialPatterns = /* @__PURE__ */ new Set([
|
|
450
|
+
"lgtm",
|
|
451
|
+
"+1",
|
|
452
|
+
"-1",
|
|
453
|
+
"\u{1F44D}",
|
|
454
|
+
"\u{1F44E}",
|
|
455
|
+
"thanks",
|
|
456
|
+
"thank you",
|
|
457
|
+
"looks good",
|
|
458
|
+
"approved",
|
|
459
|
+
"\u{1F680}",
|
|
460
|
+
"\u2705",
|
|
461
|
+
"\u274C"
|
|
462
|
+
]);
|
|
463
|
+
const filtered = [];
|
|
464
|
+
for (const note of notes) {
|
|
465
|
+
if (!include(note)) continue;
|
|
466
|
+
const body = (note.body ?? "").replace(HODOR_CACHE_MARKER_RE, "").trim();
|
|
467
|
+
if (!body) continue;
|
|
468
|
+
if (note.system) continue;
|
|
469
|
+
if (body.length < 20) continue;
|
|
470
|
+
const bodyLower = body.toLowerCase();
|
|
471
|
+
let isTrivial = false;
|
|
472
|
+
for (const pattern of trivialPatterns) {
|
|
473
|
+
if (bodyLower.includes(pattern) && body.length < 50) {
|
|
474
|
+
isTrivial = true;
|
|
475
|
+
break;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (isTrivial) continue;
|
|
479
|
+
const username = note.author?.username ?? note.author?.name ?? "unknown";
|
|
480
|
+
filtered.push({ username, body, createdAt: note.created_at ?? "" });
|
|
481
|
+
}
|
|
482
|
+
filtered.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
483
|
+
const recent = filtered.slice(-maxEntries);
|
|
484
|
+
const lines = [];
|
|
485
|
+
for (const { username, body, createdAt } of recent) {
|
|
486
|
+
let timestampStr = "";
|
|
487
|
+
if (createdAt) {
|
|
488
|
+
try {
|
|
489
|
+
const dt = new Date(createdAt);
|
|
490
|
+
timestampStr = dt.toISOString().replace("T", " ").slice(0, 16);
|
|
491
|
+
} catch {
|
|
492
|
+
timestampStr = createdAt.slice(0, 10);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
const header = timestampStr ? `- ${timestampStr} @${username}:` : `- @${username}:`;
|
|
496
|
+
const boundedBody = body.length > 2e3 ? `${body.slice(0, 1999).trimEnd()}\u2026` : body;
|
|
497
|
+
const indentedBody = boundedBody.split("\n").join("\n ");
|
|
498
|
+
lines.push(`${header}
|
|
499
|
+
${indentedBody}`);
|
|
500
|
+
}
|
|
501
|
+
return lines.join("\n");
|
|
502
|
+
}
|
|
503
|
+
async function getGitlabMrDiffRefs(owner, repo, mrNumber, host) {
|
|
504
|
+
const encoded = encodedProjectPath(owner, repo);
|
|
505
|
+
const env = glabEnv(host);
|
|
506
|
+
let mrData;
|
|
507
|
+
try {
|
|
508
|
+
mrData = await execJson(
|
|
509
|
+
"glab",
|
|
510
|
+
["api", `projects/${encoded}/merge_requests/${mrNumber}`],
|
|
511
|
+
{ env }
|
|
512
|
+
);
|
|
513
|
+
} catch (err) {
|
|
514
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
515
|
+
throw new GitLabAPIError(`Failed to fetch diff refs for MR !${mrNumber}: ${msg}`);
|
|
516
|
+
}
|
|
517
|
+
const diffRefs = mrData.diff_refs;
|
|
518
|
+
const base_sha = diffRefs?.base_sha;
|
|
519
|
+
const head_sha = diffRefs?.head_sha;
|
|
520
|
+
const start_sha = diffRefs?.start_sha;
|
|
521
|
+
if (typeof base_sha !== "string" || typeof head_sha !== "string" || typeof start_sha !== "string" || !base_sha || !head_sha || !start_sha) {
|
|
522
|
+
throw new GitLabAPIError(`MR !${mrNumber} has missing or incomplete diff_refs`);
|
|
523
|
+
}
|
|
524
|
+
return { base_sha, head_sha, start_sha };
|
|
525
|
+
}
|
|
526
|
+
async function createGitlabDraftNote(owner, repo, mrNumber, body, host, opts) {
|
|
527
|
+
const encoded = encodedProjectPath(owner, repo);
|
|
528
|
+
const env = glabEnv(host);
|
|
529
|
+
const endpoint = `projects/${encoded}/merge_requests/${mrNumber}/draft_notes`;
|
|
530
|
+
const payload = {
|
|
531
|
+
note: body
|
|
532
|
+
};
|
|
533
|
+
if (opts?.filePath && typeof opts.line === "number" && opts.diffRefs) {
|
|
534
|
+
payload.position = {
|
|
535
|
+
base_sha: opts.diffRefs.base_sha,
|
|
536
|
+
head_sha: opts.diffRefs.head_sha,
|
|
537
|
+
start_sha: opts.diffRefs.start_sha,
|
|
538
|
+
position_type: "text",
|
|
539
|
+
old_path: opts.filePath,
|
|
540
|
+
new_path: opts.filePath,
|
|
541
|
+
new_line: opts.line
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
try {
|
|
545
|
+
return await execJson(
|
|
546
|
+
"glab",
|
|
547
|
+
["api", endpoint, "--method", "POST", "-H", "Content-Type: application/json", "--input", "-"],
|
|
548
|
+
{
|
|
549
|
+
env,
|
|
550
|
+
input: JSON.stringify(payload)
|
|
551
|
+
}
|
|
552
|
+
);
|
|
553
|
+
} catch (err) {
|
|
554
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
555
|
+
throw new GitLabAPIError(`Failed to create draft note for MR !${mrNumber}: ${msg}`);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
async function bulkPublishGitlabDraftNotes(owner, repo, mrNumber, host) {
|
|
559
|
+
const encoded = encodedProjectPath(owner, repo);
|
|
560
|
+
const env = glabEnv(host);
|
|
561
|
+
try {
|
|
562
|
+
await exec(
|
|
563
|
+
"glab",
|
|
564
|
+
[
|
|
565
|
+
"api",
|
|
566
|
+
`projects/${encoded}/merge_requests/${mrNumber}/draft_notes/bulk_publish`,
|
|
567
|
+
"--method",
|
|
568
|
+
"POST"
|
|
569
|
+
],
|
|
570
|
+
{ env }
|
|
571
|
+
);
|
|
572
|
+
} catch (err) {
|
|
573
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
574
|
+
throw new GitLabAPIError(`Failed to bulk publish draft notes for MR !${mrNumber}: ${msg}`);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
async function publishGitlabDraftNote(owner, repo, mrNumber, draftNoteId, host) {
|
|
578
|
+
const encoded = encodedProjectPath(owner, repo);
|
|
579
|
+
const env = glabEnv(host);
|
|
580
|
+
try {
|
|
581
|
+
await exec(
|
|
582
|
+
"glab",
|
|
583
|
+
[
|
|
584
|
+
"api",
|
|
585
|
+
`projects/${encoded}/merge_requests/${mrNumber}/draft_notes/${draftNoteId}/publish`,
|
|
586
|
+
"--method",
|
|
587
|
+
"PUT"
|
|
588
|
+
],
|
|
589
|
+
{ env }
|
|
590
|
+
);
|
|
591
|
+
} catch (err) {
|
|
592
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
593
|
+
throw new GitLabAPIError(`Failed to publish draft note ${draftNoteId} for MR !${mrNumber}: ${msg}`);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
async function postGitlabCommitStatus(owner, repo, sha, state, host, opts) {
|
|
597
|
+
const allowedStates = /* @__PURE__ */ new Set([
|
|
598
|
+
"pending",
|
|
599
|
+
"running",
|
|
600
|
+
"success",
|
|
601
|
+
"failed",
|
|
602
|
+
"canceled"
|
|
603
|
+
]);
|
|
604
|
+
if (!allowedStates.has(state)) {
|
|
605
|
+
throw new GitLabAPIError(`Invalid GitLab commit status state: ${state}`);
|
|
606
|
+
}
|
|
607
|
+
const encoded = encodedProjectPath(owner, repo);
|
|
608
|
+
const env = glabEnv(host);
|
|
609
|
+
const endpoint = `projects/${encoded}/statuses/${sha}`;
|
|
610
|
+
const payload = {
|
|
611
|
+
state,
|
|
612
|
+
name: opts?.name ?? "hodor"
|
|
613
|
+
};
|
|
614
|
+
if (opts?.description) {
|
|
615
|
+
payload.description = opts.description;
|
|
616
|
+
}
|
|
617
|
+
if (opts?.targetUrl) {
|
|
618
|
+
payload.target_url = opts.targetUrl;
|
|
619
|
+
}
|
|
620
|
+
try {
|
|
621
|
+
await exec(
|
|
622
|
+
"glab",
|
|
623
|
+
["api", endpoint, "--method", "POST", "-H", "Content-Type: application/json", "--input", "-"],
|
|
624
|
+
{
|
|
625
|
+
env,
|
|
626
|
+
input: JSON.stringify(payload)
|
|
627
|
+
}
|
|
628
|
+
);
|
|
629
|
+
} catch (err) {
|
|
630
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
631
|
+
throw new GitLabAPIError(`Failed to post commit status for ${sha}: ${msg}`);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
async function listHodorDiscussions(owner, repo, mrNumber, host, marker = HODOR_REVIEW_MARKER) {
|
|
635
|
+
const encoded = encodedProjectPath(owner, repo);
|
|
636
|
+
const env = glabEnv(host);
|
|
637
|
+
let discussions;
|
|
638
|
+
try {
|
|
639
|
+
const { stdout: rawDiscussions } = await exec(
|
|
640
|
+
"glab",
|
|
641
|
+
[
|
|
642
|
+
"api",
|
|
643
|
+
`projects/${encoded}/merge_requests/${mrNumber}/discussions?per_page=100`,
|
|
644
|
+
"--paginate"
|
|
645
|
+
],
|
|
646
|
+
{ env }
|
|
647
|
+
);
|
|
648
|
+
discussions = parseGlabPaginatedJson(rawDiscussions);
|
|
649
|
+
} catch (err) {
|
|
650
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
651
|
+
throw new GitLabAPIError(`Failed to list discussions for MR !${mrNumber}: ${msg}`);
|
|
652
|
+
}
|
|
653
|
+
const results = [];
|
|
654
|
+
for (const discussion of discussions) {
|
|
655
|
+
const discussionId = discussion.id;
|
|
656
|
+
if (typeof discussionId !== "string") {
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
const notes = discussion.notes;
|
|
660
|
+
if (!Array.isArray(notes)) {
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
for (const note of notes) {
|
|
664
|
+
if (!note || typeof note !== "object") {
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
const noteObj = note;
|
|
668
|
+
const noteId = noteObj.id;
|
|
669
|
+
const body = noteObj.body;
|
|
670
|
+
if (typeof noteId !== "number" || typeof body !== "string" || !isHodorNote(body, marker)) {
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
const position = noteObj.position && typeof noteObj.position === "object" ? noteObj.position : void 0;
|
|
674
|
+
const filePath = typeof position?.new_path === "string" ? position.new_path : typeof position?.old_path === "string" ? position.old_path : void 0;
|
|
675
|
+
const line = typeof position?.new_line === "number" ? position.new_line : typeof position?.old_line === "number" ? position.old_line : void 0;
|
|
676
|
+
if (noteObj.resolvable !== true) {
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
results.push({
|
|
680
|
+
discussionId,
|
|
681
|
+
noteId,
|
|
682
|
+
body,
|
|
683
|
+
resolved: Boolean(noteObj.resolved),
|
|
684
|
+
filePath,
|
|
685
|
+
line
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
return results;
|
|
690
|
+
}
|
|
691
|
+
async function resolveGitlabDiscussions(owner, repo, mrNumber, discussionIds, host) {
|
|
692
|
+
const encoded = encodedProjectPath(owner, repo);
|
|
693
|
+
const env = glabEnv(host);
|
|
694
|
+
let resolvedCount = 0;
|
|
695
|
+
for (const discussionId of discussionIds) {
|
|
696
|
+
try {
|
|
697
|
+
await exec(
|
|
698
|
+
"glab",
|
|
699
|
+
[
|
|
700
|
+
"api",
|
|
701
|
+
`projects/${encoded}/merge_requests/${mrNumber}/discussions/${discussionId}`,
|
|
702
|
+
"--method",
|
|
703
|
+
"PUT",
|
|
704
|
+
"-H",
|
|
705
|
+
"Content-Type: application/json",
|
|
706
|
+
"--input",
|
|
707
|
+
"-"
|
|
708
|
+
],
|
|
709
|
+
{
|
|
710
|
+
env,
|
|
711
|
+
input: JSON.stringify({ resolved: true })
|
|
712
|
+
}
|
|
713
|
+
);
|
|
714
|
+
resolvedCount += 1;
|
|
715
|
+
} catch (err) {
|
|
716
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
717
|
+
logger.warn(`Failed to resolve discussion ${discussionId} on MR !${mrNumber}: ${msg}`);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
return resolvedCount;
|
|
721
|
+
}
|
|
23
722
|
|
|
24
723
|
// src/prompt.ts
|
|
25
724
|
import { readFileSync } from "fs";
|
|
@@ -31,6 +730,135 @@ function getTemplatePath(name) {
|
|
|
31
730
|
return resolve(dirname(fileURLToPath(import.meta.url)), "..", "templates", name);
|
|
32
731
|
}
|
|
33
732
|
|
|
733
|
+
// src/review-diff.ts
|
|
734
|
+
var HODOR_REVIEW_SHA_RE = /^\s*<!--\s*hodor:sha:([a-f0-9]{40})\s*-->/i;
|
|
735
|
+
function getReviewDiffArgs(options) {
|
|
736
|
+
const {
|
|
737
|
+
platform,
|
|
738
|
+
targetBranch,
|
|
739
|
+
diffBaseSha,
|
|
740
|
+
previousReviewSha,
|
|
741
|
+
reviewDiffMode,
|
|
742
|
+
localMode = false
|
|
743
|
+
} = options;
|
|
744
|
+
const rebasedGitlabReview = platform === "gitlab" && reviewDiffMode === "snapshot";
|
|
745
|
+
if (previousReviewSha && !rebasedGitlabReview) {
|
|
746
|
+
return reviewDiffMode === "snapshot" ? ["--no-pager", "diff", previousReviewSha, "HEAD"] : ["--no-pager", "diff", `${previousReviewSha}...HEAD`];
|
|
747
|
+
}
|
|
748
|
+
if (localMode) return ["--no-pager", "diff", targetBranch];
|
|
749
|
+
if (diffBaseSha) return ["--no-pager", "diff", diffBaseSha, "HEAD"];
|
|
750
|
+
return ["--no-pager", "diff", `origin/${targetBranch}...HEAD`];
|
|
751
|
+
}
|
|
752
|
+
function getHodorReviewShaCandidates(notes) {
|
|
753
|
+
if (!notes || notes.length === 0) return [];
|
|
754
|
+
const candidates = [];
|
|
755
|
+
for (const [index, note] of notes.entries()) {
|
|
756
|
+
const match = note.body?.match(HODOR_REVIEW_SHA_RE);
|
|
757
|
+
if (!match) continue;
|
|
758
|
+
const reviewedAtMs = Date.parse(note.updated_at ?? note.created_at ?? "");
|
|
759
|
+
candidates.push({
|
|
760
|
+
sha: match[1],
|
|
761
|
+
reviewedAtMs: Number.isFinite(reviewedAtMs) ? reviewedAtMs : null,
|
|
762
|
+
index
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
candidates.sort((a, b) => {
|
|
766
|
+
if (a.reviewedAtMs != null && b.reviewedAtMs != null && a.reviewedAtMs !== b.reviewedAtMs) {
|
|
767
|
+
return b.reviewedAtMs - a.reviewedAtMs;
|
|
768
|
+
}
|
|
769
|
+
if (a.reviewedAtMs != null && b.reviewedAtMs == null) return -1;
|
|
770
|
+
if (a.reviewedAtMs == null && b.reviewedAtMs != null) return 1;
|
|
771
|
+
return a.index - b.index;
|
|
772
|
+
});
|
|
773
|
+
return [...new Set(candidates.map(({ sha }) => sha))];
|
|
774
|
+
}
|
|
775
|
+
async function findLatestReviewBase(notes, workspacePath) {
|
|
776
|
+
const candidates = getHodorReviewShaCandidates(notes);
|
|
777
|
+
if (candidates.length === 0) return null;
|
|
778
|
+
logger.info(`Found ${candidates.length} previous Hodor review marker(s)`);
|
|
779
|
+
for (const sha of candidates) {
|
|
780
|
+
try {
|
|
781
|
+
let objectType;
|
|
782
|
+
try {
|
|
783
|
+
({ stdout: objectType } = await exec("git", ["cat-file", "-t", sha], {
|
|
784
|
+
cwd: workspacePath
|
|
785
|
+
}));
|
|
786
|
+
} catch {
|
|
787
|
+
await exec("git", ["fetch", "--quiet", "origin", sha], {
|
|
788
|
+
cwd: workspacePath
|
|
789
|
+
});
|
|
790
|
+
({ stdout: objectType } = await exec("git", ["cat-file", "-t", sha], {
|
|
791
|
+
cwd: workspacePath
|
|
792
|
+
}));
|
|
793
|
+
}
|
|
794
|
+
if (objectType.trim() !== "commit") throw new Error("not a commit");
|
|
795
|
+
try {
|
|
796
|
+
await exec("git", ["merge-base", "--is-ancestor", sha, "HEAD"], {
|
|
797
|
+
cwd: workspacePath
|
|
798
|
+
});
|
|
799
|
+
return { sha, mode: "incremental" };
|
|
800
|
+
} catch {
|
|
801
|
+
logger.info(
|
|
802
|
+
`Previous review SHA ${sha.slice(0, 8)} is not an ancestor; using snapshot delta`
|
|
803
|
+
);
|
|
804
|
+
return { sha, mode: "snapshot" };
|
|
805
|
+
}
|
|
806
|
+
} catch {
|
|
807
|
+
logger.info(
|
|
808
|
+
`Skipping previous review SHA ${sha.slice(0, 8)}; commit is unavailable`
|
|
809
|
+
);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
return null;
|
|
813
|
+
}
|
|
814
|
+
function getDiffStats(diff) {
|
|
815
|
+
let files = 0;
|
|
816
|
+
let additions = 0;
|
|
817
|
+
let deletions = 0;
|
|
818
|
+
for (const line of diff.split("\n")) {
|
|
819
|
+
if (line.startsWith("diff --git ")) files++;
|
|
820
|
+
else if (line.startsWith("+") && !line.startsWith("+++")) additions++;
|
|
821
|
+
else if (line.startsWith("-") && !line.startsWith("---")) deletions++;
|
|
822
|
+
}
|
|
823
|
+
return {
|
|
824
|
+
files,
|
|
825
|
+
additions,
|
|
826
|
+
deletions,
|
|
827
|
+
bytes: Buffer.byteLength(diff, "utf-8")
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
function getChangedFiles(diff) {
|
|
831
|
+
const files = [];
|
|
832
|
+
for (const match of diff.matchAll(/^diff --git a\/(.*?) b\/(.*?)$/gm)) {
|
|
833
|
+
files.push(match[2]);
|
|
834
|
+
}
|
|
835
|
+
return [...new Set(files)];
|
|
836
|
+
}
|
|
837
|
+
var DIFF_SKIP_PATTERNS = [
|
|
838
|
+
/(?:^|\/)testdata\//,
|
|
839
|
+
/(?:^|\/)(?:package-lock\.json|yarn\.lock|pnpm-lock\.yaml|go\.sum|Cargo\.lock|poetry\.lock|Gemfile\.lock|composer\.lock)$/,
|
|
840
|
+
/\.mdx?$/
|
|
841
|
+
];
|
|
842
|
+
function filterEmbeddedDiff(rawDiff) {
|
|
843
|
+
const skippedFiles = [];
|
|
844
|
+
const sections = rawDiff.split(/(?=^diff --git )/m);
|
|
845
|
+
const kept = [];
|
|
846
|
+
for (const section of sections) {
|
|
847
|
+
const match = section.match(/^diff --git a\/(.*?) b\//);
|
|
848
|
+
if (!match) {
|
|
849
|
+
kept.push(section);
|
|
850
|
+
continue;
|
|
851
|
+
}
|
|
852
|
+
const filePath = match[1];
|
|
853
|
+
if (DIFF_SKIP_PATTERNS.some((pattern) => pattern.test(filePath))) {
|
|
854
|
+
skippedFiles.push(filePath);
|
|
855
|
+
} else {
|
|
856
|
+
kept.push(section);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
return { filtered: kept.join(""), skippedFiles };
|
|
860
|
+
}
|
|
861
|
+
|
|
34
862
|
// src/prompt.ts
|
|
35
863
|
function buildPrReviewPrompt(opts) {
|
|
36
864
|
const {
|
|
@@ -44,8 +872,12 @@ function buildPrReviewPrompt(opts) {
|
|
|
44
872
|
reviewDiffMode,
|
|
45
873
|
changedFiles = [],
|
|
46
874
|
localMode = false,
|
|
47
|
-
singleTurn = false
|
|
875
|
+
singleTurn = false,
|
|
876
|
+
findToolAvailable = false
|
|
48
877
|
} = opts;
|
|
878
|
+
const rebasedGitlabReview = platform === "gitlab" && reviewDiffMode === "snapshot";
|
|
879
|
+
const hasPreviousReviewDelta = Boolean(previousReviewSha && !rebasedGitlabReview);
|
|
880
|
+
const previousReviewShaText = previousReviewSha ?? "";
|
|
49
881
|
let templateText;
|
|
50
882
|
try {
|
|
51
883
|
templateText = readFileSync(getTemplatePath("review-task.md"), "utf-8");
|
|
@@ -62,44 +894,36 @@ function buildPrReviewPrompt(opts) {
|
|
|
62
894
|
if (previousReviewSha && !/^[a-f0-9]{40}$/.test(previousReviewSha)) {
|
|
63
895
|
throw new Error(`Invalid previous review SHA: ${previousReviewSha}`);
|
|
64
896
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if (diffBaseSha) {
|
|
80
|
-
prDiffCmd = `git --no-pager diff ${diffBaseSha} HEAD --name-only`;
|
|
81
|
-
gitDiffCmd = `git --no-pager diff ${diffBaseSha} HEAD`;
|
|
82
|
-
logger.info(`Using GitLab CI_MERGE_REQUEST_DIFF_BASE_SHA: ${diffBaseSha.slice(0, 8)}`);
|
|
83
|
-
} else {
|
|
84
|
-
prDiffCmd = `git --no-pager diff origin/${targetBranch}...HEAD --name-only`;
|
|
85
|
-
gitDiffCmd = `git --no-pager diff origin/${targetBranch}...HEAD`;
|
|
86
|
-
}
|
|
897
|
+
const diffArgs = getReviewDiffArgs({
|
|
898
|
+
platform,
|
|
899
|
+
targetBranch,
|
|
900
|
+
diffBaseSha,
|
|
901
|
+
previousReviewSha,
|
|
902
|
+
reviewDiffMode,
|
|
903
|
+
localMode
|
|
904
|
+
});
|
|
905
|
+
const gitDiffCmd = `git ${diffArgs.join(" ")}`;
|
|
906
|
+
const prDiffCmd = `${gitDiffCmd} --name-only`;
|
|
907
|
+
if (hasPreviousReviewDelta) {
|
|
908
|
+
logger.info(`${reviewDiffMode === "snapshot" ? "Snapshot" : "Incremental"} review: diffing from ${previousReviewSha?.slice(0, 8)} to HEAD`);
|
|
909
|
+
} else if (rebasedGitlabReview) {
|
|
910
|
+
logger.info("Rebased GitLab review: diffing from the current MR base to HEAD");
|
|
87
911
|
}
|
|
88
912
|
let diffExplanation;
|
|
89
|
-
if (
|
|
90
|
-
diffExplanation = reviewDiffMode === "snapshot" ? `**Snapshot delta mode**: The MR history was rewritten. This directly compares the last reviewed snapshot (commit \`${
|
|
913
|
+
if (hasPreviousReviewDelta) {
|
|
914
|
+
diffExplanation = reviewDiffMode === "snapshot" ? `**Snapshot delta mode**: The MR history was rewritten. This directly compares the last reviewed snapshot (commit \`${previousReviewShaText.slice(0, 8)}\`) with the current HEAD; it does not imply ancestry.` : `**Incremental mode**: Showing only changes since the last hodor review (commit \`${previousReviewShaText.slice(0, 8)}\`).`;
|
|
91
915
|
} else if (diffBaseSha) {
|
|
92
|
-
diffExplanation = `**GitLab CI Advantage**: This uses
|
|
916
|
+
diffExplanation = `**GitLab CI Advantage**: This uses the merge base resolved from the current target branch, which matches the current GitLab MR diff after force pushes and rebases.`;
|
|
93
917
|
} else {
|
|
94
918
|
diffExplanation = `**Three-dot syntax** shows ONLY changes introduced on the source branch, excluding changes already on \`${targetBranch}\`.`;
|
|
95
919
|
}
|
|
96
920
|
const { contextSection, notesSection, reminderSection } = buildMrSections(mrMetadata);
|
|
97
921
|
const oneTurn = singleTurn && Boolean(embeddedDiff);
|
|
98
922
|
let incrementalSection = "";
|
|
99
|
-
if (
|
|
923
|
+
if (hasPreviousReviewDelta) {
|
|
100
924
|
incrementalSection = `## ${reviewDiffMode === "snapshot" ? "Snapshot Delta" : "Incremental Review"} Mode
|
|
101
925
|
|
|
102
|
-
This is a follow-up review. A previous hodor review was done at commit \`${
|
|
926
|
+
This is a follow-up review. A previous hodor review was done at commit \`${previousReviewShaText.slice(0, 8)}\`. ` + (reviewDiffMode === "snapshot" ? "The branch history was rewritten, so the diff below compares that reviewed snapshot directly with the current HEAD. " : "The diff below shows ONLY changes since that review. ") + "Your job is to review that delta, not the whole MR again.\n\nRules for incremental reviews:\n1. Only report findings introduced or still affected by the new delta.\n2. Do not re-report issues that are already mentioned in existing notes unless the new delta changes the same code and the issue remains newly relevant.\n3. If the delta is small and self-contained, decide from the embedded diff and submit the review without broad repository exploration.\n" + (oneTurn ? "4. No file-inspection tools are available; if a mechanical change like a route/path/string rename leaves a compatibility question you cannot settle from the diff, do not report it.\n" : "4. For mechanical changes like route/path/string renames, verify the direct call sites or tests only when the diff itself leaves a concrete compatibility question.\n") + "5. If the delta does not produce a qualifying finding under the selected review instructions, submit no findings.\n\n";
|
|
103
927
|
}
|
|
104
928
|
let embeddedDiffSection;
|
|
105
929
|
let diffFetchInstructions;
|
|
@@ -127,7 +951,7 @@ ${changedFiles.map((file) => `- \`${file}\``).join("\n")}
|
|
|
127
951
|
startInstruction = "Analyze the diff above and call `submit_review` now, in this turn.";
|
|
128
952
|
} else {
|
|
129
953
|
reviewProcessSection = "## Review Process\n\n1. Analyze the embedded diff above thoroughly\n2. Use `grep` to search for patterns when needed\n3. Use bounded line-range reads when surrounding context is essential; avoid reading entire large files\n4. Do not repeat a diff, grep, or read operation whose result is already in context\n5. Submit your review using `submit_review`\n";
|
|
130
|
-
startInstruction =
|
|
954
|
+
startInstruction = hasPreviousReviewDelta ? "Analyze only the incremental diff provided above. If it is self-contained, submit your review without extra tool calls." : "Analyze the diff provided above, then submit your review using `submit_review`.";
|
|
131
955
|
}
|
|
132
956
|
} else {
|
|
133
957
|
embeddedDiffSection = "";
|
|
@@ -157,12 +981,13 @@ ${changedFiles.map((file) => `- \`${file}\``).join("\n")}
|
|
|
157
981
|
}
|
|
158
982
|
runtimeToolsSection = oneTurn ? "## Runtime Tools\n\n- `submit_review` submits the completed review. It is the only tool available for this review.\n" : `## Runtime Tools
|
|
159
983
|
|
|
984
|
+
This list is exhaustive. No other tool is available.
|
|
985
|
+
|
|
160
986
|
- \`${prDiffCmd}\` lists the changed files when a diff is not embedded.
|
|
161
987
|
- \`${gitDiffCmd} -- path/to/file\` shows the delta for one changed file.
|
|
162
988
|
- \`read\` provides bounded surrounding context.
|
|
163
989
|
- \`grep\` searches for directly relevant code and contracts.
|
|
164
|
-
-
|
|
165
|
-
`;
|
|
990
|
+
` + (findToolAvailable ? "- `find` locates files by glob when the path is unknown.\n" : "") + "- `ls` lists the entries of one directory.\n- `bash` runs the git diff commands above and other read-only shell inspection.\n- `submit_review` submits the completed review.\n";
|
|
166
991
|
return templateText.replace(/\{pr_url\}/g, prUrl).replace(/\{pr_diff_cmd\}/g, prDiffCmd).replace(/\{git_diff_cmd\}/g, gitDiffCmd).replace(/\{mr_context_section\}/g, contextSection).replace(/\{mr_notes_section\}/g, notesSection).replace(/\{mr_reminder_section\}/g, reminderSection).replace(/\{incremental_section\}/g, incrementalSection).replace(/\{embedded_diff_section\}/g, embeddedDiffSection).replace(/\{diff_fetch_instructions\}/g, diffFetchInstructions).replace(/\{runtime_tools_section\}/g, runtimeToolsSection).replace(/\{review_process_section\}/g, reviewProcessSection).replace(/\{start_instruction\}/g, startInstruction);
|
|
167
992
|
}
|
|
168
993
|
function buildMrSections(mrMetadata) {
|
|
@@ -314,6 +1139,31 @@ function parseModelString(model) {
|
|
|
314
1139
|
}
|
|
315
1140
|
return { provider: "anthropic", modelId: trimmed };
|
|
316
1141
|
}
|
|
1142
|
+
var BEDROCK_REGIONAL_PREFIXES = ["global", "us", "eu", "apac", "in", "jp", "au", "ca"];
|
|
1143
|
+
function stripBedrockRegionalPrefix(modelId) {
|
|
1144
|
+
const dot = modelId.indexOf(".");
|
|
1145
|
+
if (dot <= 0) return null;
|
|
1146
|
+
const prefix = modelId.slice(0, dot).toLowerCase();
|
|
1147
|
+
if (!BEDROCK_REGIONAL_PREFIXES.includes(prefix)) return null;
|
|
1148
|
+
return modelId.slice(dot + 1);
|
|
1149
|
+
}
|
|
1150
|
+
function isOpenAiBedrockModel(model) {
|
|
1151
|
+
if (model.provider !== "amazon-bedrock") return false;
|
|
1152
|
+
return [model.id, model.name].filter((value) => Boolean(value)).some((value) => value.toLowerCase().includes("openai"));
|
|
1153
|
+
}
|
|
1154
|
+
function addOpenAiBedrockReasoning(payload, effort) {
|
|
1155
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return payload;
|
|
1156
|
+
const request = payload;
|
|
1157
|
+
const existingFields = request.additionalModelRequestFields;
|
|
1158
|
+
const additionalModelRequestFields = existingFields && typeof existingFields === "object" && !Array.isArray(existingFields) ? existingFields : {};
|
|
1159
|
+
return {
|
|
1160
|
+
...request,
|
|
1161
|
+
additionalModelRequestFields: {
|
|
1162
|
+
...additionalModelRequestFields,
|
|
1163
|
+
reasoning: { effort }
|
|
1164
|
+
}
|
|
1165
|
+
};
|
|
1166
|
+
}
|
|
317
1167
|
function extractBedrockArnRegion(arn) {
|
|
318
1168
|
const parts = arn.split(":");
|
|
319
1169
|
return parts.length >= 4 && parts[3] ? parts[3] : "us-east-1";
|
|
@@ -413,7 +1263,7 @@ function getApiKey(model) {
|
|
|
413
1263
|
}
|
|
414
1264
|
|
|
415
1265
|
// src/metrics.ts
|
|
416
|
-
import
|
|
1266
|
+
import chalk2 from "chalk";
|
|
417
1267
|
function tok(value) {
|
|
418
1268
|
if (value >= 1e6) return `${(value / 1e6).toFixed(2)}M`;
|
|
419
1269
|
if (value >= 1e3) return `${(value / 1e3).toFixed(1)}K`;
|
|
@@ -447,9 +1297,9 @@ function formatMetricsMarkdown(metrics) {
|
|
|
447
1297
|
return lines.join("\n");
|
|
448
1298
|
}
|
|
449
1299
|
function printMetrics(metrics, stream = process.stderr) {
|
|
450
|
-
const dim =
|
|
451
|
-
const bold =
|
|
452
|
-
const cyan =
|
|
1300
|
+
const dim = chalk2.dim;
|
|
1301
|
+
const bold = chalk2.bold;
|
|
1302
|
+
const cyan = chalk2.cyan;
|
|
453
1303
|
const write = (line) => stream.write(line + "\n");
|
|
454
1304
|
write("");
|
|
455
1305
|
write(dim("\u2500".repeat(50)));
|
|
@@ -655,7 +1505,7 @@ The selected review instructions and additional instructions are reviewer policy
|
|
|
655
1505
|
|
|
656
1506
|
## Read-Only Review
|
|
657
1507
|
|
|
658
|
-
Analyze only the changed delta and report findings at changed-line locations. Do not modify or create files, commit, install dependencies, run package managers, or write plans or agent instructions. Do not review unrelated files or report issues that exist only because the branch lacks changes already present on the target branch.
|
|
1508
|
+
Analyze only the changed delta and report findings at changed-line locations. Do not modify or create files, commit, install dependencies, run package managers, or write plans or agent instructions. Do not build, compile, run tests, or run linters or formatters. The review environment is a read-only inspection container: language toolchains, compilers, and test runners are not installed, and their absence is never a finding. Establish every finding by reading the delta and the code around it. Do not review unrelated files or report issues that exist only because the branch lacks changes already present on the target branch.
|
|
659
1509
|
|
|
660
1510
|
## Priority Mapping
|
|
661
1511
|
|
|
@@ -668,7 +1518,7 @@ Every finding title begins with its matching [P0], [P1], [P2], or [P3] tag, and
|
|
|
668
1518
|
|
|
669
1519
|
## Tool Discipline and Efficiency
|
|
670
1520
|
|
|
671
|
-
Use available tools only when they establish evidence for the changed delta. Start with the runtime task's supplied diff or changed-file command. Use bounded reads and targeted searches for directly relevant context; avoid redundant reads, searches, and diffs. Scale investigation to the delta size.
|
|
1521
|
+
Use available tools only when they establish evidence for the changed delta. Start with the runtime task's supplied diff or changed-file command. Use bounded reads and targeted searches for directly relevant context; avoid redundant reads, searches, and diffs. Never repeat a read, search, or diff whose result is already in context, and prefer a scoped diff or bounded read over one that returns the whole change or the whole file. Scale investigation to the delta size. The runtime task's tool list is exhaustive: do not call a tool it does not name, and do not probe for executables through the shell to discover what else exists. Do not substitute shell commands for supplied file-search tools.
|
|
672
1522
|
|
|
673
1523
|
## Submission
|
|
674
1524
|
|
|
@@ -820,9 +1670,101 @@ function parsePositiveNumber(raw, kind, prUrl, segment) {
|
|
|
820
1670
|
return number;
|
|
821
1671
|
}
|
|
822
1672
|
|
|
823
|
-
// src/
|
|
1673
|
+
// src/review-state.ts
|
|
824
1674
|
import { createHash } from "crypto";
|
|
825
1675
|
|
|
1676
|
+
// src/utils/path.ts
|
|
1677
|
+
function relativizeWorkspacePath(absolutePath, workspacePrefix) {
|
|
1678
|
+
let filePath = absolutePath;
|
|
1679
|
+
const prefix = workspacePrefix ?? process.env.CI_PROJECT_DIR;
|
|
1680
|
+
if (prefix) {
|
|
1681
|
+
const trimmed = prefix.replace(/\/+$/, "");
|
|
1682
|
+
if (filePath.startsWith(`${trimmed}/`)) {
|
|
1683
|
+
return filePath.slice(trimmed.length + 1);
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
const buildsMatch = filePath.match(/\/builds\/[^/]+\/[^/]+\/(.+)/);
|
|
1687
|
+
if (buildsMatch) return buildsMatch[1];
|
|
1688
|
+
if (filePath.includes("/workspace/")) {
|
|
1689
|
+
return filePath.slice(filePath.indexOf("/workspace/") + "/workspace/".length);
|
|
1690
|
+
}
|
|
1691
|
+
const stripped = filePath.replace(/^.*\/hodor-review-[^/]+\//, "");
|
|
1692
|
+
return stripped !== filePath ? stripped : filePath;
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
// src/review-state.ts
|
|
1696
|
+
var FINDING_MARKER_RE = /<!--\s*hodor:finding:([a-f0-9]{64})\s*-->/i;
|
|
1697
|
+
var FINDING_TITLE_RE = /^\*\*(\[P([0-3])\]\s+.+)\*\*\s*$/m;
|
|
1698
|
+
function getFindingFingerprint(finding, workspacePath) {
|
|
1699
|
+
const path = relativizeWorkspacePath(
|
|
1700
|
+
finding.code_location.absolute_file_path,
|
|
1701
|
+
workspacePath ?? void 0
|
|
1702
|
+
);
|
|
1703
|
+
const title = finding.title.replace(/^\[P[0-3]\]\s*/, "").trim().toLowerCase();
|
|
1704
|
+
return createHash("sha256").update(`${path}
|
|
1705
|
+
${title}`).digest("hex");
|
|
1706
|
+
}
|
|
1707
|
+
function getDiscussionFingerprint(body) {
|
|
1708
|
+
return body.match(FINDING_MARKER_RE)?.[1]?.toLowerCase() ?? null;
|
|
1709
|
+
}
|
|
1710
|
+
function parseDiscussionFinding(discussion) {
|
|
1711
|
+
const fingerprint = getDiscussionFingerprint(discussion.body);
|
|
1712
|
+
const titleMatch = discussion.body.match(FINDING_TITLE_RE);
|
|
1713
|
+
if (!fingerprint || !titleMatch) return null;
|
|
1714
|
+
const titleLineEnd = discussion.body.indexOf("\n", titleMatch.index ?? 0);
|
|
1715
|
+
const remainder = titleLineEnd >= 0 ? discussion.body.slice(titleLineEnd + 1).trim() : "";
|
|
1716
|
+
const suggestionStart = remainder.indexOf("\n\n```suggestion");
|
|
1717
|
+
const body = (suggestionStart >= 0 ? remainder.slice(0, suggestionStart) : remainder).trim();
|
|
1718
|
+
const priority = Number(titleMatch[2]);
|
|
1719
|
+
return {
|
|
1720
|
+
fingerprint,
|
|
1721
|
+
title: titleMatch[1],
|
|
1722
|
+
body,
|
|
1723
|
+
priority,
|
|
1724
|
+
filePath: discussion.filePath,
|
|
1725
|
+
lineRange: discussion.line == null ? void 0 : { start: discussion.line, end: discussion.line }
|
|
1726
|
+
};
|
|
1727
|
+
}
|
|
1728
|
+
function mergeReviewStateFindings(currentFindings, discussions, workspacePath, options = {}) {
|
|
1729
|
+
const { includeExisting = true, suppressResolvedCurrent = false } = options;
|
|
1730
|
+
const merged = /* @__PURE__ */ new Map();
|
|
1731
|
+
const openFingerprints = /* @__PURE__ */ new Set();
|
|
1732
|
+
const resolvedFingerprints = /* @__PURE__ */ new Set();
|
|
1733
|
+
for (const discussion of discussions) {
|
|
1734
|
+
const fingerprint = getDiscussionFingerprint(discussion.body);
|
|
1735
|
+
if (!fingerprint) continue;
|
|
1736
|
+
if (discussion.resolved) resolvedFingerprints.add(fingerprint);
|
|
1737
|
+
else openFingerprints.add(fingerprint);
|
|
1738
|
+
}
|
|
1739
|
+
for (const finding of currentFindings) {
|
|
1740
|
+
const fingerprint = getFindingFingerprint(finding, workspacePath);
|
|
1741
|
+
if (suppressResolvedCurrent && resolvedFingerprints.has(fingerprint) && !openFingerprints.has(fingerprint)) {
|
|
1742
|
+
continue;
|
|
1743
|
+
}
|
|
1744
|
+
merged.set(fingerprint, {
|
|
1745
|
+
fingerprint,
|
|
1746
|
+
title: finding.title,
|
|
1747
|
+
body: finding.body,
|
|
1748
|
+
priority: finding.priority,
|
|
1749
|
+
filePath: relativizeWorkspacePath(
|
|
1750
|
+
finding.code_location.absolute_file_path,
|
|
1751
|
+
workspacePath ?? void 0
|
|
1752
|
+
),
|
|
1753
|
+
lineRange: finding.code_location.line_range
|
|
1754
|
+
});
|
|
1755
|
+
}
|
|
1756
|
+
if (includeExisting) {
|
|
1757
|
+
for (const discussion of discussions) {
|
|
1758
|
+
if (discussion.resolved) continue;
|
|
1759
|
+
const finding = parseDiscussionFinding(discussion);
|
|
1760
|
+
if (finding && !merged.has(finding.fingerprint)) {
|
|
1761
|
+
merged.set(finding.fingerprint, finding);
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
return [...merged.values()];
|
|
1766
|
+
}
|
|
1767
|
+
|
|
826
1768
|
// src/gitea.ts
|
|
827
1769
|
var GiteaAPIError = class extends Error {
|
|
828
1770
|
constructor(message) {
|
|
@@ -830,7 +1772,7 @@ var GiteaAPIError = class extends Error {
|
|
|
830
1772
|
this.name = "GiteaAPIError";
|
|
831
1773
|
}
|
|
832
1774
|
};
|
|
833
|
-
function
|
|
1775
|
+
function normalizeBaseUrl2(host) {
|
|
834
1776
|
const candidate = host || process.env.GITEA_HOST || process.env.FORGEJO_HOST;
|
|
835
1777
|
if (!candidate) {
|
|
836
1778
|
throw new GiteaAPIError(
|
|
@@ -856,7 +1798,7 @@ function requireGiteaToken() {
|
|
|
856
1798
|
return token;
|
|
857
1799
|
}
|
|
858
1800
|
async function giteaFetch(host, path, options) {
|
|
859
|
-
const baseUrl =
|
|
1801
|
+
const baseUrl = normalizeBaseUrl2(host);
|
|
860
1802
|
const url = `${baseUrl}/api/v1/${path}`;
|
|
861
1803
|
const token = giteaToken();
|
|
862
1804
|
const headers = {
|
|
@@ -986,23 +1928,10 @@ async function postGiteaPrComment(owner, repo, prNumber, body, host) {
|
|
|
986
1928
|
}
|
|
987
1929
|
|
|
988
1930
|
// src/publisher.ts
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
const path = relativizeWorkspacePath(
|
|
992
|
-
finding.code_location.absolute_file_path,
|
|
993
|
-
workspacePath ?? void 0
|
|
994
|
-
);
|
|
995
|
-
const title = finding.title.replace(/^\[P[0-3]\]\s*/, "").trim().toLowerCase();
|
|
996
|
-
return createHash("sha256").update(`${path}
|
|
997
|
-
${title}`).digest("hex");
|
|
998
|
-
}
|
|
999
|
-
function getDiscussionFingerprint(body) {
|
|
1000
|
-
return body.match(FINDING_MARKER_RE)?.[1]?.toLowerCase() ?? null;
|
|
1001
|
-
}
|
|
1002
|
-
async function postGitlabReviewCommitStatus(parsed, review, diffRefs) {
|
|
1003
|
-
const blocking = review.findings.filter((finding) => finding.priority <= 1).length;
|
|
1931
|
+
async function postGitlabReviewCommitStatus(parsed, findings, diffRefs) {
|
|
1932
|
+
const blocking = findings.filter((finding) => finding.priority <= 1).length;
|
|
1004
1933
|
const state = blocking > 0 ? "failed" : "success";
|
|
1005
|
-
const description = blocking > 0 ? `${blocking} blocking issue(s) found` :
|
|
1934
|
+
const description = blocking > 0 ? `${blocking} blocking issue(s) found` : findings.length > 0 ? `${findings.length} non-blocking issue(s)` : "No issues found";
|
|
1006
1935
|
await postGitlabCommitStatus(
|
|
1007
1936
|
parsed.owner,
|
|
1008
1937
|
parsed.repo,
|
|
@@ -1012,23 +1941,38 @@ async function postGitlabReviewCommitStatus(parsed, review, diffRefs) {
|
|
|
1012
1941
|
{ description }
|
|
1013
1942
|
);
|
|
1014
1943
|
}
|
|
1944
|
+
function appendReviewDetails(body, model, metricsFooter) {
|
|
1945
|
+
if (!model && !metricsFooter) return body;
|
|
1946
|
+
const details = ["<details>", "<summary>Review details</summary>", ""];
|
|
1947
|
+
if (model) details.push(`- Model: \`${model}\``);
|
|
1948
|
+
if (metricsFooter) {
|
|
1949
|
+
if (model) details.push("");
|
|
1950
|
+
details.push(metricsFooter);
|
|
1951
|
+
}
|
|
1952
|
+
details.push("", "</details>");
|
|
1953
|
+
return `${body.trimEnd()}
|
|
1954
|
+
|
|
1955
|
+
${details.join("\n")}
|
|
1956
|
+
`;
|
|
1957
|
+
}
|
|
1015
1958
|
async function postReviewComment(opts) {
|
|
1016
1959
|
const { prUrl, reviewText, model, metricsFooter, headSha, cacheMarker } = opts;
|
|
1017
1960
|
const platform = detectPlatform(prUrl);
|
|
1018
1961
|
const parsed = parsePrUrl(prUrl);
|
|
1019
1962
|
let body = reviewText;
|
|
1963
|
+
if (platform === "gitlab" && !body.includes(HODOR_SUMMARY_MARKER)) {
|
|
1964
|
+
body = body.replace(
|
|
1965
|
+
HODOR_REVIEW_MARKER,
|
|
1966
|
+
`${HODOR_REVIEW_MARKER}
|
|
1967
|
+
${HODOR_SUMMARY_MARKER}`
|
|
1968
|
+
);
|
|
1969
|
+
}
|
|
1020
1970
|
if (headSha) body = `<!-- hodor:sha:${headSha} -->
|
|
1021
1971
|
${body}`;
|
|
1022
1972
|
if (cacheMarker) body = body.replace("\n", `
|
|
1023
1973
|
${cacheMarker}
|
|
1024
1974
|
`);
|
|
1025
|
-
|
|
1026
|
-
---
|
|
1027
|
-
|
|
1028
|
-
Review generated by Hodor (model: \`${model}\`)`;
|
|
1029
|
-
if (metricsFooter) body += `
|
|
1030
|
-
|
|
1031
|
-
${metricsFooter}`;
|
|
1975
|
+
body = appendReviewDetails(body, model, metricsFooter);
|
|
1032
1976
|
try {
|
|
1033
1977
|
if (platform === "github") {
|
|
1034
1978
|
await exec("gh", [
|
|
@@ -1053,7 +1997,7 @@ ${metricsFooter}`;
|
|
|
1053
1997
|
);
|
|
1054
1998
|
return { success: true, platform, prNumber: parsed.prNumber };
|
|
1055
1999
|
}
|
|
1056
|
-
await
|
|
2000
|
+
await upsertGitlabMrSummary(
|
|
1057
2001
|
parsed.owner,
|
|
1058
2002
|
parsed.repo,
|
|
1059
2003
|
parsed.prNumber,
|
|
@@ -1084,10 +2028,13 @@ async function postReviewStructured(opts) {
|
|
|
1084
2028
|
workspacePath,
|
|
1085
2029
|
reconcileDiscussions = false,
|
|
1086
2030
|
cacheMarker,
|
|
1087
|
-
skipSummary = false
|
|
2031
|
+
skipSummary = false,
|
|
2032
|
+
existingDiscussions,
|
|
2033
|
+
skipInline = false,
|
|
2034
|
+
reviewMode
|
|
1088
2035
|
} = opts;
|
|
1089
2036
|
const platform = detectPlatform(prUrl);
|
|
1090
|
-
if (platform !== "gitlab"
|
|
2037
|
+
if (platform !== "gitlab") {
|
|
1091
2038
|
return postReviewComment({
|
|
1092
2039
|
prUrl,
|
|
1093
2040
|
reviewText: renderMarkdown(review),
|
|
@@ -1120,13 +2067,17 @@ async function postReviewStructured(opts) {
|
|
|
1120
2067
|
});
|
|
1121
2068
|
}
|
|
1122
2069
|
const existingByFingerprint = /* @__PURE__ */ new Map();
|
|
2070
|
+
let discussions = existingDiscussions ?? [];
|
|
2071
|
+
let discussionListingFailed = false;
|
|
1123
2072
|
try {
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
2073
|
+
if (!existingDiscussions) {
|
|
2074
|
+
discussions = await listHodorDiscussions(
|
|
2075
|
+
parsed.owner,
|
|
2076
|
+
parsed.repo,
|
|
2077
|
+
parsed.prNumber,
|
|
2078
|
+
parsed.host
|
|
2079
|
+
);
|
|
2080
|
+
}
|
|
1130
2081
|
for (const discussion of discussions) {
|
|
1131
2082
|
if (discussion.resolved) continue;
|
|
1132
2083
|
const fingerprint = getDiscussionFingerprint(discussion.body);
|
|
@@ -1136,63 +2087,77 @@ async function postReviewStructured(opts) {
|
|
|
1136
2087
|
existingByFingerprint.set(fingerprint, ids);
|
|
1137
2088
|
}
|
|
1138
2089
|
} catch (error) {
|
|
2090
|
+
discussionListingFailed = true;
|
|
1139
2091
|
const message = error instanceof Error ? error.message : String(error);
|
|
1140
|
-
if (reconcileDiscussions) {
|
|
2092
|
+
if (reconcileDiscussions || commitStatus) {
|
|
1141
2093
|
errors.push(`discussion listing: ${message}`);
|
|
1142
2094
|
}
|
|
1143
|
-
logger.warn(`Failed to list open Hodor discussions for
|
|
2095
|
+
logger.warn(`Failed to list open Hodor discussions for review state: ${message}`);
|
|
1144
2096
|
}
|
|
2097
|
+
const reviewFindings = mergeReviewStateFindings(
|
|
2098
|
+
review.findings,
|
|
2099
|
+
discussions,
|
|
2100
|
+
workspacePath,
|
|
2101
|
+
{
|
|
2102
|
+
includeExisting: !reconcileDiscussions,
|
|
2103
|
+
suppressResolvedCurrent: skipInline
|
|
2104
|
+
}
|
|
2105
|
+
);
|
|
1145
2106
|
let inlineCreated = 0;
|
|
1146
2107
|
let inlineFailed = 0;
|
|
1147
2108
|
let inlineDeduplicated = 0;
|
|
1148
2109
|
const draftNoteIds = [];
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
2110
|
+
const failedFindings = [];
|
|
2111
|
+
if (reviewStyle !== "summary" && !skipInline) {
|
|
2112
|
+
for (const finding of review.findings) {
|
|
2113
|
+
const fingerprint = getFindingFingerprint(finding, workspacePath);
|
|
2114
|
+
if (existingByFingerprint.has(fingerprint)) {
|
|
2115
|
+
inlineDeduplicated++;
|
|
2116
|
+
continue;
|
|
2117
|
+
}
|
|
2118
|
+
const relPath = relativizeWorkspacePath(
|
|
2119
|
+
finding.code_location.absolute_file_path,
|
|
2120
|
+
workspacePath ?? void 0
|
|
2121
|
+
);
|
|
2122
|
+
const title = /^\[P[0-3]\]/.test(finding.title) ? finding.title : `[P${finding.priority}] ${finding.title}`;
|
|
2123
|
+
let body = `${HODOR_REVIEW_MARKER}
|
|
1161
2124
|
<!-- hodor:finding:${fingerprint} -->
|
|
1162
2125
|
**${title}**
|
|
1163
2126
|
|
|
1164
2127
|
${finding.body}`;
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
2128
|
+
if (finding.suggestion) {
|
|
2129
|
+
const { start, end } = finding.code_location.line_range;
|
|
2130
|
+
const span = Math.max(0, end - start);
|
|
2131
|
+
body += `
|
|
1169
2132
|
|
|
1170
2133
|
\`\`\`suggestion:-0+${span}
|
|
1171
2134
|
${finding.suggestion}
|
|
1172
2135
|
\`\`\``;
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
2136
|
+
}
|
|
2137
|
+
try {
|
|
2138
|
+
const draftNote = await createGitlabDraftNote(
|
|
2139
|
+
parsed.owner,
|
|
2140
|
+
parsed.repo,
|
|
2141
|
+
parsed.prNumber,
|
|
2142
|
+
body,
|
|
2143
|
+
parsed.host,
|
|
2144
|
+
{
|
|
2145
|
+
filePath: relPath,
|
|
2146
|
+
line: finding.code_location.line_range.start,
|
|
2147
|
+
diffRefs
|
|
2148
|
+
}
|
|
2149
|
+
);
|
|
2150
|
+
if (typeof draftNote.id === "number" || typeof draftNote.id === "string") {
|
|
2151
|
+
draftNoteIds.push(draftNote.id);
|
|
1185
2152
|
}
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
2153
|
+
inlineCreated++;
|
|
2154
|
+
} catch (error) {
|
|
2155
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2156
|
+
errors.push(`inline note for ${finding.title}: ${message}`);
|
|
2157
|
+
logger.warn(`Failed to create inline note for "${finding.title}": ${message}`);
|
|
2158
|
+
inlineFailed++;
|
|
2159
|
+
failedFindings.push(finding);
|
|
1189
2160
|
}
|
|
1190
|
-
inlineCreated++;
|
|
1191
|
-
} catch (error) {
|
|
1192
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
1193
|
-
errors.push(`inline note for ${finding.title}: ${message}`);
|
|
1194
|
-
logger.warn(`Failed to create inline note for "${finding.title}": ${message}`);
|
|
1195
|
-
inlineFailed++;
|
|
1196
2161
|
}
|
|
1197
2162
|
}
|
|
1198
2163
|
logger.info(
|
|
@@ -1239,22 +2204,24 @@ ${finding.suggestion}
|
|
|
1239
2204
|
}
|
|
1240
2205
|
}
|
|
1241
2206
|
let summaryPosted = false;
|
|
1242
|
-
if (!skipSummary && (reviewStyle === "hybrid" || review.findings.length === 0)) {
|
|
1243
|
-
|
|
2207
|
+
if (!skipSummary && (reviewStyle === "summary" || reviewStyle === "hybrid" || review.findings.length === 0 || failedFindings.length > 0)) {
|
|
2208
|
+
const fallbackFindings = reviewStyle === "summary" ? review.findings : failedFindings;
|
|
2209
|
+
let summaryBody = renderSummaryMarkdown(review, {
|
|
2210
|
+
openFindings: reviewFindings,
|
|
2211
|
+
fallbackFindings,
|
|
2212
|
+
fallbackHeading: reviewStyle === "summary" ? "Findings" : "Findings not posted inline",
|
|
2213
|
+
inlineCreated: reviewStyle === "summary" ? void 0 : inlineCreated,
|
|
2214
|
+
inlineDeduplicated: reviewStyle === "summary" ? void 0 : inlineDeduplicated,
|
|
2215
|
+
reviewMode
|
|
2216
|
+
});
|
|
1244
2217
|
if (headSha) summaryBody = `<!-- hodor:sha:${headSha} -->
|
|
1245
2218
|
${summaryBody}`;
|
|
1246
2219
|
if (cacheMarker) summaryBody = summaryBody.replace("\n", `
|
|
1247
2220
|
${cacheMarker}
|
|
1248
2221
|
`);
|
|
1249
|
-
|
|
1250
|
-
---
|
|
1251
|
-
|
|
1252
|
-
Review generated by Hodor (model: \`${model}\`)`;
|
|
1253
|
-
if (metricsFooter) summaryBody += `
|
|
1254
|
-
|
|
1255
|
-
${metricsFooter}`;
|
|
2222
|
+
summaryBody = appendReviewDetails(summaryBody, model, metricsFooter);
|
|
1256
2223
|
try {
|
|
1257
|
-
await
|
|
2224
|
+
await upsertGitlabMrSummary(
|
|
1258
2225
|
parsed.owner,
|
|
1259
2226
|
parsed.repo,
|
|
1260
2227
|
parsed.prNumber,
|
|
@@ -1265,13 +2232,13 @@ ${metricsFooter}`;
|
|
|
1265
2232
|
} catch (error) {
|
|
1266
2233
|
const message = error instanceof Error ? error.message : String(error);
|
|
1267
2234
|
errors.push(`summary comment: ${message}`);
|
|
1268
|
-
logger.warn(`Failed to
|
|
2235
|
+
logger.warn(`Failed to upsert summary comment: ${message}`);
|
|
1269
2236
|
}
|
|
1270
2237
|
}
|
|
1271
2238
|
let commitStatusPosted = false;
|
|
1272
|
-
if (commitStatus) {
|
|
2239
|
+
if (commitStatus && (!discussionListingFailed || reconcileDiscussions)) {
|
|
1273
2240
|
try {
|
|
1274
|
-
await postGitlabReviewCommitStatus(parsed,
|
|
2241
|
+
await postGitlabReviewCommitStatus(parsed, reviewFindings, diffRefs);
|
|
1275
2242
|
commitStatusPosted = true;
|
|
1276
2243
|
} catch (error) {
|
|
1277
2244
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -1280,7 +2247,7 @@ ${metricsFooter}`;
|
|
|
1280
2247
|
}
|
|
1281
2248
|
}
|
|
1282
2249
|
let reconciledDiscussions = 0;
|
|
1283
|
-
const baseDeliveryComplete = reviewStyle === "
|
|
2250
|
+
const baseDeliveryComplete = reviewStyle === "summary" ? summaryPosted || skipSummary : reviewStyle === "hybrid" ? (summaryPosted || skipSummary) && (inlineCreated === 0 || draftsPublished) : (inlineFailed === 0 || summaryPosted) && (inlineCreated === 0 || draftsPublished) && (review.findings.length > 0 || summaryPosted);
|
|
1284
2251
|
if (reconcileDiscussions && baseDeliveryComplete) {
|
|
1285
2252
|
const currentFingerprints = new Set(
|
|
1286
2253
|
review.findings.map((finding) => getFindingFingerprint(finding, workspacePath))
|
|
@@ -1313,13 +2280,15 @@ ${metricsFooter}`;
|
|
|
1313
2280
|
inlineFailed,
|
|
1314
2281
|
draftsPublished,
|
|
1315
2282
|
commitStatusPosted,
|
|
1316
|
-
reconciledDiscussions
|
|
2283
|
+
reconciledDiscussions,
|
|
2284
|
+
reviewStateComplete: !discussionListingFailed || reconcileDiscussions,
|
|
2285
|
+
reviewFindings
|
|
1317
2286
|
};
|
|
1318
2287
|
}
|
|
1319
2288
|
|
|
1320
2289
|
// src/agent.ts
|
|
1321
2290
|
import { existsSync } from "fs";
|
|
1322
|
-
import { join as
|
|
2291
|
+
import { join as join3 } from "path";
|
|
1323
2292
|
import {
|
|
1324
2293
|
createAgentSession,
|
|
1325
2294
|
DefaultResourceLoader,
|
|
@@ -1427,7 +2396,7 @@ function githubCommentsToNotes(comments) {
|
|
|
1427
2396
|
// src/workspace.ts
|
|
1428
2397
|
import { mkdtemp, rm } from "fs/promises";
|
|
1429
2398
|
import { tmpdir } from "os";
|
|
1430
|
-
import { join } from "path";
|
|
2399
|
+
import { join as join2 } from "path";
|
|
1431
2400
|
var WorkspaceError = class extends Error {
|
|
1432
2401
|
constructor(message) {
|
|
1433
2402
|
super(message);
|
|
@@ -1486,6 +2455,31 @@ async function detectCiWorkspace(owner, repo) {
|
|
|
1486
2455
|
}
|
|
1487
2456
|
return { path: null, targetBranch: null, diffBaseSha: null };
|
|
1488
2457
|
}
|
|
2458
|
+
async function resolveGitlabDiffBaseSha(workspace, targetBranch, fallbackSha) {
|
|
2459
|
+
if (!targetBranch) return fallbackSha;
|
|
2460
|
+
try {
|
|
2461
|
+
await exec("git", ["fetch", "--no-tags", "origin", targetBranch], { cwd: workspace });
|
|
2462
|
+
const { stdout } = await exec("git", ["merge-base", "HEAD", "FETCH_HEAD"], { cwd: workspace });
|
|
2463
|
+
const mergeBase = stdout.trim();
|
|
2464
|
+
if (mergeBase) {
|
|
2465
|
+
logger.info(`Calculated current GitLab MR diff base: ${mergeBase.slice(0, 8)}`);
|
|
2466
|
+
return mergeBase;
|
|
2467
|
+
}
|
|
2468
|
+
} catch (err) {
|
|
2469
|
+
logger.warn(`Could not calculate current GitLab MR diff base: ${err}`);
|
|
2470
|
+
}
|
|
2471
|
+
try {
|
|
2472
|
+
const { stdout } = await exec("git", ["merge-base", "HEAD", `origin/${targetBranch}`], { cwd: workspace });
|
|
2473
|
+
const mergeBase = stdout.trim();
|
|
2474
|
+
if (mergeBase) {
|
|
2475
|
+
logger.info(`Calculated GitLab MR diff base from origin/${targetBranch}: ${mergeBase.slice(0, 8)}`);
|
|
2476
|
+
return mergeBase;
|
|
2477
|
+
}
|
|
2478
|
+
} catch {
|
|
2479
|
+
}
|
|
2480
|
+
if (fallbackSha) logger.warn(`Falling back to CI_MERGE_REQUEST_DIFF_BASE_SHA: ${fallbackSha.slice(0, 8)}`);
|
|
2481
|
+
return fallbackSha;
|
|
2482
|
+
}
|
|
1489
2483
|
function normalizeGitRemotePath(remoteUrl) {
|
|
1490
2484
|
const trimmed = remoteUrl.trim().replace(/\.git$/, "");
|
|
1491
2485
|
try {
|
|
@@ -1700,16 +2694,23 @@ async function setupWorkspace(opts) {
|
|
|
1700
2694
|
try {
|
|
1701
2695
|
const ci = await detectCiWorkspace(owner, repo);
|
|
1702
2696
|
let detectedTargetBranch = ci.targetBranch;
|
|
1703
|
-
|
|
2697
|
+
let detectedDiffBaseSha = ci.diffBaseSha;
|
|
1704
2698
|
let workspace;
|
|
1705
2699
|
let isTemporary = false;
|
|
1706
2700
|
if (ci.path) {
|
|
1707
2701
|
workspace = ci.path;
|
|
2702
|
+
if (platform === "gitlab" && ci.targetBranch) {
|
|
2703
|
+
detectedDiffBaseSha = await resolveGitlabDiffBaseSha(
|
|
2704
|
+
workspace,
|
|
2705
|
+
ci.targetBranch,
|
|
2706
|
+
detectedDiffBaseSha
|
|
2707
|
+
);
|
|
2708
|
+
}
|
|
1708
2709
|
if (platform === "github" && !detectedTargetBranch) {
|
|
1709
2710
|
detectedTargetBranch = await getGithubBaseBranch(workspace, prNumber);
|
|
1710
2711
|
}
|
|
1711
2712
|
} else if (!workingDir) {
|
|
1712
|
-
workspace = await mkdtemp(
|
|
2713
|
+
workspace = await mkdtemp(join2(tmpdir(), "hodor-review-"));
|
|
1713
2714
|
isTemporary = true;
|
|
1714
2715
|
logger.info(`Created temporary workspace: ${workspace}`);
|
|
1715
2716
|
} else {
|
|
@@ -1954,118 +2955,6 @@ function resolveReviewLocations(review, opts) {
|
|
|
1954
2955
|
return { review: { ...review, findings }, stats };
|
|
1955
2956
|
}
|
|
1956
2957
|
|
|
1957
|
-
// src/review-diff.ts
|
|
1958
|
-
var HODOR_REVIEW_SHA_RE = /^\s*<!--\s*hodor:sha:([a-f0-9]{40})\s*-->/i;
|
|
1959
|
-
function getHodorReviewShaCandidates(notes) {
|
|
1960
|
-
if (!notes || notes.length === 0) return [];
|
|
1961
|
-
const candidates = [];
|
|
1962
|
-
for (const [index, note] of notes.entries()) {
|
|
1963
|
-
const match = note.body?.match(HODOR_REVIEW_SHA_RE);
|
|
1964
|
-
if (!match) continue;
|
|
1965
|
-
const createdAtMs = Date.parse(note.created_at ?? "");
|
|
1966
|
-
candidates.push({
|
|
1967
|
-
sha: match[1],
|
|
1968
|
-
createdAtMs: Number.isFinite(createdAtMs) ? createdAtMs : null,
|
|
1969
|
-
index
|
|
1970
|
-
});
|
|
1971
|
-
}
|
|
1972
|
-
candidates.sort((a, b) => {
|
|
1973
|
-
if (a.createdAtMs != null && b.createdAtMs != null && a.createdAtMs !== b.createdAtMs) {
|
|
1974
|
-
return b.createdAtMs - a.createdAtMs;
|
|
1975
|
-
}
|
|
1976
|
-
if (a.createdAtMs != null && b.createdAtMs == null) return -1;
|
|
1977
|
-
if (a.createdAtMs == null && b.createdAtMs != null) return 1;
|
|
1978
|
-
return a.index - b.index;
|
|
1979
|
-
});
|
|
1980
|
-
return [...new Set(candidates.map(({ sha }) => sha))];
|
|
1981
|
-
}
|
|
1982
|
-
async function findLatestReviewBase(notes, workspacePath) {
|
|
1983
|
-
const candidates = getHodorReviewShaCandidates(notes);
|
|
1984
|
-
if (candidates.length === 0) return null;
|
|
1985
|
-
logger.info(`Found ${candidates.length} previous Hodor review marker(s)`);
|
|
1986
|
-
for (const sha of candidates) {
|
|
1987
|
-
try {
|
|
1988
|
-
let objectType;
|
|
1989
|
-
try {
|
|
1990
|
-
({ stdout: objectType } = await exec("git", ["cat-file", "-t", sha], {
|
|
1991
|
-
cwd: workspacePath
|
|
1992
|
-
}));
|
|
1993
|
-
} catch {
|
|
1994
|
-
await exec("git", ["fetch", "--quiet", "origin", sha], {
|
|
1995
|
-
cwd: workspacePath
|
|
1996
|
-
});
|
|
1997
|
-
({ stdout: objectType } = await exec("git", ["cat-file", "-t", sha], {
|
|
1998
|
-
cwd: workspacePath
|
|
1999
|
-
}));
|
|
2000
|
-
}
|
|
2001
|
-
if (objectType.trim() !== "commit") throw new Error("not a commit");
|
|
2002
|
-
try {
|
|
2003
|
-
await exec("git", ["merge-base", "--is-ancestor", sha, "HEAD"], {
|
|
2004
|
-
cwd: workspacePath
|
|
2005
|
-
});
|
|
2006
|
-
return { sha, mode: "incremental" };
|
|
2007
|
-
} catch {
|
|
2008
|
-
logger.info(
|
|
2009
|
-
`Previous review SHA ${sha.slice(0, 8)} is not an ancestor; using snapshot delta`
|
|
2010
|
-
);
|
|
2011
|
-
return { sha, mode: "snapshot" };
|
|
2012
|
-
}
|
|
2013
|
-
} catch {
|
|
2014
|
-
logger.info(
|
|
2015
|
-
`Skipping previous review SHA ${sha.slice(0, 8)}; commit is unavailable`
|
|
2016
|
-
);
|
|
2017
|
-
}
|
|
2018
|
-
}
|
|
2019
|
-
return null;
|
|
2020
|
-
}
|
|
2021
|
-
function getDiffStats(diff) {
|
|
2022
|
-
let files = 0;
|
|
2023
|
-
let additions = 0;
|
|
2024
|
-
let deletions = 0;
|
|
2025
|
-
for (const line of diff.split("\n")) {
|
|
2026
|
-
if (line.startsWith("diff --git ")) files++;
|
|
2027
|
-
else if (line.startsWith("+") && !line.startsWith("+++")) additions++;
|
|
2028
|
-
else if (line.startsWith("-") && !line.startsWith("---")) deletions++;
|
|
2029
|
-
}
|
|
2030
|
-
return {
|
|
2031
|
-
files,
|
|
2032
|
-
additions,
|
|
2033
|
-
deletions,
|
|
2034
|
-
bytes: Buffer.byteLength(diff, "utf-8")
|
|
2035
|
-
};
|
|
2036
|
-
}
|
|
2037
|
-
function getChangedFiles(diff) {
|
|
2038
|
-
const files = [];
|
|
2039
|
-
for (const match of diff.matchAll(/^diff --git a\/(.*?) b\/(.*?)$/gm)) {
|
|
2040
|
-
files.push(match[2]);
|
|
2041
|
-
}
|
|
2042
|
-
return [...new Set(files)];
|
|
2043
|
-
}
|
|
2044
|
-
var DIFF_SKIP_PATTERNS = [
|
|
2045
|
-
/(?:^|\/)testdata\//,
|
|
2046
|
-
/(?:^|\/)(?:package-lock\.json|yarn\.lock|pnpm-lock\.yaml|go\.sum|Cargo\.lock|poetry\.lock|Gemfile\.lock|composer\.lock)$/,
|
|
2047
|
-
/\.mdx?$/
|
|
2048
|
-
];
|
|
2049
|
-
function filterEmbeddedDiff(rawDiff) {
|
|
2050
|
-
const skippedFiles = [];
|
|
2051
|
-
const sections = rawDiff.split(/(?=^diff --git )/m);
|
|
2052
|
-
const kept = [];
|
|
2053
|
-
for (const section of sections) {
|
|
2054
|
-
const match = section.match(/^diff --git a\/(.*?) b\//);
|
|
2055
|
-
if (!match) {
|
|
2056
|
-
kept.push(section);
|
|
2057
|
-
continue;
|
|
2058
|
-
}
|
|
2059
|
-
const filePath = match[1];
|
|
2060
|
-
if (DIFF_SKIP_PATTERNS.some((pattern) => pattern.test(filePath))) {
|
|
2061
|
-
skippedFiles.push(filePath);
|
|
2062
|
-
} else {
|
|
2063
|
-
kept.push(section);
|
|
2064
|
-
}
|
|
2065
|
-
}
|
|
2066
|
-
return { filtered: kept.join(""), skippedFiles };
|
|
2067
|
-
}
|
|
2068
|
-
|
|
2069
2958
|
// src/review-cache.ts
|
|
2070
2959
|
import { createHash as createHash2 } from "crypto";
|
|
2071
2960
|
import { gzipSync, gunzipSync } from "zlib";
|
|
@@ -2104,7 +2993,7 @@ function buildReviewCacheMarker(key, review, workspacePath) {
|
|
|
2104
2993
|
function findCachedReview(notes, key) {
|
|
2105
2994
|
if (!notes) return null;
|
|
2106
2995
|
const newestFirst = [...notes].sort(
|
|
2107
|
-
(a, b) => Date.parse(b.created_at ?? "") - Date.parse(a.created_at ?? "")
|
|
2996
|
+
(a, b) => Date.parse(b.updated_at ?? b.created_at ?? "") - Date.parse(a.updated_at ?? a.created_at ?? "")
|
|
2108
2997
|
);
|
|
2109
2998
|
for (const note of newestFirst) {
|
|
2110
2999
|
const encoded = note.body?.match(CACHE_MARKER_RE)?.[1];
|
|
@@ -2258,7 +3147,22 @@ async function reviewPr(opts) {
|
|
|
2258
3147
|
);
|
|
2259
3148
|
}
|
|
2260
3149
|
} else if (!piModel) {
|
|
2261
|
-
if (parsed.provider === "
|
|
3150
|
+
if (parsed.provider === "amazon-bedrock") {
|
|
3151
|
+
const inferredBaseModelId = stripBedrockRegionalPrefix(parsed.modelId);
|
|
3152
|
+
const baseModelId = parsed.baseModelId ?? inferredBaseModelId;
|
|
3153
|
+
const baseModel = baseModelId ? modelRuntime.getModel(parsed.provider, baseModelId) : void 0;
|
|
3154
|
+
if (!baseModel) {
|
|
3155
|
+
const hint = parsed.baseModelId ? `Base model "${parsed.baseModelId}" was not found in the installed pi-ai registry.` : `Append "@<base-model-id>" if this is a custom inference profile.`;
|
|
3156
|
+
throw new Error(
|
|
3157
|
+
`Unsupported Bedrock model "${parsed.modelId}". ${hint}`
|
|
3158
|
+
);
|
|
3159
|
+
}
|
|
3160
|
+
const region = process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? "us-east-1";
|
|
3161
|
+
piModel = buildBedrockArnModel({ arn: parsed.modelId, baseModel, region });
|
|
3162
|
+
logger.info(
|
|
3163
|
+
`Regional bedrock model, region: ${region}, capabilities from ${baseModel.id}`
|
|
3164
|
+
);
|
|
3165
|
+
} else if (parsed.provider === "openrouter") {
|
|
2262
3166
|
piModel = {
|
|
2263
3167
|
id: parsed.modelId,
|
|
2264
3168
|
name: parsed.modelId,
|
|
@@ -2459,7 +3363,14 @@ async function reviewPr(opts) {
|
|
|
2459
3363
|
let diffStats = null;
|
|
2460
3364
|
let changedFiles = [];
|
|
2461
3365
|
try {
|
|
2462
|
-
const diffArgs =
|
|
3366
|
+
const diffArgs = getReviewDiffArgs({
|
|
3367
|
+
platform,
|
|
3368
|
+
targetBranch,
|
|
3369
|
+
diffBaseSha,
|
|
3370
|
+
previousReviewSha,
|
|
3371
|
+
reviewDiffMode: previousReviewBase?.mode,
|
|
3372
|
+
localMode
|
|
3373
|
+
});
|
|
2463
3374
|
const { stdout: rawDiff } = await exec("git", diffArgs, { cwd: workspacePath });
|
|
2464
3375
|
const { filtered: filteredDiff, skippedFiles } = filterEmbeddedDiff(rawDiff);
|
|
2465
3376
|
if (skippedFiles.length > 0) {
|
|
@@ -2498,6 +3409,10 @@ async function reviewPr(opts) {
|
|
|
2498
3409
|
`Single-turn fast path: tiny low-risk diff (${diffStats?.files} file(s), ${(diffStats?.additions ?? 0) + (diffStats?.deletions ?? 0)} changed line(s)); exposing only submit_review`
|
|
2499
3410
|
);
|
|
2500
3411
|
}
|
|
3412
|
+
const findToolAvailable = commandOnPath("fd") || commandOnPath("fdfind");
|
|
3413
|
+
if (!findToolAvailable) {
|
|
3414
|
+
logger.warn("fd not found on PATH; disabling the agent's `find` tool");
|
|
3415
|
+
}
|
|
2501
3416
|
const prompt = buildPrReviewPrompt({
|
|
2502
3417
|
prUrl: prUrl ?? `local diff (against ${targetBranch})`,
|
|
2503
3418
|
platform,
|
|
@@ -2509,13 +3424,14 @@ async function reviewPr(opts) {
|
|
|
2509
3424
|
reviewDiffMode: reviewMode,
|
|
2510
3425
|
changedFiles,
|
|
2511
3426
|
localMode,
|
|
2512
|
-
singleTurn
|
|
3427
|
+
singleTurn,
|
|
3428
|
+
findToolAvailable
|
|
2513
3429
|
});
|
|
2514
3430
|
const startTime = Date.now();
|
|
2515
3431
|
const settingsManager = SettingsManager.inMemory({
|
|
2516
3432
|
compaction: { enabled: true }
|
|
2517
3433
|
});
|
|
2518
|
-
const skillPaths = [
|
|
3434
|
+
const skillPaths = [join3(workspacePath, ".agents", "skills")].filter((p) => existsSync(p));
|
|
2519
3435
|
const resourceLoader = new DefaultResourceLoader({
|
|
2520
3436
|
cwd: workspacePath,
|
|
2521
3437
|
agentDir: getAgentDir(),
|
|
@@ -2588,7 +3504,14 @@ async function reviewPr(opts) {
|
|
|
2588
3504
|
// (see _refreshToolRegistry in @earendil-works/pi-coding-agent's
|
|
2589
3505
|
// agent-session.ts). The submit_review custom tool must be named here
|
|
2590
3506
|
// or the LLM never sees it and the agent loop exits without calling it.
|
|
2591
|
-
tools: singleTurn ? ["submit_review"] : [
|
|
3507
|
+
tools: singleTurn ? ["submit_review"] : [
|
|
3508
|
+
"read",
|
|
3509
|
+
"bash",
|
|
3510
|
+
"grep",
|
|
3511
|
+
...findToolAvailable ? ["find"] : [],
|
|
3512
|
+
"ls",
|
|
3513
|
+
"submit_review"
|
|
3514
|
+
],
|
|
2592
3515
|
customTools: [submitReviewTool],
|
|
2593
3516
|
modelRuntime,
|
|
2594
3517
|
sessionManager: SessionManager.inMemory(),
|
|
@@ -2596,14 +3519,29 @@ async function reviewPr(opts) {
|
|
|
2596
3519
|
resourceLoader
|
|
2597
3520
|
});
|
|
2598
3521
|
activeSession = session;
|
|
2599
|
-
|
|
3522
|
+
const openAiReasoning = thinkingLevel && isOpenAiBedrockModel(piModel) ? thinkingLevel : void 0;
|
|
3523
|
+
if (parsed.provider === "amazon-bedrock" && (bedrockTags || openAiReasoning)) {
|
|
2600
3524
|
const agent = session.agent;
|
|
2601
3525
|
const originalStreamFn = agent.streamFn;
|
|
2602
3526
|
agent.streamFn = (...args) => {
|
|
2603
3527
|
const options = args[2] ?? {};
|
|
2604
|
-
|
|
3528
|
+
const originalOnPayload = options.onPayload;
|
|
3529
|
+
const onPayload = openAiReasoning ? async (payload, model2) => {
|
|
3530
|
+
const transformed = originalOnPayload ? await originalOnPayload(payload, model2) : void 0;
|
|
3531
|
+
return addOpenAiBedrockReasoning(
|
|
3532
|
+
transformed === void 0 ? payload : transformed,
|
|
3533
|
+
openAiReasoning
|
|
3534
|
+
);
|
|
3535
|
+
} : originalOnPayload;
|
|
3536
|
+
return originalStreamFn(args[0], args[1], {
|
|
3537
|
+
...options,
|
|
3538
|
+
...bedrockTags ? { requestMetadata: bedrockTags } : {},
|
|
3539
|
+
...onPayload ? { onPayload } : {}
|
|
3540
|
+
});
|
|
2605
3541
|
};
|
|
2606
|
-
|
|
3542
|
+
if (bedrockTags) {
|
|
3543
|
+
logger.info(`Bedrock cost allocation tags: ${JSON.stringify(bedrockTags)}`);
|
|
3544
|
+
}
|
|
2607
3545
|
}
|
|
2608
3546
|
let turnCount = 0;
|
|
2609
3547
|
let toolCallCount = 0;
|
|
@@ -2804,6 +3742,10 @@ async function reviewPr(opts) {
|
|
|
2804
3742
|
}
|
|
2805
3743
|
|
|
2806
3744
|
export {
|
|
3745
|
+
setLogLevel,
|
|
3746
|
+
logger,
|
|
3747
|
+
renderMarkdown,
|
|
3748
|
+
listHodorDiscussions,
|
|
2807
3749
|
buildPrReviewPrompt,
|
|
2808
3750
|
parseModelString,
|
|
2809
3751
|
mapReasoningEffort,
|
|
@@ -2820,9 +3762,9 @@ export {
|
|
|
2820
3762
|
loadDefaultReviewInstructions,
|
|
2821
3763
|
detectPlatform,
|
|
2822
3764
|
parsePrUrl,
|
|
2823
|
-
|
|
3765
|
+
mergeReviewStateFindings,
|
|
2824
3766
|
postReviewComment,
|
|
2825
3767
|
postReviewStructured,
|
|
2826
3768
|
reviewPr
|
|
2827
3769
|
};
|
|
2828
|
-
//# sourceMappingURL=chunk-
|
|
3770
|
+
//# sourceMappingURL=chunk-AFEJ4DRL.js.map
|