1import * as Cause from "effect/Cause";
2import * as Exit from "effect/Exit";
3import * as Result from "effect/Result";
4import * as Schema from "effect/Schema";
5import type {
6 PullRequestStackMembership,
7 PullRequestActor,
8 PullRequestPreview,
9 PullRequestCheck,
10 PullRequestCheckStatus,
11 PullRequestChecksState,
12 PullRequestComment,
13 PullRequestCommit,
14 PullRequestFileViewedState,
15 PullRequestLabel,
16 PullRequestMergeCapabilities,
17 PullRequestMergeMethod,
18 PullRequestOmittedFileStat,
19 PullRequestMergeability,
20 PullRequestReaction,
21 PullRequestReactionContent,
22 PullRequestReviewCommentDraft,
23 PullRequestReviewDecision,
24 PullRequestReviewPosition,
25 PullRequestReviewThread,
26 PullRequestReviewVerdict,
27 PullRequestReviewerCandidate,
28 PullRequestReviewerCandidateList,
29 PullRequestReviewerKind,
30 PullRequestLabelCandidate,
31 PullRequestLabelCandidateList,
32 PullRequestState,
33 PullRequestThreadComment,
34} from "@t3tools/contracts";
35import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath";
36import { decodeJsonResult } from "@t3tools/shared/schemaJson";
37
38import { aliasedGraphQlDocument, type GraphQlDocument } from "./githubGraphQl.ts";
39import { dedupeChecks } from "@t3tools/source-control-core/server/pullRequestChecks";
40
41/**
42 * Enum-ish GitHub CLI fields are decoded as plain strings and normalized here: a `gh`
43 * release that adds a conclusion or a review state must not fail the whole payload.
44 */
45const RawActorSchema = Schema.Struct({
46 __typename: Schema.optional(Schema.String),
47 is_bot: Schema.optional(Schema.Boolean),
48 /**
49 * Optional because a requested team goes by its slug instead, and a mannequin, which no query
50 * has a fragment for, arrives as an empty object. A reviewer with neither names nobody to show,
51 * and must not fail the response the conversation travels in.
52 */
53 login: Schema.optional(Schema.String),
54 /** The node id, which is how a listing's authors are resolved to avatars in one request. */
55 id: Schema.optional(Schema.NullOr(Schema.String)),
56 name: Schema.optional(Schema.NullOr(Schema.String)),
57 /** Only the GraphQL API reports one; `gh pr view --json` has no avatar to give. */
58 avatarUrl: Schema.optional(Schema.NullOr(Schema.String)),
59});
60
61/**
62 * A team answers with a slug where a user answers with a login. `combinedSlug` is the team's
63 * `org/slug`, which is what a reviewer row shows for it.
64 */
65const RawRequestedReviewerSchema = Schema.Struct({
66 ...RawActorSchema.fields,
67 slug: Schema.optional(Schema.NullOr(Schema.String)),
68 combinedSlug: Schema.optional(Schema.NullOr(Schema.String)),
69});
70
71const RawLabelSchema = Schema.Struct({
72 name: Schema.String,
73 color: Schema.optional(Schema.NullOr(Schema.String)),
74});
75
76const RawReviewRequestSchema = Schema.Struct({
77 login: Schema.optional(Schema.NullOr(Schema.String)),
78 slug: Schema.optional(Schema.NullOr(Schema.String)),
79 combinedSlug: Schema.optional(Schema.NullOr(Schema.String)),
80 name: Schema.optional(Schema.NullOr(Schema.String)),
81 avatarUrl: Schema.optional(Schema.NullOr(Schema.String)),
82});
83
84/** One reviewer's most recent review: the state is all the verdict needs, the author is for who. */
85const RawLatestReviewSchema = Schema.Struct({
86 author: Schema.optional(Schema.NullOr(RawActorSchema)),
87 state: Schema.optional(Schema.NullOr(Schema.String)),
88});
89
90const RawCheckSchema = Schema.Struct({
91 __typename: Schema.optional(Schema.String),
92 name: Schema.optional(Schema.NullOr(Schema.String)),
93 context: Schema.optional(Schema.NullOr(Schema.String)),
94 status: Schema.optional(Schema.NullOr(Schema.String)),
95 conclusion: Schema.optional(Schema.NullOr(Schema.String)),
96 state: Schema.optional(Schema.NullOr(Schema.String)),
97 description: Schema.optional(Schema.NullOr(Schema.String)),
98 detailsUrl: Schema.optional(Schema.NullOr(Schema.String)),
99 targetUrl: Schema.optional(Schema.NullOr(Schema.String)),
100 /**
101 * What tells two same-named checks apart, and which run of one is the newest. All three ride
102 * along with `statusCheckRollup` already — it is asked for as a whole field — so reading them
103 * costs no request. Empty for an app-provided check run, which belongs to no workflow, and
104 * absent entirely on a commit status, which is not a run at all.
105 */
106 workflowName: Schema.optional(Schema.NullOr(Schema.String)),
107 startedAt: Schema.optional(Schema.NullOr(Schema.String)),
108 completedAt: Schema.optional(Schema.NullOr(Schema.String)),
109 /** Branch protection requires this check; read by the detail query on github.com only. */
110 isRequired: Schema.optional(Schema.NullOr(Schema.Boolean)),
111});
112
113const RawListItemSchema = Schema.Struct({
114 number: Schema.Int,
115 title: Schema.String,
116 url: Schema.String,
117 author: Schema.optional(Schema.NullOr(RawActorSchema)),
118 headRefName: Schema.String,
119 baseRefName: Schema.String,
120 state: Schema.optional(Schema.NullOr(Schema.String)),
121 isDraft: Schema.optional(Schema.Boolean),
122 mergeable: Schema.optional(Schema.NullOr(Schema.String)),
123 reviewDecision: Schema.optional(Schema.NullOr(Schema.String)),
124 additions: Schema.optional(Schema.Int),
125 deletions: Schema.optional(Schema.Int),
126 createdAt: Schema.String,
127 updatedAt: Schema.String,
128 mergedAt: Schema.optional(Schema.NullOr(Schema.String)),
129 reviewRequests: Schema.optional(Schema.Array(RawReviewRequestSchema)),
130 latestReviews: Schema.optional(Schema.NullOr(Schema.Array(RawLatestReviewSchema))),
131 labels: Schema.optional(Schema.Array(RawLabelSchema)),
132 /**
133 * Every check of the head commit, which is the only rollup `gh pr list --json` can give: there
134 * is no field for the one-word verdict. Measured against `pingdotgg/t3code`, asking for it costs
135 * 0.6s -> 7.9s at a hundred rows and 0.9s -> 2.1s at thirty, for 425 KB of checks a listing
136 * reduces to one word. The listing pays it because the alternative is a request per row; the
137 * cross-repository search below asks GitHub for the verdict itself instead.
138 */
139 statusCheckRollup: Schema.optional(Schema.NullOr(Schema.Array(RawCheckSchema))),
140});
141
142const RawStackMembershipSchema = Schema.Struct({
143 stack: Schema.optional(
144 Schema.NullOr(
145 Schema.Struct({ number: Schema.Int, size: Schema.Int, baseRefName: Schema.String }),
146 ),
147 ),
148 stackEntry: Schema.optional(Schema.NullOr(Schema.Struct({ position: Schema.Int }))),
149});
150
151/**
152 * A search's own answer, which is the listing's row one connection deeper: `gh pr list --json`
153 * flattens reviewers and labels, and GraphQL does not. Everything below the row is optional
154 * because a node that is not a pull request decodes as an empty object, which is skipped.
155 */
156const RawSearchItemSchema = Schema.Struct({
157 ...RawStackMembershipSchema.fields,
158 number: Schema.Int,
159 title: Schema.String,
160 url: Schema.String,
161 author: Schema.optional(Schema.NullOr(RawActorSchema)),
162 headRefName: Schema.String,
163 baseRefName: Schema.String,
164 state: Schema.optional(Schema.NullOr(Schema.String)),
165 isDraft: Schema.optional(Schema.Boolean),
166 mergeable: Schema.optional(Schema.NullOr(Schema.String)),
167 reviewDecision: Schema.optional(Schema.NullOr(Schema.String)),
168 latestReviews: Schema.optional(
169 Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.NullOr(RawLatestReviewSchema)) })),
170 ),
171 /** Asked for by the per-repository listing, and left out of the cross-repository search. */
172 additions: Schema.optional(Schema.Int),
173 deletions: Schema.optional(Schema.Int),
174 createdAt: Schema.String,
175 updatedAt: Schema.String,
176 mergedAt: Schema.optional(Schema.NullOr(Schema.String)),
177 repository: Schema.optional(Schema.NullOr(Schema.Struct({ nameWithOwner: Schema.String }))),
178 reviewRequests: Schema.optional(
179 Schema.NullOr(
180 Schema.Struct({
181 nodes: Schema.optional(
182 Schema.NullOr(
183 Schema.Array(
184 Schema.NullOr(
185 Schema.Struct({
186 requestedReviewer: Schema.optional(
187 Schema.NullOr(
188 Schema.Struct({
189 ...RawActorSchema.fields,
190 slug: Schema.optional(Schema.NullOr(Schema.String)),
191 }),
192 ),
193 ),
194 }),
195 ),
196 ),
197 ),
198 ),
199 }),
200 ),
201 ),
202 labels: Schema.optional(
203 Schema.NullOr(
204 Schema.Struct({
205 nodes: Schema.optional(Schema.NullOr(Schema.Array(Schema.NullOr(RawLabelSchema)))),
206 }),
207 ),
208 ),
209 /**
210 * GraphQL answers the rollup a listing actually wants — one enum for the head commit, rather
211 * than the whole check array `gh pr list --json` insists on. Measured at a hundred rows across
212 * this repository: 0.8s -> 3.0s and 15 KB, against 425 KB for the same verdict over `gh`.
213 */
214 commits: Schema.optional(
215 Schema.NullOr(
216 Schema.Struct({
217 nodes: Schema.optional(
218 Schema.NullOr(
219 Schema.Array(
220 Schema.NullOr(
221 Schema.Struct({
222 commit: Schema.optional(
223 Schema.NullOr(
224 Schema.Struct({
225 statusCheckRollup: Schema.optional(
226 Schema.NullOr(Schema.Struct({ state: Schema.String })),
227 ),
228 }),
229 ),
230 ),
231 }),
232 ),
233 ),
234 ),
235 ),
236 }),
237 ),
238 ),
239});
240
241const RawRowConnectionSchema = Schema.Struct({
242 pageInfo: Schema.optional(
243 Schema.NullOr(
244 Schema.Struct({
245 hasNextPage: Schema.Boolean,
246 endCursor: Schema.optional(Schema.NullOr(Schema.String)),
247 }),
248 ),
249 ),
250 // Row by row: a node that is not a pull request — or one field GitHub changes — is skipped
251 // rather than blanking every repository at once.
252 nodes: Schema.optional(Schema.NullOr(Schema.Array(Schema.Unknown))),
253});
254
255const RawSearchSchema = Schema.Struct({
256 data: Schema.Struct({ search: RawRowConnectionSchema }),
257});
258
259const RawRepositoryPullRequestsSchema = Schema.Struct({
260 data: Schema.Struct({
261 repository: Schema.NullOr(Schema.Struct({ pullRequests: RawRowConnectionSchema })),
262 }),
263});
264
265/** One aliased lookup per row, so the response is keyed by the position it was asked in. */
266const RawStatsSchema = Schema.Struct({
267 data: Schema.optional(
268 Schema.NullOr(
269 Schema.Record(
270 Schema.String,
271 Schema.NullOr(
272 Schema.Struct({
273 pullRequest: Schema.optional(
274 Schema.NullOr(
275 Schema.Struct({
276 additions: Schema.optional(Schema.NullOr(Schema.Int)),
277 deletions: Schema.optional(Schema.NullOr(Schema.Int)),
278 }),
279 ),
280 ),
281 }),
282 ),
283 ),
284 ),
285 ),
286});
287
288const RawStackMembershipsSchema = Schema.Struct({
289 data: Schema.Record(
290 Schema.String,
291 Schema.NullOr(Schema.Struct({ pullRequest: Schema.NullOr(RawStackMembershipSchema) })),
292 ),
293});
294
295/** How many of a reaction's people the hover names before it counts the rest. */
296const REACTORS_PER_GROUP = 10;
297
298/**
299 * A reaction group as every reactable node reports it. `reactors` is bounded rather than paged:
300 * a hover says who reacted, and a hundred and forty names is a count, not a sentence.
301 */
302const REACTION_GROUPS_FIELDS = `reactionGroups {
303 content
304 viewerHasReacted
305 reactors(first: ${REACTORS_PER_GROUP}) {
306 totalCount
307 nodes {
308 ... on User { login }
309 ... on Bot { login }
310 ... on Organization { login }
311 ... on Mannequin { login }
312 }
313 }
314}`;
315
316/** GitHub's reaction names, which are the same eight the contract carries under other spellings. */
317const REACTION_CONTENT_BY_GITHUB: Readonly<Record<string, PullRequestReactionContent>> = {
318 THUMBS_UP: "thumbs-up",
319 THUMBS_DOWN: "thumbs-down",
320 LAUGH: "laugh",
321 HOORAY: "hooray",
322 CONFUSED: "confused",
323 HEART: "heart",
324 ROCKET: "rocket",
325 EYES: "eyes",
326};
327
328const GITHUB_REACTION_BY_CONTENT: Readonly<Record<PullRequestReactionContent, string>> = {
329 "thumbs-up": "THUMBS_UP",
330 "thumbs-down": "THUMBS_DOWN",
331 laugh: "LAUGH",
332 hooray: "HOORAY",
333 confused: "CONFUSED",
334 heart: "HEART",
335 rocket: "ROCKET",
336 eyes: "EYES",
337};
338
339export function gitHubReactionContent(content: PullRequestReactionContent): string {
340 return GITHUB_REACTION_BY_CONTENT[content];
341}
342
343const RawReactionGroupsSchema = Schema.optional(
344 Schema.NullOr(
345 Schema.Array(
346 Schema.Struct({
347 content: Schema.optional(Schema.NullOr(Schema.String)),
348 viewerHasReacted: Schema.optional(Schema.Boolean),
349 reactors: Schema.optional(
350 Schema.NullOr(
351 Schema.Struct({
352 totalCount: Schema.optional(Schema.Int),
353 nodes: Schema.optional(
354 Schema.NullOr(
355 Schema.Array(
356 Schema.NullOr(
357 Schema.Struct({ login: Schema.optional(Schema.NullOr(Schema.String)) }),
358 ),
359 ),
360 ),
361 ),
362 }),
363 ),
364 ),
365 }),
366 ),
367 ),
368);
369
370type RawReactionGroups = typeof RawReactionGroupsSchema.Type;
371
372/**
373 * The groups GitHub answered with, as the contract carries them. A group with nobody behind it is
374 * dropped: GitHub answers with a group per content it knows, including the ones nobody chose. The
375 * viewer's own login is left out of `actors` — the page names them "You" instead, and leaving it
376 * in would name them twice — but `count` still counts them along with everyone else.
377 */
378function toReactions(
379 groups: RawReactionGroups,
380 viewer: string | null,
381): ReadonlyArray<PullRequestReaction> {
382 const normalizedViewer = viewer?.toLowerCase() ?? null;
383 const reactions: PullRequestReaction[] = [];
384 for (const group of groups ?? []) {
385 const content = REACTION_CONTENT_BY_GITHUB[trimmed(group.content)?.toUpperCase() ?? ""];
386 if (content === undefined) continue;
387 const logins = (group.reactors?.nodes ?? []).flatMap((node) => trimmed(node?.login) ?? []);
388 const count = Math.max(group.reactors?.totalCount ?? logins.length, logins.length);
389 if (count <= 0) continue;
390 const actors =
391 normalizedViewer === null
392 ? logins
393 : logins.filter((login) => login.toLowerCase() !== normalizedViewer);
394 reactions.push({ content, count, actors, viewerHasReacted: group.viewerHasReacted === true });
395 }
396 return reactions;
397}
398
399const RawCommentSchema = Schema.Struct({
400 id: Schema.String,
401 lastEditedAt: Schema.optional(Schema.NullOr(Schema.String)),
402 author: Schema.optional(Schema.NullOr(RawActorSchema)),
403 body: Schema.optional(Schema.String),
404 createdAt: Schema.String,
405 url: Schema.optional(Schema.NullOr(Schema.String)),
406 /** Only ever present on a GraphQL read; `gh pr view --json` reports no reaction at all. */
407 reactionGroups: RawReactionGroupsSchema,
408});
409
410const RawReviewSchema = Schema.Struct({
411 id: Schema.String,
412 lastEditedAt: Schema.optional(Schema.NullOr(Schema.String)),
413 author: Schema.optional(Schema.NullOr(RawActorSchema)),
414 body: Schema.optional(Schema.String),
415 state: Schema.optional(Schema.NullOr(Schema.String)),
416 submittedAt: Schema.optional(Schema.NullOr(Schema.String)),
417 url: Schema.optional(Schema.NullOr(Schema.String)),
418});
419
420const RawCommitSchema = Schema.Struct({
421 oid: Schema.String,
422 messageHeadline: Schema.optional(Schema.String),
423 committedDate: Schema.String,
424 authors: Schema.optional(
425 Schema.Array(
426 Schema.Struct({
427 email: Schema.optional(Schema.NullOr(Schema.String)),
428 id: Schema.optional(Schema.NullOr(Schema.String)),
429 login: Schema.optional(Schema.NullOr(Schema.String)),
430 name: Schema.optional(Schema.NullOr(Schema.String)),
431 }),
432 ),
433 ),
434});
435
436const RawDetailSchema = Schema.Struct({
437 ...RawListItemSchema.fields,
438 /** GitHub's explicit distinction between a fork head and a branch in the base repository. */
439 isCrossRepository: Schema.optional(Schema.Boolean),
440 /** Names the fork a pull request came from, which is what qualifies its head ref. */
441 headRepositoryOwner: Schema.optional(Schema.NullOr(Schema.Struct({ login: Schema.String }))),
442 /** The exact head revision, used to find workflow runs that GitHub has not started yet. */
443 headRefOid: Schema.optional(Schema.NullOr(Schema.String)),
444 body: Schema.optional(Schema.String),
445 changedFiles: Schema.optional(Schema.Int),
446 closedAt: Schema.optional(Schema.NullOr(Schema.String)),
447 /** The standing instruction and strategy GitHub will use once its requirements are met. */
448 autoMergeRequest: Schema.optional(
449 Schema.NullOr(Schema.Struct({ mergeMethod: Schema.optional(Schema.NullOr(Schema.String)) })),
450 ),
451});
452
453/** `GET /repos/{owner}/{repo}/actions/runs`, one page of it. */
454const RawWorkflowRunsSchema = Schema.Struct({
455 workflow_runs: Schema.Array(
456 Schema.Struct({
457 id: Schema.Int,
458 name: Schema.optional(Schema.NullOr(Schema.String)),
459 html_url: Schema.optional(Schema.NullOr(Schema.String)),
460 }),
461 ),
462});
463
464const RawPullRequestHeadsSchema = Schema.Struct({
465 data: Schema.Struct({
466 repository: Schema.NullOr(
467 Schema.Struct({
468 pullRequests: Schema.Struct({
469 pageInfo: Schema.optional(RawPageInfoSchemaFields()),
470 nodes: Schema.Array(
471 Schema.Struct({
472 number: Schema.Int,
473 headRefOid: Schema.String,
474 isCrossRepository: Schema.optional(Schema.Boolean),
475 headRepositoryOwner: Schema.optional(
476 Schema.NullOr(Schema.Struct({ login: Schema.String })),
477 ),
478 }),
479 ),
480 }),
481 }),
482 ),
483 }),
484});
485
486const RawActivityConnection = <S extends Schema.Top>(node: S) =>
487 Schema.optional(
488 Schema.NullOr(
489 Schema.Struct({
490 pageInfo: Schema.optional(RawPageInfoSchemaFields()),
491 nodes: Schema.Array(node),
492 }),
493 ),
494 );
495
496const RawActivitySchema = Schema.Struct({
497 data: Schema.Struct({
498 repository: Schema.Struct({
499 pullRequest: Schema.Struct({
500 author: Schema.optional(Schema.NullOr(RawActorSchema)),
501 comments: RawActivityConnection(RawCommentSchema),
502 reviews: RawActivityConnection(RawReviewSchema),
503 commits: Schema.optional(
504 Schema.NullOr(
505 Schema.Struct({
506 nodes: Schema.Array(
507 Schema.Struct({
508 commit: Schema.Struct({
509 oid: Schema.String,
510 messageHeadline: Schema.optional(Schema.String),
511 committedDate: Schema.String,
512 authors: Schema.optional(
513 Schema.NullOr(
514 Schema.Struct({
515 nodes: Schema.Array(
516 Schema.Struct({
517 email: Schema.optional(Schema.NullOr(Schema.String)),
518 name: Schema.optional(Schema.NullOr(Schema.String)),
519 user: Schema.optional(
520 Schema.NullOr(
521 Schema.Struct({
522 login: Schema.optional(Schema.NullOr(Schema.String)),
523 }),
524 ),
525 ),
526 }),
527 ),
528 }),
529 ),
530 ),
531 }),
532 }),
533 ),
534 }),
535 ),
536 ),
537 }),
538 }),
539 }),
540});
541
542/** Where a connection carries on from, which is what every paged read below follows. */
543function RawPageInfoSchemaFields() {
544 return Schema.Struct({
545 hasNextPage: Schema.optional(Schema.Boolean),
546 endCursor: Schema.optional(Schema.NullOr(Schema.String)),
547 });
548}
549const RawPageInfoSchema = RawPageInfoSchemaFields();
550
551/**
552 * What GitHub says the viewer may do with a pull request. Both are optional so that an install
553 * that answers without them still delivers the conversation they travel with; an absent field
554 * reads as granted, which is what an unknown permission is.
555 */
556const RawViewerFieldsSchema = Schema.Struct({
557 viewerCanUpdate: Schema.optional(Schema.Boolean),
558 viewerDidAuthor: Schema.optional(Schema.Boolean),
559});
560
561const RawHeadBranchAccessSchema = Schema.Struct({
562 headRepository: Schema.optional(
563 Schema.NullOr(
564 Schema.Struct({
565 nameWithOwner: Schema.optional(Schema.NullOr(Schema.String)),
566 viewerPermission: Schema.optional(Schema.NullOr(Schema.String)),
567 }),
568 ),
569 ),
570 headRef: Schema.optional(Schema.NullOr(Schema.Struct({ id: Schema.String }))),
571});
572
573const RawThreadCommentsSchema = Schema.Struct({
574 totalCount: Schema.optional(Schema.Int),
575 pageInfo: Schema.optional(RawPageInfoSchema),
576 nodes: Schema.Array(RawCommentSchema),
577});
578
579/** `gh pr view --json` cannot reach review threads, so they come from the GraphQL API. */
580const RawReviewThreadsSchema = Schema.Struct({
581 data: Schema.Struct({
582 // Rides along in the same request: GitHub names who reacted but never says whether that is
583 // the reader, so the comparison is made here rather than paid for with a request of its own.
584 viewer: Schema.optional(
585 Schema.NullOr(Schema.Struct({ login: Schema.optional(Schema.NullOr(Schema.String)) })),
586 ),
587 repository: Schema.Struct({
588 pullRequest: Schema.Struct({
589 reviewThreads: Schema.Struct({
590 totalCount: Schema.optional(Schema.Int),
591 pageInfo: Schema.optional(RawPageInfoSchema),
592 nodes: Schema.Array(
593 Schema.Struct({
594 id: Schema.optional(Schema.NullOr(Schema.String)),
595 isResolved: Schema.optional(Schema.Boolean),
596 isOutdated: Schema.optional(Schema.Boolean),
597 path: Schema.optional(Schema.NullOr(Schema.String)),
598 /** Null once the thread's line has left the diff, which `isOutdated` reports. */
599 line: Schema.optional(Schema.NullOr(Schema.Int)),
600 diffSide: Schema.optional(Schema.NullOr(Schema.String)),
601 comments: RawThreadCommentsSchema,
602 }),
603 ),
604 }),
605 ...RawViewerFieldsSchema.fields,
606 author: Schema.optional(Schema.NullOr(RawActorSchema)),
607 reactionGroups: RawReactionGroupsSchema,
608 comments: Schema.optional(
609 Schema.NullOr(
610 Schema.Struct({
611 nodes: Schema.Array(
612 Schema.Struct({
613 id: Schema.optional(Schema.NullOr(Schema.String)),
614 author: Schema.optional(Schema.NullOr(RawActorSchema)),
615 lastEditedAt: Schema.optional(Schema.NullOr(Schema.String)),
616 reactionGroups: RawReactionGroupsSchema,
617 }),
618 ),
619 }),
620 ),
621 ),
622 /**
623 * Reviews for their reactions and actor identity: the words and the verdict arrive with
624 * `gh pr view --json reviews`, which reports no reaction of any kind.
625 */
626 reviews: Schema.optional(
627 Schema.NullOr(
628 Schema.Struct({
629 nodes: Schema.Array(
630 Schema.Struct({
631 id: Schema.optional(Schema.NullOr(Schema.String)),
632 author: Schema.optional(Schema.NullOr(RawActorSchema)),
633 lastEditedAt: Schema.optional(Schema.NullOr(Schema.String)),
634 reactionGroups: RawReactionGroupsSchema,
635 }),
636 ),
637 }),
638 ),
639 ),
640 reviewRequests: Schema.optional(
641 Schema.NullOr(
642 Schema.Struct({
643 nodes: Schema.Array(
644 Schema.Struct({
645 // Null where GitHub does not name who was asked.
646 requestedReviewer: Schema.optional(Schema.NullOr(RawRequestedReviewerSchema)),
647 }),
648 ),
649 }),
650 ),
651 ),
652 latestReviews: Schema.optional(
653 Schema.NullOr(Schema.Struct({ nodes: Schema.Array(RawLatestReviewSchema) })),
654 ),
655 reviewDismissals: Schema.optional(
656 Schema.NullOr(
657 Schema.Struct({
658 pageInfo: Schema.optional(RawPageInfoSchema),
659 nodes: Schema.Array(
660 Schema.Struct({
661 dismissalMessage: Schema.optional(Schema.NullOr(Schema.String)),
662 review: Schema.optional(
663 Schema.NullOr(
664 Schema.Struct({ id: Schema.optional(Schema.NullOr(Schema.String)) }),
665 ),
666 ),
667 }),
668 ),
669 }),
670 ),
671 ),
672 commits: Schema.optional(
673 Schema.NullOr(
674 Schema.Struct({
675 nodes: Schema.Array(
676 Schema.Struct({
677 commit: Schema.Struct({
678 oid: Schema.String,
679 messageHeadline: Schema.optional(Schema.NullOr(Schema.String)),
680 committedDate: Schema.optional(Schema.NullOr(Schema.String)),
681 additions: Schema.optional(Schema.Int),
682 deletions: Schema.optional(Schema.Int),
683 parents: Schema.optional(
684 Schema.NullOr(Schema.Struct({ totalCount: Schema.optional(Schema.Int) })),
685 ),
686 authors: Schema.optional(
687 Schema.NullOr(
688 Schema.Struct({
689 nodes: Schema.Array(
690 Schema.Struct({
691 name: Schema.optional(Schema.NullOr(Schema.String)),
692 avatarUrl: Schema.optional(Schema.NullOr(Schema.String)),
693 user: Schema.optional(
694 Schema.NullOr(
695 Schema.Struct({
696 login: Schema.optional(Schema.NullOr(Schema.String)),
697 }),
698 ),
699 ),
700 }),
701 ),
702 }),
703 ),
704 ),
705 }),
706 }),
707 ),
708 }),
709 ),
710 ),
711 }),
712 }),
713 }),
714});
715
716/** Requested together, so a response missing any of them fails rather than defaulting open:
717 * guessing `true` would offer a merge method the repository forbids. */
718const RawRepositoryAccessSchema = Schema.Struct({
719 mergeCommitAllowed: Schema.Boolean,
720 squashMergeAllowed: Schema.Boolean,
721 rebaseMergeAllowed: Schema.Boolean,
722 /**
723 * ADMIN, MAINTAIN, WRITE, TRIAGE, READ or NONE, or null for a GitHub App. Optional rather than
724 * required, unlike the three above: an install that does not report it still decodes rather
725 * than failing the whole detail read, and `toCanWrite` decides what that standing allows.
726 */
727 viewerPermission: Schema.optional(Schema.NullOr(Schema.String)),
728});
729
730const RawCoreSchema = Schema.Struct({
731 data: Schema.Struct({
732 repository: Schema.Struct({
733 ...RawRepositoryAccessSchema.fields,
734 pullRequest: Schema.Struct({
735 ...RawDetailSchema.fields,
736 ...RawViewerFieldsSchema.fields,
737 ...RawHeadBranchAccessSchema.fields,
738 viewerCanUpdateBranch: Schema.Boolean,
739 baseRef: Schema.NullOr(
740 Schema.Struct({
741 compare: Schema.NullOr(Schema.Struct({ behindBy: Schema.Int })),
742 }),
743 ),
744 reviewRequests: Schema.Struct({
745 nodes: Schema.Array(
746 Schema.Struct({ requestedReviewer: Schema.NullOr(RawReviewRequestSchema) }),
747 ),
748 }),
749 labels: Schema.Struct({ nodes: Schema.Array(RawLabelSchema) }),
750 commits: Schema.Struct({
751 nodes: Schema.Array(
752 Schema.Struct({
753 commit: Schema.Struct({
754 statusCheckRollup: Schema.NullOr(
755 Schema.Struct({
756 contexts: Schema.Struct({
757 nodes: Schema.Array(
758 Schema.Struct({
759 ...RawCheckSchema.fields,
760 checkSuite: Schema.optional(
761 Schema.NullOr(
762 Schema.Struct({
763 workflowRun: Schema.NullOr(
764 Schema.Struct({
765 workflow: Schema.NullOr(Schema.Struct({ name: Schema.String })),
766 }),
767 ),
768 }),
769 ),
770 ),
771 }),
772 ),
773 pageInfo: Schema.Struct({ hasNextPage: Schema.Boolean }),
774 }),
775 }),
776 ),
777 }),
778 }),
779 ),
780 }),
781 }),
782 }),
783 }),
784});
785const decodeCore = decodeJsonResult(RawCoreSchema);
786
787const RawPullRequestFileSchema = Schema.Struct({
788 filename: Schema.String,
789 status: Schema.optional(Schema.NullOr(Schema.String)),
790 /** Only on a rename, where it names the file the hunks are counted against. */
791 previous_filename: Schema.optional(Schema.NullOr(Schema.String)),
792 /** Absent for a binary file, and for one whose diff GitHub considers too large. */
793 patch: Schema.optional(Schema.NullOr(Schema.String)),
794 /** Whether anything was withheld is the difference between a binary file and a pure rename. */
795 additions: Schema.optional(Schema.NullOr(Schema.Int)),
796 deletions: Schema.optional(Schema.NullOr(Schema.Int)),
797});
798
799/** Resolves a listing's authors to avatars, which no `gh` JSON field carries. */
800export const ACTOR_AVATARS_GRAPHQL_QUERY = `query($ids: [ID!]!) {
801 nodes(ids: $ids) {
802 ... on User { login avatarUrl }
803 ... on Bot { login avatarUrl }
804 }
805}`;
806
807const RawActorAvatarsSchema = Schema.Struct({
808 data: Schema.Struct({
809 nodes: Schema.Array(Schema.NullOr(RawActorSchema)),
810 }),
811});
812
813const decodeActorAvatars = decodeJsonResult(RawActorAvatarsSchema);
814
815export function decodeActorAvatarsJson(
816 raw: string,
817): Result.Result<ReadonlyMap<string, string>, DecodeFailure> {
818 const decoded = decodeActorAvatars(raw);
819 if (!Result.isSuccess(decoded)) {
820 return Result.fail(decoded.failure);
821 }
822 const avatarsByLogin = new Map<string, string>();
823 for (const node of decoded.success.data.nodes) {
824 const login = trimmed(node?.login);
825 const avatarUrl = trimmed(node?.avatarUrl);
826 if (login !== null && avatarUrl !== null) avatarsByLogin.set(login, avatarUrl);
827 }
828 return Result.succeed(avatarsByLogin);
829}
830
831/**
832 * Pull refs let the comparison share the detail read without first resolving a fork branch.
833 * `isRequired` is asked for on github.com only: an older Enterprise server may not know it, and
834 * an unknown field fails the whole read.
835 */
836function checkContextNodesSelection(host: string): string {
837 const required =
838 host.toLowerCase() === "github.com" ? " isRequired(pullRequestNumber: $number)" : "";
839 return `nodes {
840 __typename
841 ... on StatusContext { context state targetUrl createdAt description${required} }
842 ... on CheckRun {
843 name status conclusion startedAt completedAt detailsUrl${required}
844 checkSuite { workflowRun { workflow { name } } }
845 }
846 }`;
847}
848
849export const pullRequestCoreGraphQlQuery = (host: string) => {
850 return `query($owner: String!, $name: String!, $number: Int!, $headRef: String!) {
851 repository(owner: $owner, name: $name) {
852 mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed viewerPermission
853 pullRequest(number: $number) {
854 number title url body state isDraft mergeable reviewDecision
855 additions deletions changedFiles createdAt updatedAt mergedAt closedAt
856 headRefName baseRefName headRefOid isCrossRepository
857 headRepositoryOwner { login }
858 headRepository { nameWithOwner viewerPermission }
859 headRef { id }
860 author { login avatarUrl ... on User { id name } }
861 autoMergeRequest { mergeMethod }
862 viewerCanUpdate viewerDidAuthor viewerCanUpdateBranch
863 baseRef { compare(headRef: $headRef) { behindBy } }
864 reviewRequests(first: 100) {
865 nodes { requestedReviewer { ... on User { login name } ... on Bot { login } ... on Team { slug combinedSlug name avatarUrl } } }
866 }
867 labels(first: 100) { nodes { name color } }
868 commits(last: 1) {
869 nodes { commit { statusCheckRollup { contexts(first: 100) {
870 ${checkContextNodesSelection(host)}
871 pageInfo { hasNextPage }
872 } } } }
873 }
874 }
875 }
876}`;
877};
878
879export const PULL_REQUEST_PREVIEW_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) {
880 repository(owner: $owner, name: $name) {
881 pullRequest(number: $number) {
882 number title url state isDraft createdAt
883 author { login avatarUrl ... on User { name } }
884 }
885 }
886}`;
887
888const decodePullRequestPreview = decodeJsonResult(
889 Schema.Struct({
890 data: Schema.Struct({
891 repository: Schema.Struct({
892 pullRequest: Schema.Struct({
893 number: Schema.Int,
894 title: Schema.String,
895 url: Schema.String,
896 state: Schema.String,
897 isDraft: Schema.Boolean,
898 createdAt: Schema.String,
899 author: Schema.NullOr(RawActorSchema),
900 }),
901 }),
902 }),
903 }),
904);
905
906export function decodePullRequestPreviewJson(
907 raw: string,
908): Result.Result<Omit<PullRequestPreview, "projectId" | "repository">, DecodeFailure> {
909 return Result.map(decodePullRequestPreview(raw), ({ data }) => ({
910 ...data.repository.pullRequest,
911 author: toActor(data.repository.pullRequest.author),
912 state: toState(data.repository.pullRequest),
913 }));
914}
915
916/**
917 * The conversation a pull request carries outside its review threads: who opened it, its issue
918 * comments, its reviews and its newest commits. The two remark connections page independently, so
919 * each is switched on only while it has more to give; the first read asks for everything.
920 */
921export const PULL_REQUEST_ACTIVITY_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $head: Boolean!, $withComments: Boolean!, $commentsAfter: String, $withReviews: Boolean!, $reviewsAfter: String) {
922 repository(owner: $owner, name: $name) {
923 pullRequest(number: $number) {
924 author @include(if: $head) { __typename login avatarUrl ... on User { name } }
925 commits(last: 100) @include(if: $head) {
926 nodes { commit { oid messageHeadline committedDate authors(first: 10) { nodes { name email user { login } } } } }
927 }
928 comments(first: 100, after: $commentsAfter) @include(if: $withComments) {
929 pageInfo { hasNextPage endCursor }
930 nodes { id body createdAt lastEditedAt url author { __typename login avatarUrl ... on User { name } } }
931 }
932 reviews(first: 100, after: $reviewsAfter) @include(if: $withReviews) {
933 pageInfo { hasNextPage endCursor }
934 nodes { id body state submittedAt lastEditedAt url author { __typename login avatarUrl ... on User { name } } }
935 }
936 }
937 }
938}`;
939
940/** GitHub's own ceiling on a connection page, which is what both thread reads ask for. */
941const GRAPHQL_PAGE_SIZE = 100;
942
943/**
944 * The ceiling on `search`, which refuses anything larger with EXCESSIVE_PAGINATION (measured:
945 * `first: 101` is an error, `first: 100` is not).
946 */
947export const PULL_REQUEST_SEARCH_MAX_ROWS = GRAPHQL_PAGE_SIZE;
948
949/**
950 * Every repository of a host in one read, which is what makes a listing one request rather than
951 * one process per repository.
952 *
953 * `additions` and `deletions` are deliberately absent: measured over twelve repositories at a
954 * hundred rows, this query answers in ~4.0s with them left out and ~7.1s with them in, for two
955 * numbers at the end of a row. They are read afterwards, by `buildPullRequestStatsGraphQlQuery`.
956 *
957 * The row count is written into the document rather than sent as a variable because every
958 * variable here travels as a string — and it is this module's own number, clamped by the caller,
959 * never a reader's.
960 *
961 * `first` on the two inner connections is a bound rather than a page: a pull request with more
962 * than twenty labels shows twenty, and one that has asked more than twenty people for a review
963 * is already past what a row can say.
964 */
965export function pullRequestSearchGraphQlQuery(
966 rows: number,
967 includeStacks = false,
968 includeStats = false,
969): string {
970 return `query($q: String!, $after: String) {
971 search(query: $q, type: ISSUE, first: ${pageRows(rows)}, after: $after) {
972 pageInfo { hasNextPage endCursor }
973 nodes {
974 ... on PullRequest {
975 ${pullRequestRowSelection(includeStacks, includeStats)}
976 }
977 }
978 }
979}`;
980}
981
982/**
983 * A repository's pull requests without search, newest created first: the order `gh pr list` lists
984 * in when it does not search, for a repository GitHub's search index does not cover.
985 */
986export function pullRequestListGraphQlQuery(rows: number, includeStacks = false): string {
987 return `query($owner: String!, $name: String!, $states: [PullRequestState!], $after: String) {
988 repository(owner: $owner, name: $name) {
989 pullRequests(first: ${pageRows(rows)}, after: $after, states: $states, orderBy: { field: CREATED_AT, direction: DESC }) {
990 pageInfo { hasNextPage endCursor }
991 nodes { ${pullRequestRowSelection(includeStacks, true)} }
992 }
993 }
994}`;
995}
996
997function pageRows(rows: number): number {
998 return Math.min(Math.max(Math.trunc(rows), 1), PULL_REQUEST_SEARCH_MAX_ROWS);
999}
1000
1001/** One listing row, the same whether it came from a search or from a repository's own list. */
1002function pullRequestRowSelection(includeStacks: boolean, includeStats: boolean): string {
1003 return `${includeStacks ? "stack { number size baseRefName } stackEntry { position }" : ""}
1004 number
1005 title
1006 url
1007 author { __typename login avatarUrl ... on User { name } }
1008 headRefName
1009 baseRefName
1010 state
1011 isDraft
1012 mergeable
1013 reviewDecision
1014 latestReviews(first: 20) { nodes { state author { login } } }
1015 ${includeStats ? "additions deletions" : ""}
1016 createdAt
1017 updatedAt
1018 mergedAt
1019 repository { nameWithOwner }
1020 reviewRequests(first: 20) { nodes { requestedReviewer { ... on User { login } ... on Team { slug } } } }
1021 labels(first: 20) { nodes { name color } }
1022 commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }`;
1023}
1024
1025/**
1026 * One page of review threads with their comments, and the people on the review. `$cursor` is
1027 * null for the first page and the last page's `endCursor` after that, so a pull request with
1028 * more threads than one page holds is walked rather than cut off at the first fifty.
1029 *
1030 * Only ten comments ride with each thread. A hundred threads times a hundred comments made
1031 * GitHub reserve 10,000 nested rows and charge 104 points; unfinished threads are paged from
1032 * their own cursor below.
1033 *
1034 * Reviewers come from here rather than from `gh pr view --json reviewRequests` for two reasons:
1035 * that field holds only requests still outstanding, so anyone who has already reviewed drops off
1036 * it, and neither it nor any other `gh` JSON field carries an avatar. A reviewer can be a person,
1037 * an app, or a team, and each is asked for by name because they are different GraphQL types.
1038 *
1039 * `viewerCanUpdate` and `viewerDidAuthor` ride along here for the same reason: they belong to the
1040 * pull request this query is already standing on, so what the reader may do with it arrives with
1041 * the conversation rather than costing a request of its own.
1042 *
1043 * Commits are asked for with `last` rather than `first`: `gh pr view --json commits` pages from
1044 * the start, so a pull request with more than a hundred commits loses the newest ones from its
1045 * view entirely. This query gives back the newest hundred, which is what a reader scoping a diff
1046 * wants, and stands in for the `gh` list wherever it came back non-empty.
1047 */
1048export const REVIEW_THREADS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $cursor: String) {
1049 viewer { login }
1050 repository(owner: $owner, name: $name) {
1051 pullRequest(number: $number) {
1052 reviewThreads(first: ${GRAPHQL_PAGE_SIZE}, after: $cursor) {
1053 totalCount
1054 pageInfo { hasNextPage endCursor }
1055 nodes {
1056 id
1057 isResolved
1058 isOutdated
1059 path
1060 line
1061 diffSide
1062 comments(first: 10) {
1063 totalCount
1064 pageInfo { hasNextPage endCursor }
1065 nodes { id author { __typename login avatarUrl } body createdAt lastEditedAt url ${REACTION_GROUPS_FIELDS} }
1066 }
1067 }
1068 }
1069 viewerCanUpdate
1070 viewerDidAuthor
1071 author { __typename login avatarUrl }
1072 ${REACTION_GROUPS_FIELDS}
1073 comments(first: ${GRAPHQL_PAGE_SIZE}) {
1074 nodes { id lastEditedAt author { __typename login avatarUrl } ${REACTION_GROUPS_FIELDS} }
1075 }
1076 reviews(first: ${GRAPHQL_PAGE_SIZE}) { nodes { id lastEditedAt author { __typename login avatarUrl } ${REACTION_GROUPS_FIELDS} } }
1077 reviewRequests(first: 50) {
1078 nodes {
1079 requestedReviewer {
1080 ... on User { login name avatarUrl }
1081 ... on Bot { __typename login avatarUrl }
1082 ... on Team { combinedSlug name avatarUrl }
1083 }
1084 }
1085 }
1086 latestReviews(first: 50) {
1087 nodes { state author { __typename login avatarUrl } }
1088 }
1089 reviewDismissals: timelineItems(itemTypes: [REVIEW_DISMISSED_EVENT], first: ${GRAPHQL_PAGE_SIZE}) {
1090 pageInfo { hasNextPage endCursor }
1091 nodes { ... on ReviewDismissedEvent { dismissalMessage review { id } } }
1092 }
1093 commits(last: ${GRAPHQL_PAGE_SIZE}) {
1094 nodes {
1095 commit {
1096 oid
1097 messageHeadline
1098 committedDate
1099 additions
1100 deletions
1101 parents(first: 1) { totalCount }
1102 authors(first: 3) { nodes { name avatarUrl user { login } } }
1103 }
1104 }
1105 }
1106 }
1107 }
1108}`;
1109
1110/**
1111 * The rest of one thread's conversation. GraphQL pages a connection nested inside another only
1112 * from the inner node itself, so a thread longer than a page is followed on its own — a request
1113 * GitHub makes necessary, and one no ordinary pull request ever provokes.
1114 */
1115export const REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $threadId: ID!, $cursor: String) {
1116 viewer { login }
1117 repository(owner: $owner, name: $name) { pullRequest(number: $number) { id } }
1118 node(id: $threadId) {
1119 ... on PullRequestReviewThread {
1120 pullRequest { id }
1121 comments(first: ${GRAPHQL_PAGE_SIZE}, after: $cursor) {
1122 pageInfo { hasNextPage endCursor }
1123 nodes { id author { __typename login avatarUrl } body createdAt lastEditedAt url ${REACTION_GROUPS_FIELDS} }
1124 }
1125 }
1126 }
1127}`;
1128
1129const RawReviewThreadCommentsSchema = Schema.Struct({
1130 data: Schema.Struct({
1131 viewer: Schema.optional(
1132 Schema.NullOr(Schema.Struct({ login: Schema.optional(Schema.NullOr(Schema.String)) })),
1133 ),
1134 repository: Schema.NullOr(
1135 Schema.Struct({ pullRequest: Schema.NullOr(Schema.Struct({ id: Schema.String })) }),
1136 ),
1137 /** Null for an id that names nothing the viewer can read, which is not a thread to page. */
1138 node: Schema.NullOr(
1139 Schema.Struct({
1140 pullRequest: Schema.optional(Schema.Struct({ id: Schema.String })),
1141 comments: Schema.optional(RawThreadCommentsSchema),
1142 }),
1143 ),
1144 }),
1145});
1146
1147export const REVIEW_THREAD_REPLY_GRAPHQL_MUTATION = `mutation($threadId: ID!, $body: String!) {
1148 addPullRequestReviewThreadReply(input: { pullRequestReviewThreadId: $threadId, body: $body }) {
1149 comment { id }
1150 }
1151}`;
1152
1153/**
1154 * The pull request's own node id, which is what a reaction on its description is addressed by.
1155 * Read only when one is being written: the conversation carries an id for every remark in it, and
1156 * the pull request is the one subject nothing in it names.
1157 */
1158export const PULL_REQUEST_NODE_ID_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) {
1159 repository(owner: $owner, name: $name) { pullRequest(number: $number) { id } }
1160}`;
1161
1162const RawPullRequestNodeIdSchema = Schema.Struct({
1163 data: Schema.Struct({
1164 repository: Schema.Struct({
1165 pullRequest: Schema.Struct({ id: Schema.String }),
1166 }),
1167 }),
1168});
1169
1170const decodePullRequestNodeId = decodeJsonResult(RawPullRequestNodeIdSchema);
1171
1172export function decodePullRequestNodeIdJson(raw: string): Result.Result<string, DecodeFailure> {
1173 const decoded = decodePullRequestNodeId(raw);
1174 return Result.isSuccess(decoded)
1175 ? Result.succeed(decoded.success.data.repository.pullRequest.id)
1176 : Result.fail(decoded.failure);
1177}
1178
1179/**
1180 * Where a client-given reaction subject actually hangs: the pull request itself, or the pull
1181 * request an issue comment, a review comment, or a review belongs to. Read before a mutation
1182 * reaches it, so a subject named for one pull request cannot react on another's behalf.
1183 */
1184export const REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $subjectId: ID!) {
1185 repository(owner: $owner, name: $name) { pullRequest(number: $number) { id } }
1186 node(id: $subjectId) {
1187 id
1188 ... on IssueComment { pullRequest { id } }
1189 ... on PullRequestReviewComment { pullRequest { id } }
1190 ... on PullRequestReview { pullRequest { id } }
1191 }
1192}`;
1193
1194const RawReactionSubjectScopeSchema = Schema.Struct({
1195 data: Schema.Struct({
1196 repository: Schema.NullOr(
1197 Schema.Struct({ pullRequest: Schema.NullOr(Schema.Struct({ id: Schema.String })) }),
1198 ),
1199 node: Schema.NullOr(
1200 Schema.Struct({
1201 id: Schema.String,
1202 pullRequest: Schema.optional(Schema.Struct({ id: Schema.String })),
1203 }),
1204 ),
1205 }),
1206});
1207
1208const decodeReactionSubjectScope = decodeJsonResult(RawReactionSubjectScopeSchema);
1209
1210/**
1211 * True when the subject named is the pull request itself, or hangs off it — false for anything
1212 * else, including a subject or a pull request this host could not find.
1213 */
1214export function decodeReactionSubjectScopeJson(raw: string): Result.Result<boolean, DecodeFailure> {
1215 const decoded = decodeReactionSubjectScope(raw);
1216 if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure);
1217 const expected = decoded.success.data.repository?.pullRequest?.id ?? null;
1218 const node = decoded.success.data.node;
1219 const actual = node === null ? null : (node.pullRequest?.id ?? node.id);
1220 return Result.succeed(expected !== null && actual !== null && expected === actual);
1221}
1222
1223export const ADD_REACTION_GRAPHQL_MUTATION = `mutation($subjectId: ID!, $content: ReactionContent!) {
1224 addReaction(input: { subjectId: $subjectId, content: $content }) { reaction { content } }
1225}`;
1226
1227export const REMOVE_REACTION_GRAPHQL_MUTATION = `mutation($subjectId: ID!, $content: ReactionContent!) {
1228 removeReaction(input: { subjectId: $subjectId, content: $content }) { reaction { content } }
1229}`;
1230
1231export const RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION = `mutation($threadId: ID!) {
1232 resolveReviewThread(input: { threadId: $threadId }) { thread { isResolved } }
1233}`;
1234
1235export const UNRESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION = `mutation($threadId: ID!) {
1236 unresolveReviewThread(input: { threadId: $threadId }) { thread { isResolved } }
1237}`;
1238
1239/**
1240 * Rewrites the pull request's own words. Both are nullable so that one document serves a change
1241 * to the title, to the description, or to the two together: a variable the request does not send
1242 * puts no entry in the input at all, which leaves that field as it was rather than clearing it.
1243 */
1244export const UPDATE_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!, $title: String, $body: String) {
1245 updatePullRequest(input: { pullRequestId: $pullRequestId, title: $title, body: $body }) {
1246 pullRequest { id }
1247 }
1248}`;
1249
1250/** Creates a new pull request that reverses a merged pull request. */
1251export const REVERT_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) {
1252 revertPullRequest(input: { pullRequestId: $pullRequestId }) {
1253 revertPullRequest { id }
1254 }
1255}`;
1256
1257/**
1258 * The two comment mutations name their comment differently. The variable is spelled the same in
1259 * both, so a rewrite sends one set of variables whichever kind of remark it is.
1260 */
1261export const UPDATE_ISSUE_COMMENT_GRAPHQL_MUTATION = `mutation($commentId: ID!, $body: String!) {
1262 updateIssueComment(input: { id: $commentId, body: $body }) { issueComment { id } }
1263}`;
1264
1265export const UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION = `mutation($commentId: ID!, $body: String!) {
1266 updatePullRequestReviewComment(input: { pullRequestReviewCommentId: $commentId, body: $body }) {
1267 pullRequestReviewComment { id }
1268 }
1269}`;
1270
1271/** The body of `POST /repos/{owner}/{repo}/pulls/{number}/reviews`, which sends a review whole. */
1272const ReviewSubmissionSchema = Schema.Struct({
1273 event: Schema.Literals(["COMMENT", "APPROVE", "REQUEST_CHANGES"]),
1274 body: Schema.String,
1275 comments: Schema.Array(
1276 Schema.Struct({
1277 path: Schema.String,
1278 line: Schema.Int,
1279 side: Schema.Literals(["LEFT", "RIGHT"]),
1280 body: Schema.String,
1281 }),
1282 ),
1283});
1284
1285const REVIEW_EVENTS: Record<PullRequestReviewVerdict, "COMMENT" | "APPROVE" | "REQUEST_CHANGES"> = {
1286 comment: "COMMENT",
1287 approve: "APPROVE",
1288 "request-changes": "REQUEST_CHANGES",
1289};
1290
1291/**
1292 * The dismissal events past the page the thread read carries. A pull request rarely has any:
1293 * this is followed only while the embedded page reports more.
1294 */
1295export const REVIEW_DISMISSALS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $cursor: String) {
1296 repository(owner: $owner, name: $name) {
1297 pullRequest(number: $number) {
1298 timelineItems(itemTypes: [REVIEW_DISMISSED_EVENT], first: ${GRAPHQL_PAGE_SIZE}, after: $cursor) {
1299 pageInfo { hasNextPage endCursor }
1300 nodes { ... on ReviewDismissedEvent { dismissalMessage review { id } } }
1301 }
1302 }
1303 }
1304}`;
1305
1306function gitHubReviewPosition(position: PullRequestReviewPosition): {
1307 readonly line: number;
1308 readonly side: "LEFT" | "RIGHT";
1309} {
1310 switch (position.kind) {
1311 case "added":
1312 return { line: position.newLine, side: "RIGHT" };
1313 case "deleted":
1314 return { line: position.oldLine, side: "LEFT" };
1315 case "context":
1316 return position.side === "left"
1317 ? { line: position.oldLine, side: "LEFT" }
1318 : { line: position.newLine, side: "RIGHT" };
1319 }
1320}
1321
1322/** The whole review as one request body, which is how GitHub keeps it invisible until sent. */
1323export function buildReviewSubmission(input: {
1324 readonly verdict: PullRequestReviewVerdict;
1325 readonly body: string;
1326 readonly comments: ReadonlyArray<PullRequestReviewCommentDraft>;
1327}): typeof ReviewSubmissionSchema.Type {
1328 return {
1329 event: REVIEW_EVENTS[input.verdict],
1330 body: input.body,
1331 comments: input.comments.map((comment) => ({
1332 path: comment.path,
1333 ...gitHubReviewPosition(comment.position),
1334 body: comment.body,
1335 })),
1336 };
1337}
1338
1339export interface GitHubPullRequestListItem {
1340 readonly stack?: PullRequestStackMembership;
1341 /** The author's node id, kept so a batch can resolve the avatar the listing does not carry. */
1342 readonly authorId: string | null;
1343 readonly number: number;
1344 readonly title: string;
1345 readonly url: string;
1346 readonly author: PullRequestActor | null;
1347 readonly headBranch: string;
1348 readonly baseBranch: string;
1349 readonly state: PullRequestState;
1350 readonly isDraft: boolean;
1351 readonly mergeability: PullRequestMergeability;
1352 /** Null where GitHub has no verdict to summarise, which includes a draft nobody has reviewed. */
1353 readonly reviewDecision: PullRequestReviewDecision | null;
1354 readonly additions: number;
1355 readonly deletions: number;
1356 readonly createdAt: string;
1357 readonly updatedAt: string;
1358 readonly reviewRequestLogins: ReadonlyArray<string>;
1359 /** At least one outstanding request targets a team rather than an individual login. */
1360 readonly hasTeamReviewRequest: boolean;
1361 readonly labels: ReadonlyArray<PullRequestLabel>;
1362 /** Null where the head commit reported no checks, which is not the same as passing none. */
1363 readonly checksState: PullRequestChecksState | null;
1364}
1365
1366export interface GitHubPullRequestDetail extends GitHubPullRequestListItem {
1367 /** True only when GitHub says the head belongs to another repository. */
1368 readonly isCrossRepository?: boolean;
1369 /** The owner of the head branch's repository; null where `gh` did not say. */
1370 readonly headRepositoryOwner: string | null;
1371 readonly headRepositoryNameWithOwner?: string | null;
1372 readonly headSha?: string | null;
1373 readonly body: string;
1374 readonly changedFiles: number;
1375 readonly mergedAt: string | null;
1376 readonly closedAt: string | null;
1377 readonly checks: ReadonlyArray<PullRequestCheck>;
1378 /** Absent where `gh` did not answer for auto-merge at all, which is not the same as off. */
1379 readonly autoMergeEnabled?: boolean;
1380 /** Absent where auto-merge is off or GitHub did not report the stored strategy. */
1381 readonly autoMergeMethod?: PullRequestMergeMethod;
1382}
1383
1384export interface GitHubWorkflowRunApproval {
1385 readonly id: number;
1386 readonly name: string;
1387 readonly url: string | null;
1388}
1389
1390export interface GitHubPullRequestHead {
1391 readonly number: number;
1392 readonly headSha: string;
1393 readonly isCrossRepository?: boolean;
1394 readonly headRepositoryOwner: string | null;
1395}
1396
1397export interface GitHubPullRequestActivity {
1398 readonly author: PullRequestActor | null;
1399 readonly comments: ReadonlyArray<PullRequestComment>;
1400 readonly commits: ReadonlyArray<PullRequestCommit>;
1401}
1402
1403function trimmed(value: string | null | undefined): string | null {
1404 const text = value?.trim() ?? "";
1405 return text.length > 0 ? text : null;
1406}
1407
1408/**
1409 * Null once a connection has nothing further, which is what ends every walk below. GitHub sends
1410 * an `endCursor` on a page that is also the last one, so the flag is what decides, not the
1411 * cursor's presence.
1412 */
1413function nextCursorOf(
1414 pageInfo: Schema.Schema.Type<typeof RawPageInfoSchema> | undefined,
1415): string | null {
1416 return pageInfo?.hasNextPage === true ? trimmed(pageInfo.endCursor) : null;
1417}
1418
1419/**
1420 * The viewer's standing on one pull request. The two halves take opposite defaults on purpose.
1421 *
1422 * Updating is a permission, so an install that does not report it grants it and lets the host's
1423 * own refusal explain anything that fails. Authorship is not a permission but a fact about who
1424 * wrote the thing, and it is read to decide what an author may do to their own change — so an
1425 * unknown answer is "not the author", which grants nothing it should not.
1426 */
1427function toPullRequestViewerFields(
1428 raw: Schema.Schema.Type<typeof RawViewerFieldsSchema> | null | undefined,
1429): { readonly canUpdate: boolean; readonly didAuthor: boolean } {
1430 return { canUpdate: raw?.viewerCanUpdate !== false, didAuthor: raw?.viewerDidAuthor === true };
1431}
1432
1433function toActor(raw: Schema.Schema.Type<typeof RawActorSchema> | null | undefined) {
1434 const login = trimmed(raw?.login);
1435 return login === null
1436 ? null
1437 : {
1438 login,
1439 name: trimmed(raw?.name),
1440 avatarUrl: trimmed(raw?.avatarUrl),
1441 ...(raw?.__typename === "Bot" || raw?.is_bot === true ? { isBot: true } : {}),
1442 };
1443}
1444
1445/**
1446 * A requested team as a reviewer row. It goes by `org/slug`, the handle GitHub mentions it by.
1447 * No GitHub login contains a slash, so a team never shares a row or a verdict with a user.
1448 */
1449function toRequestedTeam(
1450 raw:
1451 | Pick<Schema.Schema.Type<typeof RawReviewRequestSchema>, "combinedSlug" | "name" | "avatarUrl">
1452 | null
1453 | undefined,
1454): PullRequestActor | null {
1455 const login = trimmed(raw?.combinedSlug);
1456 return login === null
1457 ? null
1458 : { login, name: trimmed(raw?.name), avatarUrl: trimmed(raw?.avatarUrl) };
1459}
1460
1461function toCommitActor(
1462 raw: NonNullable<Schema.Schema.Type<typeof RawCommitSchema>["authors"]>[number],
1463): PullRequestActor | null {
1464 // An email-linked GitHub account has a login; an unlinked signature only has a name or email.
1465 // Keep that signature visible instead of silently turning a co-authored commit into one author.
1466 const login = trimmed(raw.login) ?? trimmed(raw.name) ?? trimmed(raw.email);
1467 return login === null ? null : { login, name: trimmed(raw.name), avatarUrl: null };
1468}
1469
1470/** An author off the GraphQL commits connection, which names an account by `user.login` where
1471 * `gh pr view --json commits` names it by a flat `login` copied off the signature. */
1472function toGraphqlCommitActor(raw: {
1473 readonly name?: string | null | undefined;
1474 readonly avatarUrl?: string | null | undefined;
1475 readonly user?: { readonly login?: string | null | undefined } | null | undefined;
1476}): PullRequestActor | null {
1477 const login = trimmed(raw.user?.login) ?? trimmed(raw.name);
1478 return login === null
1479 ? null
1480 : { login, name: trimmed(raw.name), avatarUrl: trimmed(raw.avatarUrl) };
1481}
1482
1483function toState(raw: {
1484 readonly state?: string | null | undefined;
1485 readonly mergedAt?: string | null | undefined;
1486}): PullRequestState {
1487 if (trimmed(raw.mergedAt) !== null) return "merged";
1488 const state = raw.state?.trim().toUpperCase();
1489 if (state === "MERGED") return "merged";
1490 if (state === "CLOSED") return "closed";
1491 return "open";
1492}
1493
1494function toMergeability(value: string | null | undefined): PullRequestMergeability {
1495 switch (value?.trim().toUpperCase()) {
1496 case "MERGEABLE":
1497 return "mergeable";
1498 case "CONFLICTING":
1499 return "conflicting";
1500 default:
1501 return "unknown";
1502 }
1503}
1504
1505function toMergeMethod(value: string | null | undefined): PullRequestMergeMethod | undefined {
1506 switch (value?.trim().toUpperCase()) {
1507 case "MERGE":
1508 return "merge";
1509 case "SQUASH":
1510 return "squash";
1511 case "REBASE":
1512 return "rebase";
1513 default:
1514 return undefined;
1515 }
1516}
1517
1518/**
1519 * GitHub's own `reviewDecision` counts only reviews that satisfy the branch rules, so an
1520 * approval from an app (a review bot) or from anyone without the required permission leaves it
1521 * empty. The reviewers still said something, and a row should show it: when GitHub reports no
1522 * verdict, the latest review per reviewer decides, changes requested outranking approval.
1523 */
1524function toReviewDecisionWithReviews(
1525 value: string | null | undefined,
1526 // `gh pr list` hands the reviews as an array; the GraphQL reads hand a connection.
1527 latestReviews:
1528 | ReadonlyArray<Schema.Schema.Type<typeof RawLatestReviewSchema>>
1529 | { readonly nodes: ReadonlyArray<Schema.Schema.Type<typeof RawLatestReviewSchema>> }
1530 | null
1531 | undefined,
1532): PullRequestReviewDecision | null {
1533 const summarized = toReviewDecision(value);
1534 if (summarized === "approved" || summarized === "changes-requested") return summarized;
1535 const reviews =
1536 latestReviews === null || latestReviews === undefined
1537 ? []
1538 : "nodes" in latestReviews
1539 ? latestReviews.nodes
1540 : latestReviews;
1541 const states = new Set(reviews.map((review) => review.state?.trim().toUpperCase() ?? ""));
1542 if (states.has("CHANGES_REQUESTED")) return "changes-requested";
1543 if (states.has("APPROVED")) return "approved";
1544 return summarized;
1545}
1546
1547function toReviewDecision(value: string | null | undefined): PullRequestReviewDecision | null {
1548 switch (value?.trim().toUpperCase()) {
1549 case "APPROVED":
1550 return "approved";
1551 case "CHANGES_REQUESTED":
1552 return "changes-requested";
1553 case "REVIEW_REQUIRED":
1554 return "review-required";
1555 default:
1556 return null;
1557 }
1558}
1559
1560function toLabels(
1561 raw: ReadonlyArray<Schema.Schema.Type<typeof RawLabelSchema>> | undefined,
1562): ReadonlyArray<PullRequestLabel> {
1563 return (raw ?? []).flatMap((label) => {
1564 const name = trimmed(label.name);
1565 return name === null ? [] : [{ name, color: trimmed(label.color) }];
1566 });
1567}
1568
1569/**
1570 * User review requests only. Team requests are tracked separately because a slug cannot be
1571 * compared with the viewer's login.
1572 */
1573function toReviewRequestLogins(
1574 raw: ReadonlyArray<Schema.Schema.Type<typeof RawReviewRequestSchema>> | undefined,
1575): ReadonlyArray<string> {
1576 return (raw ?? []).flatMap((request) => {
1577 const login = trimmed(request.login);
1578 return login === null ? [] : [login];
1579 });
1580}
1581
1582function hasTeamReviewRequest(
1583 raw: ReadonlyArray<Schema.Schema.Type<typeof RawReviewRequestSchema>> | undefined,
1584): boolean {
1585 return (raw ?? []).some(
1586 (request) =>
1587 trimmed(request.login) === null &&
1588 (trimmed(request.slug) !== null || trimmed(request.name) !== null),
1589 );
1590}
1591
1592function toCheckStatus(raw: Schema.Schema.Type<typeof RawCheckSchema>): PullRequestCheckStatus {
1593 // Commit statuses report a single `state`; check runs report `status` plus a `conclusion`
1594 // that only exists once the run has completed.
1595 const status = raw.status?.trim().toUpperCase();
1596 if (status !== undefined && status !== "COMPLETED" && status !== "") {
1597 return "pending";
1598 }
1599 switch ((raw.conclusion ?? raw.state)?.trim().toUpperCase()) {
1600 case "SUCCESS":
1601 return "success";
1602 case "ACTION_REQUIRED":
1603 return "action-required";
1604 case "FAILURE":
1605 case "ERROR":
1606 case "TIMED_OUT":
1607 case "STARTUP_FAILURE":
1608 return "failure";
1609 case "CANCELLED":
1610 return "cancelled";
1611 case "SKIPPED":
1612 return "skipped";
1613 case "PENDING":
1614 case "EXPECTED":
1615 return "pending";
1616 default:
1617 return "neutral";
1618 }
1619}
1620
1621/** What GitHub writes where a run has not reached that moment yet, which is not a time. */
1622const UNSET_TIMESTAMP = "0001-01-01T00:00:00Z";
1623
1624/** Sorts after every real timestamp, so the deduper keeps a run that has not finished. */
1625const STILL_RUNNING = "9999-12-31T23:59:59Z";
1626
1627function realTimestamp(value: string | null | undefined): string | null {
1628 const at = trimmed(value);
1629 return at === null || at === UNSET_TIMESTAMP ? null : at;
1630}
1631
1632/** Only a row the rollup gives no name of any kind, which is not a check anyone can show. */
1633function isNamelessCheck(raw: Schema.Schema.Type<typeof RawCheckSchema>): boolean {
1634 return trimmed(raw.name) === null && trimmed(raw.context) === null;
1635}
1636
1637/**
1638 * The rollup as the deduper reads it: a check, the workflow that owns it, and when the run last
1639 * had something to say. A run that has not finished outranks any finished run of the same check:
1640 * a workflow triggered by both `push` and `pull_request` runs the check twice on one commit, and
1641 * the run that finished first must not hide the one still going.
1642 */
1643function toCheckEntries(
1644 raw: ReadonlyArray<Schema.Schema.Type<typeof RawCheckSchema>> | null | undefined,
1645): ReadonlyArray<{
1646 readonly check: PullRequestCheck;
1647 readonly workflowName: string | null;
1648 readonly at: string | null;
1649}> {
1650 return (raw ?? []).flatMap((check) => {
1651 const name = trimmed(check.name) ?? trimmed(check.context);
1652 if (name === null) return [];
1653 const status = toCheckStatus(check);
1654 const completedAt = trimmed(check.completedAt);
1655 return [
1656 {
1657 check: {
1658 name,
1659 status,
1660 description: trimmed(check.description),
1661 url: trimmed(check.detailsUrl) ?? trimmed(check.targetUrl),
1662 ...(completedAt === null || completedAt.startsWith("0001-") ? {} : { completedAt }),
1663 ...(typeof check.isRequired === "boolean" ? { required: check.isRequired } : {}),
1664 },
1665 workflowName: trimmed(check.workflowName),
1666 at:
1667 status === "pending"
1668 ? STILL_RUNNING
1669 : (realTimestamp(check.completedAt) ?? realTimestamp(check.startedAt)),
1670 },
1671 ];
1672 });
1673}
1674
1675/**
1676 * The one word a listing row has space for. A failure outranks anything still running, the way
1677 * GitHub's own indicator reads: a run that has already gone red will not go green by finishing.
1678 *
1679 * Null rather than "passing" for a head commit with no checks at all, so a repository that runs
1680 * none shows nothing instead of a green tick it never earned. A cancelled run is a failure, as
1681 * GitHub's own rollup and the client's detail rollup both read it; skipped and neutral count
1682 * towards neither, so the row and the detail header never disagree about one head commit.
1683 *
1684 * Counted off the deduped checks rather than the raw rollup, so the word and the list under it
1685 * cannot disagree: the run a re-run replaced is not a verdict twice. A row with no name at all is
1686 * counted as it comes, since the cross-repository search dresses GitHub's own rollup enum as one
1687 * nameless row, and nothing nameless can collide with anything.
1688 */
1689function rollupChecksState(
1690 raw: ReadonlyArray<Schema.Schema.Type<typeof RawCheckSchema>> | null | undefined,
1691): PullRequestChecksState | null {
1692 const statuses = [
1693 ...toChecks(raw).map((check) => check.status),
1694 ...(raw ?? []).filter(isNamelessCheck).map((check) => toCheckStatus(check)),
1695 ];
1696 if (statuses.length === 0) return null;
1697 if (statuses.includes("failure") || statuses.includes("cancelled")) return "failing";
1698 if (statuses.includes("pending") || statuses.includes("action-required")) return "pending";
1699 return statuses.includes("success") ? "passing" : null;
1700}
1701
1702function toChecks(
1703 raw: ReadonlyArray<Schema.Schema.Type<typeof RawCheckSchema>> | null | undefined,
1704): ReadonlyArray<PullRequestCheck> {
1705 return dedupeChecks(toCheckEntries(raw));
1706}
1707
1708/** The states that are a verdict in themselves, rather than a wrapper around line comments. */
1709function isReviewVerdict(reviewState: string | null): boolean {
1710 switch (reviewState?.toUpperCase()) {
1711 case "APPROVED":
1712 case "CHANGES_REQUESTED":
1713 case "DISMISSED":
1714 return true;
1715 default:
1716 return false;
1717 }
1718}
1719
1720function toComments(raw: {
1721 readonly comments?: ReadonlyArray<Schema.Schema.Type<typeof RawCommentSchema>> | undefined;
1722 readonly reviews?: ReadonlyArray<Schema.Schema.Type<typeof RawReviewSchema>> | undefined;
1723}): ReadonlyArray<PullRequestComment> {
1724 const issueComments = (raw.comments ?? []).map((comment): PullRequestComment => ({
1725 id: comment.id,
1726 kind: "issue-comment",
1727 author: toActor(comment.author),
1728 body: comment.body ?? "",
1729 createdAt: comment.createdAt,
1730 editedAt: comment.lastEditedAt ?? null,
1731 url: trimmed(comment.url),
1732 path: null,
1733 reviewState: null,
1734 }));
1735 // A review with no body is kept only when its state is the event itself — an approval, a
1736 // request for changes, a dismissal. GitHub also opens a bodiless `COMMENTED` review as the
1737 // container for line comments, and those comments are read from the review threads, so
1738 // keeping the container too would show a row with a name and nothing under it.
1739 const reviews = (raw.reviews ?? []).flatMap((review): ReadonlyArray<PullRequestComment> => {
1740 const submittedAt = trimmed(review.submittedAt);
1741 const reviewState = trimmed(review.state);
1742 if (
1743 submittedAt === null ||
1744 ((review.body ?? "").trim().length === 0 && !isReviewVerdict(reviewState))
1745 ) {
1746 return [];
1747 }
1748 return [
1749 {
1750 id: review.id,
1751 kind: "review",
1752 author: toActor(review.author),
1753 body: review.body ?? "",
1754 createdAt: submittedAt,
1755 editedAt: review.lastEditedAt ?? null,
1756 url: trimmed(review.url),
1757 path: null,
1758 reviewState,
1759 },
1760 ];
1761 });
1762 return [...issueComments, ...reviews].toSorted((left, right) =>
1763 left.createdAt.localeCompare(right.createdAt),
1764 );
1765}
1766
1767function toCommits(
1768 commits: ReadonlyArray<Schema.Schema.Type<typeof RawCommitSchema>> | undefined,
1769): ReadonlyArray<PullRequestCommit> {
1770 return (commits ?? []).map((commit) => ({
1771 oid: commit.oid,
1772 messageHeadline: commit.messageHeadline ?? "",
1773 committedDate: commit.committedDate,
1774 authors: (commit.authors ?? []).flatMap((author) => {
1775 const actor = toCommitActor(author);
1776 return actor === null ? [] : [actor];
1777 }),
1778 }));
1779}
1780
1781function toListItem(raw: Schema.Schema.Type<typeof RawListItemSchema>): GitHubPullRequestListItem {
1782 return {
1783 authorId: trimmed(raw.author?.id),
1784 number: raw.number,
1785 title: raw.title,
1786 url: raw.url,
1787 author: toActor(raw.author),
1788 headBranch: raw.headRefName,
1789 baseBranch: raw.baseRefName,
1790 state: toState(raw),
1791 isDraft: raw.isDraft ?? false,
1792 mergeability: toMergeability(raw.mergeable),
1793 reviewDecision: toReviewDecisionWithReviews(raw.reviewDecision, raw.latestReviews),
1794 additions: raw.additions ?? 0,
1795 deletions: raw.deletions ?? 0,
1796 createdAt: raw.createdAt,
1797 updatedAt: raw.updatedAt,
1798 reviewRequestLogins: toReviewRequestLogins(raw.reviewRequests),
1799 hasTeamReviewRequest: hasTeamReviewRequest(raw.reviewRequests),
1800 labels: toLabels(raw.labels),
1801 checksState: rollupChecksState(raw.statusCheckRollup),
1802 };
1803}
1804
1805function toDetail(raw: Schema.Schema.Type<typeof RawDetailSchema>): GitHubPullRequestDetail {
1806 const autoMergeMethod = toMergeMethod(raw.autoMergeRequest?.mergeMethod);
1807 return {
1808 ...toListItem(raw),
1809 ...(typeof raw.isCrossRepository === "boolean"
1810 ? { isCrossRepository: raw.isCrossRepository }
1811 : {}),
1812 headRepositoryOwner: trimmed(raw.headRepositoryOwner?.login),
1813 headSha: trimmed(raw.headRefOid),
1814 body: raw.body ?? "",
1815 changedFiles: raw.changedFiles ?? 0,
1816 mergedAt: trimmed(raw.mergedAt),
1817 closedAt: trimmed(raw.closedAt),
1818 checks: toChecks(raw.statusCheckRollup),
1819 // A JSON null is GitHub saying "nobody armed this"; a missing key is GitHub not saying, and
1820 // the difference survives here rather than being flattened into false.
1821 ...(raw.autoMergeRequest === undefined
1822 ? {}
1823 : { autoMergeEnabled: raw.autoMergeRequest !== null }),
1824 ...(autoMergeMethod === undefined ? {} : { autoMergeMethod }),
1825 };
1826}
1827
1828const decodeUnknownList = decodeJsonResult(Schema.Array(Schema.Unknown));
1829const decodeSearch = decodeJsonResult(RawSearchSchema);
1830const decodeSearchItem = Schema.decodeUnknownExit(RawSearchItemSchema);
1831const decodeStats = decodeJsonResult(RawStatsSchema);
1832const decodeDetail = decodeJsonResult(RawDetailSchema);
1833const decodeWorkflowRuns = decodeJsonResult(RawWorkflowRunsSchema);
1834const decodePullRequestHeads = decodeJsonResult(RawPullRequestHeadsSchema);
1835const decodeActivity = decodeJsonResult(RawActivitySchema);
1836const decodeRepositoryPullRequests = decodeJsonResult(RawRepositoryPullRequestsSchema);
1837const decodeFileEntry = Schema.decodeUnknownExit(RawPullRequestFileSchema);
1838const decodeReviewThreads = decodeJsonResult(RawReviewThreadsSchema);
1839const decodeReviewThreadComments = decodeJsonResult(RawReviewThreadCommentsSchema);
1840
1841type DecodeFailure = Cause.Cause<Schema.SchemaError>;
1842
1843export interface GitHubPullRequestListBatch {
1844 readonly items: ReadonlyArray<GitHubPullRequestListItem>;
1845 /** Rows GitHub returned, counted before decoding, so a skipped row cannot hide a next page. */
1846 readonly rawCount: number;
1847 /** Where the next page starts, or null once GitHub has handed over every row. */
1848 readonly endCursor: string | null;
1849}
1850
1851/**
1852 * One page of a repository's own pull request list. Malformed rows are skipped rather than
1853 * failing the page: one unexpected pull request must not blank the whole list. A repository the
1854 * viewer cannot see answers as an empty page, which is what `gh pr list` printed for it too.
1855 */
1856export function decodePullRequestListJson(
1857 raw: string,
1858): Result.Result<GitHubPullRequestListBatch, DecodeFailure> {
1859 const decoded = decodeRepositoryPullRequests(raw);
1860 if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure);
1861 const connection = decoded.success.data.repository?.pullRequests;
1862 const nodes = connection?.nodes ?? [];
1863 const items: GitHubPullRequestListItem[] = [];
1864 for (const entry of nodes) {
1865 const row = toRow(entry);
1866 if (row !== null) items.push(row.item);
1867 }
1868 return Result.succeed({
1869 items,
1870 rawCount: nodes.length,
1871 endCursor:
1872 connection?.pageInfo?.hasNextPage === true ? trimmed(connection.pageInfo.endCursor) : null,
1873 });
1874}
1875
1876/**
1877 * A row as GraphQL answers it: reviewers and labels arrive as connections, and the checks as
1878 * GitHub's one-word rollup rather than the whole check list. Null for a node that is not a pull
1879 * request, or one whose fields no longer decode.
1880 */
1881function toRow(
1882 entry: unknown,
1883): { readonly item: GitHubPullRequestListItem; readonly repository: string | null } | null {
1884 const decodedNode = decodeSearchItem(entry);
1885 if (!Exit.isSuccess(decodedNode)) return null;
1886 const node = decodedNode.value;
1887 const stack = toStackMembership(node);
1888 const reviewRequests = (node.reviewRequests?.nodes ?? []).flatMap(
1889 (request): ReadonlyArray<{ readonly login?: string; readonly slug?: string }> => {
1890 const reviewer = request?.requestedReviewer;
1891 const login = trimmed(reviewer?.login);
1892 if (login !== null) return [{ login }];
1893 const slug = trimmed(reviewer?.slug);
1894 return slug === null ? [] : [{ slug }];
1895 },
1896 );
1897 return {
1898 item: {
1899 ...toListItem({
1900 ...node,
1901 latestReviews: (node.latestReviews?.nodes ?? []).flatMap((review) =>
1902 review === null ? [] : [review],
1903 ),
1904 reviewRequests,
1905 labels: (node.labels?.nodes ?? []).flatMap((label) => (label === null ? [] : [label])),
1906 // The rollup arrives as one enum. Dressed as a single check here so it is read the same
1907 // way the detail's checks are.
1908 statusCheckRollup: (node.commits?.nodes ?? []).flatMap((commitNode) => {
1909 const state = trimmed(commitNode?.commit?.statusCheckRollup?.state);
1910 return state === null ? [] : [{ state }];
1911 }),
1912 }),
1913 ...(stack === undefined ? {} : { stack }),
1914 },
1915 repository: trimmed(node.repository?.nameWithOwner),
1916 };
1917}
1918
1919export interface GitHubPullRequestSearchItem extends GitHubPullRequestListItem {
1920 /** `owner/name` as GitHub spells it, which is how a row from a search finds its repository. */
1921 readonly repository: string;
1922}
1923
1924export interface GitHubPullRequestSearchBatch {
1925 readonly items: ReadonlyArray<GitHubPullRequestSearchItem>;
1926 /** Rows the search returned, counted before decoding, so a skipped row cannot hide a next page. */
1927 readonly rawCount: number;
1928 /** More rows than this slice asked for, which is truncation for every repository in it. */
1929 readonly hasNextPage: boolean;
1930 /** Where the next page of the same search starts. */
1931 readonly endCursor: string | null;
1932}
1933
1934/**
1935 * A search answers with the same row a repository's list does, each naming its repository.
1936 *
1937 * Rows that are not pull requests decode as empty and are skipped, the way a malformed listing
1938 * row is — `is:pr` already excludes them, and one surprise must not blank a whole host.
1939 */
1940export function decodePullRequestSearchJson(
1941 raw: string,
1942): Result.Result<GitHubPullRequestSearchBatch, DecodeFailure> {
1943 const decoded = decodeSearch(raw);
1944 if (!Result.isSuccess(decoded)) {
1945 return Result.fail(decoded.failure);
1946 }
1947 const search = decoded.success.data.search;
1948 const nodes = search.nodes ?? [];
1949 const items: GitHubPullRequestSearchItem[] = [];
1950 for (const entry of nodes) {
1951 const row = toRow(entry);
1952 if (row === null || row.repository === null) continue;
1953 items.push({ ...row.item, repository: row.repository });
1954 }
1955 return Result.succeed({
1956 items,
1957 rawCount: nodes.length,
1958 hasNextPage: search.pageInfo?.hasNextPage ?? false,
1959 endCursor: search.pageInfo?.hasNextPage === true ? trimmed(search.pageInfo.endCursor) : null,
1960 });
1961}
1962
1963function toStackMembership(
1964 raw: Schema.Schema.Type<typeof RawStackMembershipSchema>,
1965): PullRequestStackMembership | undefined {
1966 return raw.stack && raw.stackEntry
1967 ? {
1968 number: raw.stack.number,
1969 size: raw.stack.size,
1970 base: raw.stack.baseRefName,
1971 position: raw.stackEntry.position,
1972 }
1973 : undefined;
1974}
1975
1976/** What GitHub allows in an owner or repository name. */
1977const REPOSITORY_PART = /^[A-Za-z0-9._-]+$/;
1978
1979/**
1980 * The owner and name of an `owner/name` selector, or null for one GitHub cannot name. A batch is
1981 * one document, and a single unanswerable alias fails all of it, so these are refused up front.
1982 */
1983function repositoryParts(
1984 repository: string,
1985): { readonly owner: string; readonly name: string } | null {
1986 const [owner, name, ...rest] = repository.trim().split("/");
1987 if (rest.length > 0 || owner === undefined || name === undefined) return null;
1988 return REPOSITORY_PART.test(owner) && REPOSITORY_PART.test(name) ? { owner, name } : null;
1989}
1990
1991const isPullRequestNumber = (number: number) => Number.isSafeInteger(number) && number > 0;
1992
1993/** One aliased `repository { pullRequest { selection } }` per change request, on one host. */
1994function aliasedPullRequestsDocument(
1995 name: string,
1996 alias: string,
1997 changeRequests: ReadonlyArray<{ readonly repository: string; readonly number: number }>,
1998 selection: string,
1999): GraphQlDocument | null {
2000 const addressed = changeRequests.map((changeRequest) => ({
2001 parts: repositoryParts(changeRequest.repository),
2002 number: changeRequest.number,
2003 }));
2004 if (addressed.some(({ parts, number }) => parts === null || !isPullRequestNumber(number))) {
2005 return null;
2006 }
2007 return aliasedGraphQlDocument({
2008 operation: "query",
2009 name,
2010 alias,
2011 items: addressed,
2012 variables: ({ parts, number }) => ({
2013 owner: ["String!", parts!.owner],
2014 name: ["String!", parts!.name],
2015 number: ["Int!", number],
2016 }),
2017 field: ({ owner, name, number }) =>
2018 `repository(owner: ${owner}, name: ${name}) { pullRequest(number: ${number}) { ${selection} } }`,
2019 });
2020}
2021
2022/**
2023 * The line counts for rows a listing already handed over, as one aliased lookup each.
2024 *
2025 * Aliases rather than `nodes(ids:)` because the caller asks in the terms the page holds — a
2026 * repository and a number — and never sees a node id. Null for a selector GitHub cannot name,
2027 * which the caller reports rather than sends, and for an empty request.
2028 */
2029export function buildPullRequestStatsGraphQlQuery(
2030 changeRequests: ReadonlyArray<{ readonly repository: string; readonly number: number }>,
2031): GraphQlDocument | null {
2032 return aliasedPullRequestsDocument(
2033 "PullRequestStats",
2034 "s",
2035 changeRequests,
2036 "additions deletions",
2037 );
2038}
2039
2040/** Stack membership for the visible rows of a per-repository listing. */
2041export function buildPullRequestStackMembershipsGraphQlQuery(
2042 repository: string,
2043 numbers: ReadonlyArray<number>,
2044): GraphQlDocument | null {
2045 return aliasedPullRequestsDocument(
2046 "PullRequestStackMemberships",
2047 "s",
2048 numbers.map((number) => ({ repository, number })),
2049 STACK_MEMBERSHIP_SELECTION,
2050 );
2051}
2052
2053const decodeStackMemberships = decodeJsonResult(RawStackMembershipsSchema);
2054
2055export function decodePullRequestStackMembershipsJson(
2056 raw: string,
2057): Result.Result<ReadonlyMap<number, PullRequestStackMembership>, DecodeFailure> {
2058 const decoded = decodeStackMemberships(raw);
2059 if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure);
2060 const memberships = new Map<number, PullRequestStackMembership>();
2061 for (const [alias, value] of Object.entries(decoded.success.data)) {
2062 const index = /^s(\d+)$/.exec(alias)?.[1];
2063 if (index === undefined || value?.pullRequest == null) continue;
2064 const stack = toStackMembership(value.pullRequest);
2065 if (stack !== undefined) memberships.set(Number(index), stack);
2066 }
2067 return Result.succeed(memberships);
2068}
2069
2070/**
2071 * The counts by the position they were asked in. A repository or a pull request GitHub answered
2072 * nothing for is simply absent, which leaves the row with whatever it already had.
2073 */
2074export function decodePullRequestStatsJson(
2075 raw: string,
2076): Result.Result<
2077 ReadonlyMap<number, { readonly additions: number; readonly deletions: number }>,
2078 DecodeFailure
2079> {
2080 const decoded = decodeStats(raw);
2081 if (!Result.isSuccess(decoded)) {
2082 return Result.fail(decoded.failure);
2083 }
2084 const stats = new Map<number, { readonly additions: number; readonly deletions: number }>();
2085 for (const [alias, value] of Object.entries(decoded.success.data ?? {})) {
2086 const index = /^s(\d+)$/.exec(alias)?.[1];
2087 const pullRequest = value?.pullRequest;
2088 if (index === undefined || pullRequest == null) continue;
2089 stats.set(Number(index), {
2090 additions: pullRequest.additions ?? 0,
2091 deletions: pullRequest.deletions ?? 0,
2092 });
2093 }
2094 return Result.succeed(stats);
2095}
2096
2097/**
2098 * The fields a linked thread keeps current, for many pull requests in one aliased read. Same
2099 * shape as the search row where the two overlap: the checks arrive as GitHub's one-word rollup
2100 * rather than the whole check list `gh pr view` hands back, which is what keeps a batch cheap.
2101 */
2102const STACK_MEMBERSHIP_SELECTION = "stack { number size baseRefName } stackEntry { position }";
2103
2104const PULL_REQUEST_SUMMARY_SELECTION =
2105 "number title url state isDraft mergeable reviewDecision additions deletions changedFiles " +
2106 "updatedAt mergedAt closedAt headRefName baseRefName " +
2107 "author { __typename login avatarUrl ... on User { name } } " +
2108 "latestReviews(first: 20) { nodes { state author { login } } } " +
2109 "commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }";
2110
2111/** Summaries for pull requests anywhere on one host, one aliased lookup each; null when unsafe. */
2112export function buildPullRequestSummariesGraphQlQuery(
2113 changeRequests: ReadonlyArray<{ readonly repository: string; readonly number: number }>,
2114 includeStacks = false,
2115): GraphQlDocument | null {
2116 return aliasedPullRequestsDocument(
2117 "PullRequestSummaries",
2118 "s",
2119 changeRequests,
2120 `${PULL_REQUEST_SUMMARY_SELECTION}${includeStacks ? ` ${STACK_MEMBERSHIP_SELECTION}` : ""}`,
2121 );
2122}
2123
2124/** One pull request's summary, aliased the way the batch is so the same decoder reads it. */
2125export function pullRequestSummaryGraphQlQuery(includeStacks = false): string {
2126 return `query($owner: String!, $name: String!, $number: Int!) {
2127 s0: repository(owner: $owner, name: $name) { pullRequest(number: $number) { ${PULL_REQUEST_SUMMARY_SELECTION}${includeStacks ? ` ${STACK_MEMBERSHIP_SELECTION}` : ""} } }
2128}`;
2129}
2130
2131const RawSummarySchema = Schema.Struct({
2132 ...RawSearchItemSchema.fields,
2133 changedFiles: Schema.optional(Schema.NullOr(Schema.Int)),
2134 additions: Schema.optional(Schema.NullOr(Schema.Int)),
2135 deletions: Schema.optional(Schema.NullOr(Schema.Int)),
2136 closedAt: Schema.optional(Schema.NullOr(Schema.String)),
2137 createdAt: Schema.optional(Schema.String),
2138});
2139const decodeSummaries = decodeJsonResult(
2140 Schema.Struct({
2141 data: Schema.optional(
2142 Schema.NullOr(
2143 Schema.Record(
2144 Schema.String,
2145 Schema.NullOr(Schema.Struct({ pullRequest: Schema.optional(Schema.Unknown) })),
2146 ),
2147 ),
2148 ),
2149 }),
2150);
2151const decodeSummaryEntry = Schema.decodeUnknownExit(RawSummarySchema);
2152
2153export interface GitHubPullRequestSummary {
2154 readonly number: number;
2155 readonly title: string;
2156 readonly url: string;
2157 readonly headBranch: string;
2158 readonly baseBranch: string;
2159 readonly state: PullRequestState;
2160 readonly isDraft: boolean;
2161 readonly closedAt: string | null;
2162 readonly mergedAt: string | null;
2163 readonly updatedAt: string;
2164 readonly author: PullRequestActor | null;
2165 readonly additions: number;
2166 readonly deletions: number;
2167 readonly changedFiles: number;
2168 readonly reviewDecision: PullRequestReviewDecision | null;
2169 readonly checksState: PullRequestChecksState | null;
2170 readonly mergeability: PullRequestMergeability;
2171 /** Null when GitHub says the pull request is in no stack; absent when the read did not ask. */
2172 readonly stack?: PullRequestStackMembership | null;
2173}
2174
2175/**
2176 * Summaries by the position they were asked in. A pull request GitHub answered nothing for, or
2177 * one whose fields no longer decode, is absent rather than failing the rest of the batch.
2178 */
2179export function decodePullRequestSummariesJson(
2180 raw: string,
2181): Result.Result<ReadonlyMap<number, GitHubPullRequestSummary>, DecodeFailure> {
2182 const decoded = decodeSummaries(raw);
2183 if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure);
2184 const summaries = new Map<number, GitHubPullRequestSummary>();
2185 for (const [alias, value] of Object.entries(decoded.success.data ?? {})) {
2186 const index = /^s(\d+)$/.exec(alias)?.[1];
2187 if (index === undefined || value?.pullRequest == null) continue;
2188 const entry = decodeSummaryEntry(value.pullRequest);
2189 if (!Exit.isSuccess(entry)) continue;
2190 const pr = entry.value;
2191 summaries.set(Number(index), {
2192 number: pr.number,
2193 title: pr.title,
2194 url: pr.url,
2195 headBranch: pr.headRefName,
2196 baseBranch: pr.baseRefName,
2197 state: toState(pr),
2198 isDraft: pr.isDraft ?? false,
2199 closedAt: trimmed(pr.closedAt),
2200 mergedAt: trimmed(pr.mergedAt),
2201 updatedAt: pr.updatedAt,
2202 author: toActor(pr.author),
2203 additions: pr.additions ?? 0,
2204 deletions: pr.deletions ?? 0,
2205 changedFiles: pr.changedFiles ?? 0,
2206 reviewDecision: toReviewDecisionWithReviews(
2207 pr.reviewDecision,
2208 (pr.latestReviews?.nodes ?? []).flatMap((review) => (review === null ? [] : [review])),
2209 ),
2210 // One enum for the head commit, dressed as a single check like the search row's.
2211 checksState: rollupChecksState(
2212 (pr.commits?.nodes ?? []).flatMap((commitNode) => {
2213 const state = trimmed(commitNode?.commit?.statusCheckRollup?.state);
2214 return state === null ? [] : [{ state }];
2215 }),
2216 ),
2217 mergeability: toMergeability(pr.mergeable),
2218 ...(pr.stack === undefined ? {} : { stack: toStackMembership(pr) ?? null }),
2219 });
2220 }
2221 return Result.succeed(summaries);
2222}
2223
2224const WATCH_FINGERPRINT_EDITS = "totalCount nodes { lastEditedAt }";
2225const WATCH_FINGERPRINT_CHECK_COUNTS =
2226 "checkRunCountsByState { state count } statusContextCountsByState { state count }";
2227
2228/**
2229 * What a pull request watch needs to notice, priced by GitHub at one point for twenty-five pull
2230 * requests, where the detail and activity reads it gates cost sixteen for one. Counts and the
2231 * newest edit cover new comments, reviews (a reply in a review thread is a review), threads, and
2232 * a bot rewriting its summary; check counts by state move whenever a check starts or finishes.
2233 * Comments come most recently updated first, since an edit moves `updatedAt`, so an edit to an
2234 * old comment on a long pull request is still in the page. Reviews have no such order: an edit
2235 * to a review older than the last hundred waits for the watch's periodic full read, as do edits
2236 * to comments inside review threads, which cost a point per pull request to ask for.
2237 */
2238const PULL_REQUEST_WATCH_FINGERPRINT_SELECTION =
2239 "state mergeable headRefOid " +
2240 `comments(first: 100, orderBy: { field: UPDATED_AT, direction: DESC }) { ${WATCH_FINGERPRINT_EDITS} } ` +
2241 `reviews(last: 100) { ${WATCH_FINGERPRINT_EDITS} } ` +
2242 "reviewThreads { totalCount } " +
2243 `commits(last: 1) { nodes { commit { statusCheckRollup { contexts { ${WATCH_FINGERPRINT_CHECK_COUNTS} } } } } }`;
2244
2245/** Watch fingerprints for pull requests on one host, one aliased lookup each; null when unsafe. */
2246export function buildPullRequestWatchFingerprintsGraphQlQuery(
2247 changeRequests: ReadonlyArray<{ readonly repository: string; readonly number: number }>,
2248): GraphQlDocument | null {
2249 return aliasedPullRequestsDocument(
2250 "PullRequestWatchFingerprints",
2251 "w",
2252 changeRequests,
2253 PULL_REQUEST_WATCH_FINGERPRINT_SELECTION,
2254 );
2255}
2256
2257/**
2258 * Two parts, so a watch reads only what moved: `status` (state, mergeability, head, and checks)
2259 * needs the detail read, and `remarks` also needs the far costlier activity read.
2260 */
2261export interface GitHubPullRequestWatchFingerprint {
2262 readonly status: string;
2263 readonly remarks: string;
2264}
2265
2266const RawEditedSchema = Schema.Struct({
2267 totalCount: Schema.Int,
2268 nodes: Schema.Array(Schema.NullOr(Schema.Struct({ lastEditedAt: Schema.NullOr(Schema.String) }))),
2269});
2270const RawStateCountsSchema = Schema.NullOr(
2271 Schema.Array(Schema.Struct({ state: Schema.String, count: Schema.Int })),
2272);
2273const decodeWatchFingerprintEntry = Schema.decodeUnknownExit(
2274 Schema.Struct({
2275 state: Schema.String,
2276 mergeable: Schema.NullOr(Schema.String),
2277 headRefOid: Schema.String,
2278 comments: RawEditedSchema,
2279 reviews: RawEditedSchema,
2280 reviewThreads: Schema.Struct({ totalCount: Schema.Int }),
2281 commits: Schema.Struct({
2282 nodes: Schema.Array(
2283 Schema.NullOr(
2284 Schema.Struct({
2285 commit: Schema.Struct({
2286 statusCheckRollup: Schema.NullOr(
2287 Schema.Struct({
2288 contexts: Schema.Struct({
2289 checkRunCountsByState: RawStateCountsSchema,
2290 statusContextCountsByState: RawStateCountsSchema,
2291 }),
2292 }),
2293 ),
2294 }),
2295 }),
2296 ),
2297 ),
2298 }),
2299 }),
2300);
2301
2302// An edit only ever moves `lastEditedAt` forward, so the newest one stands for them all.
2303const newestEdit = (connection: typeof RawEditedSchema.Type) =>
2304 connection.nodes.reduce(
2305 (newest, node) =>
2306 node?.lastEditedAt != null && node.lastEditedAt > newest ? node.lastEditedAt : newest,
2307 "",
2308 );
2309
2310const stateCounts = (counts: typeof RawStateCountsSchema.Type) =>
2311 (counts ?? [])
2312 .filter(({ count }) => count > 0)
2313 .map(({ state, count }) => `${state}:${count}`)
2314 .toSorted()
2315 .join(",");
2316
2317/** Fingerprints by alias index; a pull request GitHub returned nothing for is left out. */
2318export function decodePullRequestWatchFingerprintsJson(
2319 raw: string,
2320): Result.Result<ReadonlyMap<number, GitHubPullRequestWatchFingerprint>, DecodeFailure> {
2321 const decoded = decodeSummaries(raw);
2322 if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure);
2323 const fingerprints = new Map<number, GitHubPullRequestWatchFingerprint>();
2324 for (const [alias, value] of Object.entries(decoded.success.data ?? {})) {
2325 const index = /^w(\d+)$/.exec(alias)?.[1];
2326 if (index === undefined || value?.pullRequest == null) continue;
2327 const entry = decodeWatchFingerprintEntry(value.pullRequest);
2328 if (!Exit.isSuccess(entry)) continue;
2329 const pr = entry.value;
2330 const contexts = pr.commits.nodes[0]?.commit.statusCheckRollup?.contexts;
2331 fingerprints.set(Number(index), {
2332 status: [
2333 pr.state,
2334 pr.mergeable ?? "",
2335 pr.headRefOid,
2336 stateCounts(contexts?.checkRunCountsByState ?? null),
2337 stateCounts(contexts?.statusContextCountsByState ?? null),
2338 ].join(" "),
2339 remarks: [
2340 pr.comments.totalCount,
2341 newestEdit(pr.comments),
2342 pr.reviews.totalCount,
2343 newestEdit(pr.reviews),
2344 pr.reviewThreads.totalCount,
2345 ].join(" "),
2346 });
2347 }
2348 return Result.succeed(fingerprints);
2349}
2350
2351export interface GitHubPullRequestCore extends GitHubPullRequestDetail {
2352 /** Teams asked for a review. `reviewRequestLogins` holds the people. */
2353 readonly reviewRequestTeams: ReadonlyArray<PullRequestActor>;
2354 readonly viewerAccess: GitHubViewerAccess & GitHubRepositoryAccess;
2355 readonly comparison: GitHubBaseComparison | null;
2356 readonly checksTruncated: boolean;
2357}
2358
2359export function decodePullRequestCoreJson(
2360 raw: string,
2361): Result.Result<GitHubPullRequestCore, DecodeFailure> {
2362 const decoded = decodeCore(raw);
2363 if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure);
2364 const repository = decoded.success.data.repository;
2365 const pr = repository.pullRequest;
2366 const contexts = pr.commits.nodes[0]?.commit.statusCheckRollup?.contexts;
2367 return Result.succeed({
2368 reviewRequestTeams: pr.reviewRequests.nodes.flatMap(
2369 ({ requestedReviewer }) => toRequestedTeam(requestedReviewer) ?? [],
2370 ),
2371 ...toDetail({
2372 ...pr,
2373 reviewRequests: pr.reviewRequests.nodes.flatMap(({ requestedReviewer }) =>
2374 requestedReviewer === null ? [] : [requestedReviewer],
2375 ),
2376 labels: pr.labels.nodes,
2377 statusCheckRollup: toCheckContexts(contexts?.nodes ?? []),
2378 }),
2379 headRepositoryNameWithOwner: trimmed(pr.headRepository?.nameWithOwner),
2380 viewerAccess: {
2381 canWrite: toCanWrite(repository.viewerPermission),
2382 canDeleteBranch: toCanWrite(pr.headRepository?.viewerPermission) && pr.headRef !== null,
2383 canTriage: toCanTriage(repository.viewerPermission),
2384 ...toPullRequestViewerFields(pr),
2385 mergeCapabilities: {
2386 merge: repository.mergeCommitAllowed,
2387 squash: repository.squashMergeAllowed,
2388 rebase: repository.rebaseMergeAllowed,
2389 },
2390 },
2391 comparison:
2392 pr.state !== "OPEN" || pr.baseRef?.compare == null
2393 ? null
2394 : {
2395 behindBy: pr.baseRef.compare.behindBy,
2396 viewerCanUpdate: pr.viewerCanUpdateBranch,
2397 },
2398 checksTruncated: contexts?.pageInfo.hasNextPage === true,
2399 });
2400}
2401
2402export function decodePullRequestDetailJson(
2403 raw: string,
2404): Result.Result<GitHubPullRequestDetail, DecodeFailure> {
2405 const decoded = decodeDetail(raw);
2406 return Result.isSuccess(decoded)
2407 ? Result.succeed(toDetail(decoded.success))
2408 : Result.fail(decoded.failure);
2409}
2410
2411export interface GitHubWorkflowRunPage {
2412 readonly runs: ReadonlyArray<GitHubWorkflowRunApproval>;
2413 /** Runs on the page, counted before decoding, which is what decides whether to page on. */
2414 readonly rawCount: number;
2415}
2416
2417/** One page of `actions/runs`. */
2418export function decodeWorkflowRunsJson(
2419 raw: string,
2420): Result.Result<GitHubWorkflowRunPage, DecodeFailure> {
2421 const decoded = decodeWorkflowRuns(raw);
2422 if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure);
2423 return Result.succeed({
2424 runs: decoded.success.workflow_runs.map((run) => ({
2425 id: run.id,
2426 name: trimmed(run.name) ?? `Workflow run ${run.id}`,
2427 url: trimmed(run.html_url),
2428 })),
2429 rawCount: decoded.success.workflow_runs.length,
2430 });
2431}
2432
2433/** Open pull requests whose head branch has one name, from whichever repository it lives in. */
2434export const PULL_REQUEST_HEADS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $head: String!, $after: String) {
2435 repository(owner: $owner, name: $name) {
2436 pullRequests(first: 100, after: $after, states: [OPEN], headRefName: $head) {
2437 pageInfo { hasNextPage endCursor }
2438 nodes { number headRefOid isCrossRepository headRepositoryOwner { login } }
2439 }
2440 }
2441}`;
2442
2443export function decodePullRequestHeadsJson(raw: string): Result.Result<
2444 {
2445 readonly heads: ReadonlyArray<GitHubPullRequestHead>;
2446 readonly nextCursor: string | null;
2447 },
2448 DecodeFailure
2449> {
2450 const decoded = decodePullRequestHeads(raw);
2451 if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure);
2452 const connection = decoded.success.data.repository?.pullRequests;
2453 return Result.succeed({
2454 heads: (connection?.nodes ?? []).map((pullRequest) => ({
2455 number: pullRequest.number,
2456 headSha: pullRequest.headRefOid,
2457 ...(typeof pullRequest.isCrossRepository === "boolean"
2458 ? { isCrossRepository: pullRequest.isCrossRepository }
2459 : {}),
2460 headRepositoryOwner: trimmed(pullRequest.headRepositoryOwner?.login),
2461 })),
2462 nextCursor: nextCursorOf(connection?.pageInfo),
2463 });
2464}
2465
2466export interface GitHubPullRequestActivityPage {
2467 /** Only on the first page, which is the one that asks for them. */
2468 readonly author?: PullRequestActor | null;
2469 readonly commits?: ReadonlyArray<PullRequestCommit>;
2470 /** Issue comments and reviews, each list unsorted. */
2471 readonly remarks: ReadonlyArray<PullRequestComment>;
2472 readonly nextCommentsCursor: string | null;
2473 readonly nextReviewsCursor: string | null;
2474}
2475
2476/** One page of `PULL_REQUEST_ACTIVITY_GRAPHQL_QUERY`. */
2477export function decodePullRequestActivityJson(
2478 raw: string,
2479): Result.Result<GitHubPullRequestActivityPage, DecodeFailure> {
2480 const decoded = decodeActivity(raw);
2481 if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure);
2482 const pr = decoded.success.data.repository.pullRequest;
2483 return Result.succeed({
2484 ...(pr.author === undefined ? {} : { author: toActor(pr.author) }),
2485 ...(pr.commits === undefined
2486 ? {}
2487 : {
2488 commits: toCommits(
2489 (pr.commits?.nodes ?? []).map(({ commit }) => ({
2490 ...commit,
2491 authors: (commit.authors?.nodes ?? []).map((author) => ({
2492 ...author,
2493 login: author.user?.login ?? null,
2494 })),
2495 })),
2496 ),
2497 }),
2498 remarks: toComments({
2499 comments: pr.comments?.nodes ?? [],
2500 reviews: pr.reviews?.nodes ?? [],
2501 }),
2502 nextCommentsCursor: nextCursorOf(pr.comments?.pageInfo),
2503 nextReviewsCursor: nextCursorOf(pr.reviews?.pageInfo),
2504 });
2505}
2506
2507/** Every check context of the head commit, a page at a time, for a rollup past one page. */
2508export const pullRequestCheckContextsGraphQlQuery = (host: string) =>
2509 `query($owner: String!, $name: String!, $number: Int!, $after: String) {
2510 repository(owner: $owner, name: $name) {
2511 pullRequest(number: $number) {
2512 headRefOid
2513 commits(last: 1) {
2514 nodes { commit { statusCheckRollup { contexts(first: 100, after: $after) {
2515 ${checkContextNodesSelection(host)}
2516 pageInfo { hasNextPage endCursor }
2517 } } } }
2518 }
2519 }
2520 }
2521}`;
2522
2523const RawCheckContextNodeSchema = Schema.Struct({
2524 ...RawCheckSchema.fields,
2525 checkSuite: Schema.optional(
2526 Schema.NullOr(
2527 Schema.Struct({
2528 workflowRun: Schema.NullOr(
2529 Schema.Struct({ workflow: Schema.NullOr(Schema.Struct({ name: Schema.String })) }),
2530 ),
2531 }),
2532 ),
2533 ),
2534});
2535
2536const decodeCheckContexts = decodeJsonResult(
2537 Schema.Struct({
2538 data: Schema.Struct({
2539 repository: Schema.Struct({
2540 pullRequest: Schema.Struct({
2541 headRefOid: Schema.String,
2542 commits: Schema.Struct({
2543 nodes: Schema.Array(
2544 Schema.Struct({
2545 commit: Schema.Struct({
2546 statusCheckRollup: Schema.NullOr(
2547 Schema.Struct({
2548 contexts: Schema.Struct({
2549 nodes: Schema.Array(RawCheckContextNodeSchema),
2550 pageInfo: RawPageInfoSchema,
2551 }),
2552 }),
2553 ),
2554 }),
2555 }),
2556 ),
2557 }),
2558 }),
2559 }),
2560 }),
2561 }),
2562);
2563
2564/** A check context as the rollup reports it; opaque outside this module. */
2565export type GitHubCheckContext = Schema.Schema.Type<typeof RawCheckSchema>;
2566
2567function toCheckContexts(
2568 nodes: ReadonlyArray<Schema.Schema.Type<typeof RawCheckContextNodeSchema>>,
2569): ReadonlyArray<GitHubCheckContext> {
2570 return nodes.map((check) => ({
2571 ...check,
2572 workflowName: check.checkSuite?.workflowRun?.workflow?.name ?? null,
2573 }));
2574}
2575
2576export function decodePullRequestCheckContextsJson(raw: string): Result.Result<
2577 {
2578 readonly headSha: string;
2579 readonly contexts: ReadonlyArray<GitHubCheckContext>;
2580 readonly nextCursor: string | null;
2581 },
2582 DecodeFailure
2583> {
2584 const decoded = decodeCheckContexts(raw);
2585 if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure);
2586 const pr = decoded.success.data.repository.pullRequest;
2587 const contexts = pr.commits.nodes[0]?.commit.statusCheckRollup?.contexts;
2588 return Result.succeed({
2589 headSha: pr.headRefOid,
2590 contexts: toCheckContexts(contexts?.nodes ?? []),
2591 nextCursor: nextCursorOf(contexts?.pageInfo),
2592 });
2593}
2594
2595/** The checks and their one-word verdict, from every context of the head commit. */
2596export function pullRequestChecksFromContexts(contexts: ReadonlyArray<GitHubCheckContext>): {
2597 readonly checks: ReadonlyArray<PullRequestCheck>;
2598 readonly checksState: PullRequestChecksState | null;
2599} {
2600 return { checks: toChecks(contexts), checksState: rollupChecksState(contexts) };
2601}
2602
2603export interface GitHubReviewThreadComments {
2604 readonly comments: ReadonlyArray<PullRequestComment>;
2605 /** Dismissal reasons by the dismissed review's node id, read off the timeline. */
2606 readonly dismissalsByReviewId: ReadonlyMap<string, string>;
2607 /** Whole conversations, kept anchored so the diff can pin them to their line. */
2608 readonly reviewThreads: ReadonlyArray<PullRequestReviewThread>;
2609 /** The host's own count of the conversation, which a bounded read can fall short of. */
2610 readonly commentCount: number;
2611 readonly truncated: boolean;
2612 readonly reviewThreadsTruncated: boolean;
2613 /** The pull request's own reactions, which sit on its description. */
2614 readonly reactions: ReadonlyArray<PullRequestReaction>;
2615 /** Reactions by node id, for the comments and reviews the `gh` JSON read carries no reaction on. */
2616 readonly reactionsById: ReadonlyMap<string, ReadonlyArray<PullRequestReaction>>;
2617 readonly editedAtById: ReadonlyMap<string, string>;
2618 /**
2619 * Everyone on the review: those still asked and those who have already answered. Whoever has
2620 * reviewed is no longer an outstanding request, so asking only for requests reports nobody on
2621 * a pull request that has in fact been reviewed.
2622 */
2623 readonly reviewers: ReadonlyArray<PullRequestActor>;
2624 /**
2625 * Avatars by login, for the actors `gh pr view --json` reports without one — which is all of
2626 * them, since no `gh` JSON field carries an avatar. Collected from everyone this query names,
2627 * so an app's avatar arrives the same way a person's does.
2628 */
2629 readonly avatarsByLogin: ReadonlyMap<string, string>;
2630 readonly botLogins: ReadonlySet<string>;
2631 /** Per-commit line counts carried by the same bounded pull-request query. */
2632 readonly commitStats: ReadonlyMap<
2633 string,
2634 { readonly additions: number; readonly deletions: number }
2635 >;
2636 /**
2637 * The newest hundred commits, oldest to newest, off the same query's `commits(last: ...)`.
2638 * Empty wherever the read never happened (an install too old for the field, a degraded page),
2639 * which the caller reads as "keep the `gh pr view` list" rather than as "this pull request has
2640 * no commits".
2641 */
2642 readonly commits: ReadonlyArray<PullRequestCommit>;
2643 /** What GitHub says the reader may do with this pull request, read off the same response. */
2644 readonly viewer: { readonly canUpdate: boolean; readonly didAuthor: boolean };
2645}
2646
2647/** One thread as this page found it, with what it takes to finish reading it. */
2648export interface GitHubReviewThreadEntry {
2649 readonly thread: PullRequestReviewThread;
2650 /** How many comments GitHub says the thread holds, read or not. */
2651 readonly commentCount: number;
2652 /** Where the rest of this thread's comments carry on from, or null once it is whole. */
2653 readonly nextCommentCursor: string | null;
2654}
2655
2656export interface GitHubReviewThreadPage {
2657 readonly threads: ReadonlyArray<GitHubReviewThreadEntry>;
2658 /** Where the next page of threads starts, or null once the host has handed them all over. */
2659 readonly nextCursor: string | null;
2660 /** The pull request's own reactions, which sit on its description. */
2661 readonly reactions: ReadonlyArray<PullRequestReaction>;
2662 /**
2663 * Reactions by node id, for the conversation comments and reviews `gh pr view --json` answers
2664 * for without any. Only ids with a reaction are here; the rest carry none.
2665 */
2666 readonly reactionsById: ReadonlyMap<string, ReadonlyArray<PullRequestReaction>>;
2667 readonly editedAtById: ReadonlyMap<string, string>;
2668 readonly reviewers: ReadonlyArray<PullRequestActor>;
2669 readonly avatarsByLogin: ReadonlyMap<string, string>;
2670 readonly botLogins: ReadonlySet<string>;
2671 readonly commitStats: ReadonlyMap<
2672 string,
2673 { readonly additions: number; readonly deletions: number }
2674 >;
2675 readonly commits: ReadonlyArray<PullRequestCommit>;
2676 readonly viewer: { readonly canUpdate: boolean; readonly didAuthor: boolean };
2677 /** Dismissal reasons by the dismissed review's node id, which the review itself never carries. */
2678 readonly dismissalsByReviewId: ReadonlyMap<string, string>;
2679 /** Where the rest of the dismissal events start, or null once this page carried them all. */
2680 readonly nextDismissalCursor: string | null;
2681}
2682
2683/**
2684 * The threads as one flat conversation, which is what the timeline reads. Every comment of
2685 * every thread, resolved or not: a resolved conversation is still what was said, and a reply is
2686 * as much of it as the remark it answers.
2687 */
2688export function reviewThreadConversation(
2689 threads: ReadonlyArray<PullRequestReviewThread>,
2690): ReadonlyArray<PullRequestComment> {
2691 return threads.flatMap((thread) =>
2692 thread.comments.map((comment): PullRequestComment => ({
2693 id: comment.id,
2694 kind: "review-comment",
2695 author: comment.author,
2696 body: comment.body,
2697 createdAt: comment.createdAt,
2698 editedAt: comment.editedAt ?? null,
2699 url: comment.url,
2700 path: thread.path,
2701 reviewState: null,
2702 reactions: comment.reactions ?? [],
2703 })),
2704 );
2705}
2706
2707/** One page of review threads. Following the cursors it hands back is the caller's job. */
2708function toDismissalEntries(
2709 nodes:
2710 | ReadonlyArray<{
2711 readonly dismissalMessage?: string | null | undefined;
2712 readonly review?: { readonly id?: string | null | undefined } | null | undefined;
2713 }>
2714 | undefined,
2715): Map<string, string> {
2716 const entries = new Map<string, string>();
2717 for (const node of nodes ?? []) {
2718 const reviewId = trimmed(node.review?.id);
2719 const message = trimmed(node.dismissalMessage);
2720 if (reviewId !== null && message !== null) entries.set(reviewId, message);
2721 }
2722 return entries;
2723}
2724
2725const RawReviewDismissalsSchema = Schema.Struct({
2726 data: Schema.Struct({
2727 repository: Schema.Struct({
2728 pullRequest: Schema.Struct({
2729 timelineItems: Schema.Struct({
2730 pageInfo: Schema.optional(RawPageInfoSchema),
2731 nodes: Schema.Array(
2732 Schema.Struct({
2733 dismissalMessage: Schema.optional(Schema.NullOr(Schema.String)),
2734 review: Schema.optional(
2735 Schema.NullOr(Schema.Struct({ id: Schema.optional(Schema.NullOr(Schema.String)) })),
2736 ),
2737 }),
2738 ),
2739 }),
2740 }),
2741 }),
2742 }),
2743});
2744
2745const decodeReviewDismissals = decodeJsonResult(RawReviewDismissalsSchema);
2746
2747/** One further page of dismissal events, in the shape the thread read's own page carries. */
2748export function decodeReviewDismissalsJson(raw: string): Result.Result<
2749 {
2750 readonly dismissalsByReviewId: ReadonlyMap<string, string>;
2751 readonly nextCursor: string | null;
2752 },
2753 DecodeFailure
2754> {
2755 const decoded = decodeReviewDismissals(raw);
2756 if (!Result.isSuccess(decoded)) {
2757 return Result.fail(decoded.failure);
2758 }
2759 const items = decoded.success.data.repository.pullRequest.timelineItems;
2760 return Result.succeed({
2761 dismissalsByReviewId: toDismissalEntries(items.nodes),
2762 nextCursor: nextCursorOf(items.pageInfo),
2763 });
2764}
2765
2766export function decodeReviewThreadsJson(
2767 raw: string,
2768): Result.Result<GitHubReviewThreadPage, DecodeFailure> {
2769 const decoded = decodeReviewThreads(raw);
2770 if (!Result.isSuccess(decoded)) {
2771 return Result.fail(decoded.failure);
2772 }
2773 const viewer = trimmed(decoded.success.data.viewer?.login);
2774 const threads = decoded.success.data.repository.pullRequest.reviewThreads;
2775 const entries = threads.nodes.flatMap((thread): ReadonlyArray<GitHubReviewThreadEntry> => {
2776 const path = trimmed(thread.path);
2777 const id = trimmed(thread.id);
2778 if (path === null || id === null || thread.comments.nodes.length === 0) return [];
2779 return [
2780 {
2781 thread: {
2782 id,
2783 path,
2784 // Null once the thread's line has left the diff, which is exactly when GitHub reports
2785 // it outdated. Such a thread is listed rather than pinned to a line it no longer has.
2786 line:
2787 thread.line !== null && thread.line !== undefined && thread.line > 0
2788 ? thread.line
2789 : null,
2790 side: thread.diffSide?.toUpperCase() === "LEFT" ? "left" : "right",
2791 isResolved: thread.isResolved === true,
2792 isOutdated: thread.isOutdated === true,
2793 comments: thread.comments.nodes.map((comment) => ({
2794 id: comment.id,
2795 author: toActor(comment.author),
2796 body: comment.body ?? "",
2797 createdAt: comment.createdAt,
2798 editedAt: comment.lastEditedAt ?? null,
2799 url: trimmed(comment.url),
2800 reactions: toReactions(comment.reactionGroups, viewer),
2801 })),
2802 },
2803 commentCount: thread.comments.totalCount ?? thread.comments.nodes.length,
2804 nextCommentCursor: nextCursorOf(thread.comments.pageInfo),
2805 },
2806 ];
2807 });
2808 const pullRequest = decoded.success.data.repository.pullRequest;
2809 const avatarsByLogin = new Map<string, string>();
2810 const botLogins = new Set<string>();
2811 for (const raw of [
2812 pullRequest.author,
2813 ...(pullRequest.comments?.nodes ?? []).map((node) => node.author),
2814 ...(pullRequest.reviews?.nodes ?? []).map((node) => node.author),
2815 ...(pullRequest.reviewRequests?.nodes ?? []).map((node) => node.requestedReviewer),
2816 ...(pullRequest.latestReviews?.nodes ?? []).map((node) => node.author),
2817 ...threads.nodes.flatMap((thread) => thread.comments.nodes.map((comment) => comment.author)),
2818 ]) {
2819 const login = trimmed(raw?.login);
2820 const avatarUrl = trimmed(raw?.avatarUrl);
2821 if (login !== null && avatarUrl !== null) avatarsByLogin.set(login, avatarUrl);
2822 if (login !== null && toActor(raw)?.isBot) botLogins.add(login);
2823 }
2824 const reviewers = new Map<string, PullRequestActor>();
2825 for (const raw of [
2826 ...(pullRequest.reviewRequests?.nodes ?? []).map((node) => node.requestedReviewer),
2827 ...(pullRequest.latestReviews?.nodes ?? []).map((node) => node.author),
2828 ]) {
2829 const actor = toActor(raw) ?? toRequestedTeam(raw);
2830 // Keyed by login, so someone who was asked and then answered appears once.
2831 if (actor !== null && !reviewers.has(actor.login)) reviewers.set(actor.login, actor);
2832 }
2833 const commitStats = new Map<string, { readonly additions: number; readonly deletions: number }>();
2834 const commits: PullRequestCommit[] = [];
2835 for (const node of pullRequest.commits?.nodes ?? []) {
2836 const commit = node.commit;
2837 const oid = trimmed(commit.oid);
2838 if (oid === null) continue;
2839 // GitHub measures a merge commit against its first parent, so merging the base into the head
2840 // reports every upstream change as if it belonged to the pull request. There is no useful
2841 // per-commit stat to show for that integration commit without another comparison request.
2842 if (
2843 (commit.parents?.totalCount ?? 1) <= 1 &&
2844 commit.additions !== undefined &&
2845 commit.deletions !== undefined
2846 ) {
2847 commitStats.set(oid, {
2848 additions: Math.max(0, commit.additions),
2849 deletions: Math.max(0, commit.deletions),
2850 });
2851 }
2852 const committedDate = trimmed(commit.committedDate);
2853 if (committedDate === null) continue;
2854 commits.push({
2855 oid,
2856 messageHeadline: commit.messageHeadline ?? "",
2857 committedDate,
2858 authors: (commit.authors?.nodes ?? []).flatMap((author) => {
2859 const actor = toGraphqlCommitActor(author);
2860 return actor === null ? [] : [actor];
2861 }),
2862 });
2863 }
2864 const reactionsById = new Map<string, ReadonlyArray<PullRequestReaction>>();
2865 const editedAtById = new Map<string, string>();
2866 for (const node of [
2867 ...(pullRequest.comments?.nodes ?? []),
2868 ...(pullRequest.reviews?.nodes ?? []),
2869 ]) {
2870 const id = trimmed(node.id);
2871 if (id === null) continue;
2872 const editedAt = trimmed(node.lastEditedAt);
2873 if (editedAt !== null) editedAtById.set(id, editedAt);
2874 const reactions = toReactions(node.reactionGroups, viewer);
2875 if (reactions.length > 0) reactionsById.set(id, reactions);
2876 }
2877 return Result.succeed({
2878 threads: entries,
2879 nextCursor: nextCursorOf(threads.pageInfo),
2880 reactions: toReactions(pullRequest.reactionGroups, viewer),
2881 reactionsById,
2882 editedAtById,
2883 reviewers: [...reviewers.values()],
2884 avatarsByLogin,
2885 botLogins,
2886 commitStats,
2887 commits,
2888 viewer: toPullRequestViewerFields(pullRequest),
2889 dismissalsByReviewId: toDismissalEntries(pullRequest.reviewDismissals?.nodes),
2890 nextDismissalCursor: nextCursorOf(pullRequest.reviewDismissals?.pageInfo),
2891 });
2892}
2893
2894/** The rest of one thread's comments, in the shape the first page already delivered them. */
2895export function decodeReviewThreadCommentsJson(raw: string): Result.Result<
2896 {
2897 readonly belongsToPullRequest: boolean;
2898 readonly comments: ReadonlyArray<PullRequestThreadComment>;
2899 readonly nextCursor: string | null;
2900 },
2901 DecodeFailure
2902> {
2903 const decoded = decodeReviewThreadComments(raw);
2904 if (!Result.isSuccess(decoded)) {
2905 return Result.fail(decoded.failure);
2906 }
2907 const viewer = trimmed(decoded.success.data.viewer?.login);
2908 const comments = decoded.success.data.node?.comments;
2909 return Result.succeed({
2910 belongsToPullRequest:
2911 decoded.success.data.repository?.pullRequest?.id !== undefined &&
2912 decoded.success.data.repository?.pullRequest?.id ===
2913 decoded.success.data.node?.pullRequest?.id,
2914 comments: (comments?.nodes ?? []).map((comment) => ({
2915 id: comment.id,
2916 author: toActor(comment.author),
2917 body: comment.body ?? "",
2918 createdAt: comment.createdAt,
2919 editedAt: comment.lastEditedAt ?? null,
2920 url: trimmed(comment.url),
2921 reactions: toReactions(comment.reactionGroups, viewer),
2922 })),
2923 nextCursor: nextCursorOf(comments?.pageInfo),
2924 });
2925}
2926
2927/** Repository settings returned alongside the pull request viewer permissions. */
2928export interface GitHubRepositoryAccess {
2929 readonly mergeCapabilities: PullRequestMergeCapabilities;
2930 readonly canWrite: boolean;
2931}
2932
2933/**
2934 * Whether the viewer's role on the repository is one that can push, which is what merging needs.
2935 * TRIAGE and READ are not: a triager moves issues about and neither of them lands a commit.
2936 *
2937 * An install that reports no permission at all does not count as write. This is the exception to
2938 * "an unknown permission is granted": write is what merging and closing somebody else's change
2939 * need, and offering those to a reader who cannot use them wastes the press and reads as the app
2940 * being wrong. Everything softer — commenting, reviewing, resolving — keeps the granting default,
2941 * because being unable to say something is the worse failure there.
2942 *
2943 * A null permission is not that. GitHub answers null only to a GitHub App installation, and no
2944 * API says what an installation may do, so null is granted and GitHub refuses whatever the app
2945 * was not given. A person always gets a role, READ at the least.
2946 */
2947export function toCanWrite(viewerPermission: string | null | undefined): boolean {
2948 if (viewerPermission === null) return true;
2949 switch (viewerPermission?.trim().toUpperCase()) {
2950 case "ADMIN":
2951 case "MAINTAIN":
2952 case "WRITE":
2953 return true;
2954 default:
2955 return false;
2956 }
2957}
2958
2959/** Triage is the least role GitHub lets label a pull request; it is not a write. */
2960function toCanTriage(viewerPermission: string | null | undefined): boolean {
2961 return viewerPermission?.trim().toUpperCase() === "TRIAGE" || toCanWrite(viewerPermission);
2962}
2963
2964/**
2965 * Who a review may be asked of, and who it has already been asked of, in one read.
2966 *
2967 * `assignableUsers` is the list GitHub's own reviewer picker is built from — everyone with access
2968 * to the repository — rather than `collaborators`, which the REST API refuses to anyone without
2969 * push access and which would therefore be empty for exactly the reader most likely to be looking.
2970 *
2971 * Teams are asked for only where one has already been requested, so a request to a team can be
2972 * taken back. The teams a repository could newly be sent to live on the owning organization and
2973 * need `read:org`, which a repository-scoped token need not carry — and a query GitHub refuses
2974 * fails whole, taking the people down with the teams.
2975 */
2976export interface GitHubBaseComparison {
2977 /** Null where the host could not compare, which the page reads as "unknown". */
2978 readonly behindBy: number | null;
2979 readonly viewerCanUpdate: boolean;
2980}
2981
2982export const REVIEWER_CANDIDATES_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) {
2983 repository(owner: $owner, name: $name) {
2984 assignableUsers(first: ${GRAPHQL_PAGE_SIZE}) {
2985 pageInfo { hasNextPage }
2986 nodes { login name avatarUrl }
2987 }
2988 pullRequest(number: $number) {
2989 author { login }
2990 reviewRequests(first: ${GRAPHQL_PAGE_SIZE}) {
2991 nodes {
2992 requestedReviewer {
2993 ... on User { login name avatarUrl }
2994 ... on Team { slug combinedSlug name avatarUrl }
2995 ... on Bot { login avatarUrl }
2996 }
2997 }
2998 }
2999 }
3000 }
3001}`;
3002
3003const RawReviewerCandidatesSchema = Schema.Struct({
3004 data: Schema.Struct({
3005 repository: Schema.Struct({
3006 assignableUsers: Schema.Struct({
3007 pageInfo: Schema.optional(RawPageInfoSchema),
3008 nodes: Schema.Array(Schema.NullOr(RawActorSchema)),
3009 }),
3010 /** Null for a number that names no pull request the viewer can see. */
3011 pullRequest: Schema.NullOr(
3012 Schema.Struct({
3013 author: Schema.optional(Schema.NullOr(RawActorSchema)),
3014 reviewRequests: Schema.optional(
3015 Schema.NullOr(
3016 Schema.Struct({
3017 nodes: Schema.Array(
3018 Schema.Struct({
3019 requestedReviewer: Schema.optional(Schema.NullOr(RawRequestedReviewerSchema)),
3020 }),
3021 ),
3022 }),
3023 ),
3024 ),
3025 }),
3026 ),
3027 }),
3028 }),
3029});
3030
3031const decodeReviewerCandidates = decodeJsonResult(RawReviewerCandidatesSchema);
3032
3033/**
3034 * The people this pull request may be sent to, with whoever is already on it marked. The author is
3035 * dropped rather than shown as an unusable row: GitHub refuses a review request from the person
3036 * who opened the pull request, so offering them is offering a failure.
3037 *
3038 * Whoever has been asked leads the list even where GitHub does not count them assignable — an
3039 * outside collaborator, an app — because a request that cannot be seen cannot be taken back.
3040 */
3041export function decodeReviewerCandidatesJson(
3042 raw: string,
3043): Result.Result<PullRequestReviewerCandidateList, DecodeFailure> {
3044 const decoded = decodeReviewerCandidates(raw);
3045 if (!Result.isSuccess(decoded)) {
3046 return Result.fail(decoded.failure);
3047 }
3048 const repository = decoded.success.data.repository;
3049 const pullRequest = repository.pullRequest;
3050 const author = trimmed(pullRequest?.author?.login);
3051 const candidates = new Map<string, PullRequestReviewerCandidate>();
3052 for (const node of pullRequest?.reviewRequests?.nodes ?? []) {
3053 const raw = node.requestedReviewer;
3054 const slug = trimmed(raw?.slug);
3055 const id = slug ?? trimmed(raw?.login);
3056 if (id === null) continue;
3057 candidates.set(`${slug === null ? "user" : "team"} ${id}`, {
3058 id,
3059 kind: slug === null ? "user" : "team",
3060 // The same `org/slug` the reviewer row shows, so taking the request back finds that row.
3061 login: slug === null ? id : (trimmed(raw?.combinedSlug) ?? id),
3062 name: trimmed(raw?.name),
3063 avatarUrl: trimmed(raw?.avatarUrl),
3064 isRequested: true,
3065 });
3066 }
3067 for (const node of repository.assignableUsers.nodes) {
3068 const login = trimmed(node?.login);
3069 if (login === null || login === author || candidates.has(`user ${login}`)) continue;
3070 candidates.set(`user ${login}`, {
3071 id: login,
3072 kind: "user",
3073 login,
3074 name: trimmed(node?.name),
3075 avatarUrl: trimmed(node?.avatarUrl),
3076 isRequested: false,
3077 });
3078 }
3079 return Result.succeed({
3080 candidates: [...candidates.values()],
3081 truncated: repository.assignableUsers.pageInfo?.hasNextPage === true,
3082 });
3083}
3084
3085/**
3086 * The body of `POST`/`DELETE /repos/{owner}/{repo}/pulls/{number}/requested_reviewers`, which
3087 * takes people and teams in two lists of its own. The same body serves both methods, because
3088 * GitHub takes a request back from exactly whoever it was made of.
3089 */
3090const ReviewerRequestSchema = Schema.Struct({
3091 reviewers: Schema.Array(Schema.String),
3092 team_reviewers: Schema.Array(Schema.String),
3093});
3094
3095export function buildReviewerRequest(
3096 reviewers: ReadonlyArray<{ readonly id: string; readonly kind: PullRequestReviewerKind }>,
3097): typeof ReviewerRequestSchema.Type {
3098 return {
3099 reviewers: reviewers.flatMap((reviewer) => (reviewer.kind === "user" ? [reviewer.id] : [])),
3100 team_reviewers: reviewers.flatMap((reviewer) =>
3101 reviewer.kind === "team" ? [reviewer.id] : [],
3102 ),
3103 };
3104}
3105
3106export const LABEL_CANDIDATES_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) {
3107 repository(owner: $owner, name: $name) {
3108 labels(first: ${GRAPHQL_PAGE_SIZE}, orderBy: { field: NAME, direction: ASC }) {
3109 pageInfo { hasNextPage }
3110 nodes { name color description }
3111 }
3112 pullRequest(number: $number) {
3113 labels(first: ${GRAPHQL_PAGE_SIZE}) { nodes { name } }
3114 }
3115 }
3116}`;
3117
3118const RawLabelCandidatesSchema = Schema.Struct({
3119 data: Schema.Struct({
3120 repository: Schema.Struct({
3121 labels: Schema.optional(
3122 Schema.NullOr(
3123 Schema.Struct({
3124 pageInfo: Schema.optional(RawPageInfoSchema),
3125 nodes: Schema.Array(
3126 Schema.NullOr(
3127 Schema.Struct({
3128 ...RawLabelSchema.fields,
3129 description: Schema.optional(Schema.NullOr(Schema.String)),
3130 }),
3131 ),
3132 ),
3133 }),
3134 ),
3135 ),
3136 /** Null for a number that names no pull request the viewer can see. */
3137 pullRequest: Schema.NullOr(
3138 Schema.Struct({
3139 labels: Schema.optional(
3140 Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.NullOr(RawLabelSchema)) })),
3141 ),
3142 }),
3143 ),
3144 }),
3145 }),
3146});
3147
3148const decodeLabelCandidates = decodeJsonResult(RawLabelCandidatesSchema);
3149
3150/**
3151 * The repository's labels, with the ones already on this pull request marked. A label the pull
3152 * request wears that the repository no longer defines — deleted since, or past the page — leads
3153 * the list anyway, because a label that cannot be seen cannot be taken off.
3154 */
3155export function decodeLabelCandidatesJson(
3156 raw: string,
3157): Result.Result<PullRequestLabelCandidateList, DecodeFailure> {
3158 const decoded = decodeLabelCandidates(raw);
3159 if (!Result.isSuccess(decoded)) {
3160 return Result.fail(decoded.failure);
3161 }
3162 const repository = decoded.success.data.repository;
3163 const applied = new Set(
3164 (repository.pullRequest?.labels?.nodes ?? []).flatMap((label) => {
3165 const name = trimmed(label?.name);
3166 return name === null ? [] : [name];
3167 }),
3168 );
3169 const candidates = new Map<string, PullRequestLabelCandidate>();
3170 for (const node of repository.labels?.nodes ?? []) {
3171 const name = trimmed(node?.name);
3172 if (name === null) continue;
3173 candidates.set(name, {
3174 name,
3175 color: trimmed(node?.color),
3176 description: trimmed(node?.description),
3177 isApplied: applied.has(name),
3178 });
3179 }
3180 const missing = [...applied].filter((name) => !candidates.has(name));
3181 return Result.succeed({
3182 candidates: [
3183 ...missing.map((name) => ({ name, color: null, description: null, isApplied: true })),
3184 ...candidates.values(),
3185 ],
3186 truncated: repository.labels?.pageInfo?.hasNextPage === true,
3187 });
3188}
3189
3190/** The body of `POST /repos/{owner}/{repo}/issues/{number}/labels`, which adds to what is there. */
3191export function buildLabelRequest(labels: ReadonlyArray<string>): {
3192 readonly labels: ReadonlyArray<string>;
3193} {
3194 return { labels };
3195}
3196
3197/**
3198 * Everything GitHub says about what the signed-in account may do here. `canWrite` is about the
3199 * repository, the other two about this pull request in particular — which is why an author with
3200 * only read access can still be told apart from a passer-by.
3201 */
3202export interface GitHubViewerAccess {
3203 readonly canWrite: boolean;
3204 readonly canDeleteBranch?: boolean;
3205 /**
3206 * The viewer's role reaches triage, which is the least that may label. Everyone who can write
3207 * can triage; a triager is the one role that can label without being able to merge.
3208 */
3209 readonly canTriage: boolean;
3210 /** GitHub's own `viewerCanUpdate`, true for the author as well as for anyone with write. */
3211 readonly canUpdate: boolean;
3212 readonly didAuthor: boolean;
3213 /**
3214 * GitHub's own `viewerCanUpdateBranch`, read with the base comparison rather than here: it is
3215 * false for a branch that is already current, so it answers "may update, and there is
3216 * something to update" at once. Absent where the comparison was not read.
3217 */
3218 readonly canUpdateBranch?: boolean;
3219}
3220
3221/** Core detail and write checks share one read of permissions and merge settings. */
3222export const VIEWER_PERMISSIONS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) {
3223 repository(owner: $owner, name: $name) {
3224 mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed viewerPermission
3225 pullRequest(number: $number) {
3226 viewerCanUpdate viewerDidAuthor
3227 headRepository { viewerPermission }
3228 }
3229 }
3230}`;
3231
3232const RawViewerPermissionsSchema = Schema.Struct({
3233 data: Schema.Struct({
3234 repository: Schema.Struct({
3235 ...RawRepositoryAccessSchema.fields,
3236 /** Null for a number that names no pull request the viewer can see. */
3237 pullRequest: Schema.NullOr(
3238 Schema.Struct({ ...RawViewerFieldsSchema.fields, ...RawHeadBranchAccessSchema.fields }),
3239 ),
3240 }),
3241 }),
3242});
3243
3244const decodeViewerPermissions = decodeJsonResult(RawViewerPermissionsSchema);
3245
3246export function decodeViewerPermissionsJson(
3247 raw: string,
3248): Result.Result<GitHubViewerAccess & GitHubRepositoryAccess, DecodeFailure> {
3249 const decoded = decodeViewerPermissions(raw);
3250 if (!Result.isSuccess(decoded)) {
3251 return Result.fail(decoded.failure);
3252 }
3253 const repository = decoded.success.data.repository;
3254 return Result.succeed({
3255 mergeCapabilities: {
3256 merge: repository.mergeCommitAllowed,
3257 squash: repository.squashMergeAllowed,
3258 rebase: repository.rebaseMergeAllowed,
3259 },
3260 canWrite: toCanWrite(repository.viewerPermission),
3261 canDeleteBranch: toCanWrite(repository.pullRequest?.headRepository?.viewerPermission),
3262 canTriage: toCanTriage(repository.viewerPermission),
3263 ...toPullRequestViewerFields(repository.pullRequest),
3264 });
3265}
3266
3267export interface GitHubPullRequestFilesPatch {
3268 readonly patch: string;
3269 /** At least one file's hunks were withheld by GitHub, so they are missing from the patch. */
3270 readonly truncated: boolean;
3271 /** Files GitHub returned, counted before decoding, so the caller can page. */
3272 readonly rawCount: number;
3273 /** GitHub's own counts for the files whose hunks it withheld. */
3274 readonly omittedFileStats: ReadonlyArray<PullRequestOmittedFileStat>;
3275}
3276
3277/**
3278 * The files API returns hunks per file with no `diff --git` header, so the unified patch every
3279 * diff viewer expects is assembled here. This decodes one page; walking pages is the caller's
3280 * job, which is why the raw file count comes back with the patch.
3281 */
3282export function decodePullRequestFilesJson(
3283 raw: string,
3284): Result.Result<GitHubPullRequestFilesPatch, DecodeFailure> {
3285 return Result.map(decodeUnknownList(raw), toFilesPatch);
3286}
3287
3288/**
3289 * The files of one commit, which the commit endpoint lists and pages the same way, only wrapped
3290 * in an object. An empty commit carries no `files` at all, which is a commit with nothing in it.
3291 */
3292export function decodeCommitFilesJson(
3293 raw: string,
3294): Result.Result<GitHubPullRequestFilesPatch, DecodeFailure> {
3295 return Result.map(decodeCommitFiles(raw), (commit) => toFilesPatch(commit.files ?? []));
3296}
3297
3298const decodeCommitFiles = decodeJsonResult(
3299 Schema.Struct({ files: Schema.optional(Schema.NullOr(Schema.Array(Schema.Unknown))) }),
3300);
3301
3302function toFilesPatch(entries: ReadonlyArray<unknown>): GitHubPullRequestFilesPatch {
3303 const sections: string[] = [];
3304 const omittedFileStats: PullRequestOmittedFileStat[] = [];
3305 let truncated = false;
3306 for (const entry of entries) {
3307 const file = decodeFileEntry(entry);
3308 if (Exit.isFailure(file)) continue;
3309 const value = file.value;
3310 const hunks = value.patch ?? "";
3311 const status = value.status?.trim().toLowerCase();
3312 if (hunks.length === 0) {
3313 // A file with no hunks is still a file that changed: a pure rename has none to give, and
3314 // a binary one has none that can be shown. Both are listed, and only the second is a hole
3315 // in the patch — leaving them out entirely would drop them from the change altogether.
3316 const additions = value.additions ?? 0;
3317 const deletions = value.deletions ?? 0;
3318 if (additions + deletions > 0) {
3319 truncated = true;
3320 omittedFileStats.push({ path: value.filename, additions, deletions });
3321 }
3322 }
3323 // A rename counts its hunks against the old path, which is the only place it is named.
3324 const oldPath =
3325 status === "renamed" ? value.previous_filename || value.filename : value.filename;
3326 const header = [
3327 `diff --git ${quoteGitPatchPath(`a/${oldPath}`)} ${quoteGitPatchPath(`b/${value.filename}`)}`,
3328 // The files API reports no file mode, so the ordinary one stands in: the viewer reads
3329 // these lines as "added" and "removed" rather than for the mode they carry.
3330 ...(status === "added" ? ["new file mode 100644"] : []),
3331 ...(status === "removed" ? ["deleted file mode 100644"] : []),
3332 ...(status === "renamed"
3333 ? [
3334 `rename from ${quoteGitPatchPath(oldPath)}`,
3335 `rename to ${quoteGitPatchPath(value.filename)}`,
3336 ]
3337 : []),
3338 `--- ${status === "added" ? "/dev/null" : quoteGitPatchPath(`a/${oldPath}`)}`,
3339 `+++ ${status === "removed" ? "/dev/null" : quoteGitPatchPath(`b/${value.filename}`)}`,
3340 ].join("\n");
3341 sections.push(hunks.length === 0 ? `${header}\n` : `${header}\n${hunks.replace(/\n?$/, "\n")}`);
3342 }
3343 return {
3344 patch: sections.join(""),
3345 truncated,
3346 rawCount: entries.length,
3347 omittedFileStats,
3348 };
3349}
3350
3351/**
3352 * Which files of a pull request the signed-in account has cleared. GraphQL only, since the REST
3353 * files endpoint the patch is read from carries no viewed state, so this is a second read rather
3354 * than a wider version of the first.
3355 */
3356export const PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $after: String) {
3357 repository(owner: $owner, name: $name) {
3358 pullRequest(number: $number) {
3359 files(first: 100, after: $after) {
3360 pageInfo { hasNextPage endCursor }
3361 nodes { path viewerViewedState }
3362 }
3363 }
3364 }
3365}`;
3366
3367const RawPullRequestFilesViewedSchema = Schema.Struct({
3368 data: Schema.Struct({
3369 repository: Schema.NullOr(
3370 Schema.Struct({
3371 pullRequest: Schema.NullOr(
3372 Schema.Struct({
3373 files: Schema.Struct({
3374 pageInfo: Schema.Struct({
3375 hasNextPage: Schema.Boolean,
3376 endCursor: Schema.NullOr(Schema.String),
3377 }),
3378 nodes: Schema.NullOr(
3379 Schema.Array(
3380 Schema.NullOr(
3381 Schema.Struct({
3382 path: Schema.String,
3383 // Decoded as a plain string and narrowed below: a GitHub release that adds
3384 // a fourth state must not fail the whole page.
3385 viewerViewedState: Schema.String,
3386 }),
3387 ),
3388 ),
3389 ),
3390 }),
3391 }),
3392 ),
3393 }),
3394 ),
3395 }),
3396});
3397
3398const decodePullRequestFilesViewed = decodeJsonResult(RawPullRequestFilesViewedSchema);
3399
3400export interface GitHubPullRequestFilesViewedPage {
3401 readonly files: ReadonlyArray<{
3402 readonly path: string;
3403 readonly state: PullRequestFileViewedState;
3404 }>;
3405 /** Where the next page carries on, or null once the host has no more to give. */
3406 readonly nextCursor: string | null;
3407}
3408
3409/** Anything this host does not name is treated as unread, which is the state that asks for least. */
3410function toFileViewedState(raw: string): PullRequestFileViewedState {
3411 switch (raw.trim().toUpperCase()) {
3412 case "VIEWED":
3413 return "viewed";
3414 case "DISMISSED":
3415 return "dismissed";
3416 default:
3417 return "unviewed";
3418 }
3419}
3420
3421export function decodePullRequestFilesViewedJson(
3422 raw: string,
3423): Result.Result<GitHubPullRequestFilesViewedPage, DecodeFailure> {
3424 const decoded = decodePullRequestFilesViewed(raw);
3425 if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure);
3426 const files = decoded.success.data.repository?.pullRequest?.files;
3427 if (files === undefined) return Result.succeed({ files: [], nextCursor: null });
3428 return Result.succeed({
3429 files: (files.nodes ?? []).flatMap((node) =>
3430 node === null || node.path.length === 0
3431 ? []
3432 : [{ path: node.path, state: toFileViewedState(node.viewerViewedState) }],
3433 ),
3434 nextCursor: files.pageInfo.hasNextPage ? files.pageInfo.endCursor : null,
3435 });
3436}
3437
3438/**
3439 * One document that clears and restores as many files as the reader ticked, rather than one
3440 * request each. GitHub has no bulk form of `markFileAsViewed`/`unmarkFileAsViewed`, which each
3441 * take a single path, so the batching is done with aliases; top-level mutation fields run in
3442 * write order, so the last word about a path is the one that sticks.
3443 */
3444export function buildSetFilesViewedGraphQlMutation(
3445 pullRequestId: string,
3446 files: ReadonlyArray<{ readonly path: string; readonly viewed: boolean }>,
3447): GraphQlDocument | null {
3448 return aliasedGraphQlDocument({
3449 operation: "mutation",
3450 alias: "f",
3451 items: files,
3452 shared: { pullRequestId: ["ID!", pullRequestId] },
3453 variables: (file) => ({ path: ["String!", file.path] }),
3454 field: ({ path }, file) =>
3455 `${file.viewed ? "markFileAsViewed" : "unmarkFileAsViewed"}(input: { pullRequestId: $pullRequestId, path: ${path} }) { clientMutationId }`,
3456 });
3457}
3458
3459/** One pull request as the stacks API lists it: a number, a head, and whether it is done. */
3460const RawStackPullRequestSchema = Schema.Struct({
3461 title: Schema.optional(Schema.String),
3462 draft: Schema.optional(Schema.Boolean),
3463 number: Schema.Int,
3464 head: Schema.Struct({ ref: Schema.String, sha: Schema.optional(Schema.String) }),
3465 state: Schema.optional(Schema.NullOr(Schema.String)),
3466 merged_at: Schema.optional(Schema.NullOr(Schema.String)),
3467});
3468
3469/**
3470 * A stack as `GET /repos/{owner}/{repo}/stacks` answers it, in a public preview whose shape may
3471 * still move. Only what a stack is made of is required — where it lives, what it stands on, and
3472 * its pull requests — and `base` is accepted both as the ref object the preview sends today and
3473 * as the bare branch name it started out as.
3474 */
3475const RawStackSchema = Schema.Struct({
3476 id: Schema.optional(Schema.NullOr(Schema.Union([Schema.Int, Schema.String]))),
3477 number: Schema.Int,
3478 node_id: Schema.optional(Schema.NullOr(Schema.String)),
3479 url: Schema.String,
3480 html_url: Schema.optional(Schema.NullOr(Schema.String)),
3481 base: Schema.Union([Schema.String, Schema.Struct({ ref: Schema.String })]),
3482 pull_requests: Schema.Array(RawStackPullRequestSchema),
3483});
3484
3485const decodeStacks = decodeJsonResult(Schema.Array(RawStackSchema));
3486
3487export interface GitHubPullRequestStackLayer {
3488 readonly title?: string;
3489 readonly isDraft?: boolean;
3490 readonly headSha?: string;
3491 readonly number: number;
3492 readonly headBranch: string;
3493 readonly state: PullRequestState;
3494}
3495
3496export interface GitHubPullRequestStack {
3497 readonly id: string;
3498 readonly number: number;
3499 readonly url: string;
3500 readonly base: string;
3501 /** Bottom to top, which is the order GitHub lists them in. */
3502 readonly layers: ReadonlyArray<GitHubPullRequestStackLayer>;
3503}
3504
3505/**
3506 * The first stack of a `?pull_request=` listing, or null for an empty one: a pull request is in
3507 * at most one stack, so the array is GitHub's way of saying "none" rather than a page.
3508 */
3509export function decodePullRequestStacksJson(
3510 raw: string,
3511): Result.Result<GitHubPullRequestStack | null, DecodeFailure> {
3512 const decoded = decodeStacks(raw);
3513 if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure);
3514 const stack = decoded.success[0];
3515 if (stack === undefined) return Result.succeed(null);
3516 return Result.succeed({
3517 id: stack.id == null ? (trimmed(stack.node_id) ?? String(stack.number)) : String(stack.id),
3518 number: stack.number,
3519 // The page a person opens where the preview reports one; the API URL is what it always has.
3520 url: trimmed(stack.html_url) ?? stack.url,
3521 base: typeof stack.base === "string" ? stack.base : stack.base.ref,
3522 layers: stack.pull_requests.map((pullRequest) => ({
3523 ...(pullRequest.title === undefined ? {} : { title: pullRequest.title }),
3524 ...(pullRequest.draft === undefined ? {} : { isDraft: pullRequest.draft }),
3525 ...(pullRequest.head.sha === undefined ? {} : { headSha: pullRequest.head.sha }),
3526 number: pullRequest.number,
3527 headBranch: pullRequest.head.ref,
3528 state: toState({ state: pullRequest.state, mergedAt: pullRequest.merged_at }),
3529 })),
3530 });
3531}