@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.
@@ -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
+ }