@henryqw/pi-pr 3.1.10 → 4.0.2

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,519 @@
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ runChecked,
4
+ requiredOid,
5
+ requiredText,
6
+ type Exec,
7
+ type ExecOptions,
8
+ } from "./pr-execution.ts";
9
+ import type { CurrentPullRequest } from "./pr-github.ts";
10
+
11
+ export const FEEDBACK_API_PAGE_MAX_BYTES = 512 * 1024;
12
+ export const FEEDBACK_MAX_PAGES = 100;
13
+ export const FEEDBACK_MAX_RECORDS = 1_000;
14
+ export const FEEDBACK_SNAPSHOT_MAX_BYTES = 1024 * 1024;
15
+
16
+ const PAGE_SIZE = 100;
17
+ const READ_ATTEMPTS = 3;
18
+ const FEEDBACK_QUERY = `
19
+ query Feedback($owner:String!,$repo:String!,$number:Int!,$commentsCursor:String,$reviewsCursor:String,$threadsCursor:String){
20
+ repository(owner:$owner,name:$repo){pullRequest(number:$number){
21
+ comments(first:100,after:$commentsCursor){pageInfo{hasNextPage endCursor} nodes{id url body createdAt author{login}}}
22
+ reviews(first:100,after:$reviewsCursor){pageInfo{hasNextPage endCursor} nodes{id url state body submittedAt author{login}}}
23
+ reviewThreads(first:100,after:$threadsCursor){pageInfo{hasNextPage endCursor} nodes{
24
+ id isResolved isOutdated path line diffSide startLine startDiffSide originalLine originalStartLine
25
+ comments(first:100){pageInfo{hasNextPage endCursor} nodes{id url body createdAt author{login}}}
26
+ }}
27
+ }}
28
+ }`;
29
+ const THREAD_REPLIES_QUERY = `
30
+ query ThreadReplies($threadId:ID!,$cursor:String){node(id:$threadId){... on PullRequestReviewThread{
31
+ comments(first:100,after:$cursor){pageInfo{hasNextPage endCursor} nodes{id url body createdAt author{login}}}
32
+ }}}`;
33
+ const RESOLVE_THREAD_MUTATION = `
34
+ mutation ResolveThread($threadId:ID!){resolveReviewThread(input:{threadId:$threadId}){thread{id isResolved}}}`;
35
+
36
+ export type FeedbackAuthor = { login: string } | null;
37
+ export type FeedbackComment = {
38
+ id: string;
39
+ url: string;
40
+ body: string;
41
+ createdAt: string;
42
+ author: FeedbackAuthor;
43
+ };
44
+ export type FeedbackReview = {
45
+ id: string;
46
+ url: string;
47
+ state: string;
48
+ body: string;
49
+ submittedAt: string | null;
50
+ author: FeedbackAuthor;
51
+ };
52
+ export type FeedbackThread = {
53
+ id: string;
54
+ isResolved: boolean;
55
+ isOutdated: boolean;
56
+ path: string | null;
57
+ line: number | null;
58
+ diffSide: string | null;
59
+ startLine: number | null;
60
+ startDiffSide: string | null;
61
+ originalLine: number | null;
62
+ originalStartLine: number | null;
63
+ comments: FeedbackComment[];
64
+ };
65
+ export type FeedbackAuthority = {
66
+ id: string;
67
+ number: number;
68
+ url: string;
69
+ host: string;
70
+ base: { repository: string; ref: string; oid: string };
71
+ head: { repository: string; ref: string; oid: string };
72
+ };
73
+ export type FeedbackSnapshot = {
74
+ pullRequest: FeedbackAuthority;
75
+ conversationComments: FeedbackComment[];
76
+ reviews: FeedbackReview[];
77
+ reviewThreads: FeedbackThread[];
78
+ };
79
+ export type FeedbackKind = "conversation_comment" | "review" | "thread" | "thread_comment";
80
+ export type FeedbackEntry = {
81
+ id: string;
82
+ kind: FeedbackKind;
83
+ node: FeedbackComment | FeedbackReview | FeedbackThread;
84
+ thread?: FeedbackThread;
85
+ };
86
+ export type FeedbackItem =
87
+ | (FeedbackComment & { kind: "conversation_comment" })
88
+ | (FeedbackReview & { kind: "review" })
89
+ | (Omit<FeedbackThread, "comments"> & { kind: "thread"; childIds: string[] })
90
+ | (FeedbackComment & {
91
+ kind: "thread_comment";
92
+ parentThread: { id: string; isResolved: boolean; isOutdated: boolean; path: string | null; line: number | null };
93
+ });
94
+ export type FeedbackClientOptions = {
95
+ exec: Exec;
96
+ cwd: string;
97
+ signal?: AbortSignal;
98
+ pause?: (milliseconds: number) => Promise<void>;
99
+ };
100
+
101
+ type Connection = {
102
+ nodes: unknown[];
103
+ hasNextPage: boolean;
104
+ endCursor: string | null;
105
+ };
106
+
107
+ function isRecord(value: unknown): value is Record<string, unknown> {
108
+ return value !== null && typeof value === "object" && !Array.isArray(value);
109
+ }
110
+
111
+ function plainText(value: unknown, label: string): string {
112
+ if (typeof value !== "string" || value.includes("\0")) throw new Error(`${label} must be a string without NUL bytes`);
113
+ return value;
114
+ }
115
+
116
+ function nullableText(value: unknown, label: string): string | null {
117
+ return value === null ? null : plainText(value, label);
118
+ }
119
+
120
+ function positiveInteger(value: unknown, label: string): number {
121
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
122
+ throw new Error(`${label} must be a positive safe integer`);
123
+ }
124
+ return value;
125
+ }
126
+
127
+ function nullableInteger(value: unknown, label: string): number | null {
128
+ if (value === null) return null;
129
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
130
+ throw new Error(`${label} must be a non-negative safe integer or null`);
131
+ }
132
+ return value;
133
+ }
134
+
135
+ function boolean(value: unknown, label: string): boolean {
136
+ if (typeof value !== "boolean") throw new Error(`${label} must be a boolean`);
137
+ return value;
138
+ }
139
+
140
+ function repository(value: unknown, label: string): string {
141
+ const parsed = requiredText(value, label);
142
+ const parts = parsed.split("/");
143
+ if (parts.length !== 2 || parts.some((part) => !part || /\s/.test(part))) throw new Error(`${label} is invalid`);
144
+ return parsed;
145
+ }
146
+
147
+ function httpsUrl(value: unknown, label: string, host?: string, allowSuffix = false): string {
148
+ const text = requiredText(value, label);
149
+ let parsed: URL;
150
+ try {
151
+ parsed = new URL(text);
152
+ } catch {
153
+ throw new Error(`${label} is invalid`);
154
+ }
155
+ if (
156
+ parsed.protocol !== "https:" || !parsed.hostname || parsed.username || parsed.password || parsed.port ||
157
+ (!allowSuffix && (parsed.search || parsed.hash))
158
+ ) throw new Error(`${label} is invalid`);
159
+ if (host !== undefined && parsed.hostname.toLowerCase() !== host.toLowerCase()) throw new Error(`${label} host changed`);
160
+ return parsed.href;
161
+ }
162
+
163
+ export function feedbackAuthorityFromCurrent(pullRequest: CurrentPullRequest): FeedbackAuthority {
164
+ if (pullRequest.lifecycle !== "open") throw new Error("Feedback requires an open pull request");
165
+ return parseAuthority({
166
+ id: pullRequest.id,
167
+ number: pullRequest.number,
168
+ url: pullRequest.url.href,
169
+ host: pullRequest.host,
170
+ base: pullRequest.base,
171
+ head: pullRequest.head,
172
+ });
173
+ }
174
+
175
+ function parseAuthority(value: unknown): FeedbackAuthority {
176
+ if (!isRecord(value) || !isRecord(value.base) || !isRecord(value.head)) throw new Error("feedback pull request is invalid");
177
+ const host = requiredText(value.host, "pull request host").toLowerCase();
178
+ const number = positiveInteger(value.number, "pull request number");
179
+ const baseRepository = repository(value.base.repository, "base repository");
180
+ const url = httpsUrl(value.url, "pull request URL", host);
181
+ const match = /^\/([^/]+)\/([^/]+)\/pull\/([1-9][0-9]*)$/.exec(new URL(url).pathname);
182
+ if (!match || Number(match[3]) !== number || `${match[1]}/${match[2]}`.toLowerCase() !== baseRepository.toLowerCase()) {
183
+ throw new Error("pull request URL does not match its number and base repository");
184
+ }
185
+ return {
186
+ id: requiredText(value.id, "pull request ID"),
187
+ number,
188
+ url,
189
+ host,
190
+ base: {
191
+ repository: baseRepository,
192
+ ref: requiredText(value.base.ref, "base ref"),
193
+ oid: requiredOid(value.base.oid, "base OID"),
194
+ },
195
+ head: {
196
+ repository: repository(value.head.repository, "head repository"),
197
+ ref: requiredText(value.head.ref, "head ref"),
198
+ oid: requiredOid(value.head.oid, "head OID"),
199
+ },
200
+ };
201
+ }
202
+
203
+ function parseAuthor(value: unknown, label: string): FeedbackAuthor {
204
+ if (value === null) return null;
205
+ if (!isRecord(value)) throw new Error(`${label} author is invalid`);
206
+ return { login: requiredText(value.login, `${label} author login`) };
207
+ }
208
+
209
+ function parseComment(value: unknown, label: string): FeedbackComment {
210
+ if (!isRecord(value)) throw new Error(`${label} is invalid`);
211
+ return {
212
+ id: requiredText(value.id, `${label} ID`),
213
+ url: httpsUrl(value.url, `${label} URL`, undefined, true),
214
+ body: plainText(value.body, `${label} body`),
215
+ createdAt: requiredText(value.createdAt, `${label} createdAt`),
216
+ author: parseAuthor(value.author, label),
217
+ };
218
+ }
219
+
220
+ function parseReview(value: unknown, label: string): FeedbackReview {
221
+ if (!isRecord(value)) throw new Error(`${label} is invalid`);
222
+ return {
223
+ id: requiredText(value.id, `${label} ID`),
224
+ url: httpsUrl(value.url, `${label} URL`, undefined, true),
225
+ state: requiredText(value.state, `${label} state`),
226
+ body: plainText(value.body, `${label} body`),
227
+ submittedAt: nullableText(value.submittedAt, `${label} submittedAt`),
228
+ author: parseAuthor(value.author, label),
229
+ };
230
+ }
231
+
232
+ function parseThread(value: unknown, label: string): FeedbackThread {
233
+ if (!isRecord(value) || !Array.isArray(value.comments)) throw new Error(`${label} is invalid`);
234
+ return {
235
+ id: requiredText(value.id, `${label} ID`),
236
+ isResolved: boolean(value.isResolved, `${label} isResolved`),
237
+ isOutdated: boolean(value.isOutdated, `${label} isOutdated`),
238
+ path: nullableText(value.path, `${label} path`),
239
+ line: nullableInteger(value.line, `${label} line`),
240
+ diffSide: nullableText(value.diffSide, `${label} diffSide`),
241
+ startLine: nullableInteger(value.startLine, `${label} startLine`),
242
+ startDiffSide: nullableText(value.startDiffSide, `${label} startDiffSide`),
243
+ originalLine: nullableInteger(value.originalLine, `${label} originalLine`),
244
+ originalStartLine: nullableInteger(value.originalStartLine, `${label} originalStartLine`),
245
+ comments: value.comments.map((comment, index) => parseComment(comment, `${label} comment ${index + 1}`)),
246
+ };
247
+ }
248
+
249
+ function encoded(value: unknown, label: string, maxBytes = FEEDBACK_SNAPSHOT_MAX_BYTES): string {
250
+ const json = JSON.stringify(value);
251
+ if (json === undefined) throw new Error(`${label} is not JSON-serializable`);
252
+ if (Buffer.byteLength(json, "utf8") > maxBytes) throw new Error(`${label} exceeds ${maxBytes} bytes`);
253
+ return json;
254
+ }
255
+
256
+ export function parseFeedbackSnapshot(value: unknown): FeedbackSnapshot {
257
+ if (!isRecord(value) || !Array.isArray(value.conversationComments) || !Array.isArray(value.reviews) || !Array.isArray(value.reviewThreads)) {
258
+ throw new Error("feedback snapshot is invalid");
259
+ }
260
+ const snapshot: FeedbackSnapshot = {
261
+ pullRequest: parseAuthority(value.pullRequest),
262
+ conversationComments: value.conversationComments.map((comment, index) => parseComment(comment, `conversation comment ${index + 1}`)),
263
+ reviews: value.reviews.map((review, index) => parseReview(review, `review ${index + 1}`)),
264
+ reviewThreads: value.reviewThreads.map((thread, index) => parseThread(thread, `review thread ${index + 1}`)),
265
+ };
266
+ feedbackEntries(snapshot);
267
+ encoded(snapshot, "feedback snapshot");
268
+ return snapshot;
269
+ }
270
+
271
+ export function parseFeedbackSnapshotText(raw: string): FeedbackSnapshot {
272
+ if (Buffer.byteLength(raw, "utf8") > FEEDBACK_SNAPSHOT_MAX_BYTES) {
273
+ throw new Error(`feedback snapshot exceeds ${FEEDBACK_SNAPSHOT_MAX_BYTES} bytes`);
274
+ }
275
+ let value: unknown;
276
+ try {
277
+ value = JSON.parse(raw);
278
+ } catch {
279
+ throw new Error("feedback snapshot contains invalid JSON");
280
+ }
281
+ return parseFeedbackSnapshot(value);
282
+ }
283
+
284
+ export function feedbackEntries(snapshotInput: FeedbackSnapshot): FeedbackEntry[] {
285
+ const snapshot = snapshotInput;
286
+ const entries: FeedbackEntry[] = [];
287
+ for (const node of snapshot.conversationComments) entries.push({ id: node.id, kind: "conversation_comment", node });
288
+ for (const node of snapshot.reviews) entries.push({ id: node.id, kind: "review", node });
289
+ for (const thread of snapshot.reviewThreads) {
290
+ entries.push({ id: thread.id, kind: "thread", node: thread });
291
+ for (const node of thread.comments) entries.push({ id: node.id, kind: "thread_comment", node, thread });
292
+ }
293
+ if (entries.length > FEEDBACK_MAX_RECORDS) throw new Error(`feedback exceeds ${FEEDBACK_MAX_RECORDS} records`);
294
+ const ids = new Set<string>();
295
+ for (const entry of entries) {
296
+ if (ids.has(entry.id)) throw new Error(`duplicate or ambiguous feedback ID: ${entry.id}`);
297
+ ids.add(entry.id);
298
+ }
299
+ return entries;
300
+ }
301
+
302
+ export function showFeedbackItem(snapshot: FeedbackSnapshot, idInput: string): FeedbackItem {
303
+ const id = requiredText(idInput, "feedback ID");
304
+ const entry = feedbackEntries(snapshot).find((candidate) => candidate.id === id);
305
+ if (!entry) throw new Error(`feedback ID not found: ${id}`);
306
+ let item: FeedbackItem;
307
+ if (entry.kind === "thread") {
308
+ const thread = entry.node as FeedbackThread;
309
+ const { comments, ...metadata } = thread;
310
+ item = { ...metadata, kind: "thread", childIds: comments.map((comment) => comment.id) };
311
+ } else if (entry.kind === "thread_comment") {
312
+ const comment = entry.node as FeedbackComment;
313
+ const thread = entry.thread!;
314
+ item = {
315
+ ...comment,
316
+ kind: "thread_comment",
317
+ parentThread: {
318
+ id: thread.id,
319
+ isResolved: thread.isResolved,
320
+ isOutdated: thread.isOutdated,
321
+ path: thread.path,
322
+ line: thread.line ?? thread.originalLine,
323
+ },
324
+ };
325
+ } else if (entry.kind === "review") {
326
+ item = { ...(entry.node as FeedbackReview), kind: "review" };
327
+ } else {
328
+ item = { ...(entry.node as FeedbackComment), kind: "conversation_comment" };
329
+ }
330
+ encoded(item, "feedback item", FEEDBACK_API_PAGE_MAX_BYTES);
331
+ return item;
332
+ }
333
+
334
+ export function feedbackFingerprint(snapshot: FeedbackSnapshot): string {
335
+ return createHash("sha256").update(encoded(snapshot, "feedback snapshot")).digest("hex");
336
+ }
337
+
338
+ export function feedbackContentFingerprint(snapshot: FeedbackSnapshot): string {
339
+ const content = {
340
+ ...snapshot,
341
+ reviewThreads: snapshot.reviewThreads.map((thread) => ({ ...thread, isResolved: false })),
342
+ };
343
+ return createHash("sha256").update(encoded(content, "feedback content")).digest("hex");
344
+ }
345
+
346
+ function connection(value: unknown, label: string): Connection {
347
+ if (!isRecord(value) || !Array.isArray(value.nodes) || value.nodes.length > PAGE_SIZE || !isRecord(value.pageInfo)) {
348
+ throw new Error(`invalid ${label} connection`);
349
+ }
350
+ const hasNextPage = boolean(value.pageInfo.hasNextPage, `${label} hasNextPage`);
351
+ const endCursor = value.pageInfo.endCursor;
352
+ if (hasNextPage && (typeof endCursor !== "string" || !endCursor)) throw new Error(`missing pagination cursor for ${label}`);
353
+ if (!hasNextPage && endCursor !== null && typeof endCursor !== "string") throw new Error(`invalid pagination cursor for ${label}`);
354
+ return { nodes: value.nodes, hasNextPage, endCursor: typeof endCursor === "string" ? endCursor : null };
355
+ }
356
+
357
+ function transient(error: unknown): boolean {
358
+ return /\b5\d\d\b|tls|ssl|x509|certificate|handshake/i.test(error instanceof Error ? error.message : String(error));
359
+ }
360
+
361
+ async function defaultPause(milliseconds: number): Promise<void> {
362
+ await new Promise((resolve) => setTimeout(resolve, milliseconds));
363
+ }
364
+
365
+ class FeedbackClient {
366
+ private pages = 0;
367
+ private readonly options: FeedbackClientOptions;
368
+
369
+ constructor(options: FeedbackClientOptions) {
370
+ this.options = options;
371
+ }
372
+
373
+ private execOptions(query: string): ExecOptions {
374
+ return {
375
+ cwd: this.options.cwd,
376
+ signal: this.options.signal,
377
+ stdin: query,
378
+ stdoutLimitBytes: FEEDBACK_API_PAGE_MAX_BYTES,
379
+ };
380
+ }
381
+
382
+ private async request(query: string, variables: Record<string, string | number | null>, host: string, readOnly: boolean): Promise<Record<string, unknown>> {
383
+ const args = ["api", "graphql", "--hostname", requiredText(host, "GitHub host"), "-F", "query=@-"];
384
+ for (const [name, value] of Object.entries(variables)) if (value !== null) args.push("-F", `${name}=${value}`);
385
+ for (let attempt = 0; ; attempt += 1) {
386
+ try {
387
+ this.pages += 1;
388
+ if (this.pages > FEEDBACK_MAX_PAGES) throw new Error(`feedback pagination exceeds ${FEEDBACK_MAX_PAGES} pages`);
389
+ const result = await runChecked(this.options.exec, "gh", args, this.execOptions(query));
390
+ let value: unknown;
391
+ try {
392
+ value = JSON.parse(result.stdout);
393
+ } catch {
394
+ throw new Error("GitHub GraphQL returned invalid JSON");
395
+ }
396
+ if (!isRecord(value)) throw new Error("GitHub GraphQL returned invalid JSON");
397
+ if (value.errors !== undefined && (!Array.isArray(value.errors) || value.errors.length)) {
398
+ throw new Error("GitHub GraphQL returned errors");
399
+ }
400
+ if (!isRecord(value.data)) throw new Error("GitHub GraphQL response has no data");
401
+ return value.data;
402
+ } catch (error) {
403
+ if (!readOnly || attempt === READ_ATTEMPTS - 1 || !transient(error)) throw error;
404
+ await (this.options.pause ?? defaultPause)(250 * (attempt + 1));
405
+ }
406
+ }
407
+ }
408
+
409
+ async read(query: string, variables: Record<string, string | number | null>, host: string): Promise<Record<string, unknown>> {
410
+ return await this.request(query, variables, host, true);
411
+ }
412
+
413
+ async mutate(query: string, variables: Record<string, string | number | null>, host: string): Promise<Record<string, unknown>> {
414
+ return await this.request(query, variables, host, false);
415
+ }
416
+ }
417
+
418
+ function pullRequest(data: Record<string, unknown>): Record<string, unknown> {
419
+ if (!isRecord(data.repository) || !isRecord(data.repository.pullRequest)) throw new Error("pull request disappeared");
420
+ return data.repository.pullRequest;
421
+ }
422
+
423
+ function rememberCursor(seen: Set<string>, page: Connection, label: string): void {
424
+ if (!page.hasNextPage) return;
425
+ if (seen.has(page.endCursor!)) throw new Error(`duplicate pagination cursor for ${label}`);
426
+ seen.add(page.endCursor!);
427
+ }
428
+
429
+ export async function collectPullRequestFeedback(
430
+ authorityInput: FeedbackAuthority,
431
+ options: FeedbackClientOptions,
432
+ ): Promise<FeedbackSnapshot> {
433
+ const authority = parseAuthority(authorityInput);
434
+ const [owner, name, extra] = authority.base.repository.split("/");
435
+ if (!owner || !name || extra) throw new Error("base repository is invalid");
436
+ const client = new FeedbackClient(options);
437
+ const targets = [
438
+ { key: "conversationComments", field: "comments", cursor: "commentsCursor", label: "conversation comments" },
439
+ { key: "reviews", field: "reviews", cursor: "reviewsCursor", label: "reviews" },
440
+ { key: "reviewThreads", field: "reviewThreads", cursor: "threadsCursor", label: "review threads" },
441
+ ] as const;
442
+ const results: Record<(typeof targets)[number]["key"], unknown[]> = {
443
+ conversationComments: [], reviews: [], reviewThreads: [],
444
+ };
445
+ const cursors: Record<(typeof targets)[number]["key"], string | null> = {
446
+ conversationComments: null, reviews: null, reviewThreads: null,
447
+ };
448
+ const seen = new Map(targets.map((target) => [target.key, new Set<string>()]));
449
+ const pending = new Set<(typeof targets)[number]["key"]>(targets.map((target) => target.key));
450
+ let records = 0;
451
+ while (pending.size) {
452
+ const data = await client.read(FEEDBACK_QUERY, {
453
+ owner,
454
+ repo: name,
455
+ number: authority.number,
456
+ commentsCursor: cursors.conversationComments,
457
+ reviewsCursor: cursors.reviews,
458
+ threadsCursor: cursors.reviewThreads,
459
+ }, authority.host);
460
+ const pr = pullRequest(data);
461
+ for (const target of targets) {
462
+ if (!pending.has(target.key)) continue;
463
+ const page = connection(pr[target.field], target.label);
464
+ rememberCursor(seen.get(target.key)!, page, target.label);
465
+ records += page.nodes.length;
466
+ if (target.key === "reviewThreads") {
467
+ for (const [index, thread] of page.nodes.entries()) {
468
+ if (!isRecord(thread)) throw new Error(`review thread ${results.reviewThreads.length + index + 1} is invalid`);
469
+ records += connection(thread.comments, `comments for ${requiredText(thread.id, "review thread ID")}`).nodes.length;
470
+ }
471
+ }
472
+ if (records > FEEDBACK_MAX_RECORDS) throw new Error(`feedback exceeds ${FEEDBACK_MAX_RECORDS} records`);
473
+ results[target.key].push(...page.nodes);
474
+ if (page.hasNextPage) cursors[target.key] = page.endCursor;
475
+ else pending.delete(target.key);
476
+ }
477
+ }
478
+
479
+ const threads: unknown[] = [];
480
+ for (const [index, rawThread] of results.reviewThreads.entries()) {
481
+ if (!isRecord(rawThread)) throw new Error(`review thread ${index + 1} is invalid`);
482
+ const threadId = requiredText(rawThread.id, `review thread ${index + 1} ID`);
483
+ let page = connection(rawThread.comments, `comments for ${threadId}`);
484
+ const comments = [...page.nodes];
485
+ const replyCursors = new Set<string>();
486
+ rememberCursor(replyCursors, page, `comments for ${threadId}`);
487
+ while (page.hasNextPage) {
488
+ const data = await client.read(THREAD_REPLIES_QUERY, { threadId, cursor: page.endCursor }, authority.host);
489
+ if (!isRecord(data.node)) throw new Error(`review thread disappeared: ${threadId}`);
490
+ page = connection(data.node.comments, `comments for ${threadId}`);
491
+ rememberCursor(replyCursors, page, `comments for ${threadId}`);
492
+ records += page.nodes.length;
493
+ if (records > FEEDBACK_MAX_RECORDS) throw new Error(`feedback exceeds ${FEEDBACK_MAX_RECORDS} records`);
494
+ comments.push(...page.nodes);
495
+ }
496
+ threads.push({ ...rawThread, comments });
497
+ }
498
+ return parseFeedbackSnapshot({
499
+ pullRequest: authority,
500
+ conversationComments: results.conversationComments,
501
+ reviews: results.reviews,
502
+ reviewThreads: threads,
503
+ });
504
+ }
505
+
506
+ export async function resolvePullRequestThread(
507
+ authorityInput: FeedbackAuthority,
508
+ threadIdInput: string,
509
+ options: FeedbackClientOptions,
510
+ ): Promise<void> {
511
+ const authority = parseAuthority(authorityInput);
512
+ const threadId = requiredText(threadIdInput, "review thread ID");
513
+ const data = await new FeedbackClient(options).mutate(RESOLVE_THREAD_MUTATION, { threadId }, authority.host);
514
+ if (!isRecord(data.resolveReviewThread) || !isRecord(data.resolveReviewThread.thread)) {
515
+ throw new Error(`GitHub did not resolve ${threadId}`);
516
+ }
517
+ const thread = data.resolveReviewThread.thread;
518
+ if (thread.id !== threadId || thread.isResolved !== true) throw new Error(`GitHub did not resolve ${threadId}`);
519
+ }