@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
package/src/post-review.ts
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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 >
|
|
102
|
+
if (pending.length > maxThreads) {
|
|
137
103
|
console.warn(
|
|
138
|
-
`WARN: ${pending.length} inline findings exceed limit of ${
|
|
104
|
+
`WARN: ${pending.length} inline findings exceed limit of ${maxThreads}. Truncating.`,
|
|
139
105
|
);
|
|
140
106
|
}
|
|
141
|
-
const capped = pending.slice(0,
|
|
107
|
+
const capped = pending.slice(0, maxThreads);
|
|
142
108
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
const
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
256
|
-
const
|
|
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
|
|
260
|
-
const
|
|
261
|
-
const
|
|
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
|
|
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
|
-
|
|
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: ${
|
|
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
|
+
}
|