@mrkaran/hodor 0.5.0 → 0.6.1

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,263 +1,29 @@
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
- };
1
+ import {
2
+ HODOR_REVIEW_MARKER,
3
+ bulkPublishGitlabDraftNotes,
4
+ cleanupHodorComments,
5
+ createGitlabDraftNote,
6
+ exec,
7
+ execJson,
8
+ fetchGitlabMrInfo,
9
+ getGitlabMrDiffRefs,
10
+ listHodorDiscussions,
11
+ logger,
12
+ postGitlabCommitStatus,
13
+ postGitlabMrComment,
14
+ renderMarkdown,
15
+ renderSummaryMarkdown,
16
+ resolveGitlabDiscussions,
17
+ summarizeGitlabNotes
18
+ } from "./chunk-DVJVQTVW.js";
19
+ import {
20
+ relativizeWorkspacePath
21
+ } from "./chunk-AMUK6GDX.js";
45
22
 
46
23
  // src/prompt.ts
47
24
  import { readFileSync } from "fs";
48
25
  import { resolve, dirname } from "path";
49
26
  import { fileURLToPath } from "url";
50
-
51
- // src/utils/exec.ts
52
- import { execFile } from "child_process";
53
- import { promisify } from "util";
54
- var execFileAsync = promisify(execFile);
55
- async function exec(cmd, args, opts) {
56
- const { stdout, stderr } = await execFileAsync(cmd, args, {
57
- cwd: opts?.cwd,
58
- env: opts?.env ?? process.env,
59
- maxBuffer: 50 * 1024 * 1024
60
- // 50MB
61
- });
62
- return { stdout, stderr };
63
- }
64
- async function execJson(cmd, args, opts) {
65
- const { stdout } = await exec(cmd, args, opts);
66
- return JSON.parse(stdout.trim());
67
- }
68
-
69
- // src/gitlab.ts
70
- var DEFAULT_GITLAB_HOST = "gitlab.com";
71
- function parseGlabPaginatedJson(raw) {
72
- const trimmed = raw.trim();
73
- if (!trimmed) return [];
74
- const chunks = [];
75
- let depth = 0;
76
- let inString = false;
77
- let escaped = false;
78
- let start = -1;
79
- for (let i = 0; i < trimmed.length; i++) {
80
- const ch = trimmed[i];
81
- if (escaped) {
82
- escaped = false;
83
- continue;
84
- }
85
- if (ch === "\\" && inString) {
86
- escaped = true;
87
- continue;
88
- }
89
- if (ch === '"') {
90
- inString = !inString;
91
- continue;
92
- }
93
- if (inString) continue;
94
- if (ch === "[") {
95
- if (depth === 0) start = i;
96
- depth++;
97
- } else if (ch === "]") {
98
- depth--;
99
- if (depth === 0 && start >= 0) {
100
- chunks.push(trimmed.slice(start, i + 1));
101
- start = -1;
102
- }
103
- }
104
- }
105
- const results = [];
106
- for (const chunk of chunks) {
107
- const parsed = JSON.parse(chunk);
108
- if (Array.isArray(parsed)) {
109
- results.push(...parsed);
110
- }
111
- }
112
- return results;
113
- }
114
- var GitLabAPIError = class extends Error {
115
- constructor(message) {
116
- super(message);
117
- this.name = "GitLabAPIError";
118
- }
119
- };
120
- function normalizeBaseUrl(host) {
121
- const candidate = host || process.env.GITLAB_HOST || process.env.CI_SERVER_URL || DEFAULT_GITLAB_HOST;
122
- const trimmed = candidate.trim() || DEFAULT_GITLAB_HOST;
123
- if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
124
- return trimmed.replace(/\/+$/, "");
125
- }
126
- return `https://${trimmed}`.replace(/\/+$/, "");
127
- }
128
- function encodedProjectPath(owner, repo) {
129
- const projectPath = [owner.replace(/^\/+|\/+$/g, ""), repo.replace(/^\/+|\/+$/g, "")].filter(Boolean).join("/");
130
- return encodeURIComponent(projectPath);
131
- }
132
- function glabEnv(host) {
133
- const env = { ...process.env };
134
- const baseUrl = normalizeBaseUrl(host);
135
- const hostname = baseUrl.replace(/^https?:\/\//, "");
136
- env.GITLAB_HOST = hostname;
137
- return env;
138
- }
139
- async function fetchGitlabMrInfo(owner, repo, mrNumber, host, options) {
140
- const encoded = encodedProjectPath(owner, repo);
141
- const env = glabEnv(host);
142
- let mrData;
143
- try {
144
- mrData = await execJson(
145
- "glab",
146
- ["api", `projects/${encoded}/merge_requests/${mrNumber}`],
147
- { env }
148
- );
149
- } catch (err) {
150
- const msg = err instanceof Error ? err.message : String(err);
151
- throw new GitLabAPIError(`Failed to fetch MR !${mrNumber}: ${msg}`);
152
- }
153
- const metadata = {
154
- title: mrData.title,
155
- description: mrData.description ?? "",
156
- source_branch: mrData.source_branch,
157
- target_branch: mrData.target_branch,
158
- changes_count: mrData.changes_count,
159
- labels: mrData.labels,
160
- author: mrData.author,
161
- pipeline: mrData.pipeline,
162
- state: mrData.state
163
- };
164
- if (options?.includeComments) {
165
- try {
166
- const { stdout: rawNotes } = await exec(
167
- "glab",
168
- ["api", `projects/${encoded}/merge_requests/${mrNumber}/notes`, "--paginate"],
169
- { env }
170
- );
171
- const notes = parseGlabPaginatedJson(rawNotes);
172
- metadata.Notes = notes.map((n) => ({
173
- body: n.body ?? "",
174
- author: n.author,
175
- created_at: n.created_at,
176
- system: n.system
177
- }));
178
- } catch (err) {
179
- logger.warn(`Failed to fetch MR notes: ${err instanceof Error ? err.message : err}`);
180
- }
181
- }
182
- return metadata;
183
- }
184
- async function postGitlabMrComment(owner, repo, mrNumber, body, host) {
185
- const encoded = encodedProjectPath(owner, repo);
186
- const env = glabEnv(host);
187
- try {
188
- await exec(
189
- "glab",
190
- [
191
- "api",
192
- `projects/${encoded}/merge_requests/${mrNumber}/notes`,
193
- "--method",
194
- "POST",
195
- "--field",
196
- `body=${body}`
197
- ],
198
- { env }
199
- );
200
- } catch (err) {
201
- const msg = err instanceof Error ? err.message : String(err);
202
- throw new GitLabAPIError(`Failed to post comment to MR !${mrNumber}: ${msg}`);
203
- }
204
- }
205
- function summarizeGitlabNotes(notes, maxEntries = 5) {
206
- if (!notes || notes.length === 0) return "";
207
- const trivialPatterns = /* @__PURE__ */ new Set([
208
- "lgtm",
209
- "+1",
210
- "-1",
211
- "\u{1F44D}",
212
- "\u{1F44E}",
213
- "thanks",
214
- "thank you",
215
- "looks good",
216
- "approved",
217
- "\u{1F680}",
218
- "\u2705",
219
- "\u274C"
220
- ]);
221
- const filtered = [];
222
- for (const note of notes) {
223
- const body = (note.body ?? "").trim();
224
- if (!body) continue;
225
- if (note.system) continue;
226
- if (body.length < 20) continue;
227
- const bodyLower = body.toLowerCase();
228
- let isTrivial = false;
229
- for (const pattern of trivialPatterns) {
230
- if (bodyLower.includes(pattern) && body.length < 50) {
231
- isTrivial = true;
232
- break;
233
- }
234
- }
235
- if (isTrivial) continue;
236
- const username = note.author?.username ?? note.author?.name ?? "unknown";
237
- filtered.push({ username, body, createdAt: note.created_at ?? "" });
238
- }
239
- filtered.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
240
- const recent = filtered.slice(-maxEntries);
241
- const lines = [];
242
- for (const { username, body, createdAt } of recent) {
243
- let timestampStr = "";
244
- if (createdAt) {
245
- try {
246
- const dt = new Date(createdAt);
247
- timestampStr = dt.toISOString().replace("T", " ").slice(0, 16);
248
- } catch {
249
- timestampStr = createdAt.slice(0, 10);
250
- }
251
- }
252
- const header = timestampStr ? `- ${timestampStr} @${username}:` : `- @${username}:`;
253
- const indentedBody = body.split("\n").join("\n ");
254
- lines.push(`${header}
255
- ${indentedBody}`);
256
- }
257
- return lines.join("\n");
258
- }
259
-
260
- // src/prompt.ts
261
27
  function getTemplatesDir() {
262
28
  const currentDir = dirname(fileURLToPath(import.meta.url));
263
29
  return resolve(currentDir, "..", "templates");
@@ -308,7 +74,7 @@ function buildPrReviewPrompt(opts) {
308
74
  } else if (localMode) {
309
75
  prDiffCmd = `git --no-pager diff ${targetBranch} --name-only`;
310
76
  gitDiffCmd = `git --no-pager diff ${targetBranch}`;
311
- } else if (platform === "github") {
77
+ } else if (platform === "github" || platform === "gitea") {
312
78
  prDiffCmd = `git --no-pager diff origin/${targetBranch}...HEAD --name-only`;
313
79
  gitDiffCmd = `git --no-pager diff origin/${targetBranch}...HEAD`;
314
80
  } else {
@@ -334,10 +100,14 @@ function buildPrReviewPrompt(opts) {
334
100
  if (previousReviewSha) {
335
101
  incrementalSection = `## Incremental Review Mode
336
102
 
337
- This is a follow-up review. A previous hodor review was done at commit \`${previousReviewSha.slice(0, 8)}\`. The diff below shows ONLY changes since that review. Focus on:
338
- 1. New code changes introduced since the last review
339
- 2. Whether previous findings (shown in MR notes above) are still applicable
340
- 3. Do NOT re-report issues that are already mentioned in existing notes
103
+ This is a follow-up review. A previous hodor review was done at commit \`${previousReviewSha.slice(0, 8)}\`. The diff below shows ONLY changes since that review. Your job is to review that delta, not the whole MR again.
104
+
105
+ Rules for incremental reviews:
106
+ 1. Only report bugs introduced or still affected by the new delta.
107
+ 2. 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.
108
+ 3. If the delta is small and self-contained, decide from the embedded diff and submit the review without broad repository exploration.
109
+ 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.
110
+ 5. If the delta does not introduce a production bug, submit no findings.
341
111
 
342
112
  `;
343
113
  }
@@ -357,8 +127,8 @@ This is a follow-up review. A previous hodor review was done at commit \`${previ
357
127
  - NEVER flag "dependency version downgrade" (branch not rebased)
358
128
  - NEVER compare entire codebase to ${targetBranch} - DIFF ONLY
359
129
  `;
360
- reviewProcessSection = "## Review Process\n\n1. Analyze the embedded diff above thoroughly\n2. Use `grep` to search for patterns if needed\n3. Use `read` only when surrounding context is essential\n4. Submit your review using `submit_review`\n";
361
- startInstruction = "Analyze the diff provided above, then submit your review using `submit_review`.";
130
+ reviewProcessSection = "## Review Process\n\n1. Analyze the embedded diff above thoroughly\n2. Use `grep` to search for patterns when needed\n3. Use `read` only when surrounding context is essential\n4. Submit your review using `submit_review`\n";
131
+ 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`.";
362
132
  } else {
363
133
  embeddedDiffSection = "";
364
134
  diffFetchInstructions = "## Step 1: List Changed Files (MANDATORY FIRST STEP)\n\n**Run this command FIRST to get the list of changed files:**\n```bash\n" + prDiffCmd + "\n```\n\nThis lists ONLY the filenames changed in this PR. **Do NOT dump the entire diff here** - you'll inspect each file individually in Step 2. Only review files that appear in this output.\n\n## Step 2: Review Changed Files Only\n\n### Critical Rules\n- ONLY review files that appear in the diff from Step 1\n- ONLY analyze actual code changes (+ and - lines in the diff)\n- Use the most reliable diff command: `" + gitDiffCmd + `\`
@@ -489,22 +259,34 @@ function normalizeLabelNames(rawLabels) {
489
259
  }
490
260
 
491
261
  // src/model.ts
262
+ import { getEnvApiKey, getProviders } from "@earendil-works/pi-ai";
263
+ var PROVIDER_ALIASES = {
264
+ bedrock: "amazon-bedrock"
265
+ };
492
266
  function parseModelString(model) {
493
267
  const trimmed = model.trim();
494
268
  if (!trimmed) throw new Error("Model name must be provided");
495
269
  const parts = trimmed.split("/");
496
270
  if (parts.length >= 2) {
497
271
  const first = parts[0].toLowerCase();
498
- if (first === "bedrock") {
272
+ const provider = PROVIDER_ALIASES[first] ?? first;
273
+ const knownProviders = new Set(getProviders());
274
+ if (provider === "amazon-bedrock") {
499
275
  let modelId = parts.slice(1).join("/");
500
276
  if (modelId.startsWith("converse/")) {
501
277
  modelId = modelId.slice("converse/".length);
502
278
  }
503
- return { provider: "amazon-bedrock", modelId };
279
+ return { provider, modelId };
280
+ }
281
+ if (knownProviders.has(provider)) {
282
+ return { provider, modelId: parts.slice(1).join("/") };
504
283
  }
505
- if (["anthropic", "openai"].includes(first)) {
506
- return { provider: first, modelId: parts.slice(1).join("/") };
284
+ if (provider === "openrouter") {
285
+ return { provider, modelId: parts.slice(1).join("/") };
507
286
  }
287
+ throw new Error(
288
+ `Unsupported provider "${first}". Use a pi-ai provider prefix such as anthropic/, openai/, openrouter/, google/, mistral/, xai/, or bedrock/.`
289
+ );
508
290
  }
509
291
  const lower = trimmed.toLowerCase();
510
292
  if (lower.includes("claude") || lower.includes("anthropic")) {
@@ -518,41 +300,44 @@ function parseModelString(model) {
518
300
  function mapReasoningEffort(effort) {
519
301
  if (!effort) return void 0;
520
302
  switch (effort.toLowerCase()) {
303
+ case "minimal":
304
+ return "minimal";
521
305
  case "low":
522
306
  return "low";
523
307
  case "medium":
524
308
  return "medium";
525
309
  case "high":
526
- case "xhigh":
527
310
  return "high";
311
+ case "xhigh":
312
+ return "xhigh";
528
313
  default:
529
314
  return void 0;
530
315
  }
531
316
  }
317
+ function normalizeModelMatchValue(value) {
318
+ return value.toLowerCase().replace(/[\s_.:/]+/g, "-");
319
+ }
320
+ function getDefaultReasoningEffortForModel(model) {
321
+ const values = [model.id, model.name].filter((value) => Boolean(value));
322
+ const isOpus47 = values.map(normalizeModelMatchValue).some((value) => value.includes("opus-4-7"));
323
+ return isOpus47 ? "xhigh" : void 0;
324
+ }
532
325
  function getApiKey(model) {
533
326
  const llmKey = process.env.LLM_API_KEY;
534
327
  if (llmKey) return llmKey;
535
328
  if (model) {
536
329
  const { provider } = parseModelString(model);
537
330
  if (provider === "amazon-bedrock") return null;
538
- if (provider === "anthropic") {
539
- const key = process.env.ANTHROPIC_API_KEY;
540
- if (key) return key;
541
- }
542
- if (provider === "openai") {
543
- const key = process.env.OPENAI_API_KEY;
544
- if (key) return key;
545
- }
331
+ const key = getEnvApiKey(provider);
332
+ if (key) return key;
546
333
  }
547
- if (process.env.ANTHROPIC_API_KEY) return process.env.ANTHROPIC_API_KEY;
548
- if (process.env.OPENAI_API_KEY) return process.env.OPENAI_API_KEY;
549
334
  throw new Error(
550
- "No LLM API key found. Please set one of: LLM_API_KEY, ANTHROPIC_API_KEY, or OPENAI_API_KEY"
335
+ model ? `No API key found for provider "${parseModelString(model).provider}". Set the provider-specific environment variable or LLM_API_KEY.` : "No LLM API key found. Please set LLM_API_KEY or a provider-specific environment variable."
551
336
  );
552
337
  }
553
338
 
554
339
  // src/metrics.ts
555
- import chalk2 from "chalk";
340
+ import chalk from "chalk";
556
341
  function tok(value) {
557
342
  if (value >= 1e6) return `${(value / 1e6).toFixed(2)}M`;
558
343
  if (value >= 1e3) return `${(value / 1e3).toFixed(1)}K`;
@@ -583,9 +368,9 @@ function formatMetricsMarkdown(metrics) {
583
368
  return lines.join("\n");
584
369
  }
585
370
  function printMetrics(metrics, stream = process.stderr) {
586
- const dim = chalk2.dim;
587
- const bold = chalk2.bold;
588
- const cyan = chalk2.cyan;
371
+ const dim = chalk.dim;
372
+ const bold = chalk.bold;
373
+ const cyan = chalk.cyan;
589
374
  const write = (line) => stream.write(line + "\n");
590
375
  write("");
591
376
  write(dim("\u2500".repeat(50)));
@@ -607,10 +392,18 @@ function printMetrics(metrics, stream = process.stderr) {
607
392
  write(dim("\u2500".repeat(50)));
608
393
  }
609
394
  async function pushMetrics(opts) {
610
- const { pushgatewayUrl, metrics, labels = {} } = opts;
611
- const labelPairs = Object.entries(labels).map(([k, v]) => `${k}="${v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`).join(",");
612
- const labelSuffix = labelPairs ? `{${labelPairs}}` : "";
395
+ const { pushgatewayUrl, metrics, findings = [], labels = {} } = opts;
396
+ const formatLabels = (extraLabels = {}) => {
397
+ const labelPairs = Object.entries({ ...labels, ...extraLabels }).map(([k, v]) => `${k}="${v.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/"/g, '\\"')}"`).join(",");
398
+ return labelPairs ? `{${labelPairs}}` : "";
399
+ };
400
+ const labelSuffix = formatLabels();
613
401
  const totalInput = metrics.inputTokens + metrics.cacheReadTokens;
402
+ const cacheHitRatio = totalInput > 0 ? metrics.cacheReadTokens / totalInput : 0;
403
+ const priorityCounts = [0, 1, 2, 3].map((priority) => ({
404
+ priority,
405
+ count: findings.filter((finding) => finding.priority === priority).length
406
+ }));
614
407
  const lines = [
615
408
  `# HELP hodor_review_input_tokens_total Total input tokens (fresh + cached)`,
616
409
  `# TYPE hodor_review_input_tokens_total gauge`,
@@ -621,6 +414,17 @@ async function pushMetrics(opts) {
621
414
  `# HELP hodor_review_cache_read_tokens_total Tokens served from prompt cache`,
622
415
  `# TYPE hodor_review_cache_read_tokens_total gauge`,
623
416
  `hodor_review_cache_read_tokens_total${labelSuffix} ${metrics.cacheReadTokens}`,
417
+ `# HELP hodor_review_cache_write_tokens_total Tokens written to prompt cache`,
418
+ `# TYPE hodor_review_cache_write_tokens_total gauge`,
419
+ `hodor_review_cache_write_tokens_total${labelSuffix} ${metrics.cacheWriteTokens}`,
420
+ `# HELP hodor_review_cache_hit_ratio Fraction of input tokens served from cache (0-1)`,
421
+ `# TYPE hodor_review_cache_hit_ratio gauge`,
422
+ `hodor_review_cache_hit_ratio${labelSuffix} ${cacheHitRatio}`,
423
+ `# HELP hodor_review_findings_total Number of findings at each priority level`,
424
+ `# TYPE hodor_review_findings_total gauge`,
425
+ ...priorityCounts.map(
426
+ ({ priority, count }) => `hodor_review_findings_total${formatLabels({ priority: `P${priority}` })} ${count}`
427
+ ),
624
428
  `# HELP hodor_review_cost_dollars Cost of the review in USD`,
625
429
  `# TYPE hodor_review_cost_dollars gauge`,
626
430
  `hodor_review_cost_dollars${labelSuffix} ${metrics.cost}`,
@@ -637,7 +441,7 @@ async function pushMetrics(opts) {
637
441
  ];
638
442
  const body = lines.join("\n");
639
443
  const baseUrl = pushgatewayUrl.replace(/\/+$/, "");
640
- const url = `${baseUrl}/metrics/job/hodor`;
444
+ const url = baseUrl.endsWith("/api/v1/import/prometheus") ? baseUrl : `${baseUrl}/metrics/job/hodor`;
641
445
  try {
642
446
  const res = await fetch(url, {
643
447
  method: "POST",
@@ -647,12 +451,12 @@ async function pushMetrics(opts) {
647
451
  });
648
452
  if (!res.ok) {
649
453
  const text = await res.text().catch(() => "");
650
- logger.warn(`Pushgateway returned ${res.status}: ${text.slice(0, 200)}`);
454
+ logger.warn(`Metrics endpoint returned ${res.status}: ${text.slice(0, 200)}`);
651
455
  } else {
652
- logger.info("Metrics pushed to Pushgateway");
456
+ logger.info("Metrics pushed successfully");
653
457
  }
654
458
  } catch (err) {
655
- logger.warn(`Failed to push metrics to Pushgateway: ${err instanceof Error ? err.message : err}`);
459
+ logger.warn(`Failed to push metrics: ${err instanceof Error ? err.message : err}`);
656
460
  }
657
461
  }
658
462
 
@@ -683,7 +487,8 @@ var REVIEW_FINDING_SCHEMA = Type.Object(
683
487
  title: Type.String({ minLength: 1 }),
684
488
  body: Type.String({ minLength: 1 }),
685
489
  priority: Type.Integer({ minimum: 0, maximum: 3 }),
686
- code_location: REVIEW_LOCATION_SCHEMA
490
+ code_location: REVIEW_LOCATION_SCHEMA,
491
+ suggestion: Type.Optional(Type.String({ minLength: 1 }))
687
492
  },
688
493
  { additionalProperties: false }
689
494
  );
@@ -702,6 +507,11 @@ function validateReviewOutput(review) {
702
507
  if (review.overall_explanation.trim().length === 0) {
703
508
  throw new Error("submit_review overall_explanation must be non-empty");
704
509
  }
510
+ if (review.findings.length > 0 && review.overall_correctness !== "patch is incorrect") {
511
+ review = { ...review, overall_correctness: "patch is incorrect" };
512
+ } else if (review.findings.length === 0 && review.overall_correctness !== "patch is correct") {
513
+ review = { ...review, overall_correctness: "patch is correct" };
514
+ }
705
515
  for (const [index, finding] of review.findings.entries()) {
706
516
  const label = `submit_review finding ${index + 1}`;
707
517
  if (finding.title.trim().length === 0) {
@@ -738,6 +548,7 @@ function getPriorityFromTitle(title) {
738
548
  // src/agent.ts
739
549
  import { existsSync } from "fs";
740
550
  import { join as join2 } from "path";
551
+ import { Value } from "@sinclair/typebox/value";
741
552
 
742
553
  // src/github.ts
743
554
  var GitHubAPIError = class extends Error {
@@ -828,6 +639,168 @@ function githubCommentsToNotes(comments) {
828
639
  });
829
640
  }
830
641
 
642
+ // src/gitea.ts
643
+ var GiteaAPIError = class extends Error {
644
+ constructor(message) {
645
+ super(message);
646
+ this.name = "GiteaAPIError";
647
+ }
648
+ };
649
+ function normalizeBaseUrl(host) {
650
+ const candidate = host || process.env.GITEA_HOST || process.env.FORGEJO_HOST;
651
+ if (!candidate) {
652
+ throw new GiteaAPIError(
653
+ "No Gitea/Forgejo host configured. Set GITEA_HOST or FORGEJO_HOST, or provide a full PR URL that includes the hostname."
654
+ );
655
+ }
656
+ const trimmed = candidate.trim();
657
+ if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
658
+ return trimmed.replace(/\/+$/, "");
659
+ }
660
+ return `https://${trimmed}`.replace(/\/+$/, "");
661
+ }
662
+ function giteaToken() {
663
+ return process.env.GITEA_TOKEN ?? process.env.FORGEJO_TOKEN ?? null;
664
+ }
665
+ function requireGiteaToken() {
666
+ const token = giteaToken();
667
+ if (!token) {
668
+ throw new GiteaAPIError(
669
+ "No Gitea/Forgejo token found. Set GITEA_TOKEN or FORGEJO_TOKEN environment variable."
670
+ );
671
+ }
672
+ return token;
673
+ }
674
+ async function giteaFetch(host, path, options) {
675
+ const baseUrl = normalizeBaseUrl(host);
676
+ const url = `${baseUrl}/api/v1/${path}`;
677
+ const token = giteaToken();
678
+ const headers = {
679
+ Accept: "application/json",
680
+ "Content-Type": "application/json"
681
+ };
682
+ if (token) {
683
+ headers.Authorization = `token ${token}`;
684
+ }
685
+ const init = {
686
+ method: options?.method ?? "GET",
687
+ headers
688
+ };
689
+ if (options?.body) {
690
+ init.body = JSON.stringify(options.body);
691
+ }
692
+ const response = await fetch(url, init);
693
+ if (!response.ok) {
694
+ const text = await response.text().catch(() => "");
695
+ if (response.status === 401 || response.status === 403) {
696
+ throw new GiteaAPIError(
697
+ `Authentication failed (${response.status}): check GITEA_TOKEN/FORGEJO_TOKEN. ${text}`
698
+ );
699
+ }
700
+ if (response.status === 429) {
701
+ throw new GiteaAPIError(`Rate limited by Gitea API (429). ${text}`);
702
+ }
703
+ throw new GiteaAPIError(
704
+ `Gitea API error ${response.status} for ${options?.method ?? "GET"} ${path}: ${text}`
705
+ );
706
+ }
707
+ return await response.json();
708
+ }
709
+ async function fetchGiteaPrInfo(owner, repo, prNumber, host, options) {
710
+ let prData;
711
+ try {
712
+ prData = await giteaFetch(
713
+ host,
714
+ `repos/${owner}/${repo}/pulls/${prNumber}`
715
+ );
716
+ } catch (err) {
717
+ if (err instanceof GiteaAPIError) throw err;
718
+ const msg = err instanceof Error ? err.message : String(err);
719
+ throw new GiteaAPIError(`Failed to fetch PR #${prNumber}: ${msg}`);
720
+ }
721
+ const user = prData.user ?? {};
722
+ const head = prData.head ?? {};
723
+ const base = prData.base ?? {};
724
+ const labels = prData.labels ?? [];
725
+ const metadata = {
726
+ title: prData.title,
727
+ description: prData.body ?? "",
728
+ source_branch: head.ref,
729
+ target_branch: base.ref,
730
+ changes_count: prData.changed_files,
731
+ labels: labels.map((lbl) => ({ name: lbl.name })),
732
+ author: {
733
+ username: user.login,
734
+ name: user.full_name || user.login
735
+ },
736
+ state: prData.state
737
+ };
738
+ if (options?.includeComments) {
739
+ try {
740
+ metadata.Notes = await fetchGiteaPrComments(owner, repo, prNumber, host);
741
+ } catch (err) {
742
+ logger.warn(`Failed to fetch PR comments: ${err instanceof Error ? err.message : err}`);
743
+ }
744
+ }
745
+ return metadata;
746
+ }
747
+ async function fetchGiteaPrCheckoutInfo(owner, repo, prNumber, host) {
748
+ const prData = await giteaFetch(
749
+ host,
750
+ `repos/${owner}/${repo}/pulls/${prNumber}`
751
+ );
752
+ const head = prData.head ?? {};
753
+ const base = prData.base ?? {};
754
+ const headRepo = head.repo ?? null;
755
+ const sourceBranch = head.ref;
756
+ if (!sourceBranch) {
757
+ throw new GiteaAPIError(`Could not determine source branch for PR #${prNumber}`);
758
+ }
759
+ return {
760
+ sourceBranch,
761
+ targetBranch: base.ref ?? "main",
762
+ sourceCloneUrl: headRepo?.clone_url
763
+ };
764
+ }
765
+ async function fetchGiteaPrComments(owner, repo, prNumber, host) {
766
+ const comments = [];
767
+ const pageSize = 100;
768
+ for (let page = 1; ; page++) {
769
+ const batch = await giteaFetch(
770
+ host,
771
+ `repos/${owner}/${repo}/issues/${prNumber}/comments?page=${page}&limit=${pageSize}`
772
+ );
773
+ comments.push(...batch);
774
+ if (batch.length < pageSize) break;
775
+ }
776
+ return comments.map((c) => {
777
+ const user = c.user ?? {};
778
+ return {
779
+ body: c.body ?? "",
780
+ author: {
781
+ username: user.login,
782
+ name: user.full_name || user.login
783
+ },
784
+ created_at: c.created_at,
785
+ system: false
786
+ };
787
+ });
788
+ }
789
+ async function postGiteaPrComment(owner, repo, prNumber, body, host) {
790
+ requireGiteaToken();
791
+ try {
792
+ await giteaFetch(
793
+ host,
794
+ `repos/${owner}/${repo}/issues/${prNumber}/comments`,
795
+ { method: "POST", body: { body } }
796
+ );
797
+ } catch (err) {
798
+ if (err instanceof GiteaAPIError) throw err;
799
+ const msg = err instanceof Error ? err.message : String(err);
800
+ throw new GiteaAPIError(`Failed to post comment to PR #${prNumber}: ${msg}`);
801
+ }
802
+ }
803
+
831
804
  // src/workspace.ts
832
805
  import { mkdtemp, rm } from "fs/promises";
833
806
  import { tmpdir } from "os";
@@ -838,43 +811,78 @@ var WorkspaceError = class extends Error {
838
811
  this.name = "WorkspaceError";
839
812
  }
840
813
  };
841
- function detectCiWorkspace(owner, repo) {
814
+ function envOrNull(name) {
815
+ const value = process.env[name]?.trim();
816
+ return value ? value : null;
817
+ }
818
+ async function detectCiWorkspace(owner, repo) {
819
+ const expected = `${owner}/${repo}`;
842
820
  if (process.env.GITLAB_CI === "true") {
843
- const projectDir = process.env.CI_PROJECT_DIR;
844
- const projectPath = process.env.CI_PROJECT_PATH;
845
- const targetBranch = process.env.CI_MERGE_REQUEST_TARGET_BRANCH_NAME ?? null;
846
- const diffBaseSha = process.env.CI_MERGE_REQUEST_DIFF_BASE_SHA ?? null;
847
- if (projectDir && projectPath) {
848
- const expected = `${owner}/${repo}`;
849
- if (projectPath === expected || projectPath.endsWith(`/${expected}`)) {
821
+ const projectDir = envOrNull("CI_PROJECT_DIR");
822
+ const projectPath = envOrNull("CI_PROJECT_PATH");
823
+ const targetBranch = envOrNull("CI_MERGE_REQUEST_TARGET_BRANCH_NAME");
824
+ const diffBaseSha = envOrNull("CI_MERGE_REQUEST_DIFF_BASE_SHA");
825
+ if (projectDir && projectPath && (projectPath === expected || projectPath.endsWith(`/${expected}`))) {
826
+ if (await isSameRepo(projectDir, owner, repo)) {
850
827
  logger.info(`Detected GitLab CI environment (target: ${targetBranch ?? "unknown"})`);
851
828
  return { path: projectDir, targetBranch, diffBaseSha };
852
829
  }
830
+ logger.warn(
831
+ `Detected GitLab CI for ${projectPath}, but ${projectDir} is not a git checkout of ${expected}; falling back to clone`
832
+ );
833
+ }
834
+ }
835
+ if (process.env.GITEA_ACTIONS === "true" || process.env.FORGEJO_ACTIONS === "true") {
836
+ const workspaceDir = envOrNull("GITHUB_WORKSPACE");
837
+ const repository = envOrNull("GITHUB_REPOSITORY");
838
+ const baseRef = envOrNull("GITHUB_BASE_REF");
839
+ if (workspaceDir && repository === expected) {
840
+ const ciType = process.env.FORGEJO_ACTIONS ? "Forgejo" : "Gitea";
841
+ if (await isSameRepo(workspaceDir, owner, repo)) {
842
+ logger.info(`Detected ${ciType} Actions environment (base: ${baseRef ?? "unknown"})`);
843
+ return { path: workspaceDir, targetBranch: baseRef, diffBaseSha: null };
844
+ }
845
+ logger.warn(
846
+ `Detected ${ciType} Actions for ${repository}, but ${workspaceDir} is not a git checkout of ${expected}; falling back to clone`
847
+ );
853
848
  }
854
849
  }
855
850
  if (process.env.GITHUB_ACTIONS === "true") {
856
- const workspaceDir = process.env.GITHUB_WORKSPACE;
857
- const repository = process.env.GITHUB_REPOSITORY;
858
- const baseRef = process.env.GITHUB_BASE_REF ?? null;
859
- if (workspaceDir && repository) {
860
- const expected = `${owner}/${repo}`;
861
- if (repository === expected) {
851
+ const workspaceDir = envOrNull("GITHUB_WORKSPACE");
852
+ const repository = envOrNull("GITHUB_REPOSITORY");
853
+ const baseRef = envOrNull("GITHUB_BASE_REF");
854
+ if (workspaceDir && repository === expected) {
855
+ if (await isSameRepo(workspaceDir, owner, repo)) {
862
856
  logger.info(`Detected GitHub Actions environment (base: ${baseRef ?? "unknown"})`);
863
857
  return { path: workspaceDir, targetBranch: baseRef, diffBaseSha: null };
864
858
  }
859
+ logger.warn(
860
+ `Detected GitHub Actions for ${repository}, but ${workspaceDir} is not a git checkout of ${expected}; falling back to clone`
861
+ );
865
862
  }
866
863
  }
867
864
  return { path: null, targetBranch: null, diffBaseSha: null };
868
865
  }
866
+ function normalizeGitRemotePath(remoteUrl) {
867
+ const trimmed = remoteUrl.trim().replace(/\.git$/, "");
868
+ try {
869
+ const url = new URL(trimmed);
870
+ return url.pathname.replace(/^\/+/, "").replace(/\.git$/, "");
871
+ } catch {
872
+ }
873
+ const scpLikeMatch = trimmed.match(/^[^@\s]+@[^:\s]+:(.+)$/);
874
+ if (scpLikeMatch) {
875
+ return scpLikeMatch[1].replace(/^\/+/, "").replace(/\.git$/, "");
876
+ }
877
+ return trimmed.replace(/^\/+/, "").replace(/\.git$/, "");
878
+ }
869
879
  async function isSameRepo(workspace, owner, repo) {
870
880
  try {
871
881
  const { stdout } = await exec("git", ["remote", "get-url", "origin"], { cwd: workspace });
872
882
  const remoteUrl = stdout.trim();
873
- const match = remoteUrl.match(/[/:]([\w.\-\/]+?)(?:\.git)?$/);
874
- if (!match) return false;
875
- const remotePath = match[1];
876
- const expectedPath = `${owner}/${repo}`;
877
- return remotePath === expectedPath;
883
+ const expectedPath = `${owner}/${repo}`.replace(/\.git$/, "");
884
+ const remotePath = normalizeGitRemotePath(remoteUrl);
885
+ return remotePath === expectedPath || remotePath.toLowerCase() === expectedPath.toLowerCase();
878
886
  } catch {
879
887
  return false;
880
888
  }
@@ -993,16 +1001,90 @@ async function cloneAndCheckoutGitlabMr(workspace, owner, repo, prNumber, host)
993
1001
  await checkoutGitlabBranch(workspace, sourceBranch);
994
1002
  return targetBranch;
995
1003
  }
1004
+ function normalizeGiteaBaseUrl(host) {
1005
+ const giteaHost = host || process.env.GITEA_HOST || process.env.FORGEJO_HOST;
1006
+ if (!giteaHost) {
1007
+ throw new WorkspaceError("No Gitea/Forgejo host configured. Set GITEA_HOST or FORGEJO_HOST.");
1008
+ }
1009
+ const trimmed = giteaHost.trim().replace(/\/+$/, "");
1010
+ if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
1011
+ return trimmed;
1012
+ }
1013
+ return `https://${trimmed}`;
1014
+ }
1015
+ function giteaGitArgs(args) {
1016
+ const token = process.env.GITEA_TOKEN || process.env.FORGEJO_TOKEN;
1017
+ return token ? ["-c", `http.extraHeader=Authorization: token ${token}`, ...args] : args;
1018
+ }
1019
+ async function getGiteaPrCheckoutInfo(owner, repo, prNumber, host) {
1020
+ try {
1021
+ return await fetchGiteaPrCheckoutInfo(owner, repo, Number(prNumber), host);
1022
+ } catch (err) {
1023
+ const msg = err instanceof Error ? err.message : String(err);
1024
+ throw new WorkspaceError(`Failed to fetch PR info for #${prNumber}: ${msg}`);
1025
+ }
1026
+ }
1027
+ async function checkoutGiteaBranch(workspace, prNumber, checkoutInfo) {
1028
+ const { sourceBranch, sourceCloneUrl } = checkoutInfo;
1029
+ const localBranch = `hodor-pr-${prNumber}`;
1030
+ if (sourceCloneUrl) {
1031
+ try {
1032
+ await exec("git", giteaGitArgs(["fetch", sourceCloneUrl, sourceBranch]), { cwd: workspace });
1033
+ await exec("git", ["checkout", "-B", localBranch, "FETCH_HEAD"], { cwd: workspace });
1034
+ return;
1035
+ } catch (err) {
1036
+ logger.warn(`Failed to fetch Gitea PR branch from source repo, falling back to origin: ${err}`);
1037
+ }
1038
+ }
1039
+ try {
1040
+ await exec("git", ["checkout", "-B", localBranch, `origin/${sourceBranch}`], {
1041
+ cwd: workspace
1042
+ });
1043
+ } catch {
1044
+ try {
1045
+ await exec("git", ["checkout", sourceBranch], { cwd: workspace });
1046
+ } catch (err) {
1047
+ const msg = err instanceof Error ? err.message : String(err);
1048
+ throw new WorkspaceError(`Failed to checkout PR branch '${sourceBranch}': ${msg}`);
1049
+ }
1050
+ }
1051
+ }
1052
+ async function fetchAndCheckoutGiteaPr(workspace, owner, repo, prNumber, host) {
1053
+ logger.info(`Fetching and checking out PR #${prNumber} in existing workspace`);
1054
+ await exec("git", giteaGitArgs(["fetch", "origin"]), { cwd: workspace });
1055
+ const checkoutInfo = await getGiteaPrCheckoutInfo(owner, repo, prNumber, host);
1056
+ logger.info(`Source branch: ${checkoutInfo.sourceBranch}, Target branch: ${checkoutInfo.targetBranch}`);
1057
+ await checkoutGiteaBranch(workspace, prNumber, checkoutInfo);
1058
+ return checkoutInfo.targetBranch;
1059
+ }
1060
+ async function cloneAndCheckoutGiteaPr(workspace, owner, repo, prNumber, host) {
1061
+ logger.info(`Setting up Gitea workspace for ${owner}/${repo}/pulls/${prNumber}`);
1062
+ const cloneUrl = `${normalizeGiteaBaseUrl(host)}/${owner}/${repo}.git`;
1063
+ logger.info(`Cloning from ${cloneUrl}...`);
1064
+ try {
1065
+ await exec("git", giteaGitArgs(["clone", cloneUrl, workspace]));
1066
+ } catch (err) {
1067
+ const msg = err instanceof Error ? err.message : String(err);
1068
+ throw new WorkspaceError(`Failed to clone ${owner}/${repo}: ${msg}`);
1069
+ }
1070
+ const checkoutInfo = await getGiteaPrCheckoutInfo(owner, repo, prNumber, host);
1071
+ logger.info(`Source branch: ${checkoutInfo.sourceBranch}, Target branch: ${checkoutInfo.targetBranch}`);
1072
+ await checkoutGiteaBranch(workspace, prNumber, checkoutInfo);
1073
+ return checkoutInfo.targetBranch;
1074
+ }
996
1075
  async function setupWorkspace(opts) {
997
1076
  const { platform, owner, repo, prNumber, host, workingDir, reuse = true } = opts;
998
1077
  try {
999
- const ci = detectCiWorkspace(owner, repo);
1078
+ const ci = await detectCiWorkspace(owner, repo);
1000
1079
  let detectedTargetBranch = ci.targetBranch;
1001
1080
  const detectedDiffBaseSha = ci.diffBaseSha;
1002
1081
  let workspace;
1003
1082
  let isTemporary = false;
1004
1083
  if (ci.path) {
1005
1084
  workspace = ci.path;
1085
+ if (platform === "github" && !detectedTargetBranch) {
1086
+ detectedTargetBranch = await getGithubBaseBranch(workspace, prNumber);
1087
+ }
1006
1088
  } else if (!workingDir) {
1007
1089
  workspace = await mkdtemp(join(tmpdir(), "hodor-review-"));
1008
1090
  isTemporary = true;
@@ -1019,6 +1101,9 @@ async function setupWorkspace(opts) {
1019
1101
  } else if (platform === "gitlab") {
1020
1102
  const tb = await fetchAndCheckoutGitlabMr(workspace, owner, repo, prNumber, host);
1021
1103
  if (!detectedTargetBranch) detectedTargetBranch = tb;
1104
+ } else if (platform === "gitea") {
1105
+ const tb = await fetchAndCheckoutGiteaPr(workspace, owner, repo, prNumber, host);
1106
+ if (!detectedTargetBranch) detectedTargetBranch = tb;
1022
1107
  }
1023
1108
  const finalTargetBranch2 = detectedTargetBranch ?? "main";
1024
1109
  logger.info(
@@ -1034,6 +1119,9 @@ async function setupWorkspace(opts) {
1034
1119
  } else if (platform === "gitlab") {
1035
1120
  const tb = await cloneAndCheckoutGitlabMr(workspace, owner, repo, prNumber, host);
1036
1121
  if (!detectedTargetBranch) detectedTargetBranch = tb;
1122
+ } else if (platform === "gitea") {
1123
+ const tb = await cloneAndCheckoutGiteaPr(workspace, owner, repo, prNumber, host);
1124
+ if (!detectedTargetBranch) detectedTargetBranch = tb;
1037
1125
  } else {
1038
1126
  throw new WorkspaceError(`Unsupported platform: ${platform}`);
1039
1127
  }
@@ -1086,11 +1174,14 @@ function detectPlatform(prUrl) {
1086
1174
  if (prUrl.includes("/-/merge_requests/") || hostname.includes("gitlab")) {
1087
1175
  return "gitlab";
1088
1176
  }
1177
+ if (prUrl.includes("/pulls/") || hostname.includes("gitea") || hostname.includes("forgejo") || hostname.includes("codeberg")) {
1178
+ return "gitea";
1179
+ }
1089
1180
  if (prUrl.includes("/pull/") || hostname.includes("github")) {
1090
1181
  return "github";
1091
1182
  }
1092
1183
  throw new Error(
1093
- `Cannot detect platform for URL: ${prUrl}. Expected a GitHub pull request (/pull/) or GitLab merge request (/-/merge_requests/) URL.`
1184
+ `Cannot detect platform for URL: ${prUrl}. Expected a GitHub (/pull/), GitLab (/-/merge_requests/), or Gitea/Forgejo (/pulls/) URL.`
1094
1185
  );
1095
1186
  }
1096
1187
  function parsePrUrl(prUrl) {
@@ -1109,6 +1200,18 @@ function parsePrUrl(prUrl) {
1109
1200
  host
1110
1201
  };
1111
1202
  }
1203
+ if (pathParts.length >= 4 && pathParts[2] === "pulls") {
1204
+ const prNumber = parseInt(pathParts[3], 10);
1205
+ if (!Number.isSafeInteger(prNumber) || prNumber <= 0) {
1206
+ throw new Error(`Invalid PR number in URL: ${prUrl}. Expected a positive integer after /pulls/.`);
1207
+ }
1208
+ return {
1209
+ owner: pathParts[0],
1210
+ repo: pathParts[1],
1211
+ prNumber,
1212
+ host
1213
+ };
1214
+ }
1112
1215
  const mrIndex = pathParts.indexOf("merge_requests");
1113
1216
  if (mrIndex >= 0) {
1114
1217
  if (mrIndex < 2 || mrIndex + 1 >= pathParts.length) {
@@ -1131,7 +1234,23 @@ function parsePrUrl(prUrl) {
1131
1234
  return { owner, repo, prNumber, host };
1132
1235
  }
1133
1236
  throw new Error(
1134
- `Invalid PR/MR URL format: ${prUrl}. Expected GitHub pull request or GitLab merge request URL.`
1237
+ `Invalid PR/MR URL format: ${prUrl}. Expected GitHub (/pull/), GitLab (/-/merge_requests/), or Gitea/Forgejo (/pulls/) URL.`
1238
+ );
1239
+ }
1240
+ function formatLocationRelative(loc, workspacePath) {
1241
+ return relativizeWorkspacePath(loc.absolute_file_path, workspacePath ?? void 0);
1242
+ }
1243
+ async function postGitlabReviewCommitStatus(parsed, review, diffRefs) {
1244
+ const blocking = review.findings.filter((f) => f.priority <= 1).length;
1245
+ const state = blocking > 0 ? "failed" : "success";
1246
+ const description = blocking > 0 ? `${blocking} blocking issue(s) found` : review.findings.length > 0 ? `${review.findings.length} non-blocking issue(s)` : "No issues found";
1247
+ await postGitlabCommitStatus(
1248
+ parsed.owner,
1249
+ parsed.repo,
1250
+ diffRefs.head_sha,
1251
+ state,
1252
+ parsed.host,
1253
+ { description }
1135
1254
  );
1136
1255
  }
1137
1256
  async function postReviewComment(opts) {
@@ -1175,6 +1294,16 @@ ${metricsFooter}`;
1175
1294
  ]);
1176
1295
  logger.info(`Successfully posted review to GitHub PR #${parsed.prNumber}`);
1177
1296
  return { success: true, platform: "github", prNumber: parsed.prNumber };
1297
+ } else if (platform === "gitea") {
1298
+ await postGiteaPrComment(
1299
+ parsed.owner,
1300
+ parsed.repo,
1301
+ parsed.prNumber,
1302
+ body,
1303
+ parsed.host
1304
+ );
1305
+ logger.info(`Successfully posted review to Gitea PR #${parsed.prNumber}`);
1306
+ return { success: true, platform: "gitea", prNumber: parsed.prNumber };
1178
1307
  } else {
1179
1308
  await postGitlabMrComment(
1180
1309
  parsed.owner,
@@ -1198,6 +1327,361 @@ ${metricsFooter}`;
1198
1327
  return { success: false, error: msg };
1199
1328
  }
1200
1329
  }
1330
+ var HODOR_REVIEW_SHA_RE = /^\s*<!--\s*hodor:sha:([a-f0-9]{40})\s*-->/i;
1331
+ function getHodorReviewShaCandidates(notes) {
1332
+ if (!notes || notes.length === 0) return [];
1333
+ const candidates = [];
1334
+ for (const [index, note] of notes.entries()) {
1335
+ const match = note.body?.match(HODOR_REVIEW_SHA_RE);
1336
+ if (!match) continue;
1337
+ const createdAtMs = Date.parse(note.created_at ?? "");
1338
+ candidates.push({
1339
+ sha: match[1],
1340
+ createdAtMs: Number.isFinite(createdAtMs) ? createdAtMs : null,
1341
+ index
1342
+ });
1343
+ }
1344
+ candidates.sort((a, b) => {
1345
+ if (a.createdAtMs != null && b.createdAtMs != null && a.createdAtMs !== b.createdAtMs) {
1346
+ return b.createdAtMs - a.createdAtMs;
1347
+ }
1348
+ if (a.createdAtMs != null && b.createdAtMs == null) return -1;
1349
+ if (a.createdAtMs == null && b.createdAtMs != null) return 1;
1350
+ return a.index - b.index;
1351
+ });
1352
+ const seen = /* @__PURE__ */ new Set();
1353
+ const shas = [];
1354
+ for (const { sha } of candidates) {
1355
+ if (seen.has(sha)) continue;
1356
+ seen.add(sha);
1357
+ shas.push(sha);
1358
+ }
1359
+ return shas;
1360
+ }
1361
+ async function findLatestValidReviewSha(notes, workspacePath) {
1362
+ const candidates = getHodorReviewShaCandidates(notes);
1363
+ if (candidates.length === 0) return null;
1364
+ logger.info(`Found ${candidates.length} previous Hodor review marker(s)`);
1365
+ for (const sha of candidates) {
1366
+ try {
1367
+ const { stdout: objType } = await exec("git", ["cat-file", "-t", sha], { cwd: workspacePath });
1368
+ if (objType.trim() !== "commit") throw new Error("not a commit");
1369
+ await exec("git", ["merge-base", "--is-ancestor", sha, "HEAD"], { cwd: workspacePath });
1370
+ return sha;
1371
+ } catch {
1372
+ logger.info(`Skipping previous review SHA ${sha.slice(0, 8)}; not a valid ancestor of HEAD`);
1373
+ }
1374
+ }
1375
+ return null;
1376
+ }
1377
+ async function postReviewStructured(opts) {
1378
+ const {
1379
+ prUrl,
1380
+ review,
1381
+ model,
1382
+ metricsFooter,
1383
+ reviewStyle,
1384
+ commitStatus,
1385
+ codeQualityPath,
1386
+ headSha,
1387
+ workspacePath
1388
+ } = opts;
1389
+ const platform = detectPlatform(prUrl);
1390
+ if (platform === "github") {
1391
+ return postReviewComment({
1392
+ prUrl,
1393
+ reviewText: renderMarkdown(review),
1394
+ model,
1395
+ metricsFooter,
1396
+ headSha
1397
+ });
1398
+ }
1399
+ if (reviewStyle === "summary") {
1400
+ return postReviewComment({
1401
+ prUrl,
1402
+ reviewText: renderMarkdown(review),
1403
+ model,
1404
+ metricsFooter,
1405
+ headSha
1406
+ });
1407
+ }
1408
+ const parsed = parsePrUrl(prUrl);
1409
+ try {
1410
+ const discussions = await listHodorDiscussions(
1411
+ parsed.owner,
1412
+ parsed.repo,
1413
+ parsed.prNumber,
1414
+ parsed.host
1415
+ );
1416
+ const unresolvedIds = [...new Set(
1417
+ discussions.filter((d) => !d.resolved).map((d) => d.discussionId)
1418
+ )];
1419
+ if (unresolvedIds.length > 0) {
1420
+ const resolved = await resolveGitlabDiscussions(
1421
+ parsed.owner,
1422
+ parsed.repo,
1423
+ parsed.prNumber,
1424
+ unresolvedIds,
1425
+ parsed.host
1426
+ );
1427
+ if (resolved > 0) logger.info(`Resolved ${resolved} old Hodor discussion(s)`);
1428
+ }
1429
+ } catch (err) {
1430
+ logger.warn(`Failed to resolve old discussions: ${err instanceof Error ? err.message : err}`);
1431
+ }
1432
+ try {
1433
+ const deleted = await cleanupHodorComments(
1434
+ parsed.owner,
1435
+ parsed.repo,
1436
+ parsed.prNumber,
1437
+ parsed.host
1438
+ );
1439
+ if (deleted > 0) logger.info(`Cleaned up ${deleted} old Hodor comment(s)`);
1440
+ } catch (err) {
1441
+ logger.warn(`Failed to cleanup old comments: ${err instanceof Error ? err.message : err}`);
1442
+ }
1443
+ let diffRefs = null;
1444
+ try {
1445
+ diffRefs = await getGitlabMrDiffRefs(
1446
+ parsed.owner,
1447
+ parsed.repo,
1448
+ parsed.prNumber,
1449
+ parsed.host
1450
+ );
1451
+ } catch (err) {
1452
+ logger.warn(`Failed to get diff_refs, falling back to summary mode: ${err instanceof Error ? err.message : err}`);
1453
+ }
1454
+ if (!diffRefs) {
1455
+ return postReviewComment({
1456
+ prUrl,
1457
+ reviewText: renderMarkdown(review),
1458
+ model,
1459
+ metricsFooter,
1460
+ headSha
1461
+ });
1462
+ }
1463
+ let inlineCount = 0;
1464
+ let failedCount = 0;
1465
+ let summaryPosted = false;
1466
+ let draftsPublished = false;
1467
+ let statusPosted = false;
1468
+ const postingErrors = [];
1469
+ for (const finding of review.findings) {
1470
+ const relPath = formatLocationRelative(finding.code_location, workspacePath);
1471
+ const priorityTag = `[P${finding.priority}]`;
1472
+ const title = /^\[P[0-3]\]/.test(finding.title) ? finding.title : `${priorityTag} ${finding.title}`;
1473
+ let body = `${HODOR_REVIEW_MARKER}
1474
+ **${title}**
1475
+
1476
+ ${finding.body}`;
1477
+ if (finding.suggestion) {
1478
+ const { start, end } = finding.code_location.line_range;
1479
+ const span = Math.max(0, end - start);
1480
+ body += `
1481
+
1482
+ \`\`\`suggestion:-0+${span}
1483
+ ${finding.suggestion}
1484
+ \`\`\``;
1485
+ }
1486
+ try {
1487
+ await createGitlabDraftNote(
1488
+ parsed.owner,
1489
+ parsed.repo,
1490
+ parsed.prNumber,
1491
+ body,
1492
+ parsed.host,
1493
+ {
1494
+ filePath: relPath,
1495
+ line: finding.code_location.line_range.start,
1496
+ diffRefs
1497
+ }
1498
+ );
1499
+ inlineCount++;
1500
+ } catch (err) {
1501
+ const msg = err instanceof Error ? err.message : String(err);
1502
+ logger.warn(`Failed to create inline note for "${finding.title}": ${msg}`);
1503
+ postingErrors.push(`inline note: ${msg}`);
1504
+ failedCount++;
1505
+ }
1506
+ }
1507
+ logger.info(`Created ${inlineCount} inline draft note(s)${failedCount > 0 ? ` (${failedCount} failed)` : ""}`);
1508
+ if (reviewStyle === "hybrid" || reviewStyle === void 0) {
1509
+ let summaryBody = renderSummaryMarkdown(review);
1510
+ if (headSha) summaryBody = `<!-- hodor:sha:${headSha} -->
1511
+ ${summaryBody}`;
1512
+ if (model) summaryBody += `
1513
+ ---
1514
+
1515
+ Review generated by Hodor (model: \`${model}\`)`;
1516
+ if (metricsFooter) summaryBody += `
1517
+
1518
+ ${metricsFooter}`;
1519
+ try {
1520
+ await postGitlabMrComment(
1521
+ parsed.owner,
1522
+ parsed.repo,
1523
+ parsed.prNumber,
1524
+ summaryBody,
1525
+ parsed.host
1526
+ );
1527
+ summaryPosted = true;
1528
+ } catch (err) {
1529
+ const msg = err instanceof Error ? err.message : String(err);
1530
+ logger.warn(`Failed to post summary comment: ${msg}`);
1531
+ postingErrors.push(`summary comment: ${msg}`);
1532
+ }
1533
+ }
1534
+ if (inlineCount > 0) {
1535
+ try {
1536
+ await bulkPublishGitlabDraftNotes(
1537
+ parsed.owner,
1538
+ parsed.repo,
1539
+ parsed.prNumber,
1540
+ parsed.host
1541
+ );
1542
+ logger.info("Published all draft notes");
1543
+ draftsPublished = true;
1544
+ } catch (err) {
1545
+ const msg = err instanceof Error ? err.message : String(err);
1546
+ logger.warn(`Failed to bulk publish draft notes: ${msg}`);
1547
+ postingErrors.push(`draft publish: ${msg}`);
1548
+ }
1549
+ }
1550
+ if (commitStatus && diffRefs) {
1551
+ try {
1552
+ await postGitlabReviewCommitStatus(parsed, review, diffRefs);
1553
+ logger.info("Posted commit status");
1554
+ statusPosted = true;
1555
+ } catch (err) {
1556
+ const msg = err instanceof Error ? err.message : String(err);
1557
+ logger.warn(`Failed to post commit status: ${msg}`);
1558
+ postingErrors.push(`commit status: ${msg}`);
1559
+ }
1560
+ }
1561
+ if (codeQualityPath) {
1562
+ try {
1563
+ const { formatCodeQualityReport } = await import("./codequality-DTJK2LGF.js");
1564
+ const report = formatCodeQualityReport(review, workspacePath ?? void 0);
1565
+ const { writeFileSync } = await import("fs");
1566
+ writeFileSync(codeQualityPath, report, "utf-8");
1567
+ logger.info(`Wrote code quality report to ${codeQualityPath}`);
1568
+ } catch (err) {
1569
+ logger.warn(`Failed to write code quality report: ${err instanceof Error ? err.message : err}`);
1570
+ }
1571
+ }
1572
+ const visibleResult = summaryPosted || inlineCount > 0 && draftsPublished || statusPosted;
1573
+ const expectedInlineComments = reviewStyle === "inline" && review.findings.length > 0;
1574
+ if (postingErrors.length > 0 && !visibleResult || expectedInlineComments && inlineCount === 0) {
1575
+ return {
1576
+ success: false,
1577
+ platform: "gitlab",
1578
+ mrNumber: parsed.prNumber,
1579
+ error: postingErrors[0] ?? "No GitLab inline comments were created"
1580
+ };
1581
+ }
1582
+ return {
1583
+ success: true,
1584
+ platform: "gitlab",
1585
+ mrNumber: parsed.prNumber
1586
+ };
1587
+ }
1588
+ var DIFF_SKIP_PATTERNS = [
1589
+ /(?:^|\/)testdata\//,
1590
+ // test fixture directories
1591
+ /(?:^|\/)(?:package-lock\.json|yarn\.lock|pnpm-lock\.yaml|go\.sum|Cargo\.lock|poetry\.lock|Gemfile\.lock|composer\.lock)$/,
1592
+ /\.mdx?$/
1593
+ // markdown docs
1594
+ ];
1595
+ function filterEmbeddedDiff(rawDiff) {
1596
+ const skippedFiles = [];
1597
+ const sections = rawDiff.split(/(?=^diff --git )/m);
1598
+ const kept = [];
1599
+ for (const section of sections) {
1600
+ const match = section.match(/^diff --git a\/(.*?) b\//);
1601
+ if (!match) {
1602
+ kept.push(section);
1603
+ continue;
1604
+ }
1605
+ const filePath = match[1];
1606
+ if (DIFF_SKIP_PATTERNS.some((re) => re.test(filePath))) {
1607
+ skippedFiles.push(filePath);
1608
+ } else {
1609
+ kept.push(section);
1610
+ }
1611
+ }
1612
+ return { filtered: kept.join(""), skippedFiles };
1613
+ }
1614
+ var SUBMIT_REVIEW_RECOVERY_ATTEMPTS = 2;
1615
+ function buildSubmitReviewRecoveryPrompt(attempt, maxAttempts) {
1616
+ const finalAttempt = attempt >= maxAttempts ? "\nThis is the final automatic recovery attempt; do not end the turn without calling `submit_review`." : "";
1617
+ return [
1618
+ "Your previous assistant turn ended without a valid `submit_review` tool call, so Hodor cannot capture the review.",
1619
+ "Continue from the existing review context. Use only read-only tools and only the changed files/diff already identified.",
1620
+ "If more evidence is needed, inspect the relevant diff or file context now.",
1621
+ "When analysis is complete, call `submit_review` exactly once. Do not write the review as normal text.",
1622
+ 'If there are no findings, call `submit_review` with `"findings": []` and `"overall_correctness": "patch is correct"`.',
1623
+ finalAttempt
1624
+ ].filter(Boolean).join("\n");
1625
+ }
1626
+ function parseReviewFromAssistantText(text) {
1627
+ const candidates = getJsonCandidates(text);
1628
+ for (const candidate of candidates) {
1629
+ try {
1630
+ const parsed = JSON.parse(candidate);
1631
+ if (!Value.Check(SUBMIT_REVIEW_SCHEMA, parsed)) {
1632
+ continue;
1633
+ }
1634
+ return validateReviewOutput(parsed);
1635
+ } catch {
1636
+ }
1637
+ }
1638
+ return null;
1639
+ }
1640
+ function getJsonCandidates(text) {
1641
+ const candidates = [];
1642
+ const seen = /* @__PURE__ */ new Set();
1643
+ const addCandidate = (value) => {
1644
+ const trimmed = value.trim();
1645
+ if (!trimmed || seen.has(trimmed)) return;
1646
+ seen.add(trimmed);
1647
+ candidates.push(trimmed);
1648
+ };
1649
+ addCandidate(text);
1650
+ const fencedJson = /```(?:json)?\s*([\s\S]*?)```/gi;
1651
+ for (const match of text.matchAll(fencedJson)) {
1652
+ addCandidate(match[1] ?? "");
1653
+ }
1654
+ const firstBrace = text.indexOf("{");
1655
+ const lastBrace = text.lastIndexOf("}");
1656
+ if (firstBrace >= 0 && lastBrace > firstBrace) {
1657
+ addCandidate(text.slice(firstBrace, lastBrace + 1));
1658
+ }
1659
+ return candidates;
1660
+ }
1661
+ function summarizeLastAssistantMessage(session) {
1662
+ const messages = session.messages;
1663
+ const lastAssistant = [...messages].reverse().find((msg) => msg.role === "assistant");
1664
+ if (!lastAssistant) {
1665
+ return "no assistant message";
1666
+ }
1667
+ const stopReason = typeof lastAssistant.stopReason === "string" ? lastAssistant.stopReason : "unknown";
1668
+ const errorMessage = typeof lastAssistant.errorMessage === "string" ? `, error=${JSON.stringify(truncateForLog(lastAssistant.errorMessage, 300))}` : "";
1669
+ const content = Array.isArray(lastAssistant.content) ? lastAssistant.content.map((item) => {
1670
+ const block = item;
1671
+ const type = typeof block.type === "string" ? block.type : "unknown";
1672
+ if (type === "toolCall" && typeof block.name === "string") {
1673
+ return `toolCall:${block.name}`;
1674
+ }
1675
+ return type;
1676
+ }).join(",") : "unknown";
1677
+ const rawText = session.getLastAssistantText()?.trim();
1678
+ const textSummary = rawText ? `, text=${JSON.stringify(truncateForLog(rawText.replace(/\s+/g, " "), 500))}` : "";
1679
+ return `stopReason=${stopReason}, content=[${content || "none"}]${errorMessage}${textSummary}`;
1680
+ }
1681
+ function truncateForLog(text, maxLength) {
1682
+ if (text.length <= maxLength) return text;
1683
+ return `${text.slice(0, maxLength - 1)}\u2026`;
1684
+ }
1201
1685
  async function reviewPr(opts) {
1202
1686
  const {
1203
1687
  prUrl,
@@ -1227,36 +1711,23 @@ async function reviewPr(opts) {
1227
1711
  logger.info(`Platform: ${platform}, Repo: ${owner}/${repo}, PR: ${prNumber}, Host: ${host}`);
1228
1712
  }
1229
1713
  const parsed = parseModelString(model);
1230
- const thinkingLevel = mapReasoningEffort(reasoningEffort);
1231
- const apiKey = getApiKey(model);
1232
1714
  const envSnapshot = {
1233
- ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
1234
- OPENAI_API_KEY: process.env.OPENAI_API_KEY,
1235
1715
  AWS_REGION: process.env.AWS_REGION
1236
1716
  };
1237
- if (apiKey) {
1238
- if (parsed.provider === "anthropic") {
1239
- process.env.ANTHROPIC_API_KEY = apiKey;
1240
- } else if (parsed.provider === "openai") {
1241
- process.env.OPENAI_API_KEY = apiKey;
1242
- }
1243
- }
1244
1717
  const {
1718
+ AuthStorage,
1245
1719
  createAgentSession,
1246
1720
  DefaultResourceLoader,
1721
+ ModelRegistry,
1247
1722
  SessionManager,
1248
1723
  SettingsManager,
1249
- createReadTool,
1250
- createBashTool,
1251
- createGrepTool,
1252
- createFindTool,
1253
- createLsTool,
1254
- AuthStorage,
1255
- ModelRegistry
1256
- } = await import("@mariozechner/pi-coding-agent");
1257
- const { getModel } = await import("@mariozechner/pi-ai");
1724
+ getAgentDir
1725
+ } = await import("@earendil-works/pi-coding-agent");
1258
1726
  const authStorage = AuthStorage.inMemory();
1259
- const modelRegistry = new ModelRegistry(authStorage);
1727
+ if (process.env.LLM_API_KEY) {
1728
+ authStorage.setRuntimeApiKey(parsed.provider, process.env.LLM_API_KEY);
1729
+ }
1730
+ const modelRegistry = ModelRegistry.inMemory(authStorage);
1260
1731
  let piModel;
1261
1732
  if (parsed.modelId.startsWith("arn:")) {
1262
1733
  const arnParts = parsed.modelId.split(":");
@@ -1278,11 +1749,38 @@ async function reviewPr(opts) {
1278
1749
  };
1279
1750
  logger.info(`Custom bedrock ARN model \u2014 region: ${region}`);
1280
1751
  } else {
1281
- try {
1282
- piModel = getModel(parsed.provider, parsed.modelId);
1283
- } catch (err) {
1752
+ const registryModel = modelRegistry.find(parsed.provider, parsed.modelId);
1753
+ if (registryModel) {
1754
+ piModel = registryModel;
1755
+ } else if (parsed.provider === "openrouter") {
1756
+ piModel = {
1757
+ id: parsed.modelId,
1758
+ name: parsed.modelId,
1759
+ api: "openai-completions",
1760
+ provider: "openrouter",
1761
+ baseUrl: "https://openrouter.ai/api/v1",
1762
+ reasoning: true,
1763
+ input: ["text", "image"],
1764
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
1765
+ contextWindow: 256e3,
1766
+ maxTokens: 65536
1767
+ };
1768
+ logger.warn(`Using best-effort unregistered OpenRouter model \u2014 ${parsed.modelId}`);
1769
+ } else {
1770
+ throw new Error(
1771
+ `Unsupported model "${model}". Provider "${parsed.provider}" is recognized by pi-ai, but model "${parsed.modelId}" was not found in the installed registry.`
1772
+ );
1773
+ }
1774
+ }
1775
+ const thinkingLevel = mapReasoningEffort(reasoningEffort) ?? getDefaultReasoningEffortForModel(piModel);
1776
+ if (!reasoningEffort && thinkingLevel) {
1777
+ logger.info(`Default reasoning effort for ${piModel.name}: ${thinkingLevel}`);
1778
+ }
1779
+ if (parsed.provider !== "amazon-bedrock") {
1780
+ const resolvedKey = await modelRegistry.getApiKeyForProvider(parsed.provider);
1781
+ if (!resolvedKey) {
1284
1782
  throw new Error(
1285
- `Unsupported model "${model}": ${err instanceof Error ? err.message : err}`
1783
+ `No API key found for provider "${parsed.provider}". Set the provider-specific environment variable, configure pi auth, or set LLM_API_KEY.`
1286
1784
  );
1287
1785
  }
1288
1786
  }
@@ -1316,6 +1814,7 @@ async function reviewPr(opts) {
1316
1814
  diffBaseSha = wsResult.diffBaseSha;
1317
1815
  isTemporary = wsResult.isTemporary;
1318
1816
  }
1817
+ let activeSession;
1319
1818
  try {
1320
1819
  let formatToolArgs2 = function(_toolName, args) {
1321
1820
  if (typeof args === "string") return args.slice(0, 200);
@@ -1357,26 +1856,18 @@ async function reviewPr(opts) {
1357
1856
  } catch (err) {
1358
1857
  logger.warn(`Failed to fetch GitHub metadata: ${err}`);
1359
1858
  }
1360
- }
1361
- let previousReviewSha = null;
1362
- if (mrMetadata?.Notes) {
1363
- for (const note of mrMetadata.Notes) {
1364
- const match = note.body?.match(/<!-- hodor:sha:([a-f0-9]{40}) -->/);
1365
- if (match) {
1366
- previousReviewSha = match[1];
1367
- }
1859
+ } else if (!localMode && platform === "gitea") {
1860
+ try {
1861
+ mrMetadata = await fetchGiteaPrInfo(owner, repo, prNumber, host, {
1862
+ includeComments: true
1863
+ });
1864
+ } catch (err) {
1865
+ logger.warn(`Failed to fetch Gitea metadata: ${err}`);
1368
1866
  }
1369
1867
  }
1868
+ const previousReviewSha = await findLatestValidReviewSha(mrMetadata?.Notes, workspacePath);
1370
1869
  if (previousReviewSha) {
1371
- try {
1372
- const { stdout: objType } = await exec("git", ["cat-file", "-t", previousReviewSha], { cwd: workspacePath });
1373
- if (objType.trim() !== "commit") throw new Error("not a commit");
1374
- await exec("git", ["merge-base", "--is-ancestor", previousReviewSha, "HEAD"], { cwd: workspacePath });
1375
- logger.info(`Incremental mode: previous review at ${previousReviewSha.slice(0, 8)}`);
1376
- } catch {
1377
- logger.info(`Previous review SHA ${previousReviewSha.slice(0, 8)} not valid ancestor of HEAD, doing full review`);
1378
- previousReviewSha = null;
1379
- }
1870
+ logger.info(`Incremental mode: previous review at ${previousReviewSha.slice(0, 8)}`);
1380
1871
  }
1381
1872
  let headSha = null;
1382
1873
  if (!localMode) {
@@ -1388,11 +1879,15 @@ async function reviewPr(opts) {
1388
1879
  try {
1389
1880
  const diffArgs = previousReviewSha ? ["--no-pager", "diff", `${previousReviewSha}...HEAD`] : diffBaseSha ? ["--no-pager", "diff", diffBaseSha, "HEAD"] : localMode ? ["--no-pager", "diff", targetBranch] : ["--no-pager", "diff", `origin/${targetBranch}...HEAD`];
1390
1881
  const { stdout: rawDiff } = await exec("git", diffArgs, { cwd: workspacePath });
1391
- if (Buffer.byteLength(rawDiff, "utf-8") <= MAX_EMBED_BYTES) {
1392
- embeddedDiff = rawDiff;
1393
- logger.info(`Embedding diff in prompt (${Buffer.byteLength(rawDiff, "utf-8")} bytes)`);
1882
+ const { filtered: filteredDiff, skippedFiles } = filterEmbeddedDiff(rawDiff);
1883
+ if (skippedFiles.length > 0) {
1884
+ logger.info(`Filtered ${skippedFiles.length} file(s) from embedded diff: ${skippedFiles.join(", ")}`);
1885
+ }
1886
+ if (Buffer.byteLength(filteredDiff, "utf-8") <= MAX_EMBED_BYTES) {
1887
+ embeddedDiff = filteredDiff;
1888
+ logger.info(`Embedding diff in prompt (${Buffer.byteLength(filteredDiff, "utf-8")} bytes, raw: ${Buffer.byteLength(rawDiff, "utf-8")} bytes)`);
1394
1889
  } else {
1395
- logger.info(`Diff too large to embed (${Buffer.byteLength(rawDiff, "utf-8")} bytes), using command mode`);
1890
+ logger.info(`Diff too large to embed (${Buffer.byteLength(filteredDiff, "utf-8")} bytes filtered, ${Buffer.byteLength(rawDiff, "utf-8")} bytes raw), using command mode`);
1396
1891
  }
1397
1892
  } catch (err) {
1398
1893
  logger.warn(`Failed to pre-fetch diff, falling back to command mode: ${err}`);
@@ -1413,15 +1908,13 @@ async function reviewPr(opts) {
1413
1908
  const settingsManager = SettingsManager.inMemory({
1414
1909
  compaction: { enabled: true }
1415
1910
  });
1416
- const skillPaths = [
1417
- join2(workspacePath, ".pi", "skills"),
1418
- join2(workspacePath, ".hodor", "skills")
1419
- ].filter((p) => existsSync(p));
1911
+ const skillPaths = [join2(workspacePath, ".agents", "skills")].filter((p) => existsSync(p));
1420
1912
  const resourceLoader = new DefaultResourceLoader({
1421
1913
  cwd: workspacePath,
1914
+ agentDir: getAgentDir(),
1422
1915
  settingsManager,
1423
1916
  systemPrompt: REVIEW_SYSTEM_PROMPT,
1424
- appendSystemPrompt: "",
1917
+ appendSystemPrompt: [],
1425
1918
  noExtensions: true,
1426
1919
  noSkills: true,
1427
1920
  noPromptTemplates: true,
@@ -1461,7 +1954,12 @@ async function reviewPr(opts) {
1461
1954
  details: { ignoredDuplicate: true }
1462
1955
  };
1463
1956
  }
1464
- submittedReview = validateReviewOutput(params);
1957
+ try {
1958
+ submittedReview = validateReviewOutput(params);
1959
+ } catch (err) {
1960
+ logger.warn(`Invalid submit_review payload: ${err instanceof Error ? err.message : err}`);
1961
+ throw err;
1962
+ }
1465
1963
  logger.info(
1466
1964
  `Received structured review via submit_review (${submittedReview.findings.length} finding(s))`
1467
1965
  );
@@ -1470,7 +1968,8 @@ async function reviewPr(opts) {
1470
1968
  type: "text",
1471
1969
  text: "Review received. Do not output the review as normal text."
1472
1970
  }],
1473
- details: {}
1971
+ details: {},
1972
+ terminate: true
1474
1973
  };
1475
1974
  }
1476
1975
  };
@@ -1478,21 +1977,20 @@ async function reviewPr(opts) {
1478
1977
  cwd: workspacePath,
1479
1978
  model: piModel,
1480
1979
  thinkingLevel,
1481
- tools: [
1482
- createReadTool(workspacePath),
1483
- createBashTool(workspacePath),
1484
- createGrepTool(workspacePath),
1485
- createFindTool(workspacePath),
1486
- createLsTool(workspacePath)
1487
- ],
1980
+ // pi v0.74 filters customTools through the same allowlist as built-ins
1981
+ // (see _refreshToolRegistry in @earendil-works/pi-coding-agent's
1982
+ // agent-session.ts). The submit_review custom tool must be named here
1983
+ // or the LLM never sees it and the agent loop exits without calling it.
1984
+ tools: ["read", "bash", "grep", "find", "ls", "submit_review"],
1488
1985
  customTools: [submitReviewTool],
1986
+ authStorage,
1987
+ modelRegistry,
1489
1988
  sessionManager: SessionManager.inMemory(),
1490
1989
  settingsManager,
1491
- resourceLoader,
1492
- authStorage,
1493
- modelRegistry
1990
+ resourceLoader
1494
1991
  });
1495
- if (bedrockTags && parsed.provider === "bedrock") {
1992
+ activeSession = session;
1993
+ if (bedrockTags && parsed.provider === "amazon-bedrock") {
1496
1994
  const agent = session.agent;
1497
1995
  const originalStreamFn = agent.streamFn;
1498
1996
  agent.streamFn = (...args) => {
@@ -1549,25 +2047,47 @@ async function reviewPr(opts) {
1549
2047
  }
1550
2048
  }
1551
2049
  });
2050
+ const throwIfAgentErrored = () => {
2051
+ const agentError = session.state.errorMessage;
2052
+ if (agentError) {
2053
+ throw new Error(`LLM request failed: ${agentError}`);
2054
+ }
2055
+ };
2056
+ const recoverReviewFromAssistantText = (source) => {
2057
+ const rawText = session.getLastAssistantText() ?? "";
2058
+ if (!rawText.trim()) return false;
2059
+ const parsedReview = parseReviewFromAssistantText(rawText);
2060
+ if (!parsedReview) return false;
2061
+ submittedReview = parsedReview;
2062
+ logger.warn(
2063
+ `Recovered structured review from assistant text after ${source}; model did not call submit_review`
2064
+ );
2065
+ return true;
2066
+ };
1552
2067
  logger.info("Sending prompt to agent...");
1553
2068
  await session.prompt(prompt);
1554
- const agentError = session.state?.error;
1555
- if (agentError) {
1556
- throw new Error(`LLM request failed: ${agentError}`);
2069
+ throwIfAgentErrored();
2070
+ if (!submittedReview) {
2071
+ recoverReviewFromAssistantText("initial agent run");
2072
+ }
2073
+ for (let attempt = 1; !submittedReview && attempt <= SUBMIT_REVIEW_RECOVERY_ATTEMPTS; attempt++) {
2074
+ logger.warn(
2075
+ `Agent ended without a valid submit_review (${summarizeLastAssistantMessage(session)}); requesting recovery ${attempt}/${SUBMIT_REVIEW_RECOVERY_ATTEMPTS}`
2076
+ );
2077
+ await session.prompt(buildSubmitReviewRecoveryPrompt(attempt, SUBMIT_REVIEW_RECOVERY_ATTEMPTS));
2078
+ throwIfAgentErrored();
2079
+ recoverReviewFromAssistantText(`recovery attempt ${attempt}`);
1557
2080
  }
1558
2081
  if (!submittedReview) {
1559
- const rawText = session.getLastAssistantText() ?? "";
1560
- if (rawText) {
1561
- logger.debug(`Last assistant text without submit_review (first 500 chars): ${rawText.slice(0, 500)}`);
1562
- } else {
1563
- const messages = session.state?.messages;
1564
- const lastMsg = messages?.[messages.length - 1];
1565
- logger.debug(`Last message: ${JSON.stringify(lastMsg)?.slice(0, 500)}`);
1566
- }
2082
+ const diagnostic = summarizeLastAssistantMessage(session);
1567
2083
  if (submitReviewCalls > 0) {
1568
- throw new Error("Agent called submit_review but did not provide a valid review payload");
2084
+ throw new Error(
2085
+ `Agent called submit_review but did not provide a valid review payload after ${SUBMIT_REVIEW_RECOVERY_ATTEMPTS} recovery attempt(s): ${diagnostic}`
2086
+ );
1569
2087
  }
1570
- throw new Error("Agent did not call submit_review");
2088
+ throw new Error(
2089
+ `Agent did not call submit_review after ${SUBMIT_REVIEW_RECOVERY_ATTEMPTS} recovery attempt(s): ${diagnostic}`
2090
+ );
1571
2091
  }
1572
2092
  const review = submittedReview;
1573
2093
  if (submitReviewCalls > 1) {
@@ -1578,7 +2098,7 @@ async function reviewPr(opts) {
1578
2098
  );
1579
2099
  const durationSeconds = (Date.now() - startTime) / 1e3;
1580
2100
  logger.info(`Review complete (${review.findings.length} finding(s))`);
1581
- const allMessages = session.state?.messages ?? [];
2101
+ const allMessages = session.messages;
1582
2102
  let inputTokens = 0;
1583
2103
  let outputTokens = 0;
1584
2104
  let cacheReadTokens = 0;
@@ -1611,8 +2131,9 @@ async function reviewPr(opts) {
1611
2131
  if (includeMetricsFooter) {
1612
2132
  metricsFooter = formatMetricsMarkdown(metrics);
1613
2133
  }
1614
- return { review, metricsFooter, headSha, metrics };
2134
+ return { review, metricsFooter, headSha, metrics, workspacePath };
1615
2135
  } finally {
2136
+ activeSession?.dispose();
1616
2137
  for (const [key, val] of Object.entries(envSnapshot)) {
1617
2138
  if (val === void 0) {
1618
2139
  delete process.env[key];
@@ -1627,84 +2148,7 @@ async function reviewPr(opts) {
1627
2148
  }
1628
2149
  }
1629
2150
 
1630
- // src/render.ts
1631
- function renderMarkdown(review) {
1632
- const lines = [];
1633
- const critical = [];
1634
- const important = [];
1635
- const minor = [];
1636
- for (const f of review.findings) {
1637
- const p = f.priority;
1638
- if (p <= 1) critical.push(f);
1639
- else if (p === 2) important.push(f);
1640
- else minor.push(f);
1641
- }
1642
- lines.push("### Issues Found");
1643
- lines.push("");
1644
- if (review.findings.length === 0) {
1645
- lines.push("No issues found.");
1646
- lines.push("");
1647
- }
1648
- if (critical.length > 0) {
1649
- lines.push("**Critical (P0/P1)**");
1650
- for (const f of critical) {
1651
- lines.push(formatFinding(f));
1652
- }
1653
- lines.push("");
1654
- }
1655
- if (important.length > 0) {
1656
- lines.push("**Important (P2)**");
1657
- for (const f of important) {
1658
- lines.push(formatFinding(f));
1659
- }
1660
- lines.push("");
1661
- }
1662
- if (minor.length > 0) {
1663
- lines.push("**Minor (P3)**");
1664
- for (const f of minor) {
1665
- lines.push(formatFinding(f));
1666
- }
1667
- lines.push("");
1668
- }
1669
- lines.push("### Summary");
1670
- lines.push(
1671
- `Total issues: ${critical.length} critical, ${important.length} important, ${minor.length} minor.`
1672
- );
1673
- lines.push("");
1674
- lines.push("### Overall Verdict");
1675
- const isCorrect = review.overall_correctness === "patch is correct";
1676
- lines.push(
1677
- `**Status**: ${isCorrect ? "Patch is correct" : "Patch has blocking issues"}`
1678
- );
1679
- lines.push("");
1680
- if (review.overall_explanation) {
1681
- lines.push(`**Explanation**: ${review.overall_explanation}`);
1682
- }
1683
- return lines.join("\n").trimEnd() + "\n";
1684
- }
1685
- function formatFinding(f) {
1686
- const loc = ` (\`${formatLocation(f.code_location)}\`)`;
1687
- const title = `- **${f.title}**${loc}`;
1688
- const body = ` - ${f.body}`;
1689
- return `${title}
1690
- ${body}`;
1691
- }
1692
- function formatLocation(loc) {
1693
- let filePath = loc.absolute_file_path;
1694
- const buildsMatch = filePath.match(/\/builds\/[^/]+\/[^/]+\/(.+)/);
1695
- if (buildsMatch) {
1696
- filePath = buildsMatch[1];
1697
- } else if (filePath.includes("/workspace/")) {
1698
- filePath = filePath.slice(filePath.indexOf("/workspace/") + "/workspace/".length);
1699
- } else {
1700
- filePath = filePath.replace(/^.*\/hodor-review-[^/]+\//, "");
1701
- }
1702
- const { start, end } = loc.line_range;
1703
- return start === end ? `${filePath}:${start}` : `${filePath}:${start}-${end}`;
1704
- }
1705
-
1706
2151
  export {
1707
- setLogLevel,
1708
2152
  buildPrReviewPrompt,
1709
2153
  parseModelString,
1710
2154
  mapReasoningEffort,
@@ -1715,8 +2159,9 @@ export {
1715
2159
  validateReviewOutput,
1716
2160
  detectPlatform,
1717
2161
  parsePrUrl,
2162
+ postGitlabReviewCommitStatus,
1718
2163
  postReviewComment,
1719
- reviewPr,
1720
- renderMarkdown
2164
+ postReviewStructured,
2165
+ reviewPr
1721
2166
  };
1722
- //# sourceMappingURL=chunk-QGUJENIG.js.map
2167
+ //# sourceMappingURL=chunk-JSLG7EOX.js.map