@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.
- package/README.md +149 -21
- package/dist/chunk-AMUK6GDX.js +23 -0
- package/dist/chunk-AMUK6GDX.js.map +1 -0
- package/dist/chunk-DVJVQTVW.js +731 -0
- package/dist/chunk-DVJVQTVW.js.map +1 -0
- package/dist/{chunk-QGUJENIG.js → chunk-MRMLGIXO.js} +1100 -472
- package/dist/chunk-MRMLGIXO.js.map +1 -0
- package/dist/cli.js +111 -22
- package/dist/cli.js.map +1 -1
- package/dist/codequality-DTJK2LGF.js +42 -0
- package/dist/codequality-DTJK2LGF.js.map +1 -0
- package/dist/gitlab-5TWHYHEP.js +33 -0
- package/dist/gitlab-5TWHYHEP.js.map +1 -0
- package/dist/index.d.ts +24 -13
- package/dist/index.js +5 -2
- package/package.json +13 -13
- package/templates/tool-review.md +41 -5
- package/dist/chunk-QGUJENIG.js.map +0 -1
|
@@ -1,263 +1,28 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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.
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
278
|
+
return { provider, modelId };
|
|
504
279
|
}
|
|
505
|
-
if (
|
|
506
|
-
return { provider
|
|
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
|
-
|
|
539
|
-
|
|
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
|
|
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
|
|
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 =
|
|
587
|
-
const bold =
|
|
588
|
-
const 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
|
|
612
|
-
|
|
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(`
|
|
453
|
+
logger.warn(`Metrics endpoint returned ${res.status}: ${text.slice(0, 200)}`);
|
|
651
454
|
} else {
|
|
652
|
-
logger.info("Metrics pushed
|
|
455
|
+
logger.info("Metrics pushed successfully");
|
|
653
456
|
}
|
|
654
457
|
} catch (err) {
|
|
655
|
-
logger.warn(`Failed to push metrics
|
|
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
|
|
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 =
|
|
844
|
-
const projectPath =
|
|
845
|
-
const targetBranch =
|
|
846
|
-
const diffBaseSha =
|
|
847
|
-
if (projectDir && projectPath) {
|
|
848
|
-
|
|
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 =
|
|
857
|
-
const repository =
|
|
858
|
-
const baseRef =
|
|
859
|
-
if (workspaceDir && repository) {
|
|
860
|
-
|
|
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
|
|
874
|
-
|
|
875
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1250
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1392
|
-
|
|
1393
|
-
logger.info(`
|
|
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
|
-
|
|
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
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1555
|
-
if (
|
|
1556
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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.
|
|
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
|
-
|
|
1720
|
-
|
|
2347
|
+
postReviewStructured,
|
|
2348
|
+
reviewPr
|
|
1721
2349
|
};
|
|
1722
|
-
//# sourceMappingURL=chunk-
|
|
2350
|
+
//# sourceMappingURL=chunk-MRMLGIXO.js.map
|