@mrkaran/hodor 0.5.0 → 0.6.2-rc.2

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,28 @@
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
+ createGitlabDraftNote,
5
+ exec,
6
+ execJson,
7
+ fetchGitlabMrInfo,
8
+ getGitlabMrDiffRefs,
9
+ listHodorDiscussions,
10
+ logger,
11
+ postGitlabCommitStatus,
12
+ postGitlabMrComment,
13
+ renderMarkdown,
14
+ renderSummaryMarkdown,
15
+ resolveGitlabDiscussions,
16
+ summarizeGitlabNotes
17
+ } from "./chunk-DVJVQTVW.js";
18
+ import {
19
+ relativizeWorkspacePath
20
+ } from "./chunk-AMUK6GDX.js";
45
21
 
46
22
  // src/prompt.ts
47
23
  import { readFileSync } from "fs";
48
24
  import { resolve, dirname } from "path";
49
25
  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
26
  function getTemplatesDir() {
262
27
  const currentDir = dirname(fileURLToPath(import.meta.url));
263
28
  return resolve(currentDir, "..", "templates");
@@ -308,7 +73,7 @@ function buildPrReviewPrompt(opts) {
308
73
  } else if (localMode) {
309
74
  prDiffCmd = `git --no-pager diff ${targetBranch} --name-only`;
310
75
  gitDiffCmd = `git --no-pager diff ${targetBranch}`;
311
- } else if (platform === "github") {
76
+ } else if (platform === "github" || platform === "gitea") {
312
77
  prDiffCmd = `git --no-pager diff origin/${targetBranch}...HEAD --name-only`;
313
78
  gitDiffCmd = `git --no-pager diff origin/${targetBranch}...HEAD`;
314
79
  } else {
@@ -334,10 +99,14 @@ function buildPrReviewPrompt(opts) {
334
99
  if (previousReviewSha) {
335
100
  incrementalSection = `## Incremental Review Mode
336
101
 
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
102
+ 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.
103
+
104
+ Rules for incremental reviews:
105
+ 1. Only report bugs introduced or still affected by the new delta.
106
+ 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.
107
+ 3. If the delta is small and self-contained, decide from the embedded diff and submit the review without broad repository exploration.
108
+ 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.
109
+ 5. If the delta does not introduce a production bug, submit no findings.
341
110
 
342
111
  `;
343
112
  }
@@ -357,8 +126,8 @@ This is a follow-up review. A previous hodor review was done at commit \`${previ
357
126
  - NEVER flag "dependency version downgrade" (branch not rebased)
358
127
  - NEVER compare entire codebase to ${targetBranch} - DIFF ONLY
359
128
  `;
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`.";
129
+ 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";
130
+ startInstruction = previousReviewSha ? "Analyze only the incremental diff provided above. If it is self-contained, submit your review without extra tool calls." : "Analyze the diff provided above, then submit your review using `submit_review`.";
362
131
  } else {
363
132
  embeddedDiffSection = "";
364
133
  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 +258,34 @@ function normalizeLabelNames(rawLabels) {
489
258
  }
490
259
 
491
260
  // src/model.ts
261
+ import { getEnvApiKey, getProviders } from "@earendil-works/pi-ai";
262
+ var PROVIDER_ALIASES = {
263
+ bedrock: "amazon-bedrock"
264
+ };
492
265
  function parseModelString(model) {
493
266
  const trimmed = model.trim();
494
267
  if (!trimmed) throw new Error("Model name must be provided");
495
268
  const parts = trimmed.split("/");
496
269
  if (parts.length >= 2) {
497
270
  const first = parts[0].toLowerCase();
498
- if (first === "bedrock") {
271
+ const provider = PROVIDER_ALIASES[first] ?? first;
272
+ const knownProviders = new Set(getProviders());
273
+ if (provider === "amazon-bedrock") {
499
274
  let modelId = parts.slice(1).join("/");
500
275
  if (modelId.startsWith("converse/")) {
501
276
  modelId = modelId.slice("converse/".length);
502
277
  }
503
- return { provider: "amazon-bedrock", modelId };
278
+ return { provider, modelId };
504
279
  }
505
- if (["anthropic", "openai"].includes(first)) {
506
- return { provider: first, modelId: parts.slice(1).join("/") };
280
+ if (knownProviders.has(provider)) {
281
+ return { provider, modelId: parts.slice(1).join("/") };
507
282
  }
283
+ if (provider === "openrouter") {
284
+ return { provider, modelId: parts.slice(1).join("/") };
285
+ }
286
+ throw new Error(
287
+ `Unsupported provider "${first}". Use a pi-ai provider prefix such as anthropic/, openai/, openrouter/, google/, mistral/, xai/, or bedrock/.`
288
+ );
508
289
  }
509
290
  const lower = trimmed.toLowerCase();
510
291
  if (lower.includes("claude") || lower.includes("anthropic")) {
@@ -518,41 +299,44 @@ function parseModelString(model) {
518
299
  function mapReasoningEffort(effort) {
519
300
  if (!effort) return void 0;
520
301
  switch (effort.toLowerCase()) {
302
+ case "minimal":
303
+ return "minimal";
521
304
  case "low":
522
305
  return "low";
523
306
  case "medium":
524
307
  return "medium";
525
308
  case "high":
526
- case "xhigh":
527
309
  return "high";
310
+ case "xhigh":
311
+ return "xhigh";
528
312
  default:
529
313
  return void 0;
530
314
  }
531
315
  }
316
+ function normalizeModelMatchValue(value) {
317
+ return value.toLowerCase().replace(/[\s_.:/]+/g, "-");
318
+ }
319
+ function getDefaultReasoningEffortForModel(model) {
320
+ const values = [model.id, model.name].filter((value) => Boolean(value));
321
+ const isOpus47 = values.map(normalizeModelMatchValue).some((value) => value.includes("opus-4-7"));
322
+ return isOpus47 ? "xhigh" : void 0;
323
+ }
532
324
  function getApiKey(model) {
533
325
  const llmKey = process.env.LLM_API_KEY;
534
326
  if (llmKey) return llmKey;
535
327
  if (model) {
536
328
  const { provider } = parseModelString(model);
537
329
  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
- }
330
+ const key = getEnvApiKey(provider);
331
+ if (key) return key;
546
332
  }
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
333
  throw new Error(
550
- "No LLM API key found. Please set one of: LLM_API_KEY, ANTHROPIC_API_KEY, or OPENAI_API_KEY"
334
+ 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
335
  );
552
336
  }
553
337
 
554
338
  // src/metrics.ts
555
- import chalk2 from "chalk";
339
+ import chalk from "chalk";
556
340
  function tok(value) {
557
341
  if (value >= 1e6) return `${(value / 1e6).toFixed(2)}M`;
558
342
  if (value >= 1e3) return `${(value / 1e3).toFixed(1)}K`;
@@ -583,9 +367,9 @@ function formatMetricsMarkdown(metrics) {
583
367
  return lines.join("\n");
584
368
  }
585
369
  function printMetrics(metrics, stream = process.stderr) {
586
- const dim = chalk2.dim;
587
- const bold = chalk2.bold;
588
- const cyan = chalk2.cyan;
370
+ const dim = chalk.dim;
371
+ const bold = chalk.bold;
372
+ const cyan = chalk.cyan;
589
373
  const write = (line) => stream.write(line + "\n");
590
374
  write("");
591
375
  write(dim("\u2500".repeat(50)));
@@ -607,10 +391,18 @@ function printMetrics(metrics, stream = process.stderr) {
607
391
  write(dim("\u2500".repeat(50)));
608
392
  }
609
393
  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}}` : "";
394
+ const { pushgatewayUrl, metrics, findings = [], labels = {} } = opts;
395
+ const formatLabels = (extraLabels = {}) => {
396
+ const labelPairs = Object.entries({ ...labels, ...extraLabels }).map(([k, v]) => `${k}="${v.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/"/g, '\\"')}"`).join(",");
397
+ return labelPairs ? `{${labelPairs}}` : "";
398
+ };
399
+ const labelSuffix = formatLabels();
613
400
  const totalInput = metrics.inputTokens + metrics.cacheReadTokens;
401
+ const cacheHitRatio = totalInput > 0 ? metrics.cacheReadTokens / totalInput : 0;
402
+ const priorityCounts = [0, 1, 2, 3].map((priority) => ({
403
+ priority,
404
+ count: findings.filter((finding) => finding.priority === priority).length
405
+ }));
614
406
  const lines = [
615
407
  `# HELP hodor_review_input_tokens_total Total input tokens (fresh + cached)`,
616
408
  `# TYPE hodor_review_input_tokens_total gauge`,
@@ -621,6 +413,17 @@ async function pushMetrics(opts) {
621
413
  `# HELP hodor_review_cache_read_tokens_total Tokens served from prompt cache`,
622
414
  `# TYPE hodor_review_cache_read_tokens_total gauge`,
623
415
  `hodor_review_cache_read_tokens_total${labelSuffix} ${metrics.cacheReadTokens}`,
416
+ `# HELP hodor_review_cache_write_tokens_total Tokens written to prompt cache`,
417
+ `# TYPE hodor_review_cache_write_tokens_total gauge`,
418
+ `hodor_review_cache_write_tokens_total${labelSuffix} ${metrics.cacheWriteTokens}`,
419
+ `# HELP hodor_review_cache_hit_ratio Fraction of input tokens served from cache (0-1)`,
420
+ `# TYPE hodor_review_cache_hit_ratio gauge`,
421
+ `hodor_review_cache_hit_ratio${labelSuffix} ${cacheHitRatio}`,
422
+ `# HELP hodor_review_findings_total Number of findings at each priority level`,
423
+ `# TYPE hodor_review_findings_total gauge`,
424
+ ...priorityCounts.map(
425
+ ({ priority, count }) => `hodor_review_findings_total${formatLabels({ priority: `P${priority}` })} ${count}`
426
+ ),
624
427
  `# HELP hodor_review_cost_dollars Cost of the review in USD`,
625
428
  `# TYPE hodor_review_cost_dollars gauge`,
626
429
  `hodor_review_cost_dollars${labelSuffix} ${metrics.cost}`,
@@ -637,7 +440,7 @@ async function pushMetrics(opts) {
637
440
  ];
638
441
  const body = lines.join("\n");
639
442
  const baseUrl = pushgatewayUrl.replace(/\/+$/, "");
640
- const url = `${baseUrl}/metrics/job/hodor`;
443
+ const url = baseUrl.endsWith("/api/v1/import/prometheus") ? baseUrl : `${baseUrl}/metrics/job/hodor`;
641
444
  try {
642
445
  const res = await fetch(url, {
643
446
  method: "POST",
@@ -647,12 +450,12 @@ async function pushMetrics(opts) {
647
450
  });
648
451
  if (!res.ok) {
649
452
  const text = await res.text().catch(() => "");
650
- logger.warn(`Pushgateway returned ${res.status}: ${text.slice(0, 200)}`);
453
+ logger.warn(`Metrics endpoint returned ${res.status}: ${text.slice(0, 200)}`);
651
454
  } else {
652
- logger.info("Metrics pushed to Pushgateway");
455
+ logger.info("Metrics pushed successfully");
653
456
  }
654
457
  } catch (err) {
655
- logger.warn(`Failed to push metrics to Pushgateway: ${err instanceof Error ? err.message : err}`);
458
+ logger.warn(`Failed to push metrics: ${err instanceof Error ? err.message : err}`);
656
459
  }
657
460
  }
658
461
 
@@ -683,7 +486,9 @@ var REVIEW_FINDING_SCHEMA = Type.Object(
683
486
  title: Type.String({ minLength: 1 }),
684
487
  body: Type.String({ minLength: 1 }),
685
488
  priority: Type.Integer({ minimum: 0, maximum: 3 }),
686
- code_location: REVIEW_LOCATION_SCHEMA
489
+ code_location: REVIEW_LOCATION_SCHEMA,
490
+ existing_code: Type.Optional(Type.String({ minLength: 1 })),
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
  }
@@ -1058,6 +1146,191 @@ async function cleanupWorkspace(workspace) {
1058
1146
  }
1059
1147
  }
1060
1148
 
1149
+ // src/resolve-location.ts
1150
+ import { readFileSync as readFileSync2 } from "fs";
1151
+ import { resolve as resolve2, sep } from "path";
1152
+ var MAX_RESOLVE_BYTES = 2 * 1024 * 1024;
1153
+ function normalizeLine(line) {
1154
+ let s = line.replace(/\r$/, "").trim();
1155
+ if (s.startsWith("+") || s.startsWith("-")) {
1156
+ s = s.slice(1).trim();
1157
+ }
1158
+ return s;
1159
+ }
1160
+ function normalizeSnippet(code) {
1161
+ return code.split("\n").map(normalizeLine).filter((l) => l.length > 0);
1162
+ }
1163
+ function indexFile(content) {
1164
+ const result = [];
1165
+ const lines = content.split("\n");
1166
+ for (let i = 0; i < lines.length; i++) {
1167
+ const normalized = normalizeLine(lines[i]);
1168
+ if (normalized.length > 0) {
1169
+ result.push({ lineNum: i + 1, content: normalized });
1170
+ }
1171
+ }
1172
+ return result;
1173
+ }
1174
+ function isWithinWorkspace(root, filePath) {
1175
+ const target = resolve2(filePath);
1176
+ return target === root || target.startsWith(root + sep);
1177
+ }
1178
+ function findMatches(fileLines, target) {
1179
+ const matches = [];
1180
+ if (target.length === 0 || fileLines.length < target.length) return matches;
1181
+ for (let i = 0; i <= fileLines.length - target.length; i++) {
1182
+ let ok = true;
1183
+ for (let j = 0; j < target.length; j++) {
1184
+ if (fileLines[i + j].content !== target[j]) {
1185
+ ok = false;
1186
+ break;
1187
+ }
1188
+ }
1189
+ if (ok) {
1190
+ matches.push({ start: fileLines[i].lineNum, end: fileLines[i + target.length - 1].lineNum });
1191
+ }
1192
+ }
1193
+ return matches;
1194
+ }
1195
+ function resolveLineRange(input) {
1196
+ const { existingCode, fileContent, modelRange, changedLines } = input;
1197
+ const keep = (status2) => ({
1198
+ start: modelRange.start,
1199
+ end: modelRange.end,
1200
+ status: status2
1201
+ });
1202
+ const target = existingCode ? normalizeSnippet(existingCode) : [];
1203
+ if (target.length === 0) return keep("no-snippet");
1204
+ const matches = findMatches(indexFile(fileContent), target);
1205
+ if (matches.length === 0) return keep("unmatched");
1206
+ let chosen;
1207
+ if (matches.length === 1) {
1208
+ chosen = matches[0];
1209
+ } else {
1210
+ const overlapping = changedLines && changedLines.size > 0 ? matches.filter((m) => {
1211
+ for (let l = m.start; l <= m.end; l++) {
1212
+ if (changedLines.has(l)) return true;
1213
+ }
1214
+ return false;
1215
+ }) : [];
1216
+ const pool = overlapping.length > 0 ? overlapping : matches;
1217
+ chosen = pool.reduce(
1218
+ (best, m) => Math.abs(m.start - modelRange.start) < Math.abs(best.start - modelRange.start) ? m : best
1219
+ );
1220
+ }
1221
+ const status = chosen.start === modelRange.start && chosen.end === modelRange.end ? "confirmed" : "corrected";
1222
+ return { start: chosen.start, end: chosen.end, status };
1223
+ }
1224
+ function parseChangedLines(diffText) {
1225
+ const byFile = /* @__PURE__ */ new Map();
1226
+ if (!diffText) return byFile;
1227
+ let current = null;
1228
+ let newLine = 0;
1229
+ const hunkRe = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/;
1230
+ for (const line of diffText.split("\n")) {
1231
+ if (line.startsWith("+++ ")) {
1232
+ let path = line.slice(4).trim();
1233
+ if (path.startsWith("b/")) path = path.slice(2);
1234
+ if (path === "/dev/null") {
1235
+ current = null;
1236
+ } else {
1237
+ current = byFile.get(path) ?? /* @__PURE__ */ new Set();
1238
+ byFile.set(path, current);
1239
+ }
1240
+ continue;
1241
+ }
1242
+ const m = line.match(hunkRe);
1243
+ if (m) {
1244
+ newLine = parseInt(m[1], 10);
1245
+ continue;
1246
+ }
1247
+ if (!current) continue;
1248
+ if (line.startsWith("diff --git ") || line.startsWith("--- ")) continue;
1249
+ if (line.startsWith("+")) {
1250
+ current.add(newLine);
1251
+ newLine++;
1252
+ } else if (line.startsWith("-")) {
1253
+ } else if (line.startsWith("\\")) {
1254
+ } else {
1255
+ current.add(newLine);
1256
+ newLine++;
1257
+ }
1258
+ }
1259
+ return byFile;
1260
+ }
1261
+ function resolveReviewLocations(review, opts) {
1262
+ const stats = { total: 0, noSnippet: 0, confirmed: 0, corrected: 0, unmatched: 0 };
1263
+ if (review.findings.length === 0) return { review, stats };
1264
+ const changedByFile = opts.diffText ? parseChangedLines(opts.diffText) : /* @__PURE__ */ new Map();
1265
+ const fileCache = /* @__PURE__ */ new Map();
1266
+ const workspaceRoot = opts.workspacePath ? resolve2(opts.workspacePath) : null;
1267
+ const readFile = (path) => {
1268
+ if (fileCache.has(path)) return fileCache.get(path) ?? null;
1269
+ let content = null;
1270
+ try {
1271
+ const buf = readFileSync2(path);
1272
+ if (buf.byteLength <= MAX_RESOLVE_BYTES) content = buf.toString("utf-8");
1273
+ } catch {
1274
+ content = null;
1275
+ }
1276
+ fileCache.set(path, content);
1277
+ return content;
1278
+ };
1279
+ const findings = review.findings.map((finding) => {
1280
+ stats.total++;
1281
+ const { existing_code: existingCode, code_location: loc } = finding;
1282
+ if (!existingCode) {
1283
+ stats.noSnippet++;
1284
+ return finding;
1285
+ }
1286
+ if (workspaceRoot && !isWithinWorkspace(workspaceRoot, loc.absolute_file_path)) {
1287
+ stats.unmatched++;
1288
+ logger.warn(
1289
+ `Location resolution: ${loc.absolute_file_path} is outside the workspace for "${finding.title}"; keeping model range`
1290
+ );
1291
+ return finding;
1292
+ }
1293
+ const fileContent = readFile(loc.absolute_file_path);
1294
+ if (fileContent === null) {
1295
+ stats.unmatched++;
1296
+ logger.warn(`Location resolution: could not read ${loc.absolute_file_path} for "${finding.title}"`);
1297
+ return finding;
1298
+ }
1299
+ let changedLines;
1300
+ for (const [relPath, lines] of changedByFile) {
1301
+ if (loc.absolute_file_path.endsWith(`/${relPath}`) || loc.absolute_file_path === relPath) {
1302
+ changedLines = lines;
1303
+ break;
1304
+ }
1305
+ }
1306
+ const result = resolveLineRange({
1307
+ existingCode,
1308
+ fileContent,
1309
+ modelRange: loc.line_range,
1310
+ changedLines
1311
+ });
1312
+ if (result.status === "confirmed") stats.confirmed++;
1313
+ else if (result.status === "corrected") stats.corrected++;
1314
+ else stats.unmatched++;
1315
+ if (result.status === "corrected") {
1316
+ logger.info(
1317
+ `Location resolution: corrected "${finding.title}" ${loc.line_range.start}-${loc.line_range.end} -> ${result.start}-${result.end}`
1318
+ );
1319
+ return {
1320
+ ...finding,
1321
+ code_location: { ...loc, line_range: { start: result.start, end: result.end } }
1322
+ };
1323
+ }
1324
+ if (result.status === "unmatched") {
1325
+ logger.warn(
1326
+ `Location resolution: snippet not found for "${finding.title}" in ${loc.absolute_file_path}; keeping model range ${loc.line_range.start}-${loc.line_range.end}`
1327
+ );
1328
+ }
1329
+ return finding;
1330
+ });
1331
+ return { review: { ...review, findings }, stats };
1332
+ }
1333
+
1061
1334
  // src/system-prompt.ts
1062
1335
  var REVIEW_SYSTEM_PROMPT = `You are a code review agent. You analyze pull request diffs to find production bugs.
1063
1336
 
@@ -1086,11 +1359,14 @@ function detectPlatform(prUrl) {
1086
1359
  if (prUrl.includes("/-/merge_requests/") || hostname.includes("gitlab")) {
1087
1360
  return "gitlab";
1088
1361
  }
1362
+ if (prUrl.includes("/pulls/") || hostname.includes("gitea") || hostname.includes("forgejo") || hostname.includes("codeberg")) {
1363
+ return "gitea";
1364
+ }
1089
1365
  if (prUrl.includes("/pull/") || hostname.includes("github")) {
1090
1366
  return "github";
1091
1367
  }
1092
1368
  throw new Error(
1093
- `Cannot detect platform for URL: ${prUrl}. Expected a GitHub pull request (/pull/) or GitLab merge request (/-/merge_requests/) URL.`
1369
+ `Cannot detect platform for URL: ${prUrl}. Expected a GitHub (/pull/), GitLab (/-/merge_requests/), or Gitea/Forgejo (/pulls/) URL.`
1094
1370
  );
1095
1371
  }
1096
1372
  function parsePrUrl(prUrl) {
@@ -1109,6 +1385,18 @@ function parsePrUrl(prUrl) {
1109
1385
  host
1110
1386
  };
1111
1387
  }
1388
+ if (pathParts.length >= 4 && pathParts[2] === "pulls") {
1389
+ const prNumber = parseInt(pathParts[3], 10);
1390
+ if (!Number.isSafeInteger(prNumber) || prNumber <= 0) {
1391
+ throw new Error(`Invalid PR number in URL: ${prUrl}. Expected a positive integer after /pulls/.`);
1392
+ }
1393
+ return {
1394
+ owner: pathParts[0],
1395
+ repo: pathParts[1],
1396
+ prNumber,
1397
+ host
1398
+ };
1399
+ }
1112
1400
  const mrIndex = pathParts.indexOf("merge_requests");
1113
1401
  if (mrIndex >= 0) {
1114
1402
  if (mrIndex < 2 || mrIndex + 1 >= pathParts.length) {
@@ -1131,7 +1419,23 @@ function parsePrUrl(prUrl) {
1131
1419
  return { owner, repo, prNumber, host };
1132
1420
  }
1133
1421
  throw new Error(
1134
- `Invalid PR/MR URL format: ${prUrl}. Expected GitHub pull request or GitLab merge request URL.`
1422
+ `Invalid PR/MR URL format: ${prUrl}. Expected GitHub (/pull/), GitLab (/-/merge_requests/), or Gitea/Forgejo (/pulls/) URL.`
1423
+ );
1424
+ }
1425
+ function formatLocationRelative(loc, workspacePath) {
1426
+ return relativizeWorkspacePath(loc.absolute_file_path, workspacePath ?? void 0);
1427
+ }
1428
+ async function postGitlabReviewCommitStatus(parsed, review, diffRefs) {
1429
+ const blocking = review.findings.filter((f) => f.priority <= 1).length;
1430
+ const state = blocking > 0 ? "failed" : "success";
1431
+ const description = blocking > 0 ? `${blocking} blocking issue(s) found` : review.findings.length > 0 ? `${review.findings.length} non-blocking issue(s)` : "No issues found";
1432
+ await postGitlabCommitStatus(
1433
+ parsed.owner,
1434
+ parsed.repo,
1435
+ diffRefs.head_sha,
1436
+ state,
1437
+ parsed.host,
1438
+ { description }
1135
1439
  );
1136
1440
  }
1137
1441
  async function postReviewComment(opts) {
@@ -1175,6 +1479,16 @@ ${metricsFooter}`;
1175
1479
  ]);
1176
1480
  logger.info(`Successfully posted review to GitHub PR #${parsed.prNumber}`);
1177
1481
  return { success: true, platform: "github", prNumber: parsed.prNumber };
1482
+ } else if (platform === "gitea") {
1483
+ await postGiteaPrComment(
1484
+ parsed.owner,
1485
+ parsed.repo,
1486
+ parsed.prNumber,
1487
+ body,
1488
+ parsed.host
1489
+ );
1490
+ logger.info(`Successfully posted review to Gitea PR #${parsed.prNumber}`);
1491
+ return { success: true, platform: "gitea", prNumber: parsed.prNumber };
1178
1492
  } else {
1179
1493
  await postGitlabMrComment(
1180
1494
  parsed.owner,
@@ -1198,6 +1512,350 @@ ${metricsFooter}`;
1198
1512
  return { success: false, error: msg };
1199
1513
  }
1200
1514
  }
1515
+ var HODOR_REVIEW_SHA_RE = /^\s*<!--\s*hodor:sha:([a-f0-9]{40})\s*-->/i;
1516
+ function getHodorReviewShaCandidates(notes) {
1517
+ if (!notes || notes.length === 0) return [];
1518
+ const candidates = [];
1519
+ for (const [index, note] of notes.entries()) {
1520
+ const match = note.body?.match(HODOR_REVIEW_SHA_RE);
1521
+ if (!match) continue;
1522
+ const createdAtMs = Date.parse(note.created_at ?? "");
1523
+ candidates.push({
1524
+ sha: match[1],
1525
+ createdAtMs: Number.isFinite(createdAtMs) ? createdAtMs : null,
1526
+ index
1527
+ });
1528
+ }
1529
+ candidates.sort((a, b) => {
1530
+ if (a.createdAtMs != null && b.createdAtMs != null && a.createdAtMs !== b.createdAtMs) {
1531
+ return b.createdAtMs - a.createdAtMs;
1532
+ }
1533
+ if (a.createdAtMs != null && b.createdAtMs == null) return -1;
1534
+ if (a.createdAtMs == null && b.createdAtMs != null) return 1;
1535
+ return a.index - b.index;
1536
+ });
1537
+ const seen = /* @__PURE__ */ new Set();
1538
+ const shas = [];
1539
+ for (const { sha } of candidates) {
1540
+ if (seen.has(sha)) continue;
1541
+ seen.add(sha);
1542
+ shas.push(sha);
1543
+ }
1544
+ return shas;
1545
+ }
1546
+ async function findLatestValidReviewSha(notes, workspacePath) {
1547
+ const candidates = getHodorReviewShaCandidates(notes);
1548
+ if (candidates.length === 0) return null;
1549
+ logger.info(`Found ${candidates.length} previous Hodor review marker(s)`);
1550
+ for (const sha of candidates) {
1551
+ try {
1552
+ const { stdout: objType } = await exec("git", ["cat-file", "-t", sha], { cwd: workspacePath });
1553
+ if (objType.trim() !== "commit") throw new Error("not a commit");
1554
+ await exec("git", ["merge-base", "--is-ancestor", sha, "HEAD"], { cwd: workspacePath });
1555
+ return sha;
1556
+ } catch {
1557
+ logger.info(`Skipping previous review SHA ${sha.slice(0, 8)}; not a valid ancestor of HEAD`);
1558
+ }
1559
+ }
1560
+ return null;
1561
+ }
1562
+ async function postReviewStructured(opts) {
1563
+ const {
1564
+ prUrl,
1565
+ review,
1566
+ model,
1567
+ metricsFooter,
1568
+ reviewStyle,
1569
+ commitStatus,
1570
+ codeQualityPath,
1571
+ headSha,
1572
+ workspacePath
1573
+ } = opts;
1574
+ const platform = detectPlatform(prUrl);
1575
+ if (platform === "github") {
1576
+ return postReviewComment({
1577
+ prUrl,
1578
+ reviewText: renderMarkdown(review),
1579
+ model,
1580
+ metricsFooter,
1581
+ headSha
1582
+ });
1583
+ }
1584
+ if (reviewStyle === "summary") {
1585
+ return postReviewComment({
1586
+ prUrl,
1587
+ reviewText: renderMarkdown(review),
1588
+ model,
1589
+ metricsFooter,
1590
+ headSha
1591
+ });
1592
+ }
1593
+ const parsed = parsePrUrl(prUrl);
1594
+ try {
1595
+ const discussions = await listHodorDiscussions(
1596
+ parsed.owner,
1597
+ parsed.repo,
1598
+ parsed.prNumber,
1599
+ parsed.host
1600
+ );
1601
+ const unresolvedIds = [...new Set(
1602
+ discussions.filter((d) => !d.resolved).map((d) => d.discussionId)
1603
+ )];
1604
+ if (unresolvedIds.length > 0) {
1605
+ const resolved = await resolveGitlabDiscussions(
1606
+ parsed.owner,
1607
+ parsed.repo,
1608
+ parsed.prNumber,
1609
+ unresolvedIds,
1610
+ parsed.host
1611
+ );
1612
+ if (resolved > 0) logger.info(`Resolved ${resolved} old Hodor discussion(s)`);
1613
+ }
1614
+ } catch (err) {
1615
+ logger.warn(`Failed to resolve old discussions: ${err instanceof Error ? err.message : err}`);
1616
+ }
1617
+ let diffRefs = null;
1618
+ try {
1619
+ diffRefs = await getGitlabMrDiffRefs(
1620
+ parsed.owner,
1621
+ parsed.repo,
1622
+ parsed.prNumber,
1623
+ parsed.host
1624
+ );
1625
+ } catch (err) {
1626
+ logger.warn(`Failed to get diff_refs, falling back to summary mode: ${err instanceof Error ? err.message : err}`);
1627
+ }
1628
+ if (!diffRefs) {
1629
+ return postReviewComment({
1630
+ prUrl,
1631
+ reviewText: renderMarkdown(review),
1632
+ model,
1633
+ metricsFooter,
1634
+ headSha
1635
+ });
1636
+ }
1637
+ let inlineCount = 0;
1638
+ let failedCount = 0;
1639
+ let summaryPosted = false;
1640
+ let draftsPublished = false;
1641
+ let statusPosted = false;
1642
+ const postingErrors = [];
1643
+ for (const finding of review.findings) {
1644
+ const relPath = formatLocationRelative(finding.code_location, workspacePath);
1645
+ const priorityTag = `[P${finding.priority}]`;
1646
+ const title = /^\[P[0-3]\]/.test(finding.title) ? finding.title : `${priorityTag} ${finding.title}`;
1647
+ let body = `${HODOR_REVIEW_MARKER}
1648
+ **${title}**
1649
+
1650
+ ${finding.body}`;
1651
+ if (finding.suggestion) {
1652
+ const { start, end } = finding.code_location.line_range;
1653
+ const span = Math.max(0, end - start);
1654
+ body += `
1655
+
1656
+ \`\`\`suggestion:-0+${span}
1657
+ ${finding.suggestion}
1658
+ \`\`\``;
1659
+ }
1660
+ try {
1661
+ await createGitlabDraftNote(
1662
+ parsed.owner,
1663
+ parsed.repo,
1664
+ parsed.prNumber,
1665
+ body,
1666
+ parsed.host,
1667
+ {
1668
+ filePath: relPath,
1669
+ line: finding.code_location.line_range.start,
1670
+ diffRefs
1671
+ }
1672
+ );
1673
+ inlineCount++;
1674
+ } catch (err) {
1675
+ const msg = err instanceof Error ? err.message : String(err);
1676
+ logger.warn(`Failed to create inline note for "${finding.title}": ${msg}`);
1677
+ postingErrors.push(`inline note: ${msg}`);
1678
+ failedCount++;
1679
+ }
1680
+ }
1681
+ logger.info(`Created ${inlineCount} inline draft note(s)${failedCount > 0 ? ` (${failedCount} failed)` : ""}`);
1682
+ if (reviewStyle === "hybrid" || reviewStyle === void 0) {
1683
+ let summaryBody = renderSummaryMarkdown(review);
1684
+ if (headSha) summaryBody = `<!-- hodor:sha:${headSha} -->
1685
+ ${summaryBody}`;
1686
+ if (model) summaryBody += `
1687
+ ---
1688
+
1689
+ Review generated by Hodor (model: \`${model}\`)`;
1690
+ if (metricsFooter) summaryBody += `
1691
+
1692
+ ${metricsFooter}`;
1693
+ try {
1694
+ await postGitlabMrComment(
1695
+ parsed.owner,
1696
+ parsed.repo,
1697
+ parsed.prNumber,
1698
+ summaryBody,
1699
+ parsed.host
1700
+ );
1701
+ summaryPosted = true;
1702
+ } catch (err) {
1703
+ const msg = err instanceof Error ? err.message : String(err);
1704
+ logger.warn(`Failed to post summary comment: ${msg}`);
1705
+ postingErrors.push(`summary comment: ${msg}`);
1706
+ }
1707
+ }
1708
+ if (inlineCount > 0) {
1709
+ try {
1710
+ await bulkPublishGitlabDraftNotes(
1711
+ parsed.owner,
1712
+ parsed.repo,
1713
+ parsed.prNumber,
1714
+ parsed.host
1715
+ );
1716
+ logger.info("Published all draft notes");
1717
+ draftsPublished = true;
1718
+ } catch (err) {
1719
+ const msg = err instanceof Error ? err.message : String(err);
1720
+ logger.warn(`Failed to bulk publish draft notes: ${msg}`);
1721
+ postingErrors.push(`draft publish: ${msg}`);
1722
+ }
1723
+ }
1724
+ if (commitStatus && diffRefs) {
1725
+ try {
1726
+ await postGitlabReviewCommitStatus(parsed, review, diffRefs);
1727
+ logger.info("Posted commit status");
1728
+ statusPosted = true;
1729
+ } catch (err) {
1730
+ const msg = err instanceof Error ? err.message : String(err);
1731
+ logger.warn(`Failed to post commit status: ${msg}`);
1732
+ postingErrors.push(`commit status: ${msg}`);
1733
+ }
1734
+ }
1735
+ if (codeQualityPath) {
1736
+ try {
1737
+ const { formatCodeQualityReport } = await import("./codequality-DTJK2LGF.js");
1738
+ const report = formatCodeQualityReport(review, workspacePath ?? void 0);
1739
+ const { writeFileSync } = await import("fs");
1740
+ writeFileSync(codeQualityPath, report, "utf-8");
1741
+ logger.info(`Wrote code quality report to ${codeQualityPath}`);
1742
+ } catch (err) {
1743
+ logger.warn(`Failed to write code quality report: ${err instanceof Error ? err.message : err}`);
1744
+ }
1745
+ }
1746
+ const visibleResult = summaryPosted || inlineCount > 0 && draftsPublished || statusPosted;
1747
+ const expectedInlineComments = reviewStyle === "inline" && review.findings.length > 0;
1748
+ if (postingErrors.length > 0 && !visibleResult || expectedInlineComments && inlineCount === 0) {
1749
+ return {
1750
+ success: false,
1751
+ platform: "gitlab",
1752
+ mrNumber: parsed.prNumber,
1753
+ error: postingErrors[0] ?? "No GitLab inline comments were created"
1754
+ };
1755
+ }
1756
+ return {
1757
+ success: true,
1758
+ platform: "gitlab",
1759
+ mrNumber: parsed.prNumber
1760
+ };
1761
+ }
1762
+ var DIFF_SKIP_PATTERNS = [
1763
+ /(?:^|\/)testdata\//,
1764
+ // test fixture directories
1765
+ /(?:^|\/)(?:package-lock\.json|yarn\.lock|pnpm-lock\.yaml|go\.sum|Cargo\.lock|poetry\.lock|Gemfile\.lock|composer\.lock)$/,
1766
+ /\.mdx?$/
1767
+ // markdown docs
1768
+ ];
1769
+ function filterEmbeddedDiff(rawDiff) {
1770
+ const skippedFiles = [];
1771
+ const sections = rawDiff.split(/(?=^diff --git )/m);
1772
+ const kept = [];
1773
+ for (const section of sections) {
1774
+ const match = section.match(/^diff --git a\/(.*?) b\//);
1775
+ if (!match) {
1776
+ kept.push(section);
1777
+ continue;
1778
+ }
1779
+ const filePath = match[1];
1780
+ if (DIFF_SKIP_PATTERNS.some((re) => re.test(filePath))) {
1781
+ skippedFiles.push(filePath);
1782
+ } else {
1783
+ kept.push(section);
1784
+ }
1785
+ }
1786
+ return { filtered: kept.join(""), skippedFiles };
1787
+ }
1788
+ var SUBMIT_REVIEW_RECOVERY_ATTEMPTS = 2;
1789
+ function buildSubmitReviewRecoveryPrompt(attempt, maxAttempts) {
1790
+ const finalAttempt = attempt >= maxAttempts ? "\nThis is the final automatic recovery attempt; do not end the turn without calling `submit_review`." : "";
1791
+ return [
1792
+ "Your previous assistant turn ended without a valid `submit_review` tool call, so Hodor cannot capture the review.",
1793
+ "Continue from the existing review context. Use only read-only tools and only the changed files/diff already identified.",
1794
+ "If more evidence is needed, inspect the relevant diff or file context now.",
1795
+ "When analysis is complete, call `submit_review` exactly once. Do not write the review as normal text.",
1796
+ 'If there are no findings, call `submit_review` with `"findings": []` and `"overall_correctness": "patch is correct"`.',
1797
+ finalAttempt
1798
+ ].filter(Boolean).join("\n");
1799
+ }
1800
+ function parseReviewFromAssistantText(text) {
1801
+ const candidates = getJsonCandidates(text);
1802
+ for (const candidate of candidates) {
1803
+ try {
1804
+ const parsed = JSON.parse(candidate);
1805
+ if (!Value.Check(SUBMIT_REVIEW_SCHEMA, parsed)) {
1806
+ continue;
1807
+ }
1808
+ return validateReviewOutput(parsed);
1809
+ } catch {
1810
+ }
1811
+ }
1812
+ return null;
1813
+ }
1814
+ function getJsonCandidates(text) {
1815
+ const candidates = [];
1816
+ const seen = /* @__PURE__ */ new Set();
1817
+ const addCandidate = (value) => {
1818
+ const trimmed = value.trim();
1819
+ if (!trimmed || seen.has(trimmed)) return;
1820
+ seen.add(trimmed);
1821
+ candidates.push(trimmed);
1822
+ };
1823
+ addCandidate(text);
1824
+ const fencedJson = /```(?:json)?\s*([\s\S]*?)```/gi;
1825
+ for (const match of text.matchAll(fencedJson)) {
1826
+ addCandidate(match[1] ?? "");
1827
+ }
1828
+ const firstBrace = text.indexOf("{");
1829
+ const lastBrace = text.lastIndexOf("}");
1830
+ if (firstBrace >= 0 && lastBrace > firstBrace) {
1831
+ addCandidate(text.slice(firstBrace, lastBrace + 1));
1832
+ }
1833
+ return candidates;
1834
+ }
1835
+ function summarizeLastAssistantMessage(session) {
1836
+ const messages = session.messages;
1837
+ const lastAssistant = [...messages].reverse().find((msg) => msg.role === "assistant");
1838
+ if (!lastAssistant) {
1839
+ return "no assistant message";
1840
+ }
1841
+ const stopReason = typeof lastAssistant.stopReason === "string" ? lastAssistant.stopReason : "unknown";
1842
+ const errorMessage = typeof lastAssistant.errorMessage === "string" ? `, error=${JSON.stringify(truncateForLog(lastAssistant.errorMessage, 300))}` : "";
1843
+ const content = Array.isArray(lastAssistant.content) ? lastAssistant.content.map((item) => {
1844
+ const block = item;
1845
+ const type = typeof block.type === "string" ? block.type : "unknown";
1846
+ if (type === "toolCall" && typeof block.name === "string") {
1847
+ return `toolCall:${block.name}`;
1848
+ }
1849
+ return type;
1850
+ }).join(",") : "unknown";
1851
+ const rawText = session.getLastAssistantText()?.trim();
1852
+ const textSummary = rawText ? `, text=${JSON.stringify(truncateForLog(rawText.replace(/\s+/g, " "), 500))}` : "";
1853
+ return `stopReason=${stopReason}, content=[${content || "none"}]${errorMessage}${textSummary}`;
1854
+ }
1855
+ function truncateForLog(text, maxLength) {
1856
+ if (text.length <= maxLength) return text;
1857
+ return `${text.slice(0, maxLength - 1)}\u2026`;
1858
+ }
1201
1859
  async function reviewPr(opts) {
1202
1860
  const {
1203
1861
  prUrl,
@@ -1227,36 +1885,23 @@ async function reviewPr(opts) {
1227
1885
  logger.info(`Platform: ${platform}, Repo: ${owner}/${repo}, PR: ${prNumber}, Host: ${host}`);
1228
1886
  }
1229
1887
  const parsed = parseModelString(model);
1230
- const thinkingLevel = mapReasoningEffort(reasoningEffort);
1231
- const apiKey = getApiKey(model);
1232
1888
  const envSnapshot = {
1233
- ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
1234
- OPENAI_API_KEY: process.env.OPENAI_API_KEY,
1235
1889
  AWS_REGION: process.env.AWS_REGION
1236
1890
  };
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
1891
  const {
1892
+ AuthStorage,
1245
1893
  createAgentSession,
1246
1894
  DefaultResourceLoader,
1895
+ ModelRegistry,
1247
1896
  SessionManager,
1248
1897
  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");
1898
+ getAgentDir
1899
+ } = await import("@earendil-works/pi-coding-agent");
1258
1900
  const authStorage = AuthStorage.inMemory();
1259
- const modelRegistry = new ModelRegistry(authStorage);
1901
+ if (process.env.LLM_API_KEY) {
1902
+ authStorage.setRuntimeApiKey(parsed.provider, process.env.LLM_API_KEY);
1903
+ }
1904
+ const modelRegistry = ModelRegistry.inMemory(authStorage);
1260
1905
  let piModel;
1261
1906
  if (parsed.modelId.startsWith("arn:")) {
1262
1907
  const arnParts = parsed.modelId.split(":");
@@ -1278,11 +1923,38 @@ async function reviewPr(opts) {
1278
1923
  };
1279
1924
  logger.info(`Custom bedrock ARN model \u2014 region: ${region}`);
1280
1925
  } else {
1281
- try {
1282
- piModel = getModel(parsed.provider, parsed.modelId);
1283
- } catch (err) {
1926
+ const registryModel = modelRegistry.find(parsed.provider, parsed.modelId);
1927
+ if (registryModel) {
1928
+ piModel = registryModel;
1929
+ } else if (parsed.provider === "openrouter") {
1930
+ piModel = {
1931
+ id: parsed.modelId,
1932
+ name: parsed.modelId,
1933
+ api: "openai-completions",
1934
+ provider: "openrouter",
1935
+ baseUrl: "https://openrouter.ai/api/v1",
1936
+ reasoning: true,
1937
+ input: ["text", "image"],
1938
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
1939
+ contextWindow: 256e3,
1940
+ maxTokens: 65536
1941
+ };
1942
+ logger.warn(`Using best-effort unregistered OpenRouter model \u2014 ${parsed.modelId}`);
1943
+ } else {
1944
+ throw new Error(
1945
+ `Unsupported model "${model}". Provider "${parsed.provider}" is recognized by pi-ai, but model "${parsed.modelId}" was not found in the installed registry.`
1946
+ );
1947
+ }
1948
+ }
1949
+ const thinkingLevel = mapReasoningEffort(reasoningEffort) ?? getDefaultReasoningEffortForModel(piModel);
1950
+ if (!reasoningEffort && thinkingLevel) {
1951
+ logger.info(`Default reasoning effort for ${piModel.name}: ${thinkingLevel}`);
1952
+ }
1953
+ if (parsed.provider !== "amazon-bedrock") {
1954
+ const resolvedKey = await modelRegistry.getApiKeyForProvider(parsed.provider);
1955
+ if (!resolvedKey) {
1284
1956
  throw new Error(
1285
- `Unsupported model "${model}": ${err instanceof Error ? err.message : err}`
1957
+ `No API key found for provider "${parsed.provider}". Set the provider-specific environment variable, configure pi auth, or set LLM_API_KEY.`
1286
1958
  );
1287
1959
  }
1288
1960
  }
@@ -1316,6 +1988,7 @@ async function reviewPr(opts) {
1316
1988
  diffBaseSha = wsResult.diffBaseSha;
1317
1989
  isTemporary = wsResult.isTemporary;
1318
1990
  }
1991
+ let activeSession;
1319
1992
  try {
1320
1993
  let formatToolArgs2 = function(_toolName, args) {
1321
1994
  if (typeof args === "string") return args.slice(0, 200);
@@ -1357,26 +2030,18 @@ async function reviewPr(opts) {
1357
2030
  } catch (err) {
1358
2031
  logger.warn(`Failed to fetch GitHub metadata: ${err}`);
1359
2032
  }
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
- }
2033
+ } else if (!localMode && platform === "gitea") {
2034
+ try {
2035
+ mrMetadata = await fetchGiteaPrInfo(owner, repo, prNumber, host, {
2036
+ includeComments: true
2037
+ });
2038
+ } catch (err) {
2039
+ logger.warn(`Failed to fetch Gitea metadata: ${err}`);
1368
2040
  }
1369
2041
  }
2042
+ const previousReviewSha = await findLatestValidReviewSha(mrMetadata?.Notes, workspacePath);
1370
2043
  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
- }
2044
+ logger.info(`Incremental mode: previous review at ${previousReviewSha.slice(0, 8)}`);
1380
2045
  }
1381
2046
  let headSha = null;
1382
2047
  if (!localMode) {
@@ -1388,11 +2053,15 @@ async function reviewPr(opts) {
1388
2053
  try {
1389
2054
  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
2055
  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)`);
2056
+ const { filtered: filteredDiff, skippedFiles } = filterEmbeddedDiff(rawDiff);
2057
+ if (skippedFiles.length > 0) {
2058
+ logger.info(`Filtered ${skippedFiles.length} file(s) from embedded diff: ${skippedFiles.join(", ")}`);
2059
+ }
2060
+ if (Buffer.byteLength(filteredDiff, "utf-8") <= MAX_EMBED_BYTES) {
2061
+ embeddedDiff = filteredDiff;
2062
+ logger.info(`Embedding diff in prompt (${Buffer.byteLength(filteredDiff, "utf-8")} bytes, raw: ${Buffer.byteLength(rawDiff, "utf-8")} bytes)`);
1394
2063
  } else {
1395
- logger.info(`Diff too large to embed (${Buffer.byteLength(rawDiff, "utf-8")} bytes), using command mode`);
2064
+ 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
2065
  }
1397
2066
  } catch (err) {
1398
2067
  logger.warn(`Failed to pre-fetch diff, falling back to command mode: ${err}`);
@@ -1413,15 +2082,13 @@ async function reviewPr(opts) {
1413
2082
  const settingsManager = SettingsManager.inMemory({
1414
2083
  compaction: { enabled: true }
1415
2084
  });
1416
- const skillPaths = [
1417
- join2(workspacePath, ".pi", "skills"),
1418
- join2(workspacePath, ".hodor", "skills")
1419
- ].filter((p) => existsSync(p));
2085
+ const skillPaths = [join2(workspacePath, ".agents", "skills")].filter((p) => existsSync(p));
1420
2086
  const resourceLoader = new DefaultResourceLoader({
1421
2087
  cwd: workspacePath,
2088
+ agentDir: getAgentDir(),
1422
2089
  settingsManager,
1423
2090
  systemPrompt: REVIEW_SYSTEM_PROMPT,
1424
- appendSystemPrompt: "",
2091
+ appendSystemPrompt: [],
1425
2092
  noExtensions: true,
1426
2093
  noSkills: true,
1427
2094
  noPromptTemplates: true,
@@ -1461,7 +2128,12 @@ async function reviewPr(opts) {
1461
2128
  details: { ignoredDuplicate: true }
1462
2129
  };
1463
2130
  }
1464
- submittedReview = validateReviewOutput(params);
2131
+ try {
2132
+ submittedReview = validateReviewOutput(params);
2133
+ } catch (err) {
2134
+ logger.warn(`Invalid submit_review payload: ${err instanceof Error ? err.message : err}`);
2135
+ throw err;
2136
+ }
1465
2137
  logger.info(
1466
2138
  `Received structured review via submit_review (${submittedReview.findings.length} finding(s))`
1467
2139
  );
@@ -1470,7 +2142,8 @@ async function reviewPr(opts) {
1470
2142
  type: "text",
1471
2143
  text: "Review received. Do not output the review as normal text."
1472
2144
  }],
1473
- details: {}
2145
+ details: {},
2146
+ terminate: true
1474
2147
  };
1475
2148
  }
1476
2149
  };
@@ -1478,21 +2151,20 @@ async function reviewPr(opts) {
1478
2151
  cwd: workspacePath,
1479
2152
  model: piModel,
1480
2153
  thinkingLevel,
1481
- tools: [
1482
- createReadTool(workspacePath),
1483
- createBashTool(workspacePath),
1484
- createGrepTool(workspacePath),
1485
- createFindTool(workspacePath),
1486
- createLsTool(workspacePath)
1487
- ],
2154
+ // pi v0.74 filters customTools through the same allowlist as built-ins
2155
+ // (see _refreshToolRegistry in @earendil-works/pi-coding-agent's
2156
+ // agent-session.ts). The submit_review custom tool must be named here
2157
+ // or the LLM never sees it and the agent loop exits without calling it.
2158
+ tools: ["read", "bash", "grep", "find", "ls", "submit_review"],
1488
2159
  customTools: [submitReviewTool],
2160
+ authStorage,
2161
+ modelRegistry,
1489
2162
  sessionManager: SessionManager.inMemory(),
1490
2163
  settingsManager,
1491
- resourceLoader,
1492
- authStorage,
1493
- modelRegistry
2164
+ resourceLoader
1494
2165
  });
1495
- if (bedrockTags && parsed.provider === "bedrock") {
2166
+ activeSession = session;
2167
+ if (bedrockTags && parsed.provider === "amazon-bedrock") {
1496
2168
  const agent = session.agent;
1497
2169
  const originalStreamFn = agent.streamFn;
1498
2170
  agent.streamFn = (...args) => {
@@ -1549,36 +2221,67 @@ async function reviewPr(opts) {
1549
2221
  }
1550
2222
  }
1551
2223
  });
2224
+ const throwIfAgentErrored = () => {
2225
+ const agentError = session.state.errorMessage;
2226
+ if (agentError) {
2227
+ throw new Error(`LLM request failed: ${agentError}`);
2228
+ }
2229
+ };
2230
+ const recoverReviewFromAssistantText = (source) => {
2231
+ const rawText = session.getLastAssistantText() ?? "";
2232
+ if (!rawText.trim()) return false;
2233
+ const parsedReview = parseReviewFromAssistantText(rawText);
2234
+ if (!parsedReview) return false;
2235
+ submittedReview = parsedReview;
2236
+ logger.warn(
2237
+ `Recovered structured review from assistant text after ${source}; model did not call submit_review`
2238
+ );
2239
+ return true;
2240
+ };
1552
2241
  logger.info("Sending prompt to agent...");
1553
2242
  await session.prompt(prompt);
1554
- const agentError = session.state?.error;
1555
- if (agentError) {
1556
- throw new Error(`LLM request failed: ${agentError}`);
2243
+ throwIfAgentErrored();
2244
+ if (!submittedReview) {
2245
+ recoverReviewFromAssistantText("initial agent run");
2246
+ }
2247
+ for (let attempt = 1; !submittedReview && attempt <= SUBMIT_REVIEW_RECOVERY_ATTEMPTS; attempt++) {
2248
+ logger.warn(
2249
+ `Agent ended without a valid submit_review (${summarizeLastAssistantMessage(session)}); requesting recovery ${attempt}/${SUBMIT_REVIEW_RECOVERY_ATTEMPTS}`
2250
+ );
2251
+ await session.prompt(buildSubmitReviewRecoveryPrompt(attempt, SUBMIT_REVIEW_RECOVERY_ATTEMPTS));
2252
+ throwIfAgentErrored();
2253
+ recoverReviewFromAssistantText(`recovery attempt ${attempt}`);
1557
2254
  }
1558
2255
  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
- }
2256
+ const diagnostic = summarizeLastAssistantMessage(session);
1567
2257
  if (submitReviewCalls > 0) {
1568
- throw new Error("Agent called submit_review but did not provide a valid review payload");
2258
+ throw new Error(
2259
+ `Agent called submit_review but did not provide a valid review payload after ${SUBMIT_REVIEW_RECOVERY_ATTEMPTS} recovery attempt(s): ${diagnostic}`
2260
+ );
1569
2261
  }
1570
- throw new Error("Agent did not call submit_review");
2262
+ throw new Error(
2263
+ `Agent did not call submit_review after ${SUBMIT_REVIEW_RECOVERY_ATTEMPTS} recovery attempt(s): ${diagnostic}`
2264
+ );
1571
2265
  }
1572
- const review = submittedReview;
2266
+ const rawReview = submittedReview;
1573
2267
  if (submitReviewCalls > 1) {
1574
2268
  logger.warn(`Agent called submit_review ${submitReviewCalls} times; using the first valid submission`);
1575
2269
  }
2270
+ const { review, stats: locationStats } = resolveReviewLocations(rawReview, {
2271
+ workspacePath,
2272
+ diffText: embeddedDiff
2273
+ });
2274
+ if (locationStats.corrected > 0 || locationStats.unmatched > 0) {
2275
+ logger.info(
2276
+ `Location resolution: ${locationStats.corrected} corrected, ${locationStats.confirmed} confirmed, ${locationStats.unmatched} unmatched, ${locationStats.noSnippet} without snippet`
2277
+ );
2278
+ }
1576
2279
  logger.info(
1577
2280
  `Captured ${review.findings.length} finding(s), verdict: ${review.overall_correctness}`
1578
2281
  );
1579
2282
  const durationSeconds = (Date.now() - startTime) / 1e3;
1580
2283
  logger.info(`Review complete (${review.findings.length} finding(s))`);
1581
- const allMessages = session.state?.messages ?? [];
2284
+ const allMessages = session.messages;
1582
2285
  let inputTokens = 0;
1583
2286
  let outputTokens = 0;
1584
2287
  let cacheReadTokens = 0;
@@ -1611,8 +2314,9 @@ async function reviewPr(opts) {
1611
2314
  if (includeMetricsFooter) {
1612
2315
  metricsFooter = formatMetricsMarkdown(metrics);
1613
2316
  }
1614
- return { review, metricsFooter, headSha, metrics };
2317
+ return { review, metricsFooter, headSha, metrics, workspacePath };
1615
2318
  } finally {
2319
+ activeSession?.dispose();
1616
2320
  for (const [key, val] of Object.entries(envSnapshot)) {
1617
2321
  if (val === void 0) {
1618
2322
  delete process.env[key];
@@ -1627,84 +2331,7 @@ async function reviewPr(opts) {
1627
2331
  }
1628
2332
  }
1629
2333
 
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
2334
  export {
1707
- setLogLevel,
1708
2335
  buildPrReviewPrompt,
1709
2336
  parseModelString,
1710
2337
  mapReasoningEffort,
@@ -1715,8 +2342,9 @@ export {
1715
2342
  validateReviewOutput,
1716
2343
  detectPlatform,
1717
2344
  parsePrUrl,
2345
+ postGitlabReviewCommitStatus,
1718
2346
  postReviewComment,
1719
- reviewPr,
1720
- renderMarkdown
2347
+ postReviewStructured,
2348
+ reviewPr
1721
2349
  };
1722
- //# sourceMappingURL=chunk-QGUJENIG.js.map
2350
+ //# sourceMappingURL=chunk-MRMLGIXO.js.map