@mechanai/deepreview 2.7.0 → 2.9.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-validator.md +8 -2
- 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,246 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { getPrInfo } from "./graphql.ts";
|
|
4
|
+
|
|
5
|
+
import { fetchPrReviewThreads, mapGraphQLThreads } from "./build-prior-review-fetch.ts";
|
|
6
|
+
export { fetchPrReviewThreads, mapGraphQLThreads };
|
|
7
|
+
|
|
8
|
+
/** A single comment within a review thread. */
|
|
9
|
+
export interface ThreadComment {
|
|
10
|
+
authorLogin: string;
|
|
11
|
+
authorType: "human" | "bot" | "deepreview";
|
|
12
|
+
body: string;
|
|
13
|
+
createdAt: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A review thread attached to a file path and line range. */
|
|
17
|
+
export interface ReviewThread {
|
|
18
|
+
path: string;
|
|
19
|
+
startLine: number | null;
|
|
20
|
+
line: number | null;
|
|
21
|
+
isResolved: boolean;
|
|
22
|
+
isOutdated: boolean;
|
|
23
|
+
comments: ThreadComment[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const MAX_BYTES = 50 * 1024;
|
|
27
|
+
|
|
28
|
+
function formatLineRef(startLine: number | null, line: number | null): string {
|
|
29
|
+
if (startLine !== null && line !== null && startLine !== line) {
|
|
30
|
+
return `**L${startLine}-${line}**`;
|
|
31
|
+
}
|
|
32
|
+
if (line !== null) return `**L${line}**`;
|
|
33
|
+
if (startLine !== null) return `**L${startLine}**`;
|
|
34
|
+
return "**file-level**";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function formatSourceTag(comment: ThreadComment, thread: ReviewThread): string {
|
|
38
|
+
const parts = [`@${comment.authorLogin}`, comment.authorType];
|
|
39
|
+
if (thread.isResolved) parts.push("resolved");
|
|
40
|
+
if (thread.isOutdated) parts.push("outdated");
|
|
41
|
+
return `[source: ${parts.join(", ")}]`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function formatCommentBody(body: string, indent: string): string {
|
|
45
|
+
if (!body.includes("\n")) return `"${body}"`;
|
|
46
|
+
const lines = body.split("\n").map((line) => `${indent}> ${line}`);
|
|
47
|
+
return "\n" + lines.join("\n");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function formatThread(thread: ReviewThread): string {
|
|
51
|
+
if (thread.comments.length === 0) return "";
|
|
52
|
+
const first = thread.comments[0];
|
|
53
|
+
const replies = thread.comments.slice(1);
|
|
54
|
+
const lines: string[] = [];
|
|
55
|
+
const lineRef = formatLineRef(thread.startLine, thread.line);
|
|
56
|
+
const tag = formatSourceTag(first, thread);
|
|
57
|
+
lines.push(`- ${lineRef} ${tag}: ${formatCommentBody(first.body, " ")}`);
|
|
58
|
+
for (const reply of replies) {
|
|
59
|
+
lines.push(` - @${reply.authorLogin} replied: ${formatCommentBody(reply.body, " ")}`);
|
|
60
|
+
}
|
|
61
|
+
return lines.join("\n");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function formatFixedSections(prBody: string, manualContent: string | null): string[] {
|
|
65
|
+
const sections: string[] = [];
|
|
66
|
+
if (prBody.trim()) {
|
|
67
|
+
sections.push(`## PR Description\n\n${prBody.trim()}`);
|
|
68
|
+
}
|
|
69
|
+
if (manualContent !== null && manualContent.trim() !== "") {
|
|
70
|
+
sections.push(`## Manual Prior Review\n\n${manualContent.trim()}`);
|
|
71
|
+
}
|
|
72
|
+
return sections;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Format threads and PR context into a Markdown document grouped by file path. */
|
|
76
|
+
export function formatPriorReview(
|
|
77
|
+
prBody: string,
|
|
78
|
+
threads: ReviewThread[],
|
|
79
|
+
manualContent: string | null,
|
|
80
|
+
): string {
|
|
81
|
+
const sections: string[] = formatFixedSections(prBody, manualContent);
|
|
82
|
+
|
|
83
|
+
if (threads.length > 0) {
|
|
84
|
+
const sorted = [...threads].sort((a, b) => {
|
|
85
|
+
const pathCmp = a.path.localeCompare(b.path);
|
|
86
|
+
if (pathCmp !== 0) return pathCmp;
|
|
87
|
+
return (a.line ?? a.startLine ?? 0) - (b.line ?? b.startLine ?? 0);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const byFile = new Map<string, ReviewThread[]>();
|
|
91
|
+
for (const t of sorted) {
|
|
92
|
+
const existing = byFile.get(t.path);
|
|
93
|
+
if (existing) existing.push(t);
|
|
94
|
+
else byFile.set(t.path, [t]);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const fileSections: string[] = [];
|
|
98
|
+
for (const [filePath, fileThreads] of byFile) {
|
|
99
|
+
const threadLines = fileThreads.map(formatThread).filter(Boolean);
|
|
100
|
+
if (threadLines.length > 0) {
|
|
101
|
+
fileSections.push(`### ${filePath}\n\n${threadLines.join("\n")}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (fileSections.length > 0) {
|
|
106
|
+
// Insert review comments before manual section
|
|
107
|
+
const insertIdx = sections.findIndex((s) => s.startsWith("## Manual Prior Review"));
|
|
108
|
+
if (insertIdx >= 0) {
|
|
109
|
+
sections.splice(insertIdx, 0, `## Prior Review Comments\n\n${fileSections.join("\n\n")}`);
|
|
110
|
+
} else {
|
|
111
|
+
sections.push(`## Prior Review Comments\n\n${fileSections.join("\n\n")}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return sections.join("\n\n");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Truncate content to fit within a byte-length budget using binary search. */
|
|
120
|
+
export function truncateToFit(content: string, maxBytes: number = MAX_BYTES): string {
|
|
121
|
+
const byteLength = Buffer.byteLength(content, "utf8");
|
|
122
|
+
if (byteLength <= maxBytes) return content;
|
|
123
|
+
|
|
124
|
+
// Binary search for the right character count
|
|
125
|
+
let lo = 0;
|
|
126
|
+
let hi = content.length;
|
|
127
|
+
const suffix = "\n\n[truncated — content exceeds size limit]";
|
|
128
|
+
const suffixBytes = Buffer.byteLength(suffix, "utf8");
|
|
129
|
+
const target = maxBytes - suffixBytes;
|
|
130
|
+
|
|
131
|
+
while (lo < hi) {
|
|
132
|
+
const mid = (lo + hi + 1) >>> 1;
|
|
133
|
+
if (Buffer.byteLength(content.slice(0, mid), "utf8") <= target) {
|
|
134
|
+
lo = mid;
|
|
135
|
+
} else {
|
|
136
|
+
hi = mid - 1;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return content.slice(0, lo) + suffix;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function newestCommentTimestamp(thread: ReviewThread): string {
|
|
144
|
+
if (thread.comments.length === 0) return "1970-01-01T00:00:00Z";
|
|
145
|
+
return thread.comments[thread.comments.length - 1].createdAt;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Assemble a prior-review document with priority-aware truncation.
|
|
150
|
+
* PR description and manual content are always kept; oldest threads are dropped first.
|
|
151
|
+
*/
|
|
152
|
+
export function buildPriorReviewContent(
|
|
153
|
+
prBody: string,
|
|
154
|
+
threads: ReviewThread[],
|
|
155
|
+
manualContent: string | null,
|
|
156
|
+
): string {
|
|
157
|
+
if (
|
|
158
|
+
!prBody.trim() &&
|
|
159
|
+
threads.length === 0 &&
|
|
160
|
+
(manualContent === null || manualContent.trim() === "")
|
|
161
|
+
) {
|
|
162
|
+
return "";
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const fixedSections = formatFixedSections(prBody, manualContent);
|
|
166
|
+
const fixedContent = fixedSections.join("\n\n");
|
|
167
|
+
|
|
168
|
+
if (threads.length === 0) {
|
|
169
|
+
return truncateToFit(fixedContent, MAX_BYTES);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Newest threads are retained first; oldest dropped when over budget
|
|
173
|
+
const sortedByRecency = [...threads].sort((a, b) =>
|
|
174
|
+
newestCommentTimestamp(b).localeCompare(newestCommentTimestamp(a)),
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
// Estimate bytes per thread (formatThread + file header overhead).
|
|
178
|
+
// truncateToFit is the safety net if our estimate is slightly off.
|
|
179
|
+
const fixedBytes = Buffer.byteLength(fixedContent, "utf8");
|
|
180
|
+
const separator = "\n\n";
|
|
181
|
+
const separatorBytes = Buffer.byteLength(separator, "utf8");
|
|
182
|
+
const sectionHeaderBytes = Buffer.byteLength("## Prior Review Comments\n\n", "utf8");
|
|
183
|
+
let budgetRemaining =
|
|
184
|
+
MAX_BYTES - fixedBytes - (fixedContent ? separatorBytes : 0) - sectionHeaderBytes;
|
|
185
|
+
|
|
186
|
+
const keptThreads: ReviewThread[] = [];
|
|
187
|
+
const seenPaths = new Set<string>();
|
|
188
|
+
for (const thread of sortedByRecency) {
|
|
189
|
+
const threadText = formatThread(thread);
|
|
190
|
+
const fileHeaderBytes = seenPaths.has(thread.path)
|
|
191
|
+
? 0
|
|
192
|
+
: Buffer.byteLength(`### ${thread.path}\n\n`, "utf8");
|
|
193
|
+
const threadBytes = Buffer.byteLength(threadText, "utf8") + fileHeaderBytes + separatorBytes;
|
|
194
|
+
if (threadBytes <= budgetRemaining) {
|
|
195
|
+
keptThreads.push(thread);
|
|
196
|
+
seenPaths.add(thread.path);
|
|
197
|
+
budgetRemaining -= threadBytes;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const result = formatPriorReview(prBody, keptThreads, manualContent);
|
|
202
|
+
return truncateToFit(result, MAX_BYTES);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export interface BuildPriorReviewOptions {
|
|
206
|
+
/** PR number to fetch context from. */
|
|
207
|
+
prNumber: number;
|
|
208
|
+
/** Path to write the generated prior-review file. */
|
|
209
|
+
outputPath: string;
|
|
210
|
+
/** Path to a user-provided prior-review file to merge in. */
|
|
211
|
+
manualPriorReview?: string;
|
|
212
|
+
/** Working directory for path resolution and `gh` commands. */
|
|
213
|
+
cwd?: string;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Fetch PR context from GitHub, format it, and write the prior-review file. */
|
|
217
|
+
export async function buildPriorReview(opts: BuildPriorReviewOptions): Promise<string> {
|
|
218
|
+
const { prNumber, outputPath, manualPriorReview } = opts;
|
|
219
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
220
|
+
|
|
221
|
+
const prInfo = await getPrInfo(prNumber, { cwd });
|
|
222
|
+
const { prBody, threads } = await fetchPrReviewThreads(prInfo.owner, prInfo.name, prNumber);
|
|
223
|
+
|
|
224
|
+
let manualContent: string | null = null;
|
|
225
|
+
if (manualPriorReview !== undefined) {
|
|
226
|
+
manualContent = await readFile(resolve(cwd, manualPriorReview), "utf8");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const content = buildPriorReviewContent(prBody, threads, manualContent);
|
|
230
|
+
await writeFile(resolve(cwd, outputPath), content, "utf8");
|
|
231
|
+
|
|
232
|
+
if (content === "") {
|
|
233
|
+
return `No prior review content found. Written empty file to ${outputPath}.`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const bytes = Buffer.byteLength(content, "utf8");
|
|
237
|
+
const kb = (bytes / 1024).toFixed(1);
|
|
238
|
+
const uniqueAuthors = new Set(threads.flatMap((t) => t.comments.map((c) => c.authorLogin)));
|
|
239
|
+
const parts: string[] = [];
|
|
240
|
+
if (prBody.trim()) parts.push("PR description");
|
|
241
|
+
if (threads.length > 0)
|
|
242
|
+
parts.push(`${threads.length} threads from ${uniqueAuthors.size} reviewers`);
|
|
243
|
+
if (manualContent !== null) parts.push("manual prior review");
|
|
244
|
+
|
|
245
|
+
return `Built prior review: ${kb}KB (${parts.join(" + ")}). Written to ${outputPath}.`;
|
|
246
|
+
}
|