@mechanai/deepreview 2.7.0 → 2.8.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/commands/deepreview-pr-review.md +30 -11
- package/.opencode/plugins/deepreview.ts +30 -1
- package/README.md +2 -0
- package/package.json +1 -1
- package/src/build-prior-review-fetch.test.ts +368 -0
- package/src/build-prior-review-fetch.ts +234 -0
- package/src/build-prior-review.test.ts +668 -0
- package/src/build-prior-review.ts +246 -0
|
@@ -0,0 +1,668 @@
|
|
|
1
|
+
// oxlint-disable max-lines -- Why: comprehensive test coverage for mapGraphQLThreads and buildPriorReviewContent requires many inline fixture objects; extracting them would obscure intent
|
|
2
|
+
import { describe, it } from "bun:test";
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import {
|
|
5
|
+
buildPriorReview,
|
|
6
|
+
buildPriorReviewContent,
|
|
7
|
+
formatPriorReview,
|
|
8
|
+
mapGraphQLThreads,
|
|
9
|
+
truncateToFit,
|
|
10
|
+
} from "./build-prior-review.ts";
|
|
11
|
+
import type { ReviewThread, ThreadComment } from "./build-prior-review.ts";
|
|
12
|
+
|
|
13
|
+
function makeThread(
|
|
14
|
+
path: string,
|
|
15
|
+
line: number,
|
|
16
|
+
comments: { login: string; body: string }[],
|
|
17
|
+
opts?: { startLine?: number; isResolved?: boolean; isOutdated?: boolean },
|
|
18
|
+
): ReviewThread {
|
|
19
|
+
return {
|
|
20
|
+
path,
|
|
21
|
+
startLine: opts?.startLine ?? null,
|
|
22
|
+
line,
|
|
23
|
+
isResolved: opts?.isResolved ?? false,
|
|
24
|
+
isOutdated: opts?.isOutdated ?? false,
|
|
25
|
+
comments: comments.map(
|
|
26
|
+
(c): ThreadComment => ({
|
|
27
|
+
authorLogin: c.login,
|
|
28
|
+
authorType: "human",
|
|
29
|
+
body: c.body,
|
|
30
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
31
|
+
}),
|
|
32
|
+
),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("formatPriorReview: sections", () => {
|
|
37
|
+
it("formats PR description only", () => {
|
|
38
|
+
const result = formatPriorReview("This PR adds feature X.", [], null);
|
|
39
|
+
assert.ok(result.includes("## PR Description"));
|
|
40
|
+
assert.ok(result.includes("This PR adds feature X."));
|
|
41
|
+
assert.ok(!result.includes("## Prior Review Comments"));
|
|
42
|
+
assert.ok(!result.includes("## Manual Prior Review"));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("includes manual prior review section", () => {
|
|
46
|
+
const result = formatPriorReview("body", [], "Manual findings here.");
|
|
47
|
+
assert.ok(result.includes("## Manual Prior Review"));
|
|
48
|
+
assert.ok(result.includes("Manual findings here."));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("returns empty string when all inputs are empty", () => {
|
|
52
|
+
const result = formatPriorReview("", [], null);
|
|
53
|
+
assert.equal(result, "");
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("formatPriorReview: thread formatting", () => {
|
|
58
|
+
it("formats threads grouped by file with line numbers", () => {
|
|
59
|
+
const threads: ReviewThread[] = [
|
|
60
|
+
{
|
|
61
|
+
path: "src/foo.ts",
|
|
62
|
+
startLine: 10,
|
|
63
|
+
line: 15,
|
|
64
|
+
isResolved: false,
|
|
65
|
+
isOutdated: false,
|
|
66
|
+
comments: [
|
|
67
|
+
{
|
|
68
|
+
authorLogin: "octocat",
|
|
69
|
+
authorType: "human",
|
|
70
|
+
body: "Should this be async?",
|
|
71
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
authorLogin: "author",
|
|
75
|
+
authorType: "human",
|
|
76
|
+
body: "Good point, fixed.",
|
|
77
|
+
createdAt: "2026-01-01T01:00:00Z",
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
path: "src/foo.ts",
|
|
83
|
+
startLine: null,
|
|
84
|
+
line: 42,
|
|
85
|
+
isResolved: true,
|
|
86
|
+
isOutdated: false,
|
|
87
|
+
comments: [
|
|
88
|
+
{
|
|
89
|
+
authorLogin: "bot-reviewer[bot]",
|
|
90
|
+
authorType: "bot",
|
|
91
|
+
body: "Unused import.",
|
|
92
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
];
|
|
97
|
+
const result = formatPriorReview("PR body.", threads, null);
|
|
98
|
+
assert.ok(result.includes("### src/foo.ts"));
|
|
99
|
+
assert.ok(result.includes("**L10-15**"));
|
|
100
|
+
assert.ok(result.includes("[source: @octocat, human]"));
|
|
101
|
+
assert.ok(result.includes("**L42**"));
|
|
102
|
+
assert.ok(result.includes("[source: @bot-reviewer[bot], bot, resolved]"));
|
|
103
|
+
assert.ok(result.includes("@author replied:"));
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("formatPriorReview: thread ordering and flags", () => {
|
|
108
|
+
it("sorts threads by file path then line number", () => {
|
|
109
|
+
const threads = [
|
|
110
|
+
makeThread("src/z.ts", 1, [{ login: "a", body: "z" }]),
|
|
111
|
+
makeThread("src/a.ts", 5, [{ login: "a", body: "a5" }]),
|
|
112
|
+
makeThread("src/a.ts", 1, [{ login: "a", body: "a1" }]),
|
|
113
|
+
];
|
|
114
|
+
const result = formatPriorReview("", threads, null);
|
|
115
|
+
const aPos = result.indexOf("### src/a.ts");
|
|
116
|
+
const zPos = result.indexOf("### src/z.ts");
|
|
117
|
+
assert.ok(aPos < zPos, "src/a.ts should come before src/z.ts");
|
|
118
|
+
const a1Pos = result.indexOf("a1");
|
|
119
|
+
const a5Pos = result.indexOf("a5");
|
|
120
|
+
assert.ok(a1Pos < a5Pos, "line 1 should come before line 5");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("marks outdated threads", () => {
|
|
124
|
+
const threads = [
|
|
125
|
+
makeThread("src/x.ts", 1, [{ login: "a", body: "old" }], { isOutdated: true }),
|
|
126
|
+
];
|
|
127
|
+
const result = formatPriorReview("", threads, null);
|
|
128
|
+
assert.ok(result.includes("outdated"));
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("truncateToFit", () => {
|
|
133
|
+
it("returns content unchanged when under budget", () => {
|
|
134
|
+
const content = "short content";
|
|
135
|
+
assert.equal(truncateToFit(content, 50_000), content);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("truncates to fit within byte limit", () => {
|
|
139
|
+
const content = "x".repeat(60_000);
|
|
140
|
+
const result = truncateToFit(content, 50_000);
|
|
141
|
+
assert.ok(Buffer.byteLength(result, "utf8") <= 50_000);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("fetchPrReviewThreads", () => {
|
|
146
|
+
it("is exported as a function", async () => {
|
|
147
|
+
const { fetchPrReviewThreads } = await import("./build-prior-review.ts");
|
|
148
|
+
assert.equal(typeof fetchPrReviewThreads, "function");
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// oxlint-disable-next-line max-lines-per-function -- Why: covers five author-type classification cases; each requires a full GQL node fixture and an assertion; splitting into separate describes would not reduce total lines
|
|
153
|
+
describe("mapGraphQLThreads", () => {
|
|
154
|
+
it("maps GraphQL response nodes to ReviewThread[]", () => {
|
|
155
|
+
const nodes = [
|
|
156
|
+
{
|
|
157
|
+
id: "t1",
|
|
158
|
+
path: "src/foo.ts",
|
|
159
|
+
startLine: 10,
|
|
160
|
+
line: 15,
|
|
161
|
+
isResolved: false,
|
|
162
|
+
isOutdated: false,
|
|
163
|
+
comments: {
|
|
164
|
+
pageInfo: { hasNextPage: false, endCursor: null },
|
|
165
|
+
nodes: [
|
|
166
|
+
{
|
|
167
|
+
author: { login: "octocat", __typename: "User" },
|
|
168
|
+
body: "Fix this",
|
|
169
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
170
|
+
},
|
|
171
|
+
],
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
id: "t2",
|
|
176
|
+
path: "src/bar.ts",
|
|
177
|
+
startLine: null,
|
|
178
|
+
line: 5,
|
|
179
|
+
isResolved: true,
|
|
180
|
+
isOutdated: false,
|
|
181
|
+
comments: {
|
|
182
|
+
pageInfo: { hasNextPage: false, endCursor: null },
|
|
183
|
+
nodes: [
|
|
184
|
+
{
|
|
185
|
+
author: { login: "ci-bot[bot]", __typename: "Bot" },
|
|
186
|
+
body: "Lint error",
|
|
187
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
188
|
+
},
|
|
189
|
+
],
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
];
|
|
193
|
+
const result = mapGraphQLThreads(nodes);
|
|
194
|
+
assert.equal(result.length, 2);
|
|
195
|
+
assert.equal(result[0].path, "src/foo.ts");
|
|
196
|
+
assert.equal(result[0].comments[0].authorType, "human");
|
|
197
|
+
assert.equal(result[1].comments[0].authorType, "bot");
|
|
198
|
+
assert.equal(result[1].isResolved, true);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("detects bot by __typename Bot", () => {
|
|
202
|
+
const nodes = [
|
|
203
|
+
{
|
|
204
|
+
id: "t1",
|
|
205
|
+
path: "a.ts",
|
|
206
|
+
startLine: null,
|
|
207
|
+
line: 1,
|
|
208
|
+
isResolved: false,
|
|
209
|
+
isOutdated: false,
|
|
210
|
+
comments: {
|
|
211
|
+
pageInfo: { hasNextPage: false, endCursor: null },
|
|
212
|
+
nodes: [
|
|
213
|
+
{
|
|
214
|
+
author: { login: "dependabot", __typename: "Bot" },
|
|
215
|
+
body: "bump",
|
|
216
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
217
|
+
},
|
|
218
|
+
],
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
];
|
|
222
|
+
const result = mapGraphQLThreads(nodes);
|
|
223
|
+
assert.equal(result[0].comments[0].authorType, "bot");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("detects bot by [bot] suffix when __typename is missing", () => {
|
|
227
|
+
const nodes = [
|
|
228
|
+
{
|
|
229
|
+
id: "t1",
|
|
230
|
+
path: "a.ts",
|
|
231
|
+
startLine: null,
|
|
232
|
+
line: 1,
|
|
233
|
+
isResolved: false,
|
|
234
|
+
isOutdated: false,
|
|
235
|
+
comments: {
|
|
236
|
+
pageInfo: { hasNextPage: false, endCursor: null },
|
|
237
|
+
nodes: [
|
|
238
|
+
{
|
|
239
|
+
author: { login: "some-bot[bot]", __typename: "User" },
|
|
240
|
+
body: "x",
|
|
241
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
242
|
+
},
|
|
243
|
+
],
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
];
|
|
247
|
+
const result = mapGraphQLThreads(nodes);
|
|
248
|
+
assert.equal(result[0].comments[0].authorType, "bot");
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("detects deepreview by finding ID HTML comment in body", () => {
|
|
252
|
+
const nodes = [
|
|
253
|
+
{
|
|
254
|
+
id: "t1",
|
|
255
|
+
path: "src/foo.ts",
|
|
256
|
+
startLine: null,
|
|
257
|
+
line: 10,
|
|
258
|
+
isResolved: false,
|
|
259
|
+
isOutdated: false,
|
|
260
|
+
comments: {
|
|
261
|
+
pageInfo: { hasNextPage: false, endCursor: null },
|
|
262
|
+
nodes: [
|
|
263
|
+
{
|
|
264
|
+
author: { login: "reviewer-human", __typename: "User" },
|
|
265
|
+
body: "Missing error handling.\n<!-- finding:abc123 -->",
|
|
266
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
267
|
+
},
|
|
268
|
+
],
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
];
|
|
272
|
+
const result = mapGraphQLThreads(nodes);
|
|
273
|
+
assert.equal(result[0].comments[0].authorType, "deepreview");
|
|
274
|
+
assert.equal(result[0].comments[0].authorLogin, "reviewer-human");
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
describe("buildPriorReviewContent: basic behavior", () => {
|
|
279
|
+
it("keeps all content when under budget", () => {
|
|
280
|
+
const threads: ReviewThread[] = [
|
|
281
|
+
{
|
|
282
|
+
path: "a.ts",
|
|
283
|
+
startLine: null,
|
|
284
|
+
line: 1,
|
|
285
|
+
isResolved: false,
|
|
286
|
+
isOutdated: false,
|
|
287
|
+
comments: [
|
|
288
|
+
{
|
|
289
|
+
authorLogin: "a",
|
|
290
|
+
authorType: "human",
|
|
291
|
+
body: "small",
|
|
292
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
293
|
+
},
|
|
294
|
+
],
|
|
295
|
+
},
|
|
296
|
+
];
|
|
297
|
+
const result = buildPriorReviewContent("PR body", threads, "manual");
|
|
298
|
+
assert.ok(result.includes("PR body"));
|
|
299
|
+
assert.ok(result.includes("small"));
|
|
300
|
+
assert.ok(result.includes("manual"));
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("returns empty string when all inputs empty", () => {
|
|
304
|
+
const result = buildPriorReviewContent("", [], null);
|
|
305
|
+
assert.equal(result, "");
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
describe("buildPriorReview (integration shape)", () => {
|
|
310
|
+
it("exports buildPriorReview as a function", () => {
|
|
311
|
+
assert.equal(typeof buildPriorReview, "function");
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// oxlint-disable-next-line max-lines-per-function -- Why: two test cases require large inline thread fixtures (20K body strings) to exercise byte-budget truncation; extracting fixtures would obscure the budget arithmetic being tested
|
|
316
|
+
describe("buildPriorReviewContent: truncation", () => {
|
|
317
|
+
it("drops oldest threads first when over budget", () => {
|
|
318
|
+
const oldThread: ReviewThread = {
|
|
319
|
+
path: "old.ts",
|
|
320
|
+
startLine: null,
|
|
321
|
+
line: 1,
|
|
322
|
+
isResolved: false,
|
|
323
|
+
isOutdated: false,
|
|
324
|
+
comments: [
|
|
325
|
+
{
|
|
326
|
+
authorLogin: "a",
|
|
327
|
+
authorType: "human",
|
|
328
|
+
body: "x".repeat(30_000),
|
|
329
|
+
createdAt: "2020-01-01T00:00:00Z",
|
|
330
|
+
},
|
|
331
|
+
],
|
|
332
|
+
};
|
|
333
|
+
const newThread: ReviewThread = {
|
|
334
|
+
path: "new.ts",
|
|
335
|
+
startLine: null,
|
|
336
|
+
line: 1,
|
|
337
|
+
isResolved: false,
|
|
338
|
+
isOutdated: false,
|
|
339
|
+
comments: [
|
|
340
|
+
{
|
|
341
|
+
authorLogin: "b",
|
|
342
|
+
authorType: "human",
|
|
343
|
+
body: "y".repeat(30_000),
|
|
344
|
+
createdAt: "2026-06-01T00:00:00Z",
|
|
345
|
+
},
|
|
346
|
+
],
|
|
347
|
+
};
|
|
348
|
+
const result = buildPriorReviewContent("PR body", [oldThread, newThread], null);
|
|
349
|
+
const bytes = Buffer.byteLength(result, "utf8");
|
|
350
|
+
assert.ok(bytes <= 50 * 1024, `Expected <= 50KB, got ${bytes}`);
|
|
351
|
+
assert.ok(result.includes("new.ts"));
|
|
352
|
+
assert.ok(!result.includes("old.ts"), "old thread should have been dropped");
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("preserves PR description and manual content even when threads are large", () => {
|
|
356
|
+
const bigThread: ReviewThread = {
|
|
357
|
+
path: "big.ts",
|
|
358
|
+
startLine: null,
|
|
359
|
+
line: 1,
|
|
360
|
+
isResolved: false,
|
|
361
|
+
isOutdated: false,
|
|
362
|
+
comments: [
|
|
363
|
+
{
|
|
364
|
+
authorLogin: "a",
|
|
365
|
+
authorType: "human",
|
|
366
|
+
body: "z".repeat(45_000),
|
|
367
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
368
|
+
},
|
|
369
|
+
],
|
|
370
|
+
};
|
|
371
|
+
const result = buildPriorReviewContent(
|
|
372
|
+
"Important PR description",
|
|
373
|
+
[bigThread],
|
|
374
|
+
"Critical manual notes",
|
|
375
|
+
);
|
|
376
|
+
assert.ok(result.includes("Important PR description"));
|
|
377
|
+
assert.ok(result.includes("Critical manual notes"));
|
|
378
|
+
assert.ok(Buffer.byteLength(result, "utf8") <= 50 * 1024);
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
describe("formatPriorReview: formatCommentBody paths", () => {
|
|
383
|
+
it("renders multi-line body as indented blockquote", () => {
|
|
384
|
+
const threads: ReviewThread[] = [
|
|
385
|
+
{
|
|
386
|
+
path: "src/multi.ts",
|
|
387
|
+
startLine: null,
|
|
388
|
+
line: 1,
|
|
389
|
+
isResolved: false,
|
|
390
|
+
isOutdated: false,
|
|
391
|
+
comments: [
|
|
392
|
+
{
|
|
393
|
+
authorLogin: "reviewer",
|
|
394
|
+
authorType: "human",
|
|
395
|
+
body: "line one\nline two\nline three",
|
|
396
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
397
|
+
},
|
|
398
|
+
],
|
|
399
|
+
},
|
|
400
|
+
];
|
|
401
|
+
const result = formatPriorReview("", threads, null);
|
|
402
|
+
assert.ok(result.includes(" > line one"));
|
|
403
|
+
assert.ok(result.includes(" > line two"));
|
|
404
|
+
assert.ok(result.includes(" > line three"));
|
|
405
|
+
// Multi-line bodies should NOT be wrapped in quotes
|
|
406
|
+
assert.ok(!result.includes('"line one'));
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("renders single-line body in quotes", () => {
|
|
410
|
+
const threads: ReviewThread[] = [
|
|
411
|
+
{
|
|
412
|
+
path: "src/single.ts",
|
|
413
|
+
startLine: null,
|
|
414
|
+
line: 1,
|
|
415
|
+
isResolved: false,
|
|
416
|
+
isOutdated: false,
|
|
417
|
+
comments: [
|
|
418
|
+
{
|
|
419
|
+
authorLogin: "reviewer",
|
|
420
|
+
authorType: "human",
|
|
421
|
+
body: "a single line comment",
|
|
422
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
423
|
+
},
|
|
424
|
+
],
|
|
425
|
+
},
|
|
426
|
+
];
|
|
427
|
+
const result = formatPriorReview("", threads, null);
|
|
428
|
+
assert.ok(result.includes('"a single line comment"'));
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
describe("formatPriorReview: formatLineRef paths", () => {
|
|
433
|
+
it("renders startLine only when line is null", () => {
|
|
434
|
+
const threads: ReviewThread[] = [
|
|
435
|
+
{
|
|
436
|
+
path: "src/start-only.ts",
|
|
437
|
+
startLine: 42,
|
|
438
|
+
line: null,
|
|
439
|
+
isResolved: false,
|
|
440
|
+
isOutdated: false,
|
|
441
|
+
comments: [
|
|
442
|
+
{
|
|
443
|
+
authorLogin: "a",
|
|
444
|
+
authorType: "human",
|
|
445
|
+
body: "note",
|
|
446
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
447
|
+
},
|
|
448
|
+
],
|
|
449
|
+
},
|
|
450
|
+
];
|
|
451
|
+
const result = formatPriorReview("", threads, null);
|
|
452
|
+
assert.ok(result.includes("**L42**"));
|
|
453
|
+
// Should NOT include a range
|
|
454
|
+
assert.ok(!result.includes("**L42-"));
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it("renders file-level when both startLine and line are null", () => {
|
|
458
|
+
const threads: ReviewThread[] = [
|
|
459
|
+
{
|
|
460
|
+
path: "src/file-level.ts",
|
|
461
|
+
startLine: null,
|
|
462
|
+
line: null,
|
|
463
|
+
isResolved: false,
|
|
464
|
+
isOutdated: false,
|
|
465
|
+
comments: [
|
|
466
|
+
{
|
|
467
|
+
authorLogin: "a",
|
|
468
|
+
authorType: "human",
|
|
469
|
+
body: "general comment",
|
|
470
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
471
|
+
},
|
|
472
|
+
],
|
|
473
|
+
},
|
|
474
|
+
];
|
|
475
|
+
const result = formatPriorReview("", threads, null);
|
|
476
|
+
assert.ok(result.includes("**file-level**"));
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
describe("formatPriorReview: formatSourceTag paths", () => {
|
|
481
|
+
it("renders deepreview author type in source tag", () => {
|
|
482
|
+
const threads: ReviewThread[] = [
|
|
483
|
+
{
|
|
484
|
+
path: "src/dr.ts",
|
|
485
|
+
startLine: null,
|
|
486
|
+
line: 5,
|
|
487
|
+
isResolved: false,
|
|
488
|
+
isOutdated: false,
|
|
489
|
+
comments: [
|
|
490
|
+
{
|
|
491
|
+
authorLogin: "review-bot",
|
|
492
|
+
authorType: "deepreview",
|
|
493
|
+
body: "finding",
|
|
494
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
495
|
+
},
|
|
496
|
+
],
|
|
497
|
+
},
|
|
498
|
+
];
|
|
499
|
+
const result = formatPriorReview("", threads, null);
|
|
500
|
+
assert.ok(result.includes("[source: @review-bot, deepreview]"));
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it("renders both resolved and outdated simultaneously", () => {
|
|
504
|
+
const threads: ReviewThread[] = [
|
|
505
|
+
{
|
|
506
|
+
path: "src/both.ts",
|
|
507
|
+
startLine: null,
|
|
508
|
+
line: 10,
|
|
509
|
+
isResolved: true,
|
|
510
|
+
isOutdated: true,
|
|
511
|
+
comments: [
|
|
512
|
+
{
|
|
513
|
+
authorLogin: "user",
|
|
514
|
+
authorType: "human",
|
|
515
|
+
body: "old resolved",
|
|
516
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
517
|
+
},
|
|
518
|
+
],
|
|
519
|
+
},
|
|
520
|
+
];
|
|
521
|
+
const result = formatPriorReview("", threads, null);
|
|
522
|
+
assert.ok(result.includes("[source: @user, human, resolved, outdated]"));
|
|
523
|
+
});
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
describe("formatPriorReview: formatThread empty comments", () => {
|
|
527
|
+
it("returns empty output for thread with no comments", () => {
|
|
528
|
+
const threads: ReviewThread[] = [
|
|
529
|
+
{
|
|
530
|
+
path: "src/empty.ts",
|
|
531
|
+
startLine: null,
|
|
532
|
+
line: 1,
|
|
533
|
+
isResolved: false,
|
|
534
|
+
isOutdated: false,
|
|
535
|
+
comments: [],
|
|
536
|
+
},
|
|
537
|
+
];
|
|
538
|
+
const result = formatPriorReview("", threads, null);
|
|
539
|
+
// Empty thread produces no output; no file heading should appear
|
|
540
|
+
assert.ok(!result.includes("### src/empty.ts"));
|
|
541
|
+
});
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
describe("truncateToFit: edge cases", () => {
|
|
545
|
+
it("handles multi-byte UTF-8 content within byte limit", () => {
|
|
546
|
+
// Each emoji (😀) is 4 bytes in UTF-8; 15000 repeats ≈ 60KB
|
|
547
|
+
const emoji = "\u{1F600}";
|
|
548
|
+
const content = emoji.repeat(15_000);
|
|
549
|
+
const result = truncateToFit(content, 50_000);
|
|
550
|
+
const resultBytes = Buffer.byteLength(result, "utf8");
|
|
551
|
+
assert.ok(resultBytes <= 50_000, `Expected <= 50000 bytes, got ${resultBytes}`);
|
|
552
|
+
assert.ok(result.includes("[truncated"));
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it("handles CJK characters within byte limit", () => {
|
|
556
|
+
// CJK characters are 3 bytes in UTF-8; 20000 repeats ≈ 60KB
|
|
557
|
+
const content = "\u4e00".repeat(20_000);
|
|
558
|
+
const result = truncateToFit(content, 50_000);
|
|
559
|
+
const resultBytes = Buffer.byteLength(result, "utf8");
|
|
560
|
+
assert.ok(resultBytes <= 50_000, `Expected <= 50000 bytes, got ${resultBytes}`);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it("returns content unchanged at exact byte boundary", () => {
|
|
564
|
+
// Build content whose byte length is exactly maxBytes
|
|
565
|
+
const maxBytes = 1000;
|
|
566
|
+
// ASCII: 1 byte per char
|
|
567
|
+
const content = "a".repeat(maxBytes);
|
|
568
|
+
assert.equal(Buffer.byteLength(content, "utf8"), maxBytes);
|
|
569
|
+
const result = truncateToFit(content, maxBytes);
|
|
570
|
+
assert.equal(result, content);
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
it("includes truncation suffix when truncated", () => {
|
|
574
|
+
const content = "x".repeat(60_000);
|
|
575
|
+
const result = truncateToFit(content, 50_000);
|
|
576
|
+
assert.ok(result.endsWith("[truncated — content exceeds size limit]"));
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
// oxlint-disable-next-line max-lines-per-function -- Why: edge case tests require large inline fixtures to exercise byte-budget boundaries
|
|
581
|
+
describe("buildPriorReviewContent: edge cases", () => {
|
|
582
|
+
it("returns only fixed sections when all threads are too large for budget", () => {
|
|
583
|
+
// Use a large PR body so the remaining budget is too small for any thread
|
|
584
|
+
const largePrBody = "P".repeat(20_000);
|
|
585
|
+
const hugeThreadBody = "z".repeat(40_000);
|
|
586
|
+
const threads: ReviewThread[] = [
|
|
587
|
+
{
|
|
588
|
+
path: "big1.ts",
|
|
589
|
+
startLine: null,
|
|
590
|
+
line: 1,
|
|
591
|
+
isResolved: false,
|
|
592
|
+
isOutdated: false,
|
|
593
|
+
comments: [
|
|
594
|
+
{
|
|
595
|
+
authorLogin: "a",
|
|
596
|
+
authorType: "human",
|
|
597
|
+
body: hugeThreadBody,
|
|
598
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
599
|
+
},
|
|
600
|
+
],
|
|
601
|
+
},
|
|
602
|
+
{
|
|
603
|
+
path: "big2.ts",
|
|
604
|
+
startLine: null,
|
|
605
|
+
line: 1,
|
|
606
|
+
isResolved: false,
|
|
607
|
+
isOutdated: false,
|
|
608
|
+
comments: [
|
|
609
|
+
{
|
|
610
|
+
authorLogin: "b",
|
|
611
|
+
authorType: "human",
|
|
612
|
+
body: hugeThreadBody,
|
|
613
|
+
createdAt: "2026-02-01T00:00:00Z",
|
|
614
|
+
},
|
|
615
|
+
],
|
|
616
|
+
},
|
|
617
|
+
];
|
|
618
|
+
const result = buildPriorReviewContent(largePrBody, threads, null);
|
|
619
|
+
assert.ok(result.includes(largePrBody.slice(0, 100)));
|
|
620
|
+
assert.ok(!result.includes("## Prior Review Comments"));
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
it("counts file header only once for multiple threads sharing same path", () => {
|
|
624
|
+
const threads: ReviewThread[] = [
|
|
625
|
+
makeThread("shared/file.ts", 1, [{ login: "a", body: "comment 1" }]),
|
|
626
|
+
makeThread("shared/file.ts", 5, [{ login: "b", body: "comment 2" }]),
|
|
627
|
+
makeThread("shared/file.ts", 10, [{ login: "c", body: "comment 3" }]),
|
|
628
|
+
];
|
|
629
|
+
const result = buildPriorReviewContent("", threads, null);
|
|
630
|
+
// All threads should be present since they share a file header
|
|
631
|
+
assert.ok(result.includes("comment 1"));
|
|
632
|
+
assert.ok(result.includes("comment 2"));
|
|
633
|
+
assert.ok(result.includes("comment 3"));
|
|
634
|
+
// File header appears exactly once in the output
|
|
635
|
+
const headerCount = result.split("### shared/file.ts").length - 1;
|
|
636
|
+
assert.equal(headerCount, 1);
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
it("treats whitespace-only manualContent as empty", () => {
|
|
640
|
+
const result = buildPriorReviewContent("body", [], " \n\t \n ");
|
|
641
|
+
assert.ok(!result.includes("## Manual Prior Review"));
|
|
642
|
+
assert.ok(result.includes("## PR Description"));
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
it("treats whitespace-only prBody as empty", () => {
|
|
646
|
+
const result = buildPriorReviewContent(" \n \t ", [], null);
|
|
647
|
+
assert.equal(result, "");
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
it("orders sections: PR Description → Prior Review Comments → Manual Prior Review", () => {
|
|
651
|
+
const threads = [makeThread("src/x.ts", 1, [{ login: "r", body: "finding" }])];
|
|
652
|
+
const result = buildPriorReviewContent("description", threads, "manual notes");
|
|
653
|
+
const prDescIdx = result.indexOf("## PR Description");
|
|
654
|
+
const priorCommentsIdx = result.indexOf("## Prior Review Comments");
|
|
655
|
+
const manualIdx = result.indexOf("## Manual Prior Review");
|
|
656
|
+
assert.ok(prDescIdx >= 0, "PR Description section missing");
|
|
657
|
+
assert.ok(priorCommentsIdx >= 0, "Prior Review Comments section missing");
|
|
658
|
+
assert.ok(manualIdx >= 0, "Manual Prior Review section missing");
|
|
659
|
+
assert.ok(
|
|
660
|
+
prDescIdx < priorCommentsIdx,
|
|
661
|
+
"PR Description should come before Prior Review Comments",
|
|
662
|
+
);
|
|
663
|
+
assert.ok(
|
|
664
|
+
priorCommentsIdx < manualIdx,
|
|
665
|
+
"Prior Review Comments should come before Manual Prior Review",
|
|
666
|
+
);
|
|
667
|
+
});
|
|
668
|
+
});
|