@mrkaran/hodor 0.7.3 → 0.7.5

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";
@@ -56,19 +755,19 @@ function getHodorReviewShaCandidates(notes) {
56
755
  for (const [index, note] of notes.entries()) {
57
756
  const match = note.body?.match(HODOR_REVIEW_SHA_RE);
58
757
  if (!match) continue;
59
- const createdAtMs = Date.parse(note.created_at ?? "");
758
+ const reviewedAtMs = Date.parse(note.updated_at ?? note.created_at ?? "");
60
759
  candidates.push({
61
760
  sha: match[1],
62
- createdAtMs: Number.isFinite(createdAtMs) ? createdAtMs : null,
761
+ reviewedAtMs: Number.isFinite(reviewedAtMs) ? reviewedAtMs : null,
63
762
  index
64
763
  });
65
764
  }
66
765
  candidates.sort((a, b) => {
67
- if (a.createdAtMs != null && b.createdAtMs != null && a.createdAtMs !== b.createdAtMs) {
68
- return b.createdAtMs - a.createdAtMs;
766
+ if (a.reviewedAtMs != null && b.reviewedAtMs != null && a.reviewedAtMs !== b.reviewedAtMs) {
767
+ return b.reviewedAtMs - a.reviewedAtMs;
69
768
  }
70
- if (a.createdAtMs != null && b.createdAtMs == null) return -1;
71
- if (a.createdAtMs == null && b.createdAtMs != null) return 1;
769
+ if (a.reviewedAtMs != null && b.reviewedAtMs == null) return -1;
770
+ if (a.reviewedAtMs == null && b.reviewedAtMs != null) return 1;
72
771
  return a.index - b.index;
73
772
  });
74
773
  return [...new Set(candidates.map(({ sha }) => sha))];
@@ -173,7 +872,8 @@ function buildPrReviewPrompt(opts) {
173
872
  reviewDiffMode,
174
873
  changedFiles = [],
175
874
  localMode = false,
176
- singleTurn = false
875
+ singleTurn = false,
876
+ findToolAvailable = false
177
877
  } = opts;
178
878
  const rebasedGitlabReview = platform === "gitlab" && reviewDiffMode === "snapshot";
179
879
  const hasPreviousReviewDelta = Boolean(previousReviewSha && !rebasedGitlabReview);
@@ -281,12 +981,13 @@ ${changedFiles.map((file) => `- \`${file}\``).join("\n")}
281
981
  }
282
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
283
983
 
984
+ This list is exhaustive. No other tool is available.
985
+
284
986
  - \`${prDiffCmd}\` lists the changed files when a diff is not embedded.
285
987
  - \`${gitDiffCmd} -- path/to/file\` shows the delta for one changed file.
286
988
  - \`read\` provides bounded surrounding context.
287
989
  - \`grep\` searches for directly relevant code and contracts.
288
- - \`submit_review\` submits the completed review.
289
- `;
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";
290
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);
291
992
  }
292
993
  function buildMrSections(mrMetadata) {
@@ -562,7 +1263,7 @@ function getApiKey(model) {
562
1263
  }
563
1264
 
564
1265
  // src/metrics.ts
565
- import chalk from "chalk";
1266
+ import chalk2 from "chalk";
566
1267
  function tok(value) {
567
1268
  if (value >= 1e6) return `${(value / 1e6).toFixed(2)}M`;
568
1269
  if (value >= 1e3) return `${(value / 1e3).toFixed(1)}K`;
@@ -596,9 +1297,9 @@ function formatMetricsMarkdown(metrics) {
596
1297
  return lines.join("\n");
597
1298
  }
598
1299
  function printMetrics(metrics, stream = process.stderr) {
599
- const dim = chalk.dim;
600
- const bold = chalk.bold;
601
- const cyan = chalk.cyan;
1300
+ const dim = chalk2.dim;
1301
+ const bold = chalk2.bold;
1302
+ const cyan = chalk2.cyan;
602
1303
  const write = (line) => stream.write(line + "\n");
603
1304
  write("");
604
1305
  write(dim("\u2500".repeat(50)));
@@ -804,7 +1505,7 @@ The selected review instructions and additional instructions are reviewer policy
804
1505
 
805
1506
  ## Read-Only Review
806
1507
 
807
- 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.
808
1509
 
809
1510
  ## Priority Mapping
810
1511
 
@@ -817,7 +1518,7 @@ Every finding title begins with its matching [P0], [P1], [P2], or [P3] tag, and
817
1518
 
818
1519
  ## Tool Discipline and Efficiency
819
1520
 
820
- 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.
821
1522
 
822
1523
  ## Submission
823
1524
 
@@ -969,9 +1670,101 @@ function parsePositiveNumber(raw, kind, prUrl, segment) {
969
1670
  return number;
970
1671
  }
971
1672
 
972
- // src/publisher.ts
1673
+ // src/review-state.ts
973
1674
  import { createHash } from "crypto";
974
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
+
975
1768
  // src/gitea.ts
976
1769
  var GiteaAPIError = class extends Error {
977
1770
  constructor(message) {
@@ -979,7 +1772,7 @@ var GiteaAPIError = class extends Error {
979
1772
  this.name = "GiteaAPIError";
980
1773
  }
981
1774
  };
982
- function normalizeBaseUrl(host) {
1775
+ function normalizeBaseUrl2(host) {
983
1776
  const candidate = host || process.env.GITEA_HOST || process.env.FORGEJO_HOST;
984
1777
  if (!candidate) {
985
1778
  throw new GiteaAPIError(
@@ -1005,7 +1798,7 @@ function requireGiteaToken() {
1005
1798
  return token;
1006
1799
  }
1007
1800
  async function giteaFetch(host, path, options) {
1008
- const baseUrl = normalizeBaseUrl(host);
1801
+ const baseUrl = normalizeBaseUrl2(host);
1009
1802
  const url = `${baseUrl}/api/v1/${path}`;
1010
1803
  const token = giteaToken();
1011
1804
  const headers = {
@@ -1135,23 +1928,10 @@ async function postGiteaPrComment(owner, repo, prNumber, body, host) {
1135
1928
  }
1136
1929
 
1137
1930
  // src/publisher.ts
1138
- var FINDING_MARKER_RE = /<!--\s*hodor:finding:([a-f0-9]{64})\s*-->/i;
1139
- function getFindingFingerprint(finding, workspacePath) {
1140
- const path = relativizeWorkspacePath(
1141
- finding.code_location.absolute_file_path,
1142
- workspacePath ?? void 0
1143
- );
1144
- const title = finding.title.replace(/^\[P[0-3]\]\s*/, "").trim().toLowerCase();
1145
- return createHash("sha256").update(`${path}
1146
- ${title}`).digest("hex");
1147
- }
1148
- function getDiscussionFingerprint(body) {
1149
- return body.match(FINDING_MARKER_RE)?.[1]?.toLowerCase() ?? null;
1150
- }
1151
- async function postGitlabReviewCommitStatus(parsed, review, diffRefs) {
1152
- 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;
1153
1933
  const state = blocking > 0 ? "failed" : "success";
1154
- 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";
1155
1935
  await postGitlabCommitStatus(
1156
1936
  parsed.owner,
1157
1937
  parsed.repo,
@@ -1161,23 +1941,38 @@ async function postGitlabReviewCommitStatus(parsed, review, diffRefs) {
1161
1941
  { description }
1162
1942
  );
1163
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
+ }
1164
1958
  async function postReviewComment(opts) {
1165
1959
  const { prUrl, reviewText, model, metricsFooter, headSha, cacheMarker } = opts;
1166
1960
  const platform = detectPlatform(prUrl);
1167
1961
  const parsed = parsePrUrl(prUrl);
1168
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
+ }
1169
1970
  if (headSha) body = `<!-- hodor:sha:${headSha} -->
1170
1971
  ${body}`;
1171
1972
  if (cacheMarker) body = body.replace("\n", `
1172
1973
  ${cacheMarker}
1173
1974
  `);
1174
- if (model) body += `
1175
- ---
1176
-
1177
- Review generated by Hodor (model: \`${model}\`)`;
1178
- if (metricsFooter) body += `
1179
-
1180
- ${metricsFooter}`;
1975
+ body = appendReviewDetails(body, model, metricsFooter);
1181
1976
  try {
1182
1977
  if (platform === "github") {
1183
1978
  await exec("gh", [
@@ -1202,7 +1997,7 @@ ${metricsFooter}`;
1202
1997
  );
1203
1998
  return { success: true, platform, prNumber: parsed.prNumber };
1204
1999
  }
1205
- await postGitlabMrComment(
2000
+ await upsertGitlabMrSummary(
1206
2001
  parsed.owner,
1207
2002
  parsed.repo,
1208
2003
  parsed.prNumber,
@@ -1233,10 +2028,13 @@ async function postReviewStructured(opts) {
1233
2028
  workspacePath,
1234
2029
  reconcileDiscussions = false,
1235
2030
  cacheMarker,
1236
- skipSummary = false
2031
+ skipSummary = false,
2032
+ existingDiscussions,
2033
+ skipInline = false,
2034
+ reviewMode
1237
2035
  } = opts;
1238
2036
  const platform = detectPlatform(prUrl);
1239
- if (platform !== "gitlab" || reviewStyle === "summary") {
2037
+ if (platform !== "gitlab") {
1240
2038
  return postReviewComment({
1241
2039
  prUrl,
1242
2040
  reviewText: renderMarkdown(review),
@@ -1269,13 +2067,17 @@ async function postReviewStructured(opts) {
1269
2067
  });
1270
2068
  }
1271
2069
  const existingByFingerprint = /* @__PURE__ */ new Map();
2070
+ let discussions = existingDiscussions ?? [];
2071
+ let discussionListingFailed = false;
1272
2072
  try {
1273
- const discussions = await listHodorDiscussions(
1274
- parsed.owner,
1275
- parsed.repo,
1276
- parsed.prNumber,
1277
- parsed.host
1278
- );
2073
+ if (!existingDiscussions) {
2074
+ discussions = await listHodorDiscussions(
2075
+ parsed.owner,
2076
+ parsed.repo,
2077
+ parsed.prNumber,
2078
+ parsed.host
2079
+ );
2080
+ }
1279
2081
  for (const discussion of discussions) {
1280
2082
  if (discussion.resolved) continue;
1281
2083
  const fingerprint = getDiscussionFingerprint(discussion.body);
@@ -1285,63 +2087,77 @@ async function postReviewStructured(opts) {
1285
2087
  existingByFingerprint.set(fingerprint, ids);
1286
2088
  }
1287
2089
  } catch (error) {
2090
+ discussionListingFailed = true;
1288
2091
  const message = error instanceof Error ? error.message : String(error);
1289
- if (reconcileDiscussions) {
2092
+ if (reconcileDiscussions || commitStatus) {
1290
2093
  errors.push(`discussion listing: ${message}`);
1291
2094
  }
1292
- logger.warn(`Failed to list open Hodor discussions for deduplication: ${message}`);
2095
+ logger.warn(`Failed to list open Hodor discussions for review state: ${message}`);
1293
2096
  }
2097
+ const reviewFindings = mergeReviewStateFindings(
2098
+ review.findings,
2099
+ discussions,
2100
+ workspacePath,
2101
+ {
2102
+ includeExisting: !reconcileDiscussions,
2103
+ suppressResolvedCurrent: skipInline
2104
+ }
2105
+ );
1294
2106
  let inlineCreated = 0;
1295
2107
  let inlineFailed = 0;
1296
2108
  let inlineDeduplicated = 0;
1297
2109
  const draftNoteIds = [];
1298
- for (const finding of review.findings) {
1299
- const fingerprint = getFindingFingerprint(finding, workspacePath);
1300
- if (existingByFingerprint.has(fingerprint)) {
1301
- inlineDeduplicated++;
1302
- continue;
1303
- }
1304
- const relPath = relativizeWorkspacePath(
1305
- finding.code_location.absolute_file_path,
1306
- workspacePath ?? void 0
1307
- );
1308
- const title = /^\[P[0-3]\]/.test(finding.title) ? finding.title : `[P${finding.priority}] ${finding.title}`;
1309
- 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}
1310
2124
  <!-- hodor:finding:${fingerprint} -->
1311
2125
  **${title}**
1312
2126
 
1313
2127
  ${finding.body}`;
1314
- if (finding.suggestion) {
1315
- const { start, end } = finding.code_location.line_range;
1316
- const span = Math.max(0, end - start);
1317
- body += `
2128
+ if (finding.suggestion) {
2129
+ const { start, end } = finding.code_location.line_range;
2130
+ const span = Math.max(0, end - start);
2131
+ body += `
1318
2132
 
1319
2133
  \`\`\`suggestion:-0+${span}
1320
2134
  ${finding.suggestion}
1321
2135
  \`\`\``;
1322
- }
1323
- try {
1324
- const draftNote = await createGitlabDraftNote(
1325
- parsed.owner,
1326
- parsed.repo,
1327
- parsed.prNumber,
1328
- body,
1329
- parsed.host,
1330
- {
1331
- filePath: relPath,
1332
- line: finding.code_location.line_range.start,
1333
- 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);
1334
2152
  }
1335
- );
1336
- if (typeof draftNote.id === "number" || typeof draftNote.id === "string") {
1337
- 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);
1338
2160
  }
1339
- inlineCreated++;
1340
- } catch (error) {
1341
- const message = error instanceof Error ? error.message : String(error);
1342
- errors.push(`inline note for ${finding.title}: ${message}`);
1343
- logger.warn(`Failed to create inline note for "${finding.title}": ${message}`);
1344
- inlineFailed++;
1345
2161
  }
1346
2162
  }
1347
2163
  logger.info(
@@ -1388,22 +2204,24 @@ ${finding.suggestion}
1388
2204
  }
1389
2205
  }
1390
2206
  let summaryPosted = false;
1391
- if (!skipSummary && (reviewStyle === "hybrid" || review.findings.length === 0)) {
1392
- 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
+ });
1393
2217
  if (headSha) summaryBody = `<!-- hodor:sha:${headSha} -->
1394
2218
  ${summaryBody}`;
1395
2219
  if (cacheMarker) summaryBody = summaryBody.replace("\n", `
1396
2220
  ${cacheMarker}
1397
2221
  `);
1398
- if (model) summaryBody += `
1399
- ---
1400
-
1401
- Review generated by Hodor (model: \`${model}\`)`;
1402
- if (metricsFooter) summaryBody += `
1403
-
1404
- ${metricsFooter}`;
2222
+ summaryBody = appendReviewDetails(summaryBody, model, metricsFooter);
1405
2223
  try {
1406
- await postGitlabMrComment(
2224
+ await upsertGitlabMrSummary(
1407
2225
  parsed.owner,
1408
2226
  parsed.repo,
1409
2227
  parsed.prNumber,
@@ -1414,13 +2232,13 @@ ${metricsFooter}`;
1414
2232
  } catch (error) {
1415
2233
  const message = error instanceof Error ? error.message : String(error);
1416
2234
  errors.push(`summary comment: ${message}`);
1417
- logger.warn(`Failed to post summary comment: ${message}`);
2235
+ logger.warn(`Failed to upsert summary comment: ${message}`);
1418
2236
  }
1419
2237
  }
1420
2238
  let commitStatusPosted = false;
1421
- if (commitStatus) {
2239
+ if (commitStatus && (!discussionListingFailed || reconcileDiscussions)) {
1422
2240
  try {
1423
- await postGitlabReviewCommitStatus(parsed, review, diffRefs);
2241
+ await postGitlabReviewCommitStatus(parsed, reviewFindings, diffRefs);
1424
2242
  commitStatusPosted = true;
1425
2243
  } catch (error) {
1426
2244
  const message = error instanceof Error ? error.message : String(error);
@@ -1429,7 +2247,7 @@ ${metricsFooter}`;
1429
2247
  }
1430
2248
  }
1431
2249
  let reconciledDiscussions = 0;
1432
- 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);
1433
2251
  if (reconcileDiscussions && baseDeliveryComplete) {
1434
2252
  const currentFingerprints = new Set(
1435
2253
  review.findings.map((finding) => getFindingFingerprint(finding, workspacePath))
@@ -1462,13 +2280,15 @@ ${metricsFooter}`;
1462
2280
  inlineFailed,
1463
2281
  draftsPublished,
1464
2282
  commitStatusPosted,
1465
- reconciledDiscussions
2283
+ reconciledDiscussions,
2284
+ reviewStateComplete: !discussionListingFailed || reconcileDiscussions,
2285
+ reviewFindings
1466
2286
  };
1467
2287
  }
1468
2288
 
1469
2289
  // src/agent.ts
1470
2290
  import { existsSync } from "fs";
1471
- import { join as join2 } from "path";
2291
+ import { join as join3 } from "path";
1472
2292
  import {
1473
2293
  createAgentSession,
1474
2294
  DefaultResourceLoader,
@@ -1576,7 +2396,7 @@ function githubCommentsToNotes(comments) {
1576
2396
  // src/workspace.ts
1577
2397
  import { mkdtemp, rm } from "fs/promises";
1578
2398
  import { tmpdir } from "os";
1579
- import { join } from "path";
2399
+ import { join as join2 } from "path";
1580
2400
  var WorkspaceError = class extends Error {
1581
2401
  constructor(message) {
1582
2402
  super(message);
@@ -1890,7 +2710,7 @@ async function setupWorkspace(opts) {
1890
2710
  detectedTargetBranch = await getGithubBaseBranch(workspace, prNumber);
1891
2711
  }
1892
2712
  } else if (!workingDir) {
1893
- workspace = await mkdtemp(join(tmpdir(), "hodor-review-"));
2713
+ workspace = await mkdtemp(join2(tmpdir(), "hodor-review-"));
1894
2714
  isTemporary = true;
1895
2715
  logger.info(`Created temporary workspace: ${workspace}`);
1896
2716
  } else {
@@ -2173,7 +2993,7 @@ function buildReviewCacheMarker(key, review, workspacePath) {
2173
2993
  function findCachedReview(notes, key) {
2174
2994
  if (!notes) return null;
2175
2995
  const newestFirst = [...notes].sort(
2176
- (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 ?? "")
2177
2997
  );
2178
2998
  for (const note of newestFirst) {
2179
2999
  const encoded = note.body?.match(CACHE_MARKER_RE)?.[1];
@@ -2589,6 +3409,10 @@ async function reviewPr(opts) {
2589
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`
2590
3410
  );
2591
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
+ }
2592
3416
  const prompt = buildPrReviewPrompt({
2593
3417
  prUrl: prUrl ?? `local diff (against ${targetBranch})`,
2594
3418
  platform,
@@ -2600,13 +3424,14 @@ async function reviewPr(opts) {
2600
3424
  reviewDiffMode: reviewMode,
2601
3425
  changedFiles,
2602
3426
  localMode,
2603
- singleTurn
3427
+ singleTurn,
3428
+ findToolAvailable
2604
3429
  });
2605
3430
  const startTime = Date.now();
2606
3431
  const settingsManager = SettingsManager.inMemory({
2607
3432
  compaction: { enabled: true }
2608
3433
  });
2609
- const skillPaths = [join2(workspacePath, ".agents", "skills")].filter((p) => existsSync(p));
3434
+ const skillPaths = [join3(workspacePath, ".agents", "skills")].filter((p) => existsSync(p));
2610
3435
  const resourceLoader = new DefaultResourceLoader({
2611
3436
  cwd: workspacePath,
2612
3437
  agentDir: getAgentDir(),
@@ -2679,7 +3504,14 @@ async function reviewPr(opts) {
2679
3504
  // (see _refreshToolRegistry in @earendil-works/pi-coding-agent's
2680
3505
  // agent-session.ts). The submit_review custom tool must be named here
2681
3506
  // or the LLM never sees it and the agent loop exits without calling it.
2682
- 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
+ ],
2683
3515
  customTools: [submitReviewTool],
2684
3516
  modelRuntime,
2685
3517
  sessionManager: SessionManager.inMemory(),
@@ -2910,6 +3742,10 @@ async function reviewPr(opts) {
2910
3742
  }
2911
3743
 
2912
3744
  export {
3745
+ setLogLevel,
3746
+ logger,
3747
+ renderMarkdown,
3748
+ listHodorDiscussions,
2913
3749
  buildPrReviewPrompt,
2914
3750
  parseModelString,
2915
3751
  mapReasoningEffort,
@@ -2926,9 +3762,9 @@ export {
2926
3762
  loadDefaultReviewInstructions,
2927
3763
  detectPlatform,
2928
3764
  parsePrUrl,
2929
- postGitlabReviewCommitStatus,
3765
+ mergeReviewStateFindings,
2930
3766
  postReviewComment,
2931
3767
  postReviewStructured,
2932
3768
  reviewPr
2933
3769
  };
2934
- //# sourceMappingURL=chunk-GISFKKMM.js.map
3770
+ //# sourceMappingURL=chunk-AFEJ4DRL.js.map