@mrkaran/hodor 0.4.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.
@@ -0,0 +1,1722 @@
1
+ // src/utils/logger.ts
2
+ import chalk from "chalk";
3
+ var currentLevel = "warn";
4
+ var LEVELS = {
5
+ debug: 0,
6
+ info: 1,
7
+ warn: 2,
8
+ error: 3
9
+ };
10
+ function setLogLevel(level) {
11
+ currentLevel = level;
12
+ }
13
+ function shouldLog(level) {
14
+ return LEVELS[level] >= LEVELS[currentLevel];
15
+ }
16
+ function timestamp() {
17
+ return (/* @__PURE__ */ new Date()).toISOString();
18
+ }
19
+ var logger = {
20
+ debug(msg) {
21
+ if (shouldLog("debug")) {
22
+ process.stderr.write(`${chalk.gray(timestamp())} ${chalk.gray("DEBUG")} ${msg}
23
+ `);
24
+ }
25
+ },
26
+ info(msg) {
27
+ if (shouldLog("info")) {
28
+ process.stderr.write(`${chalk.gray(timestamp())} ${chalk.blue("INFO")} ${msg}
29
+ `);
30
+ }
31
+ },
32
+ warn(msg) {
33
+ if (shouldLog("warn")) {
34
+ process.stderr.write(`${chalk.gray(timestamp())} ${chalk.yellow("WARN")} ${msg}
35
+ `);
36
+ }
37
+ },
38
+ error(msg) {
39
+ if (shouldLog("error")) {
40
+ process.stderr.write(`${chalk.gray(timestamp())} ${chalk.red("ERROR")} ${msg}
41
+ `);
42
+ }
43
+ }
44
+ };
45
+
46
+ // src/prompt.ts
47
+ import { readFileSync } from "fs";
48
+ import { resolve, dirname } from "path";
49
+ 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
+ function getTemplatesDir() {
262
+ const currentDir = dirname(fileURLToPath(import.meta.url));
263
+ return resolve(currentDir, "..", "templates");
264
+ }
265
+ function buildPrReviewPrompt(opts) {
266
+ const {
267
+ prUrl,
268
+ platform,
269
+ targetBranch = "main",
270
+ diffBaseSha,
271
+ mrMetadata,
272
+ customInstructions,
273
+ customPromptFile,
274
+ embeddedDiff,
275
+ previousReviewSha,
276
+ localMode = false
277
+ } = opts;
278
+ let templateFile;
279
+ if (customPromptFile) {
280
+ templateFile = customPromptFile;
281
+ logger.info(`Using custom prompt file: ${templateFile}`);
282
+ } else {
283
+ templateFile = resolve(getTemplatesDir(), "tool-review.md");
284
+ logger.info("Using tool-based review template");
285
+ }
286
+ let templateText;
287
+ try {
288
+ templateText = readFileSync(templateFile, "utf-8");
289
+ } catch (err) {
290
+ throw new Error(`Failed to load prompt template from ${templateFile}: ${err}`);
291
+ }
292
+ const dangerousChars = /[;\|`$&<>(){}\n\r\0\\!]/;
293
+ if (dangerousChars.test(targetBranch)) {
294
+ throw new Error(`Invalid target branch name: ${targetBranch}`);
295
+ }
296
+ if (diffBaseSha && dangerousChars.test(diffBaseSha)) {
297
+ throw new Error(`Invalid diff base SHA: ${diffBaseSha}`);
298
+ }
299
+ if (previousReviewSha && !/^[a-f0-9]{40}$/.test(previousReviewSha)) {
300
+ throw new Error(`Invalid previous review SHA: ${previousReviewSha}`);
301
+ }
302
+ let prDiffCmd;
303
+ let gitDiffCmd;
304
+ if (previousReviewSha) {
305
+ prDiffCmd = `git --no-pager diff ${previousReviewSha}...HEAD --name-only`;
306
+ gitDiffCmd = `git --no-pager diff ${previousReviewSha}...HEAD`;
307
+ logger.info(`Incremental review: diffing from ${previousReviewSha.slice(0, 8)} to HEAD`);
308
+ } else if (localMode) {
309
+ prDiffCmd = `git --no-pager diff ${targetBranch} --name-only`;
310
+ gitDiffCmd = `git --no-pager diff ${targetBranch}`;
311
+ } else if (platform === "github") {
312
+ prDiffCmd = `git --no-pager diff origin/${targetBranch}...HEAD --name-only`;
313
+ gitDiffCmd = `git --no-pager diff origin/${targetBranch}...HEAD`;
314
+ } else {
315
+ if (diffBaseSha) {
316
+ prDiffCmd = `git --no-pager diff ${diffBaseSha} HEAD --name-only`;
317
+ gitDiffCmd = `git --no-pager diff ${diffBaseSha} HEAD`;
318
+ logger.info(`Using GitLab CI_MERGE_REQUEST_DIFF_BASE_SHA: ${diffBaseSha.slice(0, 8)}`);
319
+ } else {
320
+ prDiffCmd = `git --no-pager diff origin/${targetBranch}...HEAD --name-only`;
321
+ gitDiffCmd = `git --no-pager diff origin/${targetBranch}...HEAD`;
322
+ }
323
+ }
324
+ let diffExplanation;
325
+ if (previousReviewSha) {
326
+ diffExplanation = `**Incremental mode**: Showing only changes since the last hodor review (commit \`${previousReviewSha.slice(0, 8)}\`).`;
327
+ } else if (diffBaseSha) {
328
+ diffExplanation = `**GitLab CI Advantage**: This uses GitLab's pre-calculated merge base SHA (\`CI_MERGE_REQUEST_DIFF_BASE_SHA\`), which matches exactly what the GitLab UI shows. This is more reliable than three-dot syntax because it handles force pushes, rebases, and messy histories correctly.`;
329
+ } else {
330
+ diffExplanation = `**Three-dot syntax** shows ONLY changes introduced on the source branch, excluding changes already on \`${targetBranch}\`.`;
331
+ }
332
+ const { contextSection, notesSection, reminderSection } = buildMrSections(mrMetadata);
333
+ let incrementalSection = "";
334
+ if (previousReviewSha) {
335
+ incrementalSection = `## Incremental Review Mode
336
+
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
341
+
342
+ `;
343
+ }
344
+ let embeddedDiffSection;
345
+ let diffFetchInstructions;
346
+ let reviewProcessSection;
347
+ let startInstruction;
348
+ if (embeddedDiff) {
349
+ embeddedDiffSection = "## Full Diff (Pre-fetched)\n\nThe complete diff for this PR is provided below. Analyze it directly. Use `read` or `grep` only if you need additional file context beyond what the diff shows.\n\n````diff\n" + embeddedDiff + "\n````\n";
350
+ diffFetchInstructions = `## Review the Diff Above
351
+
352
+ ### Critical Rules
353
+ - ONLY review files that appear in the diff above
354
+ - ONLY analyze actual code changes (+ and - lines in the diff)
355
+ - NEVER review files not in the diff
356
+ - NEVER flag "files will be deleted when merging" (outdated branch)
357
+ - NEVER flag "dependency version downgrade" (branch not rebased)
358
+ - NEVER compare entire codebase to ${targetBranch} - DIFF ONLY
359
+ `;
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`.";
362
+ } else {
363
+ embeddedDiffSection = "";
364
+ 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 + `\`
365
+ - NEVER review files not in the diff
366
+ - NEVER flag "files will be deleted when merging" (outdated branch)
367
+ - NEVER flag "dependency version downgrade" (branch not rebased)
368
+ - NEVER compare entire codebase to ${targetBranch} - DIFF ONLY
369
+
370
+ ### Git Diff Command
371
+
372
+ **Most reliable command to see changes:**
373
+ \`\`\`bash
374
+ ` + gitDiffCmd + "\n```\n\n" + diffExplanation;
375
+ reviewProcessSection = `## Review Process
376
+
377
+ **Efficient Sequential Workflow:**
378
+
379
+ 1. **List files first**: Run \`${prDiffCmd}\` to get the list of changed files (NOT full diff)
380
+ 2. **Per-file analysis**: For each file, run \`${gitDiffCmd} -- path/to/file\` to see its specific changes
381
+ 3. **Batch pattern search**: Use \`grep\` across multiple files to find common bug patterns (null, undefined, TODO, FIXME, etc.)
382
+ 4. **Selective deep dive**: Only use \`read\` to read full file context when the diff alone is insufficient
383
+ 5. **Group related files**: Analyze related files together (e.g., implementation + tests, interfaces + implementations)
384
+ 6. **Avoid redundancy**: Don't re-read files unnecessarily; make decisions based on diff context
385
+ `;
386
+ startInstruction = `Start by running \`${prDiffCmd}\` to list the changed files, then analyze each file individually using \`${gitDiffCmd} -- path/to/file\`.`;
387
+ }
388
+ let prompt = templateText.replace(/\{pr_url\}/g, prUrl).replace(/\{pr_diff_cmd\}/g, prDiffCmd).replace(/\{git_diff_cmd\}/g, gitDiffCmd).replace(/\{target_branch\}/g, targetBranch).replace(/\{diff_explanation\}/g, diffExplanation).replace(/\{mr_context_section\}/g, contextSection).replace(/\{mr_notes_section\}/g, notesSection).replace(/\{mr_reminder_section\}/g, reminderSection).replace(/\{incremental_section\}/g, incrementalSection).replace(/\{embedded_diff_section\}/g, embeddedDiffSection).replace(/\{diff_fetch_instructions\}/g, diffFetchInstructions).replace(/\{review_process_section\}/g, reviewProcessSection).replace(/\{start_instruction\}/g, startInstruction);
389
+ if (customInstructions) {
390
+ prompt += `
391
+
392
+ ## Additional Instructions
393
+
394
+ ${customInstructions}
395
+ `;
396
+ logger.info("Appended custom instructions to prompt");
397
+ }
398
+ return prompt;
399
+ }
400
+ function buildMrSections(mrMetadata) {
401
+ if (!mrMetadata) {
402
+ return { contextSection: "", notesSection: "", reminderSection: "" };
403
+ }
404
+ const contextLines = [];
405
+ if (mrMetadata.title) {
406
+ contextLines.push(`- Title: ${mrMetadata.title}`);
407
+ }
408
+ const author = mrMetadata.author?.username ?? mrMetadata.author?.name;
409
+ if (author) {
410
+ contextLines.push(`- Author: @${author}`);
411
+ }
412
+ if (mrMetadata.source_branch && mrMetadata.target_branch) {
413
+ contextLines.push(
414
+ `- Branches: ${mrMetadata.source_branch} \u2192 ${mrMetadata.target_branch}`
415
+ );
416
+ }
417
+ if (mrMetadata.changes_count) {
418
+ contextLines.push(`- Files changed: ${mrMetadata.changes_count}`);
419
+ }
420
+ const pipelineStatus = mrMetadata.pipeline?.status;
421
+ const pipelineUrl = mrMetadata.pipeline?.web_url;
422
+ if (pipelineStatus) {
423
+ const statusText = pipelineStatus.replace(/_/g, " ");
424
+ contextLines.push(
425
+ pipelineUrl ? `- Pipeline: ${statusText} (${pipelineUrl})` : `- Pipeline: ${statusText}`
426
+ );
427
+ }
428
+ let labelNames = normalizeLabelNames(mrMetadata.label_details);
429
+ if (labelNames.length === 0) {
430
+ labelNames = normalizeLabelNames(mrMetadata.labels);
431
+ }
432
+ if (labelNames.length > 0) {
433
+ contextLines.push(`- Labels: ${labelNames.join(", ")}`);
434
+ }
435
+ const description = (mrMetadata.description ?? "").trim();
436
+ let descriptionSection = "";
437
+ if (description) {
438
+ descriptionSection = "**Author Description:**\n" + truncateBlock(description, 800);
439
+ }
440
+ let contextSection = "";
441
+ if (contextLines.length > 0 || descriptionSection) {
442
+ contextSection = "## MR Context\n" + contextLines.join("\n");
443
+ if (descriptionSection) {
444
+ contextSection += "\n\n" + descriptionSection;
445
+ }
446
+ contextSection += "\n";
447
+ }
448
+ let notesSection = "";
449
+ const notesSummary = summarizeGitlabNotes(mrMetadata.Notes);
450
+ if (notesSummary) {
451
+ notesSection = `## Existing MR Notes
452
+ ${notesSummary}
453
+ `;
454
+ }
455
+ let reminderSection = "";
456
+ if (notesSummary) {
457
+ reminderSection = "## Review Note Deduplication\n\nThe discussions above may already cover some issues. Before reporting a finding:\n1. Check if it's already mentioned in existing notes\n2. Only report if your finding is materially different or more specific\n3. If an existing note is incorrect/outdated, explain why in your finding\n\nFocus on discovering NEW issues not yet discussed.\n";
458
+ }
459
+ return { contextSection, notesSection, reminderSection };
460
+ }
461
+ function truncateBlock(text, limit) {
462
+ const trimmed = text.trim();
463
+ if (trimmed.length <= limit) return trimmed;
464
+ return trimmed.slice(0, limit - 1).trimEnd() + "\u2026";
465
+ }
466
+ function normalizeLabelNames(rawLabels) {
467
+ if (!rawLabels) return [];
468
+ const names = [];
469
+ function addLabel(value) {
470
+ let name = "";
471
+ if (typeof value === "string") {
472
+ name = value.trim();
473
+ } else if (typeof value === "object" && value !== null) {
474
+ const labelValue = value.name;
475
+ if (typeof labelValue === "string") {
476
+ name = labelValue.trim();
477
+ }
478
+ } else if (value != null) {
479
+ name = String(value).trim();
480
+ }
481
+ if (name) names.push(name);
482
+ }
483
+ if (Array.isArray(rawLabels)) {
484
+ for (const label of rawLabels) addLabel(label);
485
+ } else {
486
+ addLabel(rawLabels);
487
+ }
488
+ return names;
489
+ }
490
+
491
+ // src/model.ts
492
+ function parseModelString(model) {
493
+ const trimmed = model.trim();
494
+ if (!trimmed) throw new Error("Model name must be provided");
495
+ const parts = trimmed.split("/");
496
+ if (parts.length >= 2) {
497
+ const first = parts[0].toLowerCase();
498
+ if (first === "bedrock") {
499
+ let modelId = parts.slice(1).join("/");
500
+ if (modelId.startsWith("converse/")) {
501
+ modelId = modelId.slice("converse/".length);
502
+ }
503
+ return { provider: "amazon-bedrock", modelId };
504
+ }
505
+ if (["anthropic", "openai"].includes(first)) {
506
+ return { provider: first, modelId: parts.slice(1).join("/") };
507
+ }
508
+ }
509
+ const lower = trimmed.toLowerCase();
510
+ if (lower.includes("claude") || lower.includes("anthropic")) {
511
+ return { provider: "anthropic", modelId: trimmed };
512
+ }
513
+ if (lower.startsWith("gpt") || lower.startsWith("o1") || lower.startsWith("o3") || lower.startsWith("o4") || lower.includes("openai")) {
514
+ return { provider: "openai", modelId: trimmed };
515
+ }
516
+ return { provider: "anthropic", modelId: trimmed };
517
+ }
518
+ function mapReasoningEffort(effort) {
519
+ if (!effort) return void 0;
520
+ switch (effort.toLowerCase()) {
521
+ case "low":
522
+ return "low";
523
+ case "medium":
524
+ return "medium";
525
+ case "high":
526
+ case "xhigh":
527
+ return "high";
528
+ default:
529
+ return void 0;
530
+ }
531
+ }
532
+ function getApiKey(model) {
533
+ const llmKey = process.env.LLM_API_KEY;
534
+ if (llmKey) return llmKey;
535
+ if (model) {
536
+ const { provider } = parseModelString(model);
537
+ 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
+ }
546
+ }
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
+ throw new Error(
550
+ "No LLM API key found. Please set one of: LLM_API_KEY, ANTHROPIC_API_KEY, or OPENAI_API_KEY"
551
+ );
552
+ }
553
+
554
+ // src/metrics.ts
555
+ import chalk2 from "chalk";
556
+ function tok(value) {
557
+ if (value >= 1e6) return `${(value / 1e6).toFixed(2)}M`;
558
+ if (value >= 1e3) return `${(value / 1e3).toFixed(1)}K`;
559
+ return String(value);
560
+ }
561
+ function formatDuration(seconds) {
562
+ if (seconds >= 60) {
563
+ const m = Math.floor(seconds / 60);
564
+ const s = seconds % 60;
565
+ return s > 0 ? `${m}m ${s}s` : `${m}m`;
566
+ }
567
+ return `${seconds}s`;
568
+ }
569
+ function formatMetricsMarkdown(metrics) {
570
+ const totalInput = metrics.inputTokens + metrics.cacheReadTokens;
571
+ const parts = [`in \`${tok(totalInput)}\``];
572
+ if (metrics.cacheReadTokens > 0) {
573
+ parts.push(`cached \`${tok(metrics.cacheReadTokens)}\``);
574
+ }
575
+ parts.push(`out \`${tok(metrics.outputTokens)}\``);
576
+ const lines = [
577
+ `**Review Metrics** \u2014 ${metrics.turns} turns, ${metrics.toolCalls} tool calls, ${formatDuration(metrics.durationSeconds)}`,
578
+ `- Tokens: ${parts.join(" | ")} (total \`${tok(metrics.totalTokens)}\`)`
579
+ ];
580
+ if (metrics.cost > 0) {
581
+ lines.push(`- Cost: \`$${metrics.cost.toFixed(4)}\``);
582
+ }
583
+ return lines.join("\n");
584
+ }
585
+ function printMetrics(metrics, stream = process.stderr) {
586
+ const dim = chalk2.dim;
587
+ const bold = chalk2.bold;
588
+ const cyan = chalk2.cyan;
589
+ const write = (line) => stream.write(line + "\n");
590
+ write("");
591
+ write(dim("\u2500".repeat(50)));
592
+ const totalInput = metrics.inputTokens + metrics.cacheReadTokens;
593
+ let tokenLine = `${dim("Tokens:")} ${bold(tok(totalInput))} in`;
594
+ if (metrics.cacheReadTokens > 0) {
595
+ const hitPct = (metrics.cacheReadTokens / totalInput * 100).toFixed(0);
596
+ tokenLine += dim(` (${tok(metrics.cacheReadTokens)} cached ${hitPct}% \xB7 ${tok(metrics.inputTokens)} fresh)`);
597
+ }
598
+ tokenLine += ` ${bold(tok(metrics.outputTokens))} out`;
599
+ tokenLine += dim(` (${tok(metrics.totalTokens)} total)`);
600
+ write(tokenLine);
601
+ write(
602
+ `${dim("Agent:")} ${bold(String(metrics.turns))} turns ${bold(String(metrics.toolCalls))} tool calls ${cyan(formatDuration(metrics.durationSeconds))}`
603
+ );
604
+ if (metrics.cost > 0) {
605
+ write(`${dim("Cost:")} ${bold("$" + metrics.cost.toFixed(4))}`);
606
+ }
607
+ write(dim("\u2500".repeat(50)));
608
+ }
609
+ 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}}` : "";
613
+ const totalInput = metrics.inputTokens + metrics.cacheReadTokens;
614
+ const lines = [
615
+ `# HELP hodor_review_input_tokens_total Total input tokens (fresh + cached)`,
616
+ `# TYPE hodor_review_input_tokens_total gauge`,
617
+ `hodor_review_input_tokens_total${labelSuffix} ${totalInput}`,
618
+ `# HELP hodor_review_output_tokens_total Total output tokens`,
619
+ `# TYPE hodor_review_output_tokens_total gauge`,
620
+ `hodor_review_output_tokens_total${labelSuffix} ${metrics.outputTokens}`,
621
+ `# HELP hodor_review_cache_read_tokens_total Tokens served from prompt cache`,
622
+ `# TYPE hodor_review_cache_read_tokens_total gauge`,
623
+ `hodor_review_cache_read_tokens_total${labelSuffix} ${metrics.cacheReadTokens}`,
624
+ `# HELP hodor_review_cost_dollars Cost of the review in USD`,
625
+ `# TYPE hodor_review_cost_dollars gauge`,
626
+ `hodor_review_cost_dollars${labelSuffix} ${metrics.cost}`,
627
+ `# HELP hodor_review_turns_total Number of agent turns`,
628
+ `# TYPE hodor_review_turns_total gauge`,
629
+ `hodor_review_turns_total${labelSuffix} ${metrics.turns}`,
630
+ `# HELP hodor_review_tool_calls_total Number of tool calls`,
631
+ `# TYPE hodor_review_tool_calls_total gauge`,
632
+ `hodor_review_tool_calls_total${labelSuffix} ${metrics.toolCalls}`,
633
+ `# HELP hodor_review_duration_seconds Review duration in seconds`,
634
+ `# TYPE hodor_review_duration_seconds gauge`,
635
+ `hodor_review_duration_seconds${labelSuffix} ${metrics.durationSeconds}`,
636
+ ""
637
+ ];
638
+ const body = lines.join("\n");
639
+ const baseUrl = pushgatewayUrl.replace(/\/+$/, "");
640
+ const url = `${baseUrl}/metrics/job/hodor`;
641
+ try {
642
+ const res = await fetch(url, {
643
+ method: "POST",
644
+ headers: { "Content-Type": "text/plain" },
645
+ body,
646
+ signal: AbortSignal.timeout(1e4)
647
+ });
648
+ if (!res.ok) {
649
+ const text = await res.text().catch(() => "");
650
+ logger.warn(`Pushgateway returned ${res.status}: ${text.slice(0, 200)}`);
651
+ } else {
652
+ logger.info("Metrics pushed to Pushgateway");
653
+ }
654
+ } catch (err) {
655
+ logger.warn(`Failed to push metrics to Pushgateway: ${err instanceof Error ? err.message : err}`);
656
+ }
657
+ }
658
+
659
+ // src/review.ts
660
+ import { isAbsolute } from "path";
661
+ import { Type } from "@sinclair/typebox";
662
+ var REVIEW_PRIORITY_TAGS = /* @__PURE__ */ new Map([
663
+ ["[P0]", 0],
664
+ ["[P1]", 1],
665
+ ["[P2]", 2],
666
+ ["[P3]", 3]
667
+ ]);
668
+ var REVIEW_LOCATION_SCHEMA = Type.Object(
669
+ {
670
+ absolute_file_path: Type.String({ minLength: 1 }),
671
+ line_range: Type.Object(
672
+ {
673
+ start: Type.Integer({ minimum: 1 }),
674
+ end: Type.Integer({ minimum: 1 })
675
+ },
676
+ { additionalProperties: false }
677
+ )
678
+ },
679
+ { additionalProperties: false }
680
+ );
681
+ var REVIEW_FINDING_SCHEMA = Type.Object(
682
+ {
683
+ title: Type.String({ minLength: 1 }),
684
+ body: Type.String({ minLength: 1 }),
685
+ priority: Type.Integer({ minimum: 0, maximum: 3 }),
686
+ code_location: REVIEW_LOCATION_SCHEMA
687
+ },
688
+ { additionalProperties: false }
689
+ );
690
+ var SUBMIT_REVIEW_SCHEMA = Type.Object(
691
+ {
692
+ findings: Type.Array(REVIEW_FINDING_SCHEMA),
693
+ overall_correctness: Type.Union([
694
+ Type.Literal("patch is correct"),
695
+ Type.Literal("patch is incorrect")
696
+ ]),
697
+ overall_explanation: Type.String({ minLength: 1 })
698
+ },
699
+ { additionalProperties: false }
700
+ );
701
+ function validateReviewOutput(review) {
702
+ if (review.overall_explanation.trim().length === 0) {
703
+ throw new Error("submit_review overall_explanation must be non-empty");
704
+ }
705
+ for (const [index, finding] of review.findings.entries()) {
706
+ const label = `submit_review finding ${index + 1}`;
707
+ if (finding.title.trim().length === 0) {
708
+ throw new Error(`${label} title must be non-empty`);
709
+ }
710
+ if (finding.body.trim().length === 0) {
711
+ throw new Error(`${label} body must be non-empty`);
712
+ }
713
+ const taggedPriority = getPriorityFromTitle(finding.title);
714
+ if (taggedPriority == null) {
715
+ throw new Error(`${label} title must start with [P0], [P1], [P2], or [P3]`);
716
+ }
717
+ if (finding.priority !== taggedPriority) {
718
+ throw new Error(
719
+ `${label} priority ${finding.priority} does not match title tag ${taggedPriority}`
720
+ );
721
+ }
722
+ const { absolute_file_path: filePath, line_range: lineRange } = finding.code_location;
723
+ if (!isAbsolute(filePath)) {
724
+ throw new Error(`${label} code_location.absolute_file_path must be absolute`);
725
+ }
726
+ if (lineRange.start > lineRange.end) {
727
+ throw new Error(`${label} code_location line_range start must be <= end`);
728
+ }
729
+ }
730
+ return review;
731
+ }
732
+ function getPriorityFromTitle(title) {
733
+ const match = title.match(/^\[(P[0-3])\]/);
734
+ if (!match) return null;
735
+ return REVIEW_PRIORITY_TAGS.get(`[${match[1]}]`) ?? null;
736
+ }
737
+
738
+ // src/agent.ts
739
+ import { existsSync } from "fs";
740
+ import { join as join2 } from "path";
741
+
742
+ // src/github.ts
743
+ var GitHubAPIError = class extends Error {
744
+ constructor(message) {
745
+ super(message);
746
+ this.name = "GitHubAPIError";
747
+ }
748
+ };
749
+ async function fetchGithubPrInfo(owner, repo, prNumber) {
750
+ const fields = [
751
+ "number",
752
+ "title",
753
+ "body",
754
+ "author",
755
+ "baseRefName",
756
+ "headRefName",
757
+ "baseRefOid",
758
+ "headRefOid",
759
+ "changedFiles",
760
+ "labels",
761
+ "comments",
762
+ "state",
763
+ "isDraft",
764
+ "createdAt",
765
+ "updatedAt",
766
+ "mergeable",
767
+ "url"
768
+ ];
769
+ const repoFullPath = `${owner}/${repo}`;
770
+ try {
771
+ return await execJson("gh", [
772
+ "pr",
773
+ "view",
774
+ String(prNumber),
775
+ "-R",
776
+ repoFullPath,
777
+ "--json",
778
+ fields.join(",")
779
+ ]);
780
+ } catch (err) {
781
+ const msg = err instanceof Error ? err.message : String(err);
782
+ throw new GitHubAPIError(msg);
783
+ }
784
+ }
785
+ function normalizeGithubMetadata(raw) {
786
+ const author = raw.author ?? {};
787
+ const labels = raw.labels ?? [];
788
+ const comments = raw.comments;
789
+ return {
790
+ title: raw.title,
791
+ description: raw.body ?? "",
792
+ source_branch: raw.headRefName,
793
+ target_branch: raw.baseRefName,
794
+ changes_count: raw.changedFiles,
795
+ labels: labels.map((lbl) => ({ name: lbl.name ?? lbl.id })),
796
+ author: {
797
+ username: author.login ?? author.name,
798
+ name: author.name
799
+ },
800
+ Notes: githubCommentsToNotes(comments)
801
+ };
802
+ }
803
+ function githubCommentsToNotes(comments) {
804
+ if (!comments) return [];
805
+ let nodes;
806
+ if (Array.isArray(comments)) {
807
+ nodes = comments;
808
+ } else if (typeof comments === "object") {
809
+ nodes = comments.nodes ?? comments.edges ?? [];
810
+ if (nodes.length > 0 && typeof nodes[0] === "object" && "node" in nodes[0]) {
811
+ nodes = nodes.map(
812
+ (edge) => edge.node ?? {}
813
+ );
814
+ }
815
+ } else {
816
+ nodes = [];
817
+ }
818
+ return nodes.map((node) => {
819
+ const author = node.author ?? {};
820
+ return {
821
+ body: node.body ?? "",
822
+ author: {
823
+ username: author.login ?? author.name,
824
+ name: author.name
825
+ },
826
+ created_at: node.createdAt
827
+ };
828
+ });
829
+ }
830
+
831
+ // src/workspace.ts
832
+ import { mkdtemp, rm } from "fs/promises";
833
+ import { tmpdir } from "os";
834
+ import { join } from "path";
835
+ var WorkspaceError = class extends Error {
836
+ constructor(message) {
837
+ super(message);
838
+ this.name = "WorkspaceError";
839
+ }
840
+ };
841
+ function detectCiWorkspace(owner, repo) {
842
+ 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}`)) {
850
+ logger.info(`Detected GitLab CI environment (target: ${targetBranch ?? "unknown"})`);
851
+ return { path: projectDir, targetBranch, diffBaseSha };
852
+ }
853
+ }
854
+ }
855
+ 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) {
862
+ logger.info(`Detected GitHub Actions environment (base: ${baseRef ?? "unknown"})`);
863
+ return { path: workspaceDir, targetBranch: baseRef, diffBaseSha: null };
864
+ }
865
+ }
866
+ }
867
+ return { path: null, targetBranch: null, diffBaseSha: null };
868
+ }
869
+ async function isSameRepo(workspace, owner, repo) {
870
+ try {
871
+ const { stdout } = await exec("git", ["remote", "get-url", "origin"], { cwd: workspace });
872
+ 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;
878
+ } catch {
879
+ return false;
880
+ }
881
+ }
882
+ async function getGithubBaseBranch(workspace, prNumber) {
883
+ try {
884
+ const prInfo = await execJson(
885
+ "gh",
886
+ ["pr", "view", prNumber, "--json", "headRefName,baseRefName"],
887
+ { cwd: workspace }
888
+ );
889
+ const baseBranch = prInfo.baseRefName ?? "main";
890
+ logger.info(`Base branch: ${baseBranch}`);
891
+ return baseBranch;
892
+ } catch {
893
+ logger.warn("Could not fetch PR metadata for base branch detection");
894
+ return "main";
895
+ }
896
+ }
897
+ async function fetchAndCheckoutGithubPr(workspace, prNumber) {
898
+ logger.info(`Fetching and checking out PR #${prNumber} in existing workspace`);
899
+ await exec("git", ["fetch", "origin"], { cwd: workspace });
900
+ try {
901
+ await exec("gh", ["pr", "checkout", prNumber], { cwd: workspace });
902
+ } catch (err) {
903
+ const msg = err instanceof Error ? err.message : String(err);
904
+ throw new WorkspaceError(`Failed to checkout PR #${prNumber}: ${msg}`);
905
+ }
906
+ return getGithubBaseBranch(workspace, prNumber);
907
+ }
908
+ async function cloneAndCheckoutGithubPr(workspace, owner, repo, prNumber) {
909
+ logger.info(`Setting up GitHub workspace for ${owner}/${repo}/pull/${prNumber}`);
910
+ try {
911
+ await exec("gh", ["version"]);
912
+ } catch {
913
+ throw new WorkspaceError("GitHub CLI (gh) is not available. Install it: https://cli.github.com");
914
+ }
915
+ logger.info(`Cloning repository ${owner}/${repo}...`);
916
+ try {
917
+ await exec("gh", ["repo", "clone", `${owner}/${repo}`, workspace]);
918
+ } catch (err) {
919
+ const msg = err instanceof Error ? err.message : String(err);
920
+ throw new WorkspaceError(`Failed to clone repository ${owner}/${repo}: ${msg}`);
921
+ }
922
+ logger.info(`Checking out PR #${prNumber}...`);
923
+ try {
924
+ await exec("gh", ["pr", "checkout", prNumber], { cwd: workspace });
925
+ } catch (err) {
926
+ const msg = err instanceof Error ? err.message : String(err);
927
+ throw new WorkspaceError(`Failed to checkout PR #${prNumber}: ${msg}`);
928
+ }
929
+ return getGithubBaseBranch(workspace, prNumber);
930
+ }
931
+ async function getGitlabMrBranches(owner, repo, prNumber, host) {
932
+ const gitlabHost = host || process.env.GITLAB_HOST || "gitlab.com";
933
+ let mrInfo;
934
+ try {
935
+ mrInfo = await fetchGitlabMrInfo(owner, repo, Number(prNumber), gitlabHost);
936
+ } catch (err) {
937
+ const msg = err instanceof Error ? err.message : String(err);
938
+ throw new WorkspaceError(`Failed to fetch MR info for !${prNumber}: ${msg}`);
939
+ }
940
+ const sourceBranch = mrInfo.source_branch;
941
+ if (!sourceBranch) {
942
+ throw new WorkspaceError(`Could not determine source branch for MR !${prNumber}`);
943
+ }
944
+ return { sourceBranch, targetBranch: mrInfo.target_branch ?? "main" };
945
+ }
946
+ async function checkoutGitlabBranch(workspace, sourceBranch) {
947
+ try {
948
+ await exec("git", ["checkout", "-b", sourceBranch, `origin/${sourceBranch}`], {
949
+ cwd: workspace
950
+ });
951
+ } catch {
952
+ try {
953
+ await exec("git", ["checkout", sourceBranch], { cwd: workspace });
954
+ } catch (err) {
955
+ const msg = err instanceof Error ? err.message : String(err);
956
+ throw new WorkspaceError(`Failed to checkout MR branch '${sourceBranch}': ${msg}`);
957
+ }
958
+ }
959
+ }
960
+ async function fetchAndCheckoutGitlabMr(workspace, owner, repo, prNumber, host) {
961
+ logger.info(`Fetching and checking out MR !${prNumber} in existing workspace`);
962
+ await exec("git", ["fetch", "origin"], { cwd: workspace });
963
+ const { sourceBranch, targetBranch } = await getGitlabMrBranches(owner, repo, prNumber, host);
964
+ logger.info(`Source branch: ${sourceBranch}, Target branch: ${targetBranch}`);
965
+ await checkoutGitlabBranch(workspace, sourceBranch);
966
+ return targetBranch;
967
+ }
968
+ async function cloneAndCheckoutGitlabMr(workspace, owner, repo, prNumber, host) {
969
+ const gitlabHost = host || process.env.GITLAB_HOST || "gitlab.com";
970
+ logger.info(`Setting up GitLab workspace for ${owner}/${repo}/merge_requests/${prNumber}`);
971
+ try {
972
+ await exec("glab", ["version"]);
973
+ } catch {
974
+ throw new WorkspaceError(
975
+ "GitLab CLI (glab) is not available. Install it: https://gitlab.com/gitlab-org/cli"
976
+ );
977
+ }
978
+ const cloneUrl = `https://${gitlabHost}/${owner}/${repo}.git`;
979
+ logger.info(`Cloning from ${cloneUrl}...`);
980
+ try {
981
+ await exec("git", ["clone", cloneUrl, workspace]);
982
+ } catch (err) {
983
+ const msg = err instanceof Error ? err.message : String(err);
984
+ if (msg.includes("Permission denied") || msg.includes("publickey")) {
985
+ throw new WorkspaceError(
986
+ `Failed to clone ${owner}/${repo}: SSH authentication failed. Ensure your SSH key is available (ssh-add) or configure a GITLAB_TOKEN and use HTTPS: git config --global url."https://oauth2:$GITLAB_TOKEN@${gitlabHost}/".insteadOf "git@${gitlabHost}:"`
987
+ );
988
+ }
989
+ throw new WorkspaceError(`Failed to clone ${owner}/${repo}: ${msg}`);
990
+ }
991
+ const { sourceBranch, targetBranch } = await getGitlabMrBranches(owner, repo, prNumber, host);
992
+ logger.info(`Source branch: ${sourceBranch}, Target branch: ${targetBranch}`);
993
+ await checkoutGitlabBranch(workspace, sourceBranch);
994
+ return targetBranch;
995
+ }
996
+ async function setupWorkspace(opts) {
997
+ const { platform, owner, repo, prNumber, host, workingDir, reuse = true } = opts;
998
+ try {
999
+ const ci = detectCiWorkspace(owner, repo);
1000
+ let detectedTargetBranch = ci.targetBranch;
1001
+ const detectedDiffBaseSha = ci.diffBaseSha;
1002
+ let workspace;
1003
+ let isTemporary = false;
1004
+ if (ci.path) {
1005
+ workspace = ci.path;
1006
+ } else if (!workingDir) {
1007
+ workspace = await mkdtemp(join(tmpdir(), "hodor-review-"));
1008
+ isTemporary = true;
1009
+ logger.info(`Created temporary workspace: ${workspace}`);
1010
+ } else {
1011
+ workspace = workingDir;
1012
+ const { mkdir } = await import("fs/promises");
1013
+ await mkdir(workspace, { recursive: true });
1014
+ if (reuse && await isSameRepo(workspace, owner, repo)) {
1015
+ logger.info(`Reusing existing workspace: ${workspace}`);
1016
+ if (platform === "github") {
1017
+ const tb = await fetchAndCheckoutGithubPr(workspace, prNumber);
1018
+ if (!detectedTargetBranch) detectedTargetBranch = tb;
1019
+ } else if (platform === "gitlab") {
1020
+ const tb = await fetchAndCheckoutGitlabMr(workspace, owner, repo, prNumber, host);
1021
+ if (!detectedTargetBranch) detectedTargetBranch = tb;
1022
+ }
1023
+ const finalTargetBranch2 = detectedTargetBranch ?? "main";
1024
+ logger.info(
1025
+ `Workspace ready at: ${workspace} (target: ${finalTargetBranch2}, diff_base_sha: ${detectedDiffBaseSha?.slice(0, 8) ?? "N/A"})`
1026
+ );
1027
+ return { workspace, targetBranch: finalTargetBranch2, diffBaseSha: detectedDiffBaseSha, isTemporary: false };
1028
+ }
1029
+ }
1030
+ if (!ci.path) {
1031
+ if (platform === "github") {
1032
+ const tb = await cloneAndCheckoutGithubPr(workspace, owner, repo, prNumber);
1033
+ if (!detectedTargetBranch) detectedTargetBranch = tb;
1034
+ } else if (platform === "gitlab") {
1035
+ const tb = await cloneAndCheckoutGitlabMr(workspace, owner, repo, prNumber, host);
1036
+ if (!detectedTargetBranch) detectedTargetBranch = tb;
1037
+ } else {
1038
+ throw new WorkspaceError(`Unsupported platform: ${platform}`);
1039
+ }
1040
+ }
1041
+ const finalTargetBranch = detectedTargetBranch ?? "main";
1042
+ logger.info(
1043
+ `Workspace ready at: ${workspace} (target: ${finalTargetBranch}, diff_base_sha: ${detectedDiffBaseSha?.slice(0, 8) ?? "N/A"})`
1044
+ );
1045
+ return { workspace, targetBranch: finalTargetBranch, diffBaseSha: detectedDiffBaseSha, isTemporary };
1046
+ } catch (err) {
1047
+ if (err instanceof WorkspaceError) throw err;
1048
+ const msg = err instanceof Error ? err.message : String(err);
1049
+ throw new WorkspaceError(`Failed to setup workspace: ${msg}`);
1050
+ }
1051
+ }
1052
+ async function cleanupWorkspace(workspace) {
1053
+ try {
1054
+ await rm(workspace, { recursive: true, force: true });
1055
+ logger.info(`Cleaned up workspace: ${workspace}`);
1056
+ } catch (err) {
1057
+ logger.warn(`Failed to cleanup workspace ${workspace}: ${err}`);
1058
+ }
1059
+ }
1060
+
1061
+ // src/system-prompt.ts
1062
+ var REVIEW_SYSTEM_PROMPT = `You are a code review agent. You analyze pull request diffs to find production bugs.
1063
+
1064
+ <ROLE>
1065
+ * You are in READ-ONLY mode. Do NOT modify any files, create files, commit, or install dependencies.
1066
+ * Your only job is to analyze the diff, identify bugs, and produce a review.
1067
+ * Submit the final review via the \`submit_review\` tool. Do NOT output the final review as normal assistant text.
1068
+ * Be proportional: scale your analysis depth to the diff size. A small, single-file diff needs only a few iterations; a large multi-file refactor warrants deeper investigation.
1069
+ * Do NOT write to PLAN.md or AGENTS.md.
1070
+ * Do NOT run package managers (npm install, go mod download, pip install, etc.).
1071
+ * Follow the instructions in the user prompt exactly as given.
1072
+ </ROLE>
1073
+
1074
+ <EFFICIENCY>
1075
+ * Combine multiple bash commands where possible (e.g. \`cmd1 && cmd2\`).
1076
+ * Use the grep and find tools for code search \u2014 do not shell out to grep/find.
1077
+ * Prefer \`git diff\` to see changes for specific files. Only use read when you need surrounding context that the diff alone cannot provide.
1078
+ * Do not use cat/head/tail to read files.
1079
+ * Keep reasoning proportional to the task. A small diff does not need extensive deliberation.
1080
+ </EFFICIENCY>`;
1081
+
1082
+ // src/agent.ts
1083
+ function detectPlatform(prUrl) {
1084
+ const url = new URL(prUrl);
1085
+ const hostname = url.hostname;
1086
+ if (prUrl.includes("/-/merge_requests/") || hostname.includes("gitlab")) {
1087
+ return "gitlab";
1088
+ }
1089
+ if (prUrl.includes("/pull/") || hostname.includes("github")) {
1090
+ return "github";
1091
+ }
1092
+ throw new Error(
1093
+ `Cannot detect platform for URL: ${prUrl}. Expected a GitHub pull request (/pull/) or GitLab merge request (/-/merge_requests/) URL.`
1094
+ );
1095
+ }
1096
+ function parsePrUrl(prUrl) {
1097
+ const url = new URL(prUrl);
1098
+ const pathParts = url.pathname.split("/").filter(Boolean);
1099
+ const host = url.host;
1100
+ if (pathParts.length >= 4 && pathParts[2] === "pull") {
1101
+ const prNumber = parseInt(pathParts[3], 10);
1102
+ if (!Number.isSafeInteger(prNumber) || prNumber <= 0) {
1103
+ throw new Error(`Invalid PR number in URL: ${prUrl}. Expected a positive integer after /pull/.`);
1104
+ }
1105
+ return {
1106
+ owner: pathParts[0],
1107
+ repo: pathParts[1],
1108
+ prNumber,
1109
+ host
1110
+ };
1111
+ }
1112
+ const mrIndex = pathParts.indexOf("merge_requests");
1113
+ if (mrIndex >= 0) {
1114
+ if (mrIndex < 2 || mrIndex + 1 >= pathParts.length) {
1115
+ throw new Error(
1116
+ `Invalid GitLab MR URL format: ${prUrl}. Expected .../-/merge_requests/<number>`
1117
+ );
1118
+ }
1119
+ if (pathParts[mrIndex - 1] !== "-") {
1120
+ throw new Error(
1121
+ `Invalid GitLab MR URL format: ${prUrl}. Missing '/-/' segment before merge_requests.`
1122
+ );
1123
+ }
1124
+ const repo = pathParts[mrIndex - 2];
1125
+ const ownerParts = pathParts.slice(0, mrIndex - 2);
1126
+ const owner = ownerParts.length > 0 ? ownerParts.join("/") : pathParts[0];
1127
+ const prNumber = parseInt(pathParts[mrIndex + 1], 10);
1128
+ if (!Number.isSafeInteger(prNumber) || prNumber <= 0) {
1129
+ throw new Error(`Invalid MR number in URL: ${prUrl}. Expected a positive integer after /merge_requests/.`);
1130
+ }
1131
+ return { owner, repo, prNumber, host };
1132
+ }
1133
+ throw new Error(
1134
+ `Invalid PR/MR URL format: ${prUrl}. Expected GitHub pull request or GitLab merge request URL.`
1135
+ );
1136
+ }
1137
+ async function postReviewComment(opts) {
1138
+ const { prUrl, reviewText, model, metricsFooter, headSha } = opts;
1139
+ const platform = detectPlatform(prUrl);
1140
+ logger.info(`Posting comment to ${platform} PR/MR: ${prUrl}`);
1141
+ let parsed;
1142
+ try {
1143
+ parsed = parsePrUrl(prUrl);
1144
+ } catch (err) {
1145
+ return { success: false, error: String(err) };
1146
+ }
1147
+ let body = reviewText;
1148
+ if (headSha) {
1149
+ body = `<!-- hodor:sha:${headSha} -->
1150
+ ${body}`;
1151
+ }
1152
+ if (model) {
1153
+ body = `${body}
1154
+
1155
+ ---
1156
+
1157
+ Review generated by Hodor (model: \`${model}\`)`;
1158
+ }
1159
+ if (metricsFooter) {
1160
+ body = `${body}
1161
+
1162
+ ${metricsFooter}`;
1163
+ }
1164
+ try {
1165
+ if (platform === "github") {
1166
+ await exec("gh", [
1167
+ "pr",
1168
+ "review",
1169
+ String(parsed.prNumber),
1170
+ "--repo",
1171
+ `${parsed.owner}/${parsed.repo}`,
1172
+ "--comment",
1173
+ "--body",
1174
+ body
1175
+ ]);
1176
+ logger.info(`Successfully posted review to GitHub PR #${parsed.prNumber}`);
1177
+ return { success: true, platform: "github", prNumber: parsed.prNumber };
1178
+ } else {
1179
+ await postGitlabMrComment(
1180
+ parsed.owner,
1181
+ parsed.repo,
1182
+ parsed.prNumber,
1183
+ body,
1184
+ parsed.host
1185
+ );
1186
+ logger.info(
1187
+ `Successfully posted review to GitLab MR !${parsed.prNumber}`
1188
+ );
1189
+ return {
1190
+ success: true,
1191
+ platform: "gitlab",
1192
+ mrNumber: parsed.prNumber
1193
+ };
1194
+ }
1195
+ } catch (err) {
1196
+ const msg = err instanceof Error ? err.message : String(err);
1197
+ logger.error(`Failed to post comment: ${msg}`);
1198
+ return { success: false, error: msg };
1199
+ }
1200
+ }
1201
+ async function reviewPr(opts) {
1202
+ const {
1203
+ prUrl,
1204
+ model = "anthropic/claude-sonnet-4-5-20250929",
1205
+ reasoningEffort,
1206
+ customPrompt,
1207
+ promptFile,
1208
+ cleanup = true,
1209
+ workspaceDir,
1210
+ includeMetricsFooter = false,
1211
+ onEvent,
1212
+ bedrockTags,
1213
+ localMode = false,
1214
+ diffAgainst
1215
+ } = opts;
1216
+ logger.info(`Starting PR review for: ${localMode ? "local diff" : prUrl}`);
1217
+ let owner = "", repo = "", host = "";
1218
+ let prNumber = 0;
1219
+ let platform = "github";
1220
+ if (!localMode && prUrl) {
1221
+ const urlParsed = parsePrUrl(prUrl);
1222
+ owner = urlParsed.owner;
1223
+ repo = urlParsed.repo;
1224
+ prNumber = urlParsed.prNumber;
1225
+ host = urlParsed.host;
1226
+ platform = detectPlatform(prUrl);
1227
+ logger.info(`Platform: ${platform}, Repo: ${owner}/${repo}, PR: ${prNumber}, Host: ${host}`);
1228
+ }
1229
+ const parsed = parseModelString(model);
1230
+ const thinkingLevel = mapReasoningEffort(reasoningEffort);
1231
+ const apiKey = getApiKey(model);
1232
+ const envSnapshot = {
1233
+ ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
1234
+ OPENAI_API_KEY: process.env.OPENAI_API_KEY,
1235
+ AWS_REGION: process.env.AWS_REGION
1236
+ };
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
+ const {
1245
+ createAgentSession,
1246
+ DefaultResourceLoader,
1247
+ SessionManager,
1248
+ 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");
1258
+ const authStorage = AuthStorage.inMemory();
1259
+ const modelRegistry = new ModelRegistry(authStorage);
1260
+ let piModel;
1261
+ if (parsed.modelId.startsWith("arn:")) {
1262
+ const arnParts = parsed.modelId.split(":");
1263
+ const region = arnParts.length >= 4 ? arnParts[3] : "us-east-1";
1264
+ if (!process.env.AWS_REGION && !process.env.AWS_DEFAULT_REGION) {
1265
+ process.env.AWS_REGION = region;
1266
+ }
1267
+ piModel = {
1268
+ id: parsed.modelId,
1269
+ name: parsed.modelId,
1270
+ api: "bedrock-converse-stream",
1271
+ provider: "amazon-bedrock",
1272
+ baseUrl: `https://bedrock-runtime.${region}.amazonaws.com`,
1273
+ reasoning: false,
1274
+ input: ["text"],
1275
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
1276
+ contextWindow: 2e5,
1277
+ maxTokens: 16384
1278
+ };
1279
+ logger.info(`Custom bedrock ARN model \u2014 region: ${region}`);
1280
+ } else {
1281
+ try {
1282
+ piModel = getModel(parsed.provider, parsed.modelId);
1283
+ } catch (err) {
1284
+ throw new Error(
1285
+ `Unsupported model "${model}": ${err instanceof Error ? err.message : err}`
1286
+ );
1287
+ }
1288
+ }
1289
+ logger.info("Preflight OK \u2014 model and credentials validated");
1290
+ let workspacePath;
1291
+ let targetBranch;
1292
+ let diffBaseSha = null;
1293
+ let isTemporary = false;
1294
+ if (localMode) {
1295
+ const cwd = workspaceDir ?? process.cwd();
1296
+ try {
1297
+ const { stdout: toplevel } = await exec("git", ["rev-parse", "--show-toplevel"], { cwd });
1298
+ workspacePath = toplevel.trim();
1299
+ } catch {
1300
+ workspacePath = cwd;
1301
+ }
1302
+ targetBranch = diffAgainst ?? "origin/main";
1303
+ logger.info(`Local mode: workspace=${workspacePath}, diffAgainst=${targetBranch}`);
1304
+ } else {
1305
+ const wsResult = await setupWorkspace({
1306
+ platform,
1307
+ owner,
1308
+ repo,
1309
+ prNumber: String(prNumber),
1310
+ host,
1311
+ workingDir: workspaceDir ?? void 0,
1312
+ reuse: workspaceDir != null
1313
+ });
1314
+ workspacePath = wsResult.workspace;
1315
+ targetBranch = wsResult.targetBranch;
1316
+ diffBaseSha = wsResult.diffBaseSha;
1317
+ isTemporary = wsResult.isTemporary;
1318
+ }
1319
+ try {
1320
+ let formatToolArgs2 = function(_toolName, args) {
1321
+ if (typeof args === "string") return args.slice(0, 200);
1322
+ const obj = args;
1323
+ if (!obj) return "";
1324
+ if (obj.command) {
1325
+ return String(obj.command).replace(new RegExp(`cd ${workspacePath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} && `), "").slice(0, 200);
1326
+ }
1327
+ if (obj.pattern) {
1328
+ const path = obj.path ? ` in ${obj.path}` : "";
1329
+ return `${obj.pattern}${path}`;
1330
+ }
1331
+ if (obj.path || obj.file_path) return String(obj.path ?? obj.file_path);
1332
+ return JSON.stringify(obj).slice(0, 200);
1333
+ }, formatToolResult2 = function(result) {
1334
+ if (typeof result === "string") return result;
1335
+ const obj = result;
1336
+ if (!obj) return "";
1337
+ const content = obj.content;
1338
+ if (Array.isArray(content)) {
1339
+ return content.filter((c) => c.type === "text" && c.text).map((c) => c.text).join("\n");
1340
+ }
1341
+ return JSON.stringify(result)?.slice(0, 500) ?? "";
1342
+ };
1343
+ var formatToolArgs = formatToolArgs2, formatToolResult = formatToolResult2;
1344
+ let mrMetadata = null;
1345
+ if (!localMode && platform === "gitlab") {
1346
+ try {
1347
+ mrMetadata = await fetchGitlabMrInfo(owner, repo, prNumber, host, {
1348
+ includeComments: true
1349
+ });
1350
+ } catch (err) {
1351
+ logger.warn(`Failed to fetch GitLab metadata: ${err}`);
1352
+ }
1353
+ } else if (!localMode && platform === "github") {
1354
+ try {
1355
+ const githubRaw = await fetchGithubPrInfo(owner, repo, prNumber);
1356
+ mrMetadata = normalizeGithubMetadata(githubRaw);
1357
+ } catch (err) {
1358
+ logger.warn(`Failed to fetch GitHub metadata: ${err}`);
1359
+ }
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
+ }
1368
+ }
1369
+ }
1370
+ 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
+ }
1380
+ }
1381
+ let headSha = null;
1382
+ if (!localMode) {
1383
+ const { stdout: headShaRaw } = await exec("git", ["rev-parse", "HEAD"], { cwd: workspacePath });
1384
+ headSha = headShaRaw.trim();
1385
+ }
1386
+ const MAX_EMBED_BYTES = 200 * 1024;
1387
+ let embeddedDiff = null;
1388
+ try {
1389
+ 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
+ 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)`);
1394
+ } else {
1395
+ logger.info(`Diff too large to embed (${Buffer.byteLength(rawDiff, "utf-8")} bytes), using command mode`);
1396
+ }
1397
+ } catch (err) {
1398
+ logger.warn(`Failed to pre-fetch diff, falling back to command mode: ${err}`);
1399
+ }
1400
+ const prompt = buildPrReviewPrompt({
1401
+ prUrl: prUrl ?? `local diff (against ${targetBranch})`,
1402
+ platform,
1403
+ targetBranch,
1404
+ diffBaseSha,
1405
+ mrMetadata,
1406
+ customInstructions: customPrompt,
1407
+ customPromptFile: promptFile,
1408
+ embeddedDiff,
1409
+ previousReviewSha,
1410
+ localMode
1411
+ });
1412
+ const startTime = Date.now();
1413
+ const settingsManager = SettingsManager.inMemory({
1414
+ compaction: { enabled: true }
1415
+ });
1416
+ const skillPaths = [
1417
+ join2(workspacePath, ".pi", "skills"),
1418
+ join2(workspacePath, ".hodor", "skills")
1419
+ ].filter((p) => existsSync(p));
1420
+ const resourceLoader = new DefaultResourceLoader({
1421
+ cwd: workspacePath,
1422
+ settingsManager,
1423
+ systemPrompt: REVIEW_SYSTEM_PROMPT,
1424
+ appendSystemPrompt: "",
1425
+ noExtensions: true,
1426
+ noSkills: true,
1427
+ noPromptTemplates: true,
1428
+ noThemes: true,
1429
+ additionalSkillPaths: skillPaths,
1430
+ agentsFilesOverride: () => ({ agentsFiles: [] })
1431
+ });
1432
+ await resourceLoader.reload();
1433
+ const { skills, diagnostics: skillDiagnostics } = resourceLoader.getSkills();
1434
+ if (skills.length > 0) {
1435
+ logger.info(`Discovered ${skills.length} repository skill(s)`);
1436
+ for (const skill of skills) {
1437
+ logger.info(`Found skill: ${skill.name} (${skill.filePath})`);
1438
+ }
1439
+ }
1440
+ for (const diagnostic of skillDiagnostics) {
1441
+ const path = diagnostic.path ? ` (${diagnostic.path})` : "";
1442
+ logger.warn(`Skill diagnostic: ${diagnostic.message}${path}`);
1443
+ }
1444
+ let submittedReview = null;
1445
+ let submitReviewCalls = 0;
1446
+ const submitReviewTool = {
1447
+ name: "submit_review",
1448
+ label: "Submit Review",
1449
+ description: "Submit the final structured review after the analysis is complete.",
1450
+ promptSnippet: "Submit the final structured review (call exactly once when done)",
1451
+ parameters: SUBMIT_REVIEW_SCHEMA,
1452
+ execute: async (_toolCallId, params, _signal, _onUpdate, _ctx) => {
1453
+ submitReviewCalls++;
1454
+ if (submittedReview) {
1455
+ logger.warn("Agent called submit_review more than once; ignoring duplicate submission");
1456
+ return {
1457
+ content: [{
1458
+ type: "text",
1459
+ text: "Review already submitted. Do not call submit_review again."
1460
+ }],
1461
+ details: { ignoredDuplicate: true }
1462
+ };
1463
+ }
1464
+ submittedReview = validateReviewOutput(params);
1465
+ logger.info(
1466
+ `Received structured review via submit_review (${submittedReview.findings.length} finding(s))`
1467
+ );
1468
+ return {
1469
+ content: [{
1470
+ type: "text",
1471
+ text: "Review received. Do not output the review as normal text."
1472
+ }],
1473
+ details: {}
1474
+ };
1475
+ }
1476
+ };
1477
+ const { session } = await createAgentSession({
1478
+ cwd: workspacePath,
1479
+ model: piModel,
1480
+ thinkingLevel,
1481
+ tools: [
1482
+ createReadTool(workspacePath),
1483
+ createBashTool(workspacePath),
1484
+ createGrepTool(workspacePath),
1485
+ createFindTool(workspacePath),
1486
+ createLsTool(workspacePath)
1487
+ ],
1488
+ customTools: [submitReviewTool],
1489
+ sessionManager: SessionManager.inMemory(),
1490
+ settingsManager,
1491
+ resourceLoader,
1492
+ authStorage,
1493
+ modelRegistry
1494
+ });
1495
+ if (bedrockTags && parsed.provider === "bedrock") {
1496
+ const agent = session.agent;
1497
+ const originalStreamFn = agent.streamFn;
1498
+ agent.streamFn = (...args) => {
1499
+ const options = args[2] ?? {};
1500
+ return originalStreamFn(args[0], args[1], { ...options, requestMetadata: bedrockTags });
1501
+ };
1502
+ logger.info(`Bedrock cost allocation tags: ${JSON.stringify(bedrockTags)}`);
1503
+ }
1504
+ let turnCount = 0;
1505
+ let toolCallCount = 0;
1506
+ session.subscribe((event) => {
1507
+ switch (event.type) {
1508
+ case "agent_start":
1509
+ onEvent?.({ type: "agent_start" });
1510
+ break;
1511
+ case "agent_end":
1512
+ onEvent?.({ type: "agent_end" });
1513
+ break;
1514
+ case "turn_start":
1515
+ turnCount++;
1516
+ onEvent?.({ type: "turn_start", turnIndex: turnCount });
1517
+ break;
1518
+ case "turn_end":
1519
+ onEvent?.({ type: "turn_end", turnIndex: turnCount });
1520
+ break;
1521
+ case "tool_execution_start":
1522
+ toolCallCount++;
1523
+ onEvent?.({
1524
+ type: "tool_start",
1525
+ toolName: event.toolName,
1526
+ toolArgs: formatToolArgs2(event.toolName, event.args)
1527
+ });
1528
+ break;
1529
+ case "tool_execution_end":
1530
+ onEvent?.({
1531
+ type: "tool_end",
1532
+ toolName: event.toolName,
1533
+ isError: event.isError,
1534
+ result: formatToolResult2(event.result)
1535
+ });
1536
+ break;
1537
+ case "message_start":
1538
+ onEvent?.({ type: "thinking" });
1539
+ break;
1540
+ case "message_update": {
1541
+ const msgEvent = event.assistantMessageEvent;
1542
+ if (!msgEvent?.delta) break;
1543
+ if (msgEvent.type === "text_delta") {
1544
+ onEvent?.({ type: "text_delta", delta: msgEvent.delta });
1545
+ } else if (msgEvent.type === "thinking_delta") {
1546
+ onEvent?.({ type: "thinking_delta", delta: msgEvent.delta });
1547
+ }
1548
+ break;
1549
+ }
1550
+ }
1551
+ });
1552
+ logger.info("Sending prompt to agent...");
1553
+ await session.prompt(prompt);
1554
+ const agentError = session.state?.error;
1555
+ if (agentError) {
1556
+ throw new Error(`LLM request failed: ${agentError}`);
1557
+ }
1558
+ 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
+ }
1567
+ if (submitReviewCalls > 0) {
1568
+ throw new Error("Agent called submit_review but did not provide a valid review payload");
1569
+ }
1570
+ throw new Error("Agent did not call submit_review");
1571
+ }
1572
+ const review = submittedReview;
1573
+ if (submitReviewCalls > 1) {
1574
+ logger.warn(`Agent called submit_review ${submitReviewCalls} times; using the first valid submission`);
1575
+ }
1576
+ logger.info(
1577
+ `Captured ${review.findings.length} finding(s), verdict: ${review.overall_correctness}`
1578
+ );
1579
+ const durationSeconds = (Date.now() - startTime) / 1e3;
1580
+ logger.info(`Review complete (${review.findings.length} finding(s))`);
1581
+ const allMessages = session.state?.messages ?? [];
1582
+ let inputTokens = 0;
1583
+ let outputTokens = 0;
1584
+ let cacheReadTokens = 0;
1585
+ let cacheWriteTokens = 0;
1586
+ let totalTokens = 0;
1587
+ let cost = 0;
1588
+ for (const msg of allMessages) {
1589
+ if (msg.role === "assistant" && msg.usage) {
1590
+ inputTokens += msg.usage.input ?? 0;
1591
+ outputTokens += msg.usage.output ?? 0;
1592
+ cacheReadTokens += msg.usage.cacheRead ?? 0;
1593
+ cacheWriteTokens += msg.usage.cacheWrite ?? 0;
1594
+ totalTokens += msg.usage.totalTokens ?? 0;
1595
+ cost += msg.usage.cost?.total ?? 0;
1596
+ }
1597
+ }
1598
+ const metrics = {
1599
+ inputTokens,
1600
+ outputTokens,
1601
+ cacheReadTokens,
1602
+ cacheWriteTokens,
1603
+ totalTokens,
1604
+ cost,
1605
+ turns: turnCount,
1606
+ toolCalls: toolCallCount,
1607
+ durationSeconds: Math.round(durationSeconds)
1608
+ };
1609
+ printMetrics(metrics);
1610
+ let metricsFooter = null;
1611
+ if (includeMetricsFooter) {
1612
+ metricsFooter = formatMetricsMarkdown(metrics);
1613
+ }
1614
+ return { review, metricsFooter, headSha, metrics };
1615
+ } finally {
1616
+ for (const [key, val] of Object.entries(envSnapshot)) {
1617
+ if (val === void 0) {
1618
+ delete process.env[key];
1619
+ } else {
1620
+ process.env[key] = val;
1621
+ }
1622
+ }
1623
+ if (cleanup && isTemporary) {
1624
+ logger.info("Cleaning up workspace...");
1625
+ await cleanupWorkspace(workspacePath);
1626
+ }
1627
+ }
1628
+ }
1629
+
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
+ export {
1707
+ setLogLevel,
1708
+ buildPrReviewPrompt,
1709
+ parseModelString,
1710
+ mapReasoningEffort,
1711
+ getApiKey,
1712
+ formatMetricsMarkdown,
1713
+ printMetrics,
1714
+ pushMetrics,
1715
+ validateReviewOutput,
1716
+ detectPlatform,
1717
+ parsePrUrl,
1718
+ postReviewComment,
1719
+ reviewPr,
1720
+ renderMarkdown
1721
+ };
1722
+ //# sourceMappingURL=chunk-QGUJENIG.js.map