@mechanai/deepreview 2.12.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.
@@ -24,6 +24,21 @@ Read all provided files.
24
24
 
25
25
  When `prior-review.md` is provided, emit findings from **both** the synthesis and the prior review, but deduplicate: if a prior-review finding and a synthesis finding refer to the same file path and line (or overlapping line range) AND describe the same issue, keep only the synthesis version (it may have updated wording). When in doubt, keep both — false duplicates are worse than a missing dedup.
26
26
 
27
+ ## Replying to existing threads
28
+
29
+ When a finding overlaps with an existing thread from the prior review (identified by its `[thread: ID]` tag), decide:
30
+
31
+ 1. Does your finding add substantive value beyond what the thread already says?
32
+ - A concrete suggestion block the thread lacks: reply
33
+ - Additional analysis or a related issue the original missed: reply
34
+ - Just restating what's already there: omit the finding entirely
35
+
36
+ 2. If replying, use `replyTo: <thread ID>` in the frontmatter instead of `startLine`. The body should be written as a reply — it appears below existing comments in the thread. Don't repeat context the reader can see above.
37
+
38
+ 3. If the thread is marked `[resolved]` or `[outdated]` in the prior review, do NOT reply to it. Post as a new finding instead (or omit if redundant).
39
+
40
+ 4. `replyTo` and `startLine` are mutually exclusive. When using `replyTo`, omit `startLine`.
41
+
27
42
  ## Prior-review-only mode
28
43
 
29
44
  This mode activates when the orchestrator provides `prior-review.md` but no `synthesis.md` (synthesis failed or was skipped). In this mode:
@@ -74,6 +89,17 @@ line: <line number>
74
89
  <markdown body of the comment>
75
90
  ```
76
91
 
92
+ If replying to an existing thread:
93
+
94
+ ```
95
+ ---
96
+ path: <file path>
97
+ line: <line number>
98
+ replyTo: <thread ID from prior review>
99
+ ---
100
+ <markdown body of the reply>
101
+ ```
102
+
77
103
  ## Content rules
78
104
 
79
105
  - One finding per document. Never bundle multiple issues.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mechanai/deepreview",
3
- "version": "2.12.0",
3
+ "version": "2.13.0",
4
4
  "description": "Multi-agent parallel code/spec review for OpenCode",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -0,0 +1,62 @@
1
+ import { isRateLimitError } from "./review-helpers.ts";
2
+
3
+ const BATCH_CONCURRENCY = 2;
4
+ const BATCH_DELAY_MS = 1000;
5
+ const RATE_LIMIT_DELAY_MS = 60_000;
6
+ const RATE_LIMIT_JITTER_MS = 10_000;
7
+ const MAX_RATE_LIMIT_RETRIES = 2;
8
+
9
+ export interface BatchOptions {
10
+ concurrency?: number;
11
+ delayMs?: number;
12
+ }
13
+
14
+ /** Process items in batches with concurrency and inter-batch delay. */
15
+ export async function mapBatch<T, R>(
16
+ items: T[],
17
+ fn: (item: T) => Promise<R>,
18
+ opts: BatchOptions = {},
19
+ ): Promise<R[]> {
20
+ const concurrency = opts.concurrency ?? BATCH_CONCURRENCY;
21
+ const delayMs = opts.delayMs ?? BATCH_DELAY_MS;
22
+ const results: R[] = [];
23
+ for (let i = 0; i < items.length; i += concurrency) {
24
+ if (i > 0) await Bun.sleep(delayMs);
25
+ results.push(...(await Promise.all(items.slice(i, i + concurrency).map(fn))));
26
+ }
27
+ return results;
28
+ }
29
+
30
+ /** Retry an async operation with rate-limit-aware backoff. Returns true on success. */
31
+ export async function withRateLimitRetry(
32
+ fn: () => Promise<void>,
33
+ label: string,
34
+ maxRetries: number = MAX_RATE_LIMIT_RETRIES,
35
+ ): Promise<boolean> {
36
+ try {
37
+ await fn();
38
+ return true;
39
+ } catch (err: unknown) {
40
+ if (!isRateLimitError(err)) {
41
+ const message = err instanceof Error ? err.message : String(err);
42
+ console.error(`FAIL: ${label} — ${message}`);
43
+ return false;
44
+ }
45
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
46
+ console.warn(`Rate limited on ${label}. Waiting 60s (retry ${attempt + 1}/${maxRetries})...`);
47
+ await Bun.sleep(RATE_LIMIT_DELAY_MS + Math.floor(Math.random() * RATE_LIMIT_JITTER_MS));
48
+ try {
49
+ await fn();
50
+ return true;
51
+ } catch (retryErr: unknown) {
52
+ if (!isRateLimitError(retryErr)) {
53
+ const message = retryErr instanceof Error ? retryErr.message : String(retryErr);
54
+ console.error(`FAIL: ${label} — ${message}`);
55
+ return false;
56
+ }
57
+ }
58
+ }
59
+ console.error(`FAIL: ${label} — rate limited after retries`);
60
+ return false;
61
+ }
62
+ }
@@ -82,6 +82,7 @@ function classifyAuthorType(
82
82
  /** Map raw GraphQL thread nodes to domain ReviewThread objects with author classification. */
83
83
  export function mapGraphQLThreads(nodes: GQLThreadNode[]): ReviewThread[] {
84
84
  return nodes.map((node) => ({
85
+ id: node.id,
85
86
  path: node.path,
86
87
  startLine: node.startLine,
87
88
  line: node.line,
@@ -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
+ });
@@ -1,8 +1,5 @@
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, type ClassifiedFinding } from "./diff-classifier.ts";
5
- import { type PrInfo, getPrInfo, execFileAsync } from "./graphql.ts";
1
+ import { type ClassifiedFinding, type ReplyFinding } from "./diff-classifier.ts";
2
+ import { type PrInfo, execFileAsync } from "./graphql.ts";
6
3
  import {
7
4
  type PendingReview,
8
5
  findPendingReview,
@@ -11,16 +8,17 @@ import {
11
8
  addLineThread,
12
9
  addFileThread,
13
10
  updateReviewComment,
11
+ replyToThread,
14
12
  } from "./review-api.ts";
15
13
  import {
16
- isValidPath,
17
- isRateLimitError,
18
14
  findingId,
19
15
  embedFindingId,
20
16
  extractFindingId,
21
17
  buildReviewBody,
22
18
  classifyAndLog,
23
19
  } from "./review-helpers.ts";
20
+ import { mapBatch, withRateLimitRetry } from "./batch-retry.ts";
21
+ import { loadAndValidatePr } from "./load-pr.ts";
24
22
 
25
23
  export interface PostReviewOptions {
26
24
  threadsPath: string;
@@ -59,17 +57,10 @@ async function updateExistingThreads(
59
57
  }
60
58
  }
61
59
 
62
- const CONCURRENCY = 2;
63
- for (let i = 0; i < toUpdate.length; i += CONCURRENCY) {
64
- if (i > 0) await Bun.sleep(1000);
65
- const batch = toUpdate.slice(i, i + CONCURRENCY);
66
- await Promise.all(
67
- batch.map(async ({ finding, commentId, id }) => {
68
- const body = embedFindingId(finding.renderedBody ?? finding.body, id);
69
- await updateReviewComment(commentId, body);
70
- }),
71
- );
72
- }
60
+ await mapBatch(toUpdate, async ({ finding, commentId, id }) => {
61
+ const body = embedFindingId(finding.renderedBody ?? finding.body, id);
62
+ await updateReviewComment(commentId, body);
63
+ });
73
64
 
74
65
  console.log(`Updated ${toUpdate.length} existing threads.`);
75
66
  return new Set(toUpdate.map(({ id }) => id));
@@ -92,71 +83,65 @@ async function postWithRetry(
92
83
  finding: ClassifiedFinding,
93
84
  body: string,
94
85
  ): Promise<boolean> {
95
- try {
96
- await postThread(reviewId, finding, body);
97
- return true;
98
- } catch (err: unknown) {
99
- if (!isRateLimitError(err)) {
100
- const message = err instanceof Error ? err.message : String(err);
101
- console.error(`FAIL: ${finding.path}:${finding.line} — ${message}`);
102
- return false;
103
- }
104
- for (let attempt = 0; attempt < 2; attempt++) {
105
- console.warn(
106
- `Rate limited on ${finding.path}:${finding.line}. Waiting 60s (retry ${attempt + 1}/2)...`,
107
- );
108
- await Bun.sleep(60_000 + Math.floor(Math.random() * 10_000));
109
- try {
110
- await postThread(reviewId, finding, body);
111
- return true;
112
- } catch (retryErr: unknown) {
113
- if (!isRateLimitError(retryErr)) {
114
- const message = retryErr instanceof Error ? retryErr.message : String(retryErr);
115
- console.error(`FAIL: ${finding.path}:${finding.line} — ${message}`);
116
- return false;
117
- }
118
- }
119
- }
120
- console.error(`FAIL: ${finding.path}:${finding.line} — rate limited after retries`);
121
- return false;
122
- }
86
+ return withRateLimitRetry(
87
+ async () => postThread(reviewId, finding, body),
88
+ `${finding.path}:${finding.line}`,
89
+ );
123
90
  }
124
91
 
125
92
  async function postInlineThreads(
126
93
  reviewId: string,
127
94
  inlineFindings: ClassifiedFinding[],
128
95
  updatedIds: Set<string>,
96
+ maxThreads: number = MAX_INLINE_FINDINGS,
129
97
  ): Promise<string[]> {
130
- const CONCURRENCY = 2;
131
- const failedFindings: string[] = [];
132
98
  const pending = inlineFindings.filter(
133
99
  (f) => !updatedIds.has(findingId(f.path, f.startLine, f.line, f.body)),
134
100
  );
135
101
 
136
- if (pending.length > MAX_INLINE_FINDINGS) {
102
+ if (pending.length > maxThreads) {
137
103
  console.warn(
138
- `WARN: ${pending.length} inline findings exceed limit of ${MAX_INLINE_FINDINGS}. Truncating.`,
104
+ `WARN: ${pending.length} inline findings exceed limit of ${maxThreads}. Truncating.`,
139
105
  );
140
106
  }
141
- const capped = pending.slice(0, MAX_INLINE_FINDINGS);
107
+ const capped = pending.slice(0, maxThreads);
142
108
 
143
- for (let i = 0; i < capped.length; i += CONCURRENCY) {
144
- if (i > 0) await Bun.sleep(1000);
145
- const batch = capped.slice(i, i + CONCURRENCY);
146
- const results = await Promise.all(
147
- batch.map(async (f) => {
148
- const id = findingId(f.path, f.startLine, f.line, f.body);
149
- // Body rendered by GitHub's Markdown sanitizer; no escaping needed here
150
- const body = embedFindingId(f.renderedBody ?? f.body, id);
151
- const ok = await postWithRetry(reviewId, f, body);
152
- return ok ? null : id;
153
- }),
109
+ const results = await mapBatch(capped, async (f) => {
110
+ const id = findingId(f.path, f.startLine, f.line, f.body);
111
+ const body = embedFindingId(f.renderedBody ?? f.body, id);
112
+ const ok = await postWithRetry(reviewId, f, body);
113
+ return ok ? null : id;
114
+ });
115
+ return results.filter((id): id is string => id !== null);
116
+ }
117
+
118
+ async function postReplyThreads(
119
+ replyFindings: ReplyFinding[],
120
+ reviewId: string,
121
+ inlineBudgetRemaining: number,
122
+ ): Promise<{ failedIds: string[]; fallbackCount: number }> {
123
+ let fallbackCount = 0;
124
+ const results = await mapBatch(replyFindings, async (f) => {
125
+ const id = findingId(f.path, f.startLine, f.line, f.body);
126
+ const body = embedFindingId(f.renderedBody ?? f.body, id);
127
+ const replied = await withRateLimitRetry(
128
+ async () => replyToThread(f.replyTo, body),
129
+ `reply to ${f.replyTo}`,
154
130
  );
155
- for (const id of results) {
156
- if (id !== null) failedFindings.push(id);
131
+ if (replied) return null;
132
+ // Fallback: post as new thread, subject to inline cap
133
+ if (fallbackCount >= inlineBudgetRemaining) {
134
+ console.warn(
135
+ `WARN: Reply to thread ${f.replyTo} failed. Inline cap reached — skipping fallback.`,
136
+ );
137
+ return id;
157
138
  }
158
- }
159
- return failedFindings;
139
+ console.warn(`WARN: Reply to thread ${f.replyTo} failed. Falling back to new thread.`);
140
+ fallbackCount++;
141
+ const ok = await postWithRetry(reviewId, f, body);
142
+ return ok ? null : id;
143
+ });
144
+ return { failedIds: results.filter((id): id is string => id !== null), fallbackCount };
160
145
  }
161
146
 
162
147
  async function ensureReview(
@@ -188,44 +173,32 @@ async function ensureReview(
188
173
  return { reviewId, updatedFindings: new Set() };
189
174
  }
190
175
 
191
- async function loadAndValidatePr(
192
- threadsPath: string,
193
- prNumber: number,
194
- expectedSha: string | undefined,
195
- cwd: string | undefined,
196
- ): Promise<{ findings: Finding[]; prInfo: PrInfo; summary?: string } | null> {
197
- if (!isValidPath(threadsPath)) {
198
- throw new Error(`Invalid threadsPath: ${threadsPath}`);
199
- }
200
- // cwd is a trusted parameter set by the plugin framework (not user-supplied)
201
- const base = await realpath(resolve(cwd ?? process.cwd()));
202
- const candidatePath = resolve(base, threadsPath);
203
- let resolved: string;
204
- try {
205
- resolved = await realpath(candidatePath);
206
- } catch {
207
- throw new Error(`Threads file not found: ${candidatePath}`);
208
- }
209
- if (!resolved.startsWith(base + "/") && resolved !== base) {
210
- throw new Error(`threadsPath escapes working directory: ${threadsPath}`);
211
- }
212
- const content = await readFile(resolved, "utf8");
213
- const { findings, summary } = parseThreads(content);
214
- if (findings.length === 0 && (summary === undefined || summary === "")) return null;
215
-
216
- const prInfo = await getPrInfo(prNumber, { cwd });
217
- if (prInfo.state !== "OPEN") {
218
- throw new Error(`PR is ${prInfo.state}. Aborting.`);
219
- }
220
-
221
- const resolvedSha = expectedSha ?? process.env.PR_HEAD_SHA;
222
- if (resolvedSha !== undefined && resolvedSha !== "" && resolvedSha !== prInfo.headOid) {
223
- throw new Error(`ABORT: PR head moved (expected ${resolvedSha}, got ${prInfo.headOid}).`);
224
- }
176
+ interface StatusCounts {
177
+ posted: number;
178
+ total: number;
179
+ replies: number;
180
+ upToDate: number;
181
+ skipped: number;
182
+ failed: number;
183
+ truncated: number;
184
+ tier3Count: number;
185
+ }
225
186
 
226
- return { findings, prInfo, summary };
187
+ function formatStatusSummary(counts: StatusCounts): string {
188
+ const parts: string[] = [];
189
+ if (counts.replies > 0) parts.push(`${counts.replies} replies`);
190
+ if (counts.upToDate > 0) parts.push(`${counts.upToDate} up-to-date`);
191
+ if (counts.skipped > 0) parts.push(`${counts.skipped} skipped`);
192
+ if (counts.failed > 0) parts.push(`${counts.failed} failed`);
193
+ if (counts.truncated > 0) parts.push(`${counts.truncated} truncated`);
194
+ const detail = parts.length > 0 ? ` (${parts.join(", ")})` : "";
195
+ return (
196
+ `Posted ${counts.posted}/${counts.total} inline threads${detail}. ` +
197
+ `${counts.tier3Count} findings in review body.`
198
+ );
227
199
  }
228
200
 
201
+ // oxlint-disable-next-line max-lines-per-function -- Why: orchestration function that coordinates reply/new posting and computes stats; splitting further would fragment the logic
229
202
  async function postFindings(
230
203
  tier1: ClassifiedFinding[],
231
204
  tier2: ClassifiedFinding[],
@@ -247,26 +220,57 @@ async function postFindings(
247
220
  );
248
221
 
249
222
  const skipSet = new Set(skipIds);
250
- const inlineFindings = [...tier1, ...tier2].filter((f) => {
223
+ const allInline = [...tier1, ...tier2].filter((f) => {
251
224
  const id = findingId(f.path, f.startLine, f.line, f.body);
252
225
  return !skipSet.has(id);
253
226
  });
254
227
 
255
- const failedIds = await postInlineThreads(reviewId, inlineFindings, updatedFindings);
256
- const skipped = inlineFindings.filter((f) =>
228
+ // Split into reply findings and new findings
229
+ const replyFindings = allInline.filter(
230
+ (f): f is ReplyFinding =>
231
+ f.replyTo !== undefined &&
232
+ !updatedFindings.has(findingId(f.path, f.startLine, f.line, f.body)),
233
+ );
234
+ const newFindings = allInline.filter((f) => f.replyTo === undefined);
235
+
236
+ // Post replies first (fallbacks count against inline cap)
237
+ const inlineBudget = MAX_INLINE_FINDINGS;
238
+ const { failedIds: replyFailedIds, fallbackCount } = await postReplyThreads(
239
+ replyFindings,
240
+ reviewId,
241
+ inlineBudget,
242
+ );
243
+
244
+ // Post new threads with remaining budget after reply fallbacks
245
+ const newFailedIds = await postInlineThreads(
246
+ reviewId,
247
+ newFindings,
248
+ updatedFindings,
249
+ inlineBudget - fallbackCount,
250
+ );
251
+
252
+ const failedIds = [...replyFailedIds, ...newFailedIds];
253
+ const skipped = newFindings.filter((f) =>
257
254
  updatedFindings.has(findingId(f.path, f.startLine, f.line, f.body)),
258
255
  ).length;
259
- const pending = inlineFindings.length - skipped;
260
- const truncatedCount = Math.max(0, pending - MAX_INLINE_FINDINGS);
261
- const posted = Math.min(pending, MAX_INLINE_FINDINGS) - failedIds.length;
256
+ const pendingNew = newFindings.length - skipped;
257
+ const postedNew = Math.min(pendingNew, inlineBudget - fallbackCount) - newFailedIds.length;
258
+ const postedReplies = replyFindings.length - replyFailedIds.length;
262
259
  const totalFindings = [...tier1, ...tier2].length;
263
- const skippedByUser = totalFindings - inlineFindings.length;
264
- const statusSummary =
265
- `Posted ${posted}/${totalFindings} inline threads (${skipped} up-to-date, ${skippedByUser} skipped, ${failedIds.length} failed${truncatedCount > 0 ? `, ${truncatedCount} truncated` : ""}). ` +
266
- `${tier3.length} findings in review body.`;
260
+ const truncated = Math.max(0, pendingNew - (inlineBudget - fallbackCount));
267
261
 
268
- console.log(statusSummary);
262
+ const statusSummary = formatStatusSummary({
263
+ posted: postedNew + postedReplies,
264
+ total: totalFindings,
265
+ replies: postedReplies,
266
+ upToDate: skipped,
267
+ skipped: totalFindings - allInline.length,
268
+ failed: failedIds.length,
269
+ truncated,
270
+ tier3Count: tier3.length,
271
+ });
269
272
 
273
+ console.log(statusSummary);
270
274
  return { summary: statusSummary, failed: failedIds };
271
275
  }
272
276
 
@@ -291,8 +295,15 @@ export async function postReview(opts: PostReviewOptions): Promise<PostReviewRes
291
295
  const { tier1, tier2, tier3 } = classifyAndLog(findings, diff);
292
296
 
293
297
  if (dryRun) {
298
+ const replyCount = findings.filter((f) => f.replyTo !== undefined).length;
299
+ const parts = [
300
+ `${tier1.length} line-level`,
301
+ `${tier2.length} file-level`,
302
+ `${tier3.length} review-body`,
303
+ ];
304
+ if (replyCount > 0) parts.push(`${replyCount} replies`);
294
305
  return {
295
- summary: `Dry run: ${tier1.length} line-level, ${tier2.length} file-level, ${tier3.length} review-body findings.`,
306
+ summary: `Dry run: ${parts.join(", ")} findings.`,
296
307
  failed: [],
297
308
  };
298
309
  }
package/src/review-api.ts CHANGED
@@ -259,3 +259,19 @@ export async function updateReviewComment(commentId: string, body: string): Prom
259
259
  { input: { pullRequestReviewCommentId: commentId, body } },
260
260
  );
261
261
  }
262
+
263
+ /** Post a reply comment on an existing review thread. */
264
+ export async function replyToThread(threadId: string, body: string): Promise<void> {
265
+ await graphql(
266
+ `
267
+ mutation ($input: AddPullRequestReviewThreadReplyInput!) {
268
+ addPullRequestReviewThreadReply(input: $input) {
269
+ comment {
270
+ id
271
+ }
272
+ }
273
+ }
274
+ `,
275
+ { input: { pullRequestReviewThreadId: threadId, body } },
276
+ );
277
+ }