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