@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.
@@ -1,25 +1,724 @@
1
- import {
2
- HODOR_REVIEW_MARKER,
3
- bulkPublishGitlabDraftNotes,
4
- createGitlabDraftNote,
5
- exec,
6
- execJson,
7
- fetchGitlabMrInfo,
8
- getGitlabMrDiffRefs,
9
- listHodorDiscussions,
10
- logger,
11
- postGitlabCommitStatus,
12
- postGitlabMrComment,
13
- publishGitlabDraftNote,
14
- renderMarkdown,
15
- renderSummaryMarkdown,
16
- resolveGitlabDiscussions,
17
- summarizeGitlabNotes,
18
- summarizeHodorNotes
19
- } from "./chunk-DALI4QRT.js";
20
- import {
21
- relativizeWorkspacePath
22
- } from "./chunk-AMUK6GDX.js";
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
- let prDiffCmd;
66
- let gitDiffCmd;
67
- if (previousReviewSha) {
68
- const separator = reviewDiffMode === "snapshot" ? " " : "...";
69
- prDiffCmd = `git --no-pager diff ${previousReviewSha}${separator}HEAD --name-only`;
70
- gitDiffCmd = `git --no-pager diff ${previousReviewSha}${separator}HEAD`;
71
- logger.info(`${reviewDiffMode === "snapshot" ? "Snapshot" : "Incremental"} review: diffing from ${previousReviewSha.slice(0, 8)} to HEAD`);
72
- } else if (localMode) {
73
- prDiffCmd = `git --no-pager diff ${targetBranch} --name-only`;
74
- gitDiffCmd = `git --no-pager diff ${targetBranch}`;
75
- } else if (platform === "github" || platform === "gitea") {
76
- prDiffCmd = `git --no-pager diff origin/${targetBranch}...HEAD --name-only`;
77
- gitDiffCmd = `git --no-pager diff origin/${targetBranch}...HEAD`;
78
- } else {
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 (previousReviewSha) {
90
- diffExplanation = reviewDiffMode === "snapshot" ? `**Snapshot delta mode**: The MR history was rewritten. This directly compares the last reviewed snapshot (commit \`${previousReviewSha.slice(0, 8)}\`) with the current HEAD; it does not imply ancestry.` : `**Incremental mode**: Showing only changes since the last hodor review (commit \`${previousReviewSha.slice(0, 8)}\`).`;
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 GitLab's pre-calculated merge base SHA (\`CI_MERGE_REQUEST_DIFF_BASE_SHA\`), which matches exactly what the GitLab UI shows. This is more reliable than three-dot syntax because it handles force pushes, rebases, and messy histories correctly.`;
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 (previousReviewSha) {
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 \`${previousReviewSha.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";
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 = previousReviewSha ? "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`.";
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
- - \`submit_review\` submits the completed review.
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 chalk from "chalk";
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 = chalk.dim;
451
- const bold = chalk.bold;
452
- const cyan = chalk.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. Do not use unavailable tools or substitute shell commands for supplied file-search tools.
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/publisher.ts
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 normalizeBaseUrl(host) {
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 = normalizeBaseUrl(host);
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
- var FINDING_MARKER_RE = /<!--\s*hodor:finding:([a-f0-9]{64})\s*-->/i;
990
- function getFindingFingerprint(finding, workspacePath) {
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` : review.findings.length > 0 ? `${review.findings.length} non-blocking issue(s)` : "No issues 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
- if (model) body += `
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 postGitlabMrComment(
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" || reviewStyle === "summary") {
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
- const discussions = await listHodorDiscussions(
1125
- parsed.owner,
1126
- parsed.repo,
1127
- parsed.prNumber,
1128
- parsed.host
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 deduplication: ${message}`);
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
- for (const finding of review.findings) {
1150
- const fingerprint = getFindingFingerprint(finding, workspacePath);
1151
- if (existingByFingerprint.has(fingerprint)) {
1152
- inlineDeduplicated++;
1153
- continue;
1154
- }
1155
- const relPath = relativizeWorkspacePath(
1156
- finding.code_location.absolute_file_path,
1157
- workspacePath ?? void 0
1158
- );
1159
- const title = /^\[P[0-3]\]/.test(finding.title) ? finding.title : `[P${finding.priority}] ${finding.title}`;
1160
- let body = `${HODOR_REVIEW_MARKER}
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
- if (finding.suggestion) {
1166
- const { start, end } = finding.code_location.line_range;
1167
- const span = Math.max(0, end - start);
1168
- body += `
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
- try {
1175
- const draftNote = await createGitlabDraftNote(
1176
- parsed.owner,
1177
- parsed.repo,
1178
- parsed.prNumber,
1179
- body,
1180
- parsed.host,
1181
- {
1182
- filePath: relPath,
1183
- line: finding.code_location.line_range.start,
1184
- diffRefs
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
- if (typeof draftNote.id === "number" || typeof draftNote.id === "string") {
1188
- draftNoteIds.push(draftNote.id);
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
- let summaryBody = renderSummaryMarkdown(review);
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
- if (model) summaryBody += `
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 postGitlabMrComment(
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 post summary comment: ${message}`);
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, review, diffRefs);
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 === "hybrid" ? (summaryPosted || skipSummary) && inlineFailed === 0 && (inlineCreated === 0 || draftsPublished) : inlineFailed === 0 && (review.findings.length === 0 ? summaryPosted : inlineCreated === 0 || draftsPublished);
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 join2 } from "path";
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
- const detectedDiffBaseSha = ci.diffBaseSha;
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(join(tmpdir(), "hodor-review-"));
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 === "openrouter") {
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 = previousReviewSha ? previousReviewBase?.mode === "snapshot" ? ["--no-pager", "diff", previousReviewSha, "HEAD"] : ["--no-pager", "diff", `${previousReviewSha}...HEAD`] : diffBaseSha ? ["--no-pager", "diff", diffBaseSha, "HEAD"] : localMode ? ["--no-pager", "diff", targetBranch] : ["--no-pager", "diff", `origin/${targetBranch}...HEAD`];
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 = [join2(workspacePath, ".agents", "skills")].filter((p) => existsSync(p));
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"] : ["read", "bash", "grep", "find", "ls", "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
- if (bedrockTags && parsed.provider === "amazon-bedrock") {
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
- return originalStreamFn(args[0], args[1], { ...options, requestMetadata: bedrockTags });
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
- logger.info(`Bedrock cost allocation tags: ${JSON.stringify(bedrockTags)}`);
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
- postGitlabReviewCommitStatus,
3765
+ mergeReviewStateFindings,
2824
3766
  postReviewComment,
2825
3767
  postReviewStructured,
2826
3768
  reviewPr
2827
3769
  };
2828
- //# sourceMappingURL=chunk-YKB3BRJM.js.map
3770
+ //# sourceMappingURL=chunk-AFEJ4DRL.js.map