@plannotator/pi-extension 0.16.7 → 0.17.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/generated/agent-jobs.ts +11 -0
- package/generated/claude-review.ts +345 -0
- package/generated/codex-review.ts +382 -0
- package/generated/external-annotation.ts +6 -0
- package/generated/path-utils.ts +19 -0
- package/generated/pr-github.ts +100 -2
- package/generated/pr-gitlab.ts +1 -0
- package/generated/pr-provider.ts +26 -0
- package/generated/repo.ts +13 -5
- package/generated/resolve-file.ts +56 -3
- package/generated/review-core.ts +32 -0
- package/generated/worktree.ts +120 -0
- package/index.ts +9 -3
- package/package.json +1 -1
- package/plannotator-browser.ts +193 -9
- package/plannotator-events.ts +2 -0
- package/plannotator.html +78 -77
- package/review-editor.html +596 -273
- package/server/agent-jobs.ts +139 -12
- package/server/external-annotations.ts +8 -0
- package/server/network.test.ts +96 -0
- package/server/network.ts +21 -4
- package/server/serverReview.ts +178 -52
- package/server.ts +1 -0
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
// @generated — DO NOT EDIT. Source: packages/server/codex-review.ts
|
|
2
|
+
/**
|
|
3
|
+
* Codex Review Agent — prompt, command builder, output parser, and finding transformer.
|
|
4
|
+
*
|
|
5
|
+
* Encapsulates all Codex-specific logic for the AI review agent integration.
|
|
6
|
+
* The review server (review.ts) calls into this module via the agent-jobs callbacks.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { homedir, tmpdir } from "node:os";
|
|
11
|
+
import { appendFile, mkdir, unlink, writeFile, readFile } from "node:fs/promises";
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
|
+
import type { DiffType } from "./review-core.js";
|
|
14
|
+
import type { PRMetadata } from "./pr-provider.js";
|
|
15
|
+
import { toRelativePath } from "./path-utils.js";
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Debug log — only active when PLANNOTATOR_DEBUG is set
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
const DEBUG_ENABLED = !!process.env.PLANNOTATOR_DEBUG;
|
|
22
|
+
const DEBUG_LOG_PATH = join(homedir(), ".plannotator", "codex-review-debug.log");
|
|
23
|
+
|
|
24
|
+
async function debugLog(label: string, data?: unknown): Promise<void> {
|
|
25
|
+
if (!DEBUG_ENABLED) return;
|
|
26
|
+
try {
|
|
27
|
+
await mkdir(join(homedir(), ".plannotator"), { recursive: true });
|
|
28
|
+
const timestamp = new Date().toISOString();
|
|
29
|
+
const line = data !== undefined
|
|
30
|
+
? `[${timestamp}] ${label}: ${typeof data === "string" ? data : JSON.stringify(data, null, 2)}\n`
|
|
31
|
+
: `[${timestamp}] ${label}\n`;
|
|
32
|
+
await appendFile(DEBUG_LOG_PATH, line);
|
|
33
|
+
} catch { /* never fail the main flow */ }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Schema — embedded as a string, written to disk on first use.
|
|
38
|
+
// Bun's compiled binary uses a virtual FS that external processes (codex)
|
|
39
|
+
// can't read, so we materialize the schema to a real file.
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
const CODEX_REVIEW_SCHEMA = JSON.stringify({
|
|
43
|
+
type: "object",
|
|
44
|
+
properties: {
|
|
45
|
+
findings: {
|
|
46
|
+
type: "array",
|
|
47
|
+
items: {
|
|
48
|
+
type: "object",
|
|
49
|
+
properties: {
|
|
50
|
+
title: { type: "string" },
|
|
51
|
+
body: { type: "string" },
|
|
52
|
+
confidence_score: { type: "number" },
|
|
53
|
+
priority: { type: ["integer", "null"] },
|
|
54
|
+
code_location: {
|
|
55
|
+
type: "object",
|
|
56
|
+
properties: {
|
|
57
|
+
absolute_file_path: { type: "string" },
|
|
58
|
+
line_range: {
|
|
59
|
+
type: "object",
|
|
60
|
+
properties: {
|
|
61
|
+
start: { type: "integer" },
|
|
62
|
+
end: { type: "integer" },
|
|
63
|
+
},
|
|
64
|
+
required: ["start", "end"],
|
|
65
|
+
additionalProperties: false,
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
required: ["absolute_file_path", "line_range"],
|
|
69
|
+
additionalProperties: false,
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
required: ["title", "body", "confidence_score", "priority", "code_location"],
|
|
73
|
+
additionalProperties: false,
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
overall_correctness: { type: "string" },
|
|
77
|
+
overall_explanation: { type: "string" },
|
|
78
|
+
overall_confidence_score: { type: "number" },
|
|
79
|
+
},
|
|
80
|
+
required: ["findings", "overall_correctness", "overall_explanation", "overall_confidence_score"],
|
|
81
|
+
additionalProperties: false,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const SCHEMA_DIR = join(homedir(), ".plannotator");
|
|
85
|
+
const SCHEMA_FILE = join(SCHEMA_DIR, "codex-review-schema.json");
|
|
86
|
+
let schemaMaterialized = false;
|
|
87
|
+
|
|
88
|
+
/** Ensure the schema file exists on disk and return its path. */
|
|
89
|
+
async function ensureSchemaFile(): Promise<string> {
|
|
90
|
+
if (!schemaMaterialized) {
|
|
91
|
+
await mkdir(SCHEMA_DIR, { recursive: true });
|
|
92
|
+
await writeFile(SCHEMA_FILE, CODEX_REVIEW_SCHEMA);
|
|
93
|
+
schemaMaterialized = true;
|
|
94
|
+
}
|
|
95
|
+
return SCHEMA_FILE;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export { SCHEMA_FILE as CODEX_REVIEW_SCHEMA_PATH };
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// System prompt — copied verbatim from codex-rs/core/review_prompt.md
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
export const CODEX_REVIEW_SYSTEM_PROMPT = `# Review guidelines:
|
|
105
|
+
|
|
106
|
+
You are acting as a reviewer for a proposed code change made by another engineer.
|
|
107
|
+
|
|
108
|
+
Below are some default guidelines for determining whether the original author would appreciate the issue being flagged.
|
|
109
|
+
|
|
110
|
+
These are not the final word in determining whether an issue is a bug. In many cases, you will encounter other, more specific guidelines. These may be present elsewhere in a developer message, a user message, a file, or even elsewhere in this system message.
|
|
111
|
+
Those guidelines should be considered to override these general instructions.
|
|
112
|
+
|
|
113
|
+
Here are the general guidelines for determining whether something is a bug and should be flagged.
|
|
114
|
+
|
|
115
|
+
1. It meaningfully impacts the accuracy, performance, security, or maintainability of the code.
|
|
116
|
+
2. The bug is discrete and actionable (i.e. not a general issue with the codebase or a combination of multiple issues).
|
|
117
|
+
3. Fixing the bug does not demand a level of rigor that is not present in the rest of the codebase (e.g. one doesn't need very detailed comments and input validation in a repository of one-off scripts in personal projects)
|
|
118
|
+
4. The bug was introduced in the commit (pre-existing bugs should not be flagged).
|
|
119
|
+
5. The author of the original PR would likely fix the issue if they were made aware of it.
|
|
120
|
+
6. The bug does not rely on unstated assumptions about the codebase or author's intent.
|
|
121
|
+
7. It is not enough to speculate that a change may disrupt another part of the codebase, to be considered a bug, one must identify the other parts of the code that are provably affected.
|
|
122
|
+
8. The bug is clearly not just an intentional change by the original author.
|
|
123
|
+
|
|
124
|
+
When flagging a bug, you will also provide an accompanying comment. Once again, these guidelines are not the final word on how to construct a comment -- defer to any subsequent guidelines that you encounter.
|
|
125
|
+
|
|
126
|
+
1. The comment should be clear about why the issue is a bug.
|
|
127
|
+
2. The comment should appropriately communicate the severity of the issue. It should not claim that an issue is more severe than it actually is.
|
|
128
|
+
3. The comment should be brief. The body should be at most 1 paragraph. It should not introduce line breaks within the natural language flow unless it is necessary for the code fragment.
|
|
129
|
+
4. The comment should not include any chunks of code longer than 3 lines. Any code chunks should be wrapped in markdown inline code tags or a code block.
|
|
130
|
+
5. The comment should clearly and explicitly communicate the scenarios, environments, or inputs that are necessary for the bug to arise. The comment should immediately indicate that the issue's severity depends on these factors.
|
|
131
|
+
6. The comment's tone should be matter-of-fact and not accusatory or overly positive. It should read as a helpful AI assistant suggestion without sounding too much like a human reviewer.
|
|
132
|
+
7. The comment should be written such that the original author can immediately grasp the idea without close reading.
|
|
133
|
+
8. The comment should avoid excessive flattery and comments that are not helpful to the original author. The comment should avoid phrasing like "Great job ...", "Thanks for ...".
|
|
134
|
+
|
|
135
|
+
Below are some more detailed guidelines that you should apply to this specific review.
|
|
136
|
+
|
|
137
|
+
HOW MANY FINDINGS TO RETURN:
|
|
138
|
+
|
|
139
|
+
Output all findings that the original author would fix if they knew about it. If there is no finding that a person would definitely love to see and fix, prefer outputting no findings. Do not stop at the first qualifying finding. Continue until you've listed every qualifying finding.
|
|
140
|
+
|
|
141
|
+
GUIDELINES:
|
|
142
|
+
|
|
143
|
+
- Ignore trivial style unless it obscures meaning or violates documented standards.
|
|
144
|
+
- Use one comment per distinct issue (or a multi-line range if necessary).
|
|
145
|
+
- Use \`\`\`suggestion blocks ONLY for concrete replacement code (minimal lines; no commentary inside the block).
|
|
146
|
+
- In every \`\`\`suggestion block, preserve the exact leading whitespace of the replaced lines (spaces vs tabs, number of spaces).
|
|
147
|
+
- Do NOT introduce or remove outer indentation levels unless that is the actual fix.
|
|
148
|
+
|
|
149
|
+
The comments will be presented in the code review as inline comments. You should avoid providing unnecessary location details in the comment body. Always keep the line range as short as possible for interpreting the issue. Avoid ranges longer than 5–10 lines; instead, choose the most suitable subrange that pinpoints the problem.
|
|
150
|
+
|
|
151
|
+
At the beginning of the finding title, tag the bug with priority level. For example "[P1] Un-padding slices along wrong tensor dimensions". [P0] – Drop everything to fix. Blocking release, operations, or major usage. Only use for universal issues that do not depend on any assumptions about the inputs. · [P1] – Urgent. Should be addressed in the next cycle · [P2] – Normal. To be fixed eventually · [P3] – Low. Nice to have.
|
|
152
|
+
|
|
153
|
+
Additionally, include a numeric priority field in the JSON output for each finding: set "priority" to 0 for P0, 1 for P1, 2 for P2, or 3 for P3. If a priority cannot be determined, omit the field or use null.
|
|
154
|
+
|
|
155
|
+
At the end of your findings, output an "overall correctness" verdict of whether or not the patch should be considered "correct".
|
|
156
|
+
Correct implies that existing code and tests will not break, and the patch is free of bugs and other blocking issues.
|
|
157
|
+
Ignore non-blocking issues such as style, formatting, typos, documentation, and other nits.
|
|
158
|
+
|
|
159
|
+
FORMATTING GUIDELINES:
|
|
160
|
+
The finding description should be one paragraph.`;
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// User message builder
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
/** Build the dynamic user message based on review context. */
|
|
167
|
+
export function buildCodexReviewUserMessage(
|
|
168
|
+
patch: string,
|
|
169
|
+
diffType: DiffType,
|
|
170
|
+
options?: { defaultBranch?: string; hasLocalAccess?: boolean },
|
|
171
|
+
prMetadata?: PRMetadata,
|
|
172
|
+
): string {
|
|
173
|
+
// PR/MR mode — pass the link, with local context if --local
|
|
174
|
+
if (prMetadata) {
|
|
175
|
+
if (options?.hasLocalAccess) {
|
|
176
|
+
return [
|
|
177
|
+
prMetadata.url,
|
|
178
|
+
"",
|
|
179
|
+
"You are in a local worktree checked out at the PR head. The code is available locally.",
|
|
180
|
+
`To see the PR changes, diff against the remote base branch: git diff origin/${prMetadata.baseBranch}...HEAD`,
|
|
181
|
+
"Do NOT diff against the local `main` branch — it may be stale. Always use origin/.",
|
|
182
|
+
].join("\n");
|
|
183
|
+
}
|
|
184
|
+
return prMetadata.url;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Local mode — Codex has full file/git access
|
|
188
|
+
const effectiveDiffType = diffType.startsWith("worktree:")
|
|
189
|
+
? diffType.split(":").pop() || "uncommitted"
|
|
190
|
+
: diffType;
|
|
191
|
+
|
|
192
|
+
switch (effectiveDiffType) {
|
|
193
|
+
case "uncommitted":
|
|
194
|
+
return "Review the current code changes (staged, unstaged, and untracked files) and provide prioritized findings.";
|
|
195
|
+
|
|
196
|
+
case "staged":
|
|
197
|
+
return "Review the currently staged code changes (`git diff --staged`) and provide prioritized findings.";
|
|
198
|
+
|
|
199
|
+
case "unstaged":
|
|
200
|
+
return "Review the unstaged code changes (tracked modifications and untracked files) and provide prioritized findings.";
|
|
201
|
+
|
|
202
|
+
case "last-commit":
|
|
203
|
+
return "Review the code changes introduced in the last commit (`git diff HEAD~1..HEAD`) and provide prioritized findings.";
|
|
204
|
+
|
|
205
|
+
case "branch": {
|
|
206
|
+
const base = options?.defaultBranch || "main";
|
|
207
|
+
return `Review the code changes against the base branch '${base}'. Run \`git diff ${base}..HEAD\` to inspect the changes. Provide prioritized, actionable findings.`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
default:
|
|
211
|
+
// p4 or unknown — fall back to generic with inlined diff
|
|
212
|
+
return [
|
|
213
|
+
"Review the following code changes and provide prioritized findings.",
|
|
214
|
+
"",
|
|
215
|
+
"```diff",
|
|
216
|
+
patch,
|
|
217
|
+
"```",
|
|
218
|
+
].join("\n");
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
// Command builder
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
export interface CodexCommandOptions {
|
|
227
|
+
cwd: string;
|
|
228
|
+
outputPath: string;
|
|
229
|
+
prompt: string;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Build the `codex exec` argv array. Materializes the schema file on first call. */
|
|
233
|
+
export async function buildCodexCommand(options: CodexCommandOptions): Promise<string[]> {
|
|
234
|
+
const { cwd, outputPath, prompt } = options;
|
|
235
|
+
const schemaPath = await ensureSchemaFile();
|
|
236
|
+
|
|
237
|
+
const command = [
|
|
238
|
+
"codex",
|
|
239
|
+
"exec",
|
|
240
|
+
"--output-schema", schemaPath,
|
|
241
|
+
"-o", outputPath,
|
|
242
|
+
"--full-auto",
|
|
243
|
+
"--ephemeral",
|
|
244
|
+
"-C", cwd,
|
|
245
|
+
prompt,
|
|
246
|
+
];
|
|
247
|
+
|
|
248
|
+
debugLog("BUILD_COMMAND", {
|
|
249
|
+
cwd,
|
|
250
|
+
outputPath,
|
|
251
|
+
schemaPath,
|
|
252
|
+
promptLength: prompt.length,
|
|
253
|
+
command: command.map((c, i) => i === command.length - 1 ? `<prompt: ${c.length} chars>` : c),
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
return command;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Generate a unique temp file path for Codex output. */
|
|
260
|
+
export function generateOutputPath(): string {
|
|
261
|
+
return join(tmpdir(), `plannotator-codex-${crypto.randomUUID()}.json`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
// Output parsing — matches Codex's native ReviewOutputEvent schema
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
export interface CodexCodeLocation {
|
|
269
|
+
absolute_file_path: string;
|
|
270
|
+
line_range: { start: number; end: number };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export interface CodexFinding {
|
|
274
|
+
title: string;
|
|
275
|
+
body: string;
|
|
276
|
+
confidence_score: number;
|
|
277
|
+
priority: number | null;
|
|
278
|
+
code_location: CodexCodeLocation;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export interface CodexReviewOutput {
|
|
282
|
+
findings: CodexFinding[];
|
|
283
|
+
overall_correctness: string;
|
|
284
|
+
overall_explanation: string;
|
|
285
|
+
overall_confidence_score: number;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Read and parse the Codex -o output file. Returns null on any failure. */
|
|
289
|
+
export async function parseCodexOutput(outputPath: string): Promise<CodexReviewOutput | null> {
|
|
290
|
+
await debugLog("PARSE_OUTPUT_START", { outputPath });
|
|
291
|
+
|
|
292
|
+
try {
|
|
293
|
+
if (!existsSync(outputPath)) {
|
|
294
|
+
await debugLog("PARSE_OUTPUT_FILE_MISSING", outputPath);
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const text = await readFile(outputPath, "utf-8");
|
|
299
|
+
|
|
300
|
+
// Clean up temp file
|
|
301
|
+
try { await unlink(outputPath); } catch { /* ignore */ }
|
|
302
|
+
|
|
303
|
+
if (!text.trim()) {
|
|
304
|
+
await debugLog("PARSE_OUTPUT_EMPTY");
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const parsed = JSON.parse(text);
|
|
309
|
+
if (!parsed || !Array.isArray(parsed.findings)) {
|
|
310
|
+
await debugLog("PARSE_OUTPUT_INVALID_SHAPE", { hasFindings: !!parsed?.findings });
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
await debugLog("PARSE_OUTPUT_SUCCESS", {
|
|
315
|
+
findingsCount: parsed.findings.length,
|
|
316
|
+
overall_correctness: parsed.overall_correctness,
|
|
317
|
+
overall_confidence_score: parsed.overall_confidence_score,
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
return parsed as CodexReviewOutput;
|
|
321
|
+
} catch (err) {
|
|
322
|
+
await debugLog("PARSE_OUTPUT_ERROR", err instanceof Error ? err.message : String(err));
|
|
323
|
+
// Clean up on error too
|
|
324
|
+
try { await unlink(outputPath); } catch { /* ignore */ }
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
// Finding → external annotation transform
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
export interface ReviewAnnotationInput {
|
|
334
|
+
source: string;
|
|
335
|
+
filePath: string;
|
|
336
|
+
lineStart: number;
|
|
337
|
+
lineEnd: number;
|
|
338
|
+
type: string;
|
|
339
|
+
side: string;
|
|
340
|
+
scope: string;
|
|
341
|
+
text: string;
|
|
342
|
+
author: string;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Transform review findings (provider-agnostic) into the external annotation format. */
|
|
346
|
+
export function transformReviewFindings(
|
|
347
|
+
findings: CodexFinding[],
|
|
348
|
+
source: string,
|
|
349
|
+
cwd?: string,
|
|
350
|
+
author?: string,
|
|
351
|
+
): ReviewAnnotationInput[] {
|
|
352
|
+
const annotations = findings
|
|
353
|
+
.filter((f) =>
|
|
354
|
+
f.code_location?.absolute_file_path &&
|
|
355
|
+
typeof f.code_location?.line_range?.start === "number" &&
|
|
356
|
+
typeof f.code_location?.line_range?.end === "number"
|
|
357
|
+
)
|
|
358
|
+
.map((f) => ({
|
|
359
|
+
source,
|
|
360
|
+
filePath: toRelativePath(f.code_location.absolute_file_path, cwd),
|
|
361
|
+
lineStart: f.code_location.line_range.start,
|
|
362
|
+
lineEnd: f.code_location.line_range.end,
|
|
363
|
+
type: "comment",
|
|
364
|
+
side: "new",
|
|
365
|
+
scope: "line",
|
|
366
|
+
text: `${f.title}\n\n${f.body}`.trim(),
|
|
367
|
+
author: author ?? "Review Agent",
|
|
368
|
+
}));
|
|
369
|
+
|
|
370
|
+
debugLog("TRANSFORM_FINDINGS", {
|
|
371
|
+
inputCount: findings.length,
|
|
372
|
+
outputCount: annotations.length,
|
|
373
|
+
annotations: annotations.map((a) => ({
|
|
374
|
+
filePath: a.filePath,
|
|
375
|
+
lineStart: a.lineStart,
|
|
376
|
+
lineEnd: a.lineEnd,
|
|
377
|
+
textPreview: a.text.slice(0, 80),
|
|
378
|
+
})),
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
return annotations;
|
|
382
|
+
}
|
|
@@ -181,6 +181,9 @@ interface ReviewAnnotation {
|
|
|
181
181
|
createdAt: number;
|
|
182
182
|
author?: string;
|
|
183
183
|
source?: string;
|
|
184
|
+
// Agent review metadata (optional — only set by Claude review findings)
|
|
185
|
+
severity?: string; // "important" | "nit" | "pre_existing"
|
|
186
|
+
reasoning?: string; // Validation chain explaining how the issue was confirmed
|
|
184
187
|
}
|
|
185
188
|
|
|
186
189
|
const VALID_REVIEW_TYPES = ["comment", "suggestion", "concern"];
|
|
@@ -255,6 +258,9 @@ export function transformReviewInput(
|
|
|
255
258
|
createdAt: Date.now(),
|
|
256
259
|
author: typeof obj.author === "string" ? obj.author : undefined,
|
|
257
260
|
source,
|
|
261
|
+
// Agent review metadata (optional — only set by Claude review findings)
|
|
262
|
+
...(typeof obj.severity === "string" && { severity: obj.severity }),
|
|
263
|
+
...(typeof obj.reasoning === "string" && { reasoning: obj.reasoning }),
|
|
258
264
|
});
|
|
259
265
|
}
|
|
260
266
|
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// @generated — DO NOT EDIT. Source: packages/server/path-utils.ts
|
|
2
|
+
/**
|
|
3
|
+
* Strip a cwd prefix from an absolute path to get a repo-relative path.
|
|
4
|
+
* Used by review agent transforms to convert absolute file paths from
|
|
5
|
+
* agent output into diff-compatible relative paths.
|
|
6
|
+
*
|
|
7
|
+
* Uses path.relative for cross-platform support (Windows backslashes)
|
|
8
|
+
* and normalizes to forward slashes for git diff path matching.
|
|
9
|
+
*/
|
|
10
|
+
import { relative } from "node:path";
|
|
11
|
+
|
|
12
|
+
export function toRelativePath(absolutePath: string, cwd?: string): string {
|
|
13
|
+
if (!cwd) return absolutePath;
|
|
14
|
+
const rel = relative(cwd, absolutePath);
|
|
15
|
+
// Don't relativize if the result goes outside cwd (different drive, symlink escape)
|
|
16
|
+
if (rel.startsWith("..")) return absolutePath;
|
|
17
|
+
// Normalize to forward slashes for diff path matching
|
|
18
|
+
return rel.replace(/\\/g, "/");
|
|
19
|
+
}
|
package/generated/pr-github.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* All functions use the `gh` CLI via the PRRuntime abstraction.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type { PRRuntime, PRMetadata, PRContext, PRReviewFileComment, CommandResult } from "./pr-provider";
|
|
8
|
+
import type { PRRuntime, PRMetadata, PRContext, PRReviewThread, PRThreadComment, PRReviewFileComment, CommandResult } from "./pr-provider";
|
|
9
9
|
import { encodeApiFilePath } from "./pr-provider";
|
|
10
10
|
|
|
11
11
|
// GitHub-specific PRRef shape (used internally)
|
|
@@ -102,6 +102,21 @@ export async function fetchGhPR(
|
|
|
102
102
|
url: string;
|
|
103
103
|
};
|
|
104
104
|
|
|
105
|
+
// Fetch the merge-base SHA — the common ancestor commit GitHub uses to compute the PR diff.
|
|
106
|
+
// baseSha (baseRefOid) is the tip of the base branch, which may have moved since the branch point.
|
|
107
|
+
// File contents must be fetched at the merge-base to match the diff hunks.
|
|
108
|
+
let mergeBaseSha: string | undefined;
|
|
109
|
+
try {
|
|
110
|
+
const compareResult = await runtime.runCommand("gh", hostnameArgs(ref.host, [
|
|
111
|
+
"api",
|
|
112
|
+
`repos/${ref.owner}/${ref.repo}/compare/${raw.baseRefOid}...${raw.headRefOid}`,
|
|
113
|
+
"--jq", ".merge_base_commit.sha",
|
|
114
|
+
]));
|
|
115
|
+
if (compareResult.exitCode === 0 && compareResult.stdout.trim()) {
|
|
116
|
+
mergeBaseSha = compareResult.stdout.trim();
|
|
117
|
+
}
|
|
118
|
+
} catch { /* fallback to baseSha if compare API fails */ }
|
|
119
|
+
|
|
105
120
|
const metadata: PRMetadata = {
|
|
106
121
|
platform: "github",
|
|
107
122
|
host: ref.host,
|
|
@@ -115,6 +130,7 @@ export async function fetchGhPR(
|
|
|
115
130
|
headBranch: raw.headRefName,
|
|
116
131
|
baseSha: raw.baseRefOid,
|
|
117
132
|
headSha: raw.headRefOid,
|
|
133
|
+
mergeBaseSha,
|
|
118
134
|
url: raw.url,
|
|
119
135
|
};
|
|
120
136
|
|
|
@@ -162,7 +178,9 @@ function parseGhPRContext(raw: Record<string, unknown>): PRContext {
|
|
|
162
178
|
state: str(r?.state),
|
|
163
179
|
body: str(r?.body),
|
|
164
180
|
submittedAt: str(r?.submittedAt),
|
|
181
|
+
...(r?.url ? { url: str(r.url) } : {}),
|
|
165
182
|
})),
|
|
183
|
+
reviewThreads: [], // populated via GraphQL after initial fetch
|
|
166
184
|
checks: arr(raw.statusCheckRollup).map((c: any) => ({
|
|
167
185
|
name: str(c?.name),
|
|
168
186
|
status: str(c?.status),
|
|
@@ -199,7 +217,87 @@ export async function fetchGhPRContext(
|
|
|
199
217
|
}
|
|
200
218
|
|
|
201
219
|
const raw = JSON.parse(result.stdout) as Record<string, unknown>;
|
|
202
|
-
|
|
220
|
+
const context = parseGhPRContext(raw);
|
|
221
|
+
|
|
222
|
+
// Fetch inline review threads via GraphQL (parallel-safe, non-blocking failure)
|
|
223
|
+
try {
|
|
224
|
+
context.reviewThreads = await fetchGhReviewThreads(runtime, ref);
|
|
225
|
+
} catch {
|
|
226
|
+
// GraphQL may not be available or may fail — degrade gracefully
|
|
227
|
+
context.reviewThreads = [];
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return context;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// --- Review Threads (GraphQL) ---
|
|
234
|
+
|
|
235
|
+
const REVIEW_THREADS_QUERY = `
|
|
236
|
+
query($owner: String!, $repo: String!, $number: Int!) {
|
|
237
|
+
repository(owner: $owner, name: $repo) {
|
|
238
|
+
pullRequest(number: $number) {
|
|
239
|
+
reviewThreads(first: 100) {
|
|
240
|
+
nodes {
|
|
241
|
+
id
|
|
242
|
+
isResolved
|
|
243
|
+
isOutdated
|
|
244
|
+
line
|
|
245
|
+
startLine
|
|
246
|
+
path
|
|
247
|
+
diffSide
|
|
248
|
+
comments(first: 50) {
|
|
249
|
+
nodes {
|
|
250
|
+
id
|
|
251
|
+
body
|
|
252
|
+
author { login }
|
|
253
|
+
createdAt
|
|
254
|
+
url
|
|
255
|
+
diffHunk
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}`;
|
|
263
|
+
|
|
264
|
+
async function fetchGhReviewThreads(
|
|
265
|
+
runtime: PRRuntime,
|
|
266
|
+
ref: GhPRRef,
|
|
267
|
+
): Promise<PRReviewThread[]> {
|
|
268
|
+
const result = await runtime.runCommand("gh", hostnameArgs(ref.host, [
|
|
269
|
+
"api", "graphql",
|
|
270
|
+
"-f", `query=${REVIEW_THREADS_QUERY}`,
|
|
271
|
+
"-f", `owner=${ref.owner}`,
|
|
272
|
+
"-f", `repo=${ref.repo}`,
|
|
273
|
+
"-F", `number=${ref.number}`,
|
|
274
|
+
]));
|
|
275
|
+
|
|
276
|
+
if (result.exitCode !== 0) return [];
|
|
277
|
+
|
|
278
|
+
const data = JSON.parse(result.stdout);
|
|
279
|
+
const threads = data?.data?.repository?.pullRequest?.reviewThreads?.nodes;
|
|
280
|
+
if (!Array.isArray(threads)) return [];
|
|
281
|
+
|
|
282
|
+
return threads.map((t: any): PRReviewThread => ({
|
|
283
|
+
id: String(t.id ?? ''),
|
|
284
|
+
isResolved: t.isResolved === true,
|
|
285
|
+
isOutdated: t.isOutdated === true,
|
|
286
|
+
path: String(t.path ?? ''),
|
|
287
|
+
line: typeof t.line === 'number' ? t.line : null,
|
|
288
|
+
startLine: typeof t.startLine === 'number' ? t.startLine : null,
|
|
289
|
+
diffSide: t.diffSide === 'LEFT' || t.diffSide === 'RIGHT' ? t.diffSide : null,
|
|
290
|
+
comments: Array.isArray(t.comments?.nodes)
|
|
291
|
+
? t.comments.nodes.map((c: any): PRThreadComment => ({
|
|
292
|
+
id: String(c.id ?? ''),
|
|
293
|
+
author: c.author?.login ? String(c.author.login) : '',
|
|
294
|
+
body: String(c.body ?? ''),
|
|
295
|
+
createdAt: String(c.createdAt ?? ''),
|
|
296
|
+
url: String(c.url ?? ''),
|
|
297
|
+
...(c.diffHunk ? { diffHunk: String(c.diffHunk) } : {}),
|
|
298
|
+
}))
|
|
299
|
+
: [],
|
|
300
|
+
}));
|
|
203
301
|
}
|
|
204
302
|
|
|
205
303
|
// --- File Content ---
|
package/generated/pr-gitlab.ts
CHANGED
package/generated/pr-provider.ts
CHANGED
|
@@ -71,6 +71,8 @@ export interface GithubPRMetadata {
|
|
|
71
71
|
headBranch: string;
|
|
72
72
|
baseSha: string;
|
|
73
73
|
headSha: string;
|
|
74
|
+
/** Merge-base SHA — the common ancestor commit used to compute the PR diff. Differs from baseSha when the base branch has moved. */
|
|
75
|
+
mergeBaseSha?: string;
|
|
74
76
|
url: string;
|
|
75
77
|
}
|
|
76
78
|
|
|
@@ -86,6 +88,8 @@ export interface GitlabMRMetadata {
|
|
|
86
88
|
headBranch: string;
|
|
87
89
|
baseSha: string;
|
|
88
90
|
headSha: string;
|
|
91
|
+
/** Merge-base SHA — the common ancestor commit used to compute the MR diff. */
|
|
92
|
+
mergeBaseSha?: string;
|
|
89
93
|
url: string;
|
|
90
94
|
}
|
|
91
95
|
|
|
@@ -108,6 +112,7 @@ export interface PRReview {
|
|
|
108
112
|
state: string;
|
|
109
113
|
body: string;
|
|
110
114
|
submittedAt: string;
|
|
115
|
+
url?: string;
|
|
111
116
|
}
|
|
112
117
|
|
|
113
118
|
export interface PRCheck {
|
|
@@ -124,6 +129,26 @@ export interface PRLinkedIssue {
|
|
|
124
129
|
repo: string;
|
|
125
130
|
}
|
|
126
131
|
|
|
132
|
+
export interface PRThreadComment {
|
|
133
|
+
id: string;
|
|
134
|
+
author: string;
|
|
135
|
+
body: string;
|
|
136
|
+
createdAt: string;
|
|
137
|
+
url: string;
|
|
138
|
+
diffHunk?: string;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface PRReviewThread {
|
|
142
|
+
id: string;
|
|
143
|
+
isResolved: boolean;
|
|
144
|
+
isOutdated: boolean;
|
|
145
|
+
path: string;
|
|
146
|
+
line: number | null;
|
|
147
|
+
startLine: number | null;
|
|
148
|
+
diffSide: 'LEFT' | 'RIGHT' | null;
|
|
149
|
+
comments: PRThreadComment[];
|
|
150
|
+
}
|
|
151
|
+
|
|
127
152
|
export interface PRContext {
|
|
128
153
|
body: string;
|
|
129
154
|
state: string;
|
|
@@ -134,6 +159,7 @@ export interface PRContext {
|
|
|
134
159
|
mergeStateStatus: string;
|
|
135
160
|
comments: PRComment[];
|
|
136
161
|
reviews: PRReview[];
|
|
162
|
+
reviewThreads: PRReviewThread[];
|
|
137
163
|
checks: PRCheck[];
|
|
138
164
|
linkedIssues: PRLinkedIssue[];
|
|
139
165
|
}
|
package/generated/repo.ts
CHANGED
|
@@ -13,16 +13,24 @@ export interface RepoInfo {
|
|
|
13
13
|
* - SSH: git@github.com:org/repo.git
|
|
14
14
|
* - HTTPS: https://github.com/org/repo.git
|
|
15
15
|
* - SSH with port: ssh://git@github.com:22/org/repo.git
|
|
16
|
+
* - GitLab subgroups: git@gitlab.com:group/subgroup/project.git
|
|
16
17
|
*/
|
|
17
18
|
export function parseRemoteUrl(url: string): string | null {
|
|
18
19
|
if (!url) return null;
|
|
19
20
|
|
|
20
|
-
// SSH
|
|
21
|
-
const
|
|
22
|
-
if (
|
|
21
|
+
// SSH with port: ssh://git@host:22/path.git — strip scheme+host+port
|
|
22
|
+
const sshPortMatch = url.match(/^ssh:\/\/[^/]+(?::\d+)?\/(.+?)(?:\.git)?$/);
|
|
23
|
+
if (sshPortMatch) return sshPortMatch[1];
|
|
23
24
|
|
|
24
|
-
//
|
|
25
|
-
|
|
25
|
+
// SSH format: git@host:path.git — capture full path after ':'
|
|
26
|
+
// Reject URLs with :// scheme (HTTPS with non-standard ports like :8443)
|
|
27
|
+
if (!url.includes("://")) {
|
|
28
|
+
const sshMatch = url.match(/:([^/][^:]*?)(?:\.git)?$/);
|
|
29
|
+
if (sshMatch) return sshMatch[1];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// HTTPS format: https://host/path.git — capture full path after host
|
|
33
|
+
const httpsMatch = url.match(/^https?:\/\/[^/]+\/(.+?)(?:\.git)?$/);
|
|
26
34
|
if (httpsMatch) return httpsMatch[1];
|
|
27
35
|
|
|
28
36
|
return null;
|