@mechanai/deepreview 2.6.2 → 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.
@@ -0,0 +1,234 @@
1
+ import { graphql } from "./graphql.ts";
2
+ import type { ReviewThread } from "./build-prior-review.ts";
3
+
4
+ // --- GraphQL types ---
5
+
6
+ interface GQLPageInfo {
7
+ hasNextPage: boolean;
8
+ endCursor: string | null;
9
+ }
10
+
11
+ interface GQLCommentAuthor {
12
+ login: string;
13
+ __typename: string;
14
+ }
15
+
16
+ interface GQLComment {
17
+ author: GQLCommentAuthor | null;
18
+ body: string;
19
+ createdAt: string;
20
+ }
21
+
22
+ interface GQLCommentsConnection {
23
+ pageInfo: GQLPageInfo;
24
+ nodes: GQLComment[];
25
+ }
26
+
27
+ interface GQLThreadNode {
28
+ id: string;
29
+ path: string;
30
+ startLine: number | null;
31
+ line: number | null;
32
+ isResolved: boolean;
33
+ isOutdated: boolean;
34
+ comments: GQLCommentsConnection;
35
+ }
36
+
37
+ interface GQLReviewThreadsConnection {
38
+ pageInfo: GQLPageInfo;
39
+ nodes: GQLThreadNode[];
40
+ }
41
+
42
+ interface GQLPullRequest {
43
+ body: string;
44
+ reviewThreads: GQLReviewThreadsConnection;
45
+ }
46
+
47
+ interface GQLFetchResponse {
48
+ repository: {
49
+ pullRequest: GQLPullRequest | null;
50
+ };
51
+ }
52
+
53
+ interface GQLThreadCommentsResponse {
54
+ node: {
55
+ comments: GQLCommentsConnection;
56
+ };
57
+ }
58
+
59
+ // --- Constants ---
60
+
61
+ const MAX_THREAD_PAGES = 20;
62
+ const MAX_COMMENT_PAGES = 10;
63
+ const AGGREGATE_TIMEOUT_MS = 5 * 60 * 1000;
64
+
65
+ // --- Mapping ---
66
+
67
+ const FINDING_ID_RE = /<!-- finding:[a-f0-9]+ -->/u;
68
+
69
+ function classifyAuthorType(
70
+ author: GQLCommentAuthor | null,
71
+ body: string,
72
+ ): "human" | "bot" | "deepreview" {
73
+ // deepreview embeds finding IDs as HTML comments; detect regardless of posting account
74
+ if (FINDING_ID_RE.test(body)) return "deepreview";
75
+ // ghost/deleted users treated as bot
76
+ if (!author) return "bot";
77
+ if (author.__typename === "Bot" || author.__typename === "Mannequin") return "bot";
78
+ if (author.login.endsWith("[bot]")) return "bot";
79
+ return "human";
80
+ }
81
+
82
+ /** Map raw GraphQL thread nodes to domain ReviewThread objects with author classification. */
83
+ export function mapGraphQLThreads(nodes: GQLThreadNode[]): ReviewThread[] {
84
+ return nodes.map((node) => ({
85
+ path: node.path,
86
+ startLine: node.startLine,
87
+ line: node.line,
88
+ isResolved: node.isResolved,
89
+ isOutdated: node.isOutdated,
90
+ comments: node.comments.nodes.map((c) => ({
91
+ authorLogin: c.author?.login ?? "ghost",
92
+ authorType: classifyAuthorType(c.author, c.body),
93
+ body: c.body,
94
+ createdAt: c.createdAt,
95
+ })),
96
+ }));
97
+ }
98
+
99
+ // --- Fetching ---
100
+
101
+ const REVIEW_THREADS_QUERY = `
102
+ query ($owner: String!, $name: String!, $number: Int!, $after: String) {
103
+ repository(owner: $owner, name: $name) {
104
+ pullRequest(number: $number) {
105
+ body
106
+ reviewThreads(first: 50, after: $after) {
107
+ pageInfo { hasNextPage endCursor }
108
+ nodes {
109
+ id
110
+ path
111
+ startLine
112
+ line
113
+ isResolved
114
+ isOutdated
115
+ comments(first: 100) {
116
+ pageInfo { hasNextPage endCursor }
117
+ nodes {
118
+ author { login __typename }
119
+ body
120
+ createdAt
121
+ }
122
+ }
123
+ }
124
+ }
125
+ }
126
+ }
127
+ }
128
+ `;
129
+
130
+ const THREAD_COMMENTS_QUERY = `
131
+ query ($threadId: ID!, $after: String!) {
132
+ node(id: $threadId) {
133
+ ... on PullRequestReviewThread {
134
+ comments(first: 100, after: $after) {
135
+ pageInfo { hasNextPage endCursor }
136
+ nodes {
137
+ author { login __typename }
138
+ body
139
+ createdAt
140
+ }
141
+ }
142
+ }
143
+ }
144
+ }
145
+ `;
146
+
147
+ async function paginateThreads(
148
+ owner: string,
149
+ name: string,
150
+ prNumber: number,
151
+ deadline: number,
152
+ ): Promise<{ prBody: string; nodes: GQLThreadNode[] }> {
153
+ const nodes: GQLThreadNode[] = [];
154
+ let prBody = "";
155
+ let after: string | null = null;
156
+ let pages = 0;
157
+
158
+ while (pages < MAX_THREAD_PAGES) {
159
+ if (Date.now() > deadline) {
160
+ console.warn(
161
+ "WARN: Aggregate timeout reached fetching review threads. Returning partial results.",
162
+ );
163
+ break;
164
+ }
165
+ pages++;
166
+
167
+ const data: GQLFetchResponse = await graphql<GQLFetchResponse>(REVIEW_THREADS_QUERY, {
168
+ owner,
169
+ name,
170
+ number: prNumber,
171
+ after,
172
+ });
173
+
174
+ const pr: GQLPullRequest | null = data.repository.pullRequest;
175
+ if (!pr) throw new Error(`PR #${prNumber} not found in ${owner}/${name}`);
176
+
177
+ if (pages === 1) prBody = pr.body;
178
+
179
+ nodes.push(...pr.reviewThreads.nodes);
180
+
181
+ if (!pr.reviewThreads.pageInfo.hasNextPage) break;
182
+ after = pr.reviewThreads.pageInfo.endCursor;
183
+ }
184
+
185
+ if (pages >= MAX_THREAD_PAGES) {
186
+ console.warn(
187
+ `WARN: Reached MAX_THREAD_PAGES (${MAX_THREAD_PAGES}) — thread data may be incomplete.`,
188
+ );
189
+ }
190
+
191
+ return { prBody, nodes };
192
+ }
193
+
194
+ async function paginateThreadComments(nodes: GQLThreadNode[], deadline: number): Promise<void> {
195
+ for (const thread of nodes) {
196
+ if (!thread.comments.pageInfo.hasNextPage) continue;
197
+ if (Date.now() > deadline) {
198
+ console.warn(
199
+ "WARN: Aggregate timeout reached fetching thread comments. Returning partial results.",
200
+ );
201
+ break;
202
+ }
203
+
204
+ let commentAfter: string | null = thread.comments.pageInfo.endCursor;
205
+ let commentPages = 0;
206
+ while (commentAfter !== null && commentPages < MAX_COMMENT_PAGES) {
207
+ if (Date.now() > deadline) break;
208
+ commentPages++;
209
+
210
+ const data = await graphql<GQLThreadCommentsResponse>(THREAD_COMMENTS_QUERY, {
211
+ threadId: thread.id,
212
+ after: commentAfter,
213
+ });
214
+
215
+ thread.comments.nodes.push(...data.node.comments.nodes);
216
+ if (!data.node.comments.pageInfo.hasNextPage) break;
217
+ commentAfter = data.node.comments.pageInfo.endCursor;
218
+ }
219
+ }
220
+ }
221
+
222
+ /** Fetch PR body and all review threads via paginated GraphQL queries. */
223
+ export async function fetchPrReviewThreads(
224
+ owner: string,
225
+ name: string,
226
+ prNumber: number,
227
+ ): Promise<{ prBody: string; threads: ReviewThread[] }> {
228
+ const deadline = Date.now() + AGGREGATE_TIMEOUT_MS;
229
+
230
+ const { prBody, nodes } = await paginateThreads(owner, name, prNumber, deadline);
231
+ await paginateThreadComments(nodes, deadline);
232
+
233
+ return { prBody, threads: mapGraphQLThreads(nodes) };
234
+ }