@mechanai/deepreview 2.11.0 → 2.13.0
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/.opencode/agents/deepreview-quick-reviewer.md +154 -0
- package/.opencode/agents/deepreview-review-formatter.md +26 -0
- package/.opencode/commands/deepreview-quick.md +100 -0
- package/.opencode/commands/deepreview.md +46 -5
- package/README.md +19 -3
- package/package.json +1 -1
- package/src/batch-retry.ts +62 -0
- package/src/build-prior-review-fetch.ts +1 -0
- package/src/build-prior-review.test.ts +61 -1
- package/src/build-prior-review.ts +3 -1
- package/src/diff-classifier.ts +6 -0
- package/src/load-pr.test.ts +75 -0
- package/src/load-pr.ts +44 -0
- package/src/parse-threads.test.ts +41 -0
- package/src/parse-threads.ts +31 -12
- package/src/post-review.test.ts +84 -1
- package/src/post-review.ts +124 -113
- package/src/review-api.ts +16 -0
|
@@ -14,9 +14,10 @@ function makeThread(
|
|
|
14
14
|
path: string,
|
|
15
15
|
line: number,
|
|
16
16
|
comments: { login: string; body: string }[],
|
|
17
|
-
opts?: { startLine?: number; isResolved?: boolean; isOutdated?: boolean },
|
|
17
|
+
opts?: { id?: string; startLine?: number; isResolved?: boolean; isOutdated?: boolean },
|
|
18
18
|
): ReviewThread {
|
|
19
19
|
return {
|
|
20
|
+
id: opts?.id ?? `PRT_${path}_${line}`,
|
|
20
21
|
path,
|
|
21
22
|
startLine: opts?.startLine ?? null,
|
|
22
23
|
line,
|
|
@@ -54,10 +55,12 @@ describe("formatPriorReview: sections", () => {
|
|
|
54
55
|
});
|
|
55
56
|
});
|
|
56
57
|
|
|
58
|
+
// oxlint-disable-next-line max-lines-per-function -- Why: two inline thread fixtures with full comment arrays are required to test multi-thread grouping and line-range formatting; extracting them would obscure the test intent
|
|
57
59
|
describe("formatPriorReview: thread formatting", () => {
|
|
58
60
|
it("formats threads grouped by file with line numbers", () => {
|
|
59
61
|
const threads: ReviewThread[] = [
|
|
60
62
|
{
|
|
63
|
+
id: "PRT_1",
|
|
61
64
|
path: "src/foo.ts",
|
|
62
65
|
startLine: 10,
|
|
63
66
|
line: 15,
|
|
@@ -79,6 +82,7 @@ describe("formatPriorReview: thread formatting", () => {
|
|
|
79
82
|
],
|
|
80
83
|
},
|
|
81
84
|
{
|
|
85
|
+
id: "PRT_2",
|
|
82
86
|
path: "src/foo.ts",
|
|
83
87
|
startLine: null,
|
|
84
88
|
line: 42,
|
|
@@ -248,6 +252,31 @@ describe("mapGraphQLThreads", () => {
|
|
|
248
252
|
assert.equal(result[0].comments[0].authorType, "bot");
|
|
249
253
|
});
|
|
250
254
|
|
|
255
|
+
it("preserves thread id from GraphQL node", () => {
|
|
256
|
+
const nodes = [
|
|
257
|
+
{
|
|
258
|
+
id: "PRT_kwDOABC123",
|
|
259
|
+
path: "src/foo.ts",
|
|
260
|
+
startLine: null,
|
|
261
|
+
line: 10,
|
|
262
|
+
isResolved: false,
|
|
263
|
+
isOutdated: false,
|
|
264
|
+
comments: {
|
|
265
|
+
pageInfo: { hasNextPage: false, endCursor: null },
|
|
266
|
+
nodes: [
|
|
267
|
+
{
|
|
268
|
+
author: { login: "alice", __typename: "User" },
|
|
269
|
+
body: "Fix this",
|
|
270
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
271
|
+
},
|
|
272
|
+
],
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
];
|
|
276
|
+
const result = mapGraphQLThreads(nodes);
|
|
277
|
+
assert.equal(result[0].id, "PRT_kwDOABC123");
|
|
278
|
+
});
|
|
279
|
+
|
|
251
280
|
it("detects deepreview by finding ID HTML comment in body", () => {
|
|
252
281
|
const nodes = [
|
|
253
282
|
{
|
|
@@ -279,6 +308,7 @@ describe("buildPriorReviewContent: basic behavior", () => {
|
|
|
279
308
|
it("keeps all content when under budget", () => {
|
|
280
309
|
const threads: ReviewThread[] = [
|
|
281
310
|
{
|
|
311
|
+
id: "PRT_basic",
|
|
282
312
|
path: "a.ts",
|
|
283
313
|
startLine: null,
|
|
284
314
|
line: 1,
|
|
@@ -316,6 +346,7 @@ describe("buildPriorReview (integration shape)", () => {
|
|
|
316
346
|
describe("buildPriorReviewContent: truncation", () => {
|
|
317
347
|
it("drops oldest threads first when over budget", () => {
|
|
318
348
|
const oldThread: ReviewThread = {
|
|
349
|
+
id: "PRT_old",
|
|
319
350
|
path: "old.ts",
|
|
320
351
|
startLine: null,
|
|
321
352
|
line: 1,
|
|
@@ -331,6 +362,7 @@ describe("buildPriorReviewContent: truncation", () => {
|
|
|
331
362
|
],
|
|
332
363
|
};
|
|
333
364
|
const newThread: ReviewThread = {
|
|
365
|
+
id: "PRT_new",
|
|
334
366
|
path: "new.ts",
|
|
335
367
|
startLine: null,
|
|
336
368
|
line: 1,
|
|
@@ -354,6 +386,7 @@ describe("buildPriorReviewContent: truncation", () => {
|
|
|
354
386
|
|
|
355
387
|
it("preserves PR description and manual content even when threads are large", () => {
|
|
356
388
|
const bigThread: ReviewThread = {
|
|
389
|
+
id: "PRT_big",
|
|
357
390
|
path: "big.ts",
|
|
358
391
|
startLine: null,
|
|
359
392
|
line: 1,
|
|
@@ -383,6 +416,7 @@ describe("formatPriorReview: formatCommentBody paths", () => {
|
|
|
383
416
|
it("renders multi-line body as indented blockquote", () => {
|
|
384
417
|
const threads: ReviewThread[] = [
|
|
385
418
|
{
|
|
419
|
+
id: "PRT_multi",
|
|
386
420
|
path: "src/multi.ts",
|
|
387
421
|
startLine: null,
|
|
388
422
|
line: 1,
|
|
@@ -409,6 +443,7 @@ describe("formatPriorReview: formatCommentBody paths", () => {
|
|
|
409
443
|
it("renders single-line body in quotes", () => {
|
|
410
444
|
const threads: ReviewThread[] = [
|
|
411
445
|
{
|
|
446
|
+
id: "PRT_single",
|
|
412
447
|
path: "src/single.ts",
|
|
413
448
|
startLine: null,
|
|
414
449
|
line: 1,
|
|
@@ -433,6 +468,7 @@ describe("formatPriorReview: formatLineRef paths", () => {
|
|
|
433
468
|
it("renders startLine only when line is null", () => {
|
|
434
469
|
const threads: ReviewThread[] = [
|
|
435
470
|
{
|
|
471
|
+
id: "PRT_start_only",
|
|
436
472
|
path: "src/start-only.ts",
|
|
437
473
|
startLine: 42,
|
|
438
474
|
line: null,
|
|
@@ -457,6 +493,7 @@ describe("formatPriorReview: formatLineRef paths", () => {
|
|
|
457
493
|
it("renders file-level when both startLine and line are null", () => {
|
|
458
494
|
const threads: ReviewThread[] = [
|
|
459
495
|
{
|
|
496
|
+
id: "PRT_file_level",
|
|
460
497
|
path: "src/file-level.ts",
|
|
461
498
|
startLine: null,
|
|
462
499
|
line: null,
|
|
@@ -481,6 +518,7 @@ describe("formatPriorReview: formatSourceTag paths", () => {
|
|
|
481
518
|
it("renders deepreview author type in source tag", () => {
|
|
482
519
|
const threads: ReviewThread[] = [
|
|
483
520
|
{
|
|
521
|
+
id: "PRT_dr",
|
|
484
522
|
path: "src/dr.ts",
|
|
485
523
|
startLine: null,
|
|
486
524
|
line: 5,
|
|
@@ -503,6 +541,7 @@ describe("formatPriorReview: formatSourceTag paths", () => {
|
|
|
503
541
|
it("renders both resolved and outdated simultaneously", () => {
|
|
504
542
|
const threads: ReviewThread[] = [
|
|
505
543
|
{
|
|
544
|
+
id: "PRT_both",
|
|
506
545
|
path: "src/both.ts",
|
|
507
546
|
startLine: null,
|
|
508
547
|
line: 10,
|
|
@@ -523,10 +562,29 @@ describe("formatPriorReview: formatSourceTag paths", () => {
|
|
|
523
562
|
});
|
|
524
563
|
});
|
|
525
564
|
|
|
565
|
+
describe("formatPriorReview: thread ID tags", () => {
|
|
566
|
+
it("includes thread ID tag in formatted output", () => {
|
|
567
|
+
const thread = makeThread("src/foo.ts", 10, [{ login: "alice", body: "Fix this" }], {
|
|
568
|
+
id: "PRT_kwDOABC123",
|
|
569
|
+
});
|
|
570
|
+
const result = formatPriorReview("", [thread], null);
|
|
571
|
+
assert.ok(result.includes("[thread: PRT_kwDOABC123]"));
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it("places thread ID after source tag", () => {
|
|
575
|
+
const thread = makeThread("src/foo.ts", 10, [{ login: "alice", body: "Fix this" }], {
|
|
576
|
+
id: "PRT_kwDOXYZ789",
|
|
577
|
+
});
|
|
578
|
+
const result = formatPriorReview("", [thread], null);
|
|
579
|
+
assert.match(result, /\[source: [^\]]+\] \[thread: PRT_kwDOXYZ789\]/u);
|
|
580
|
+
});
|
|
581
|
+
});
|
|
582
|
+
|
|
526
583
|
describe("formatPriorReview: formatThread empty comments", () => {
|
|
527
584
|
it("returns empty output for thread with no comments", () => {
|
|
528
585
|
const threads: ReviewThread[] = [
|
|
529
586
|
{
|
|
587
|
+
id: "PRT_empty",
|
|
530
588
|
path: "src/empty.ts",
|
|
531
589
|
startLine: null,
|
|
532
590
|
line: 1,
|
|
@@ -585,6 +643,7 @@ describe("buildPriorReviewContent: edge cases", () => {
|
|
|
585
643
|
const hugeThreadBody = "z".repeat(40_000);
|
|
586
644
|
const threads: ReviewThread[] = [
|
|
587
645
|
{
|
|
646
|
+
id: "PRT_big1",
|
|
588
647
|
path: "big1.ts",
|
|
589
648
|
startLine: null,
|
|
590
649
|
line: 1,
|
|
@@ -600,6 +659,7 @@ describe("buildPriorReviewContent: edge cases", () => {
|
|
|
600
659
|
],
|
|
601
660
|
},
|
|
602
661
|
{
|
|
662
|
+
id: "PRT_big2",
|
|
603
663
|
path: "big2.ts",
|
|
604
664
|
startLine: null,
|
|
605
665
|
line: 1,
|
|
@@ -15,6 +15,7 @@ export interface ThreadComment {
|
|
|
15
15
|
|
|
16
16
|
/** A review thread attached to a file path and line range. */
|
|
17
17
|
export interface ReviewThread {
|
|
18
|
+
id: string;
|
|
18
19
|
path: string;
|
|
19
20
|
startLine: number | null;
|
|
20
21
|
line: number | null;
|
|
@@ -54,7 +55,8 @@ function formatThread(thread: ReviewThread): string {
|
|
|
54
55
|
const lines: string[] = [];
|
|
55
56
|
const lineRef = formatLineRef(thread.startLine, thread.line);
|
|
56
57
|
const tag = formatSourceTag(first, thread);
|
|
57
|
-
|
|
58
|
+
const threadTag = `[thread: ${thread.id}]`;
|
|
59
|
+
lines.push(`- ${lineRef} ${tag} ${threadTag}: ${formatCommentBody(first.body, " ")}`);
|
|
58
60
|
for (const reply of replies) {
|
|
59
61
|
lines.push(` - @${reply.authorLogin} replied: ${formatCommentBody(reply.body, " ")}`);
|
|
60
62
|
}
|
package/src/diff-classifier.ts
CHANGED
|
@@ -5,6 +5,7 @@ export interface Finding {
|
|
|
5
5
|
line: number;
|
|
6
6
|
startLine?: number;
|
|
7
7
|
body: string;
|
|
8
|
+
replyTo?: string;
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
export interface ClassifiedFinding extends Finding {
|
|
@@ -13,6 +14,11 @@ export interface ClassifiedFinding extends Finding {
|
|
|
13
14
|
renderedBody?: string;
|
|
14
15
|
}
|
|
15
16
|
|
|
17
|
+
/** A classified finding that targets an existing thread for reply. */
|
|
18
|
+
export interface ReplyFinding extends ClassifiedFinding {
|
|
19
|
+
replyTo: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
16
22
|
interface FileHunks {
|
|
17
23
|
hunks: { newStart: number; newLines: number }[];
|
|
18
24
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, it } from "bun:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { writeFile, mkdir, rm, symlink } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { loadAndValidatePr } from "./load-pr.ts";
|
|
6
|
+
|
|
7
|
+
const TMP_DIR = "/tmp/opencode/load-pr-test";
|
|
8
|
+
|
|
9
|
+
describe("loadAndValidatePr — path validation", () => {
|
|
10
|
+
it("rejects paths with null bytes", async () => {
|
|
11
|
+
await assert.rejects(
|
|
12
|
+
loadAndValidatePr("threads\x00.md", 1, undefined, TMP_DIR),
|
|
13
|
+
/Invalid threadsPath/u,
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("rejects absolute paths", async () => {
|
|
18
|
+
await assert.rejects(
|
|
19
|
+
loadAndValidatePr("/etc/passwd", 1, undefined, TMP_DIR),
|
|
20
|
+
/Invalid threadsPath/u,
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("rejects paths with directory traversal", async () => {
|
|
25
|
+
await assert.rejects(
|
|
26
|
+
loadAndValidatePr("../../../etc/passwd", 1, undefined, TMP_DIR),
|
|
27
|
+
/Invalid threadsPath/u,
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("rejects empty path", async () => {
|
|
32
|
+
await assert.rejects(loadAndValidatePr("", 1, undefined, TMP_DIR), /Invalid threadsPath/u);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("loadAndValidatePr — file system", () => {
|
|
37
|
+
it("throws when file does not exist", async () => {
|
|
38
|
+
await mkdir(TMP_DIR, { recursive: true });
|
|
39
|
+
await assert.rejects(
|
|
40
|
+
loadAndValidatePr("nonexistent.md", 1, undefined, TMP_DIR),
|
|
41
|
+
/Threads file not found/u,
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("throws when resolved path escapes working directory via symlink", async () => {
|
|
46
|
+
await mkdir(TMP_DIR, { recursive: true });
|
|
47
|
+
const linkPath = join(TMP_DIR, "escape.md");
|
|
48
|
+
try {
|
|
49
|
+
await rm(linkPath);
|
|
50
|
+
} catch {
|
|
51
|
+
// ignore if not present
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
await symlink("/etc/hostname", linkPath);
|
|
55
|
+
} catch {
|
|
56
|
+
// symlink creation may fail on some systems — skip test
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
await assert.rejects(
|
|
61
|
+
loadAndValidatePr("escape.md", 1, undefined, TMP_DIR),
|
|
62
|
+
/escapes working directory/u,
|
|
63
|
+
);
|
|
64
|
+
} finally {
|
|
65
|
+
await rm(linkPath);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("returns null when threads file has no findings", async () => {
|
|
70
|
+
await mkdir(TMP_DIR, { recursive: true });
|
|
71
|
+
await writeFile(join(TMP_DIR, "empty.md"), "");
|
|
72
|
+
const result = await loadAndValidatePr("empty.md", 1, undefined, TMP_DIR);
|
|
73
|
+
assert.equal(result, null);
|
|
74
|
+
});
|
|
75
|
+
});
|
package/src/load-pr.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { readFile, realpath } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { parseThreads } from "./parse-threads.ts";
|
|
4
|
+
import { type Finding } from "./diff-classifier.ts";
|
|
5
|
+
import { type PrInfo, getPrInfo } from "./graphql.ts";
|
|
6
|
+
import { isValidPath } from "./review-helpers.ts";
|
|
7
|
+
|
|
8
|
+
/** Load and parse a threads file, validate the PR is open, and verify the head SHA matches. */
|
|
9
|
+
export async function loadAndValidatePr(
|
|
10
|
+
threadsPath: string,
|
|
11
|
+
prNumber: number,
|
|
12
|
+
expectedSha: string | undefined,
|
|
13
|
+
cwd: string | undefined,
|
|
14
|
+
): Promise<{ findings: Finding[]; prInfo: PrInfo; summary?: string } | null> {
|
|
15
|
+
if (!isValidPath(threadsPath)) {
|
|
16
|
+
throw new Error(`Invalid threadsPath: ${threadsPath}`);
|
|
17
|
+
}
|
|
18
|
+
const base = await realpath(resolve(cwd ?? process.cwd()));
|
|
19
|
+
const candidatePath = resolve(base, threadsPath);
|
|
20
|
+
let resolved: string;
|
|
21
|
+
try {
|
|
22
|
+
resolved = await realpath(candidatePath);
|
|
23
|
+
} catch {
|
|
24
|
+
throw new Error(`Threads file not found: ${candidatePath}`);
|
|
25
|
+
}
|
|
26
|
+
if (!resolved.startsWith(base + "/") && resolved !== base) {
|
|
27
|
+
throw new Error(`threadsPath escapes working directory: ${threadsPath}`);
|
|
28
|
+
}
|
|
29
|
+
const content = await readFile(resolved, "utf8");
|
|
30
|
+
const { findings, summary } = parseThreads(content);
|
|
31
|
+
if (findings.length === 0 && (summary === undefined || summary === "")) return null;
|
|
32
|
+
|
|
33
|
+
const prInfo = await getPrInfo(prNumber, { cwd });
|
|
34
|
+
if (prInfo.state !== "OPEN") {
|
|
35
|
+
throw new Error(`PR is ${prInfo.state}. Aborting.`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const resolvedSha = expectedSha ?? process.env.PR_HEAD_SHA;
|
|
39
|
+
if (resolvedSha !== undefined && resolvedSha !== "" && resolvedSha !== prInfo.headOid) {
|
|
40
|
+
throw new Error(`ABORT: PR head moved (expected ${resolvedSha}, got ${prInfo.headOid}).`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { findings, prInfo, summary };
|
|
44
|
+
}
|
|
@@ -125,3 +125,44 @@ describe("parseThreads findings", () => {
|
|
|
125
125
|
assert.equal(result.findings.length, 0);
|
|
126
126
|
});
|
|
127
127
|
});
|
|
128
|
+
|
|
129
|
+
describe("parseThreads replyTo", () => {
|
|
130
|
+
it("extracts replyTo from frontmatter", () => {
|
|
131
|
+
const input = [
|
|
132
|
+
"---",
|
|
133
|
+
"path: src/foo.ts",
|
|
134
|
+
"line: 42",
|
|
135
|
+
"replyTo: PRT_kwDOABC123",
|
|
136
|
+
"---",
|
|
137
|
+
"This adds a suggestion.",
|
|
138
|
+
].join("\n");
|
|
139
|
+
|
|
140
|
+
const result = parseThreads(input);
|
|
141
|
+
assert.equal(result.findings.length, 1);
|
|
142
|
+
assert.equal(result.findings[0].replyTo, "PRT_kwDOABC123");
|
|
143
|
+
assert.equal(result.findings[0].startLine, undefined);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("returns undefined replyTo when not present", () => {
|
|
147
|
+
const input = ["---", "path: src/foo.ts", "line: 42", "---", "Normal finding."].join("\n");
|
|
148
|
+
|
|
149
|
+
const result = parseThreads(input);
|
|
150
|
+
assert.equal(result.findings[0].replyTo, undefined);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("replyTo and startLine are mutually exclusive — replyTo wins", () => {
|
|
154
|
+
const input = [
|
|
155
|
+
"---",
|
|
156
|
+
"path: src/foo.ts",
|
|
157
|
+
"startLine: 40",
|
|
158
|
+
"line: 45",
|
|
159
|
+
"replyTo: PRT_kwDOABC123",
|
|
160
|
+
"---",
|
|
161
|
+
"Reply body.",
|
|
162
|
+
].join("\n");
|
|
163
|
+
|
|
164
|
+
const result = parseThreads(input);
|
|
165
|
+
assert.equal(result.findings[0].replyTo, "PRT_kwDOABC123");
|
|
166
|
+
assert.equal(result.findings[0].startLine, undefined);
|
|
167
|
+
});
|
|
168
|
+
});
|
package/src/parse-threads.ts
CHANGED
|
@@ -21,6 +21,36 @@ const safeYamlEngine = (s: string): Record<string, unknown> => {
|
|
|
21
21
|
};
|
|
22
22
|
const matterOptions = { engines: { yaml: safeYamlEngine } };
|
|
23
23
|
|
|
24
|
+
/** Build a Finding from parsed frontmatter data and body content. */
|
|
25
|
+
function buildFinding(
|
|
26
|
+
data: Record<string, unknown>,
|
|
27
|
+
path: string,
|
|
28
|
+
lineNum: number,
|
|
29
|
+
body: string,
|
|
30
|
+
): Finding {
|
|
31
|
+
const rawStartLine: unknown = data.startLine;
|
|
32
|
+
const startLineNum =
|
|
33
|
+
rawStartLine !== null && rawStartLine !== undefined ? Number(rawStartLine) : undefined;
|
|
34
|
+
|
|
35
|
+
const rawReplyTo: unknown = data.replyTo;
|
|
36
|
+
const replyTo = typeof rawReplyTo === "string" && rawReplyTo.length > 0 ? rawReplyTo : undefined;
|
|
37
|
+
|
|
38
|
+
// Replies don't use startLine; only set it when present and valid
|
|
39
|
+
const validStartLine =
|
|
40
|
+
replyTo === undefined &&
|
|
41
|
+
startLineNum !== undefined &&
|
|
42
|
+
startLineNum > 0 &&
|
|
43
|
+
Number.isFinite(startLineNum);
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
path,
|
|
47
|
+
line: lineNum,
|
|
48
|
+
startLine: validStartLine ? startLineNum : undefined,
|
|
49
|
+
body,
|
|
50
|
+
replyTo,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
24
54
|
/**
|
|
25
55
|
* Parse a threads.md file into findings and an optional summary.
|
|
26
56
|
* The file uses --- separators between documents, each with YAML frontmatter.
|
|
@@ -60,18 +90,7 @@ function parseThreads(content: string): ParsedThreads {
|
|
|
60
90
|
continue;
|
|
61
91
|
}
|
|
62
92
|
|
|
63
|
-
|
|
64
|
-
const startLineNum =
|
|
65
|
-
rawStartLine !== null && rawStartLine !== undefined ? Number(rawStartLine) : undefined;
|
|
66
|
-
findings.push({
|
|
67
|
-
path,
|
|
68
|
-
line: lineNum,
|
|
69
|
-
startLine:
|
|
70
|
-
startLineNum !== undefined && startLineNum > 0 && Number.isFinite(startLineNum)
|
|
71
|
-
? startLineNum
|
|
72
|
-
: undefined,
|
|
73
|
-
body: parsed.content.trim(),
|
|
74
|
-
});
|
|
93
|
+
findings.push(buildFinding(data, path, lineNum, parsed.content.trim()));
|
|
75
94
|
}
|
|
76
95
|
|
|
77
96
|
return { findings, summary };
|
package/src/post-review.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, it } from "bun:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import { parseThreads } from "./parse-threads.ts";
|
|
4
|
-
import { classifyFindings } from "./diff-classifier.ts";
|
|
4
|
+
import { classifyFindings, type ReplyFinding } from "./diff-classifier.ts";
|
|
5
5
|
import { buildReviewBody } from "./review-helpers.ts";
|
|
6
6
|
|
|
7
7
|
describe("buildReviewBody", () => {
|
|
@@ -33,6 +33,59 @@ describe("buildReviewBody", () => {
|
|
|
33
33
|
});
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
+
describe("postReview reply routing", () => {
|
|
37
|
+
it("classifies findings with replyTo separately from new findings", () => {
|
|
38
|
+
const threadsContent = [
|
|
39
|
+
"---",
|
|
40
|
+
"path: src/foo.ts",
|
|
41
|
+
"line: 10",
|
|
42
|
+
"replyTo: PRT_kwDOABC123",
|
|
43
|
+
"---",
|
|
44
|
+
"Reply body here.",
|
|
45
|
+
"---",
|
|
46
|
+
"path: src/bar.ts",
|
|
47
|
+
"line: 20",
|
|
48
|
+
"---",
|
|
49
|
+
"New finding body.",
|
|
50
|
+
].join("\n");
|
|
51
|
+
|
|
52
|
+
const { findings } = parseThreads(threadsContent);
|
|
53
|
+
const replyFindings = findings.filter((f) => f.replyTo !== undefined);
|
|
54
|
+
const newFindings = findings.filter((f) => f.replyTo === undefined);
|
|
55
|
+
|
|
56
|
+
assert.equal(replyFindings.length, 1);
|
|
57
|
+
assert.equal(replyFindings[0].replyTo, "PRT_kwDOABC123");
|
|
58
|
+
assert.equal(newFindings.length, 1);
|
|
59
|
+
assert.equal(newFindings[0].path, "src/bar.ts");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("postReview dry-run with replyTo", () => {
|
|
64
|
+
it("includes reply findings in dry-run count", () => {
|
|
65
|
+
const threadsContent = [
|
|
66
|
+
"---",
|
|
67
|
+
"path: src/foo.ts",
|
|
68
|
+
"line: 10",
|
|
69
|
+
"replyTo: PRT_kwDOABC123",
|
|
70
|
+
"---",
|
|
71
|
+
"Reply finding.",
|
|
72
|
+
"---",
|
|
73
|
+
"path: src/bar.ts",
|
|
74
|
+
"startLine: 5",
|
|
75
|
+
"line: 10",
|
|
76
|
+
"---",
|
|
77
|
+
"New finding.",
|
|
78
|
+
].join("\n");
|
|
79
|
+
|
|
80
|
+
const { findings } = parseThreads(threadsContent);
|
|
81
|
+
assert.equal(findings.length, 2);
|
|
82
|
+
assert.equal(findings[0].replyTo, "PRT_kwDOABC123");
|
|
83
|
+
assert.equal(findings[0].startLine, undefined);
|
|
84
|
+
assert.equal(findings[1].replyTo, undefined);
|
|
85
|
+
assert.equal(findings[1].startLine, 5);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
36
89
|
describe("integration: parse → classify", () => {
|
|
37
90
|
const threadsContent = [
|
|
38
91
|
"---",
|
|
@@ -80,3 +133,33 @@ describe("integration: parse → classify", () => {
|
|
|
80
133
|
assert.equal(classified[2].tier, 3);
|
|
81
134
|
});
|
|
82
135
|
});
|
|
136
|
+
|
|
137
|
+
describe("ReplyFinding type narrowing", () => {
|
|
138
|
+
it("filter produces ReplyFinding[] when checking replyTo", () => {
|
|
139
|
+
const { findings } = parseThreads(
|
|
140
|
+
[
|
|
141
|
+
"---",
|
|
142
|
+
"path: src/foo.ts",
|
|
143
|
+
"line: 10",
|
|
144
|
+
"replyTo: PRT_kwDOABC123",
|
|
145
|
+
"---",
|
|
146
|
+
"Reply body.",
|
|
147
|
+
"---",
|
|
148
|
+
"path: src/bar.ts",
|
|
149
|
+
"line: 20",
|
|
150
|
+
"---",
|
|
151
|
+
"New body.",
|
|
152
|
+
].join("\n"),
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const replies: ReplyFinding[] = findings
|
|
156
|
+
.filter((f): f is ReplyFinding => f.replyTo !== undefined)
|
|
157
|
+
.map((f) => ({ ...f, tier: 1 as const }));
|
|
158
|
+
|
|
159
|
+
assert.equal(replies.length, 1);
|
|
160
|
+
assert.equal(replies[0].replyTo, "PRT_kwDOABC123");
|
|
161
|
+
// Type check: replyTo is string (not string | undefined)
|
|
162
|
+
const threadId: string = replies[0].replyTo;
|
|
163
|
+
assert.equal(threadId, "PRT_kwDOABC123");
|
|
164
|
+
});
|
|
165
|
+
});
|