@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.
@@ -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
- lines.push(`- ${lineRef} ${tag}: ${formatCommentBody(first.body, " ")}`);
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
  }
@@ -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
+ });
@@ -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
- const rawStartLine: unknown = data.startLine;
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 };
@@ -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
+ });