1import { removeAgentCredits } from "@t3tools/source-control-core/server/mergeMessage";
2import { KnownWorkflowRuns, makeChecksRevalidator } from "./gitHubConditionalChecks.ts";
3import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts";
4import * as Cause from "effect/Cause";
5import * as Context from "effect/Context";
6import * as Clock from "effect/Clock";
7import * as Duration from "effect/Duration";
8import * as Effect from "effect/Effect";
9import * as FileSystem from "effect/FileSystem";
10import * as Layer from "effect/Layer";
11import * as Exit from "effect/Exit";
12import * as Option from "effect/Option";
13import * as Request from "effect/Request";
14import * as RequestResolver from "effect/RequestResolver";
15import * as Result from "effect/Result";
16import * as Schema from "effect/Schema";
17import * as Semaphore from "effect/Semaphore";
18import {
19 resolvePullRequestAuthorFilter,
20 PositiveInt,
21 TrimmedNonEmptyString,
22 type PullRequestAction,
23 type PullRequestStackHead,
24 type PullRequestActor,
25 type PullRequestComment,
26 type PullRequestCommit,
27 type PullRequestFileViewed,
28 type PullRequestInvolvement,
29 type PullRequestListFilters,
30 type PullRequestListState,
31 type PullRequestMergeMethod,
32 type PullRequestOmittedFileStat,
33 type PullRequestReaction,
34 type PullRequestReactionContent,
35 type PullRequestReviewCommentDraft,
36 type PullRequestReviewVerdict,
37 type PullRequestReviewerCandidateList,
38 type PullRequestReviewerKind,
39 type PullRequestLabelCandidateList,
40 type PullRequestThreadCommentsResult,
41 type PullRequestUpdateMethod,
42 type PullRequestPreview,
43} from "@t3tools/contracts";
44
45import * as GitHubApi from "./GitHubApi.ts";
46import { readGraphQlPages } from "./githubGraphQl.ts";
47import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
48import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
49import {
50 ACTOR_AVATARS_GRAPHQL_QUERY,
51 ADD_REACTION_GRAPHQL_MUTATION,
52 buildReviewSubmission,
53 buildReviewerRequest,
54 buildSetFilesViewedGraphQlMutation,
55 decodeActorAvatarsJson,
56 decodePullRequestActivityJson,
57 decodePullRequestCheckContextsJson,
58 decodePullRequestCoreJson,
59 decodeCommitFilesJson,
60 pullRequestCheckContextsGraphQlQuery,
61 pullRequestChecksFromContexts,
62 pullRequestListGraphQlQuery,
63 pullRequestSummaryGraphQlQuery,
64 PULL_REQUEST_ACTIVITY_GRAPHQL_QUERY,
65 PULL_REQUEST_HEADS_GRAPHQL_QUERY,
66 decodeWorkflowRunsJson,
67 pullRequestCoreGraphQlQuery,
68 type GitHubPullRequestCore,
69 type GitHubPullRequestSummary,
70 type GitHubPullRequestWatchFingerprint,
71 decodePullRequestPreviewJson,
72 PULL_REQUEST_PREVIEW_GRAPHQL_QUERY,
73 decodePullRequestFilesJson,
74 decodePullRequestFilesViewedJson,
75 decodePullRequestHeadsJson,
76 decodePullRequestListJson,
77 decodePullRequestNodeIdJson,
78 decodePullRequestSearchJson,
79 decodePullRequestStacksJson,
80 decodePullRequestStatsJson,
81 decodePullRequestSummariesJson,
82 decodePullRequestWatchFingerprintsJson,
83 decodeReactionSubjectScopeJson,
84 decodeReviewerCandidatesJson,
85 decodeLabelCandidatesJson,
86 buildLabelRequest,
87 LABEL_CANDIDATES_GRAPHQL_QUERY,
88 decodeReviewDismissalsJson,
89 decodeReviewThreadCommentsJson,
90 decodeReviewThreadsJson,
91 buildPullRequestStatsGraphQlQuery,
92 buildPullRequestSummariesGraphQlQuery,
93 buildPullRequestWatchFingerprintsGraphQlQuery,
94 buildPullRequestStackMembershipsGraphQlQuery,
95 decodePullRequestStackMembershipsJson,
96 pullRequestSearchGraphQlQuery,
97 PULL_REQUEST_SEARCH_MAX_ROWS,
98 PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY,
99 PULL_REQUEST_NODE_ID_GRAPHQL_QUERY,
100 REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY,
101 REMOVE_REACTION_GRAPHQL_MUTATION,
102 REVERT_PULL_REQUEST_GRAPHQL_MUTATION,
103 gitHubReactionContent,
104 RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION,
105 REVIEWER_CANDIDATES_GRAPHQL_QUERY,
106 REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY,
107 REVIEW_DISMISSALS_GRAPHQL_QUERY,
108 REVIEW_THREAD_REPLY_GRAPHQL_MUTATION,
109 REVIEW_THREADS_GRAPHQL_QUERY,
110 reviewThreadConversation,
111 UNRESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION,
112 UPDATE_ISSUE_COMMENT_GRAPHQL_MUTATION,
113 UPDATE_PULL_REQUEST_GRAPHQL_MUTATION,
114 UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION,
115 VIEWER_PERMISSIONS_GRAPHQL_QUERY,
116 decodeViewerPermissionsJson,
117 type GitHubPullRequestActivity,
118 type GitHubPullRequestActivityPage,
119 type GitHubWorkflowRunPage,
120 type GitHubPullRequestHead,
121 type GitHubPullRequestListItem,
122 type GitHubPullRequestSearchItem,
123 type GitHubPullRequestStack,
124 type GitHubReviewThreadComments,
125 type GitHubRepositoryAccess,
126 type GitHubWorkflowRunApproval,
127 type GitHubReviewThreadEntry,
128 type GitHubReviewThreadPage,
129 type GitHubViewerAccess,
130} from "./gitHubPullRequestJson.ts";
131import type {
132 ProviderChangeRequestSummary,
133 ProviderListCursor,
134} from "@t3tools/source-control-core/server/PullRequestProvider";
135
136/**
137 * Names the read that produced unusable output, so a failure reports the call it came from
138 * rather than borrowing another operation's message.
139 */
140export class GitHubPullRequestReadError extends Schema.TaggedError<GitHubPullRequestReadError>()(
141 "GitHubPullRequestReadError",
142 {
143 cwd: Schema.String,
144 operation: Schema.String,
145 cause: Schema.Defect(),
146 },
147) {
148 override get message(): string {
149 return `GitHub returned an unreadable ${this.operation} response.`;
150 }
151}
152
153/** Not a decode failure: GitHub answered, the account it answered for just has no login. */
154export class GitHubViewerLoginUnavailableError extends Schema.TaggedError<GitHubViewerLoginUnavailableError>()(
155 "GitHubViewerLoginUnavailableError",
156 {
157 cwd: Schema.String,
158 },
159) {
160 override get message(): string {
161 return "GitHub returned no login for the authenticated account.";
162 }
163}
164
165/** Not a decode failure: GitHub answered, but the pull request carried no update time. */
166export class GitHubPullRequestUpdatedAtUnavailableError extends Schema.TaggedError<GitHubPullRequestUpdatedAtUnavailableError>()(
167 "GitHubPullRequestUpdatedAtUnavailableError",
168 {
169 cwd: Schema.String,
170 repository: Schema.String,
171 number: Schema.Int,
172 },
173) {
174 override get message(): string {
175 return `Pull request ${this.repository}#${this.number} reported no update time.`;
176 }
177}
178
179/** Not a decode failure: the reader asked to carry on from a cursor this walk never handed out. */
180export class GitHubDiffCursorError extends Schema.TaggedError<GitHubDiffCursorError>()(
181 "GitHubDiffCursorError",
182 {
183 cwd: Schema.String,
184 },
185) {
186 override get message(): string {
187 return "The diff cursor was not one this pull request handed out.";
188 }
189}
190
191/** Not a decode failure: the reader named a commit that is not a sha this repository could hold. */
192export class GitHubDiffCommitError extends Schema.TaggedError<GitHubDiffCommitError>()(
193 "GitHubDiffCommitError",
194 {
195 cwd: Schema.String,
196 },
197) {
198 override get message(): string {
199 return "The named commit was not a commit sha.";
200 }
201}
202
203/** The revisions read successfully, but cannot name both sides this file needs. */
204export class GitHubDiffRevisionsUnavailableError extends Schema.TaggedError<GitHubDiffRevisionsUnavailableError>()(
205 "GitHubDiffRevisionsUnavailableError",
206 {
207 cwd: Schema.String,
208 number: Schema.Int,
209 commit: Schema.optional(Schema.String),
210 },
211) {
212 override get message(): string {
213 return this.commit === undefined
214 ? `Pull request #${this.number} reported no usable base and head revisions.`
215 : `Commit ${this.commit} reported no usable revisions for this file.`;
216 }
217}
218
219/** A blob exists, but expanding it would be unsafe or would not produce text. */
220export class GitHubDiffFileContentsUnavailableError extends Schema.TaggedError<GitHubDiffFileContentsUnavailableError>()(
221 "GitHubDiffFileContentsUnavailableError",
222 {
223 cwd: Schema.String,
224 path: Schema.String,
225 reason: Schema.Literals(["oversized", "binary"]),
226 },
227) {
228 override get message(): string {
229 return this.reason === "oversized"
230 ? `The diff file '${this.path}' exceeds the 1 MB expansion limit.`
231 : `The diff file '${this.path}' is binary.`;
232 }
233}
234
235/**
236 * Not a decode failure: a repository was named that cannot go into a search or into a GraphQL
237 * document as itself. Every qualifier and every alias below is composed from `owner/name`, so a
238 * name that is not one is refused here rather than escaped into something GitHub might read as a
239 * qualifier of its own.
240 */
241export class GitHubRepositorySelectorError extends Schema.TaggedError<GitHubRepositorySelectorError>()(
242 "GitHubRepositorySelectorError",
243 {
244 cwd: Schema.String,
245 operation: Schema.String,
246 },
247) {
248 override get message(): string {
249 return "A repository was named that GitHub cannot address.";
250 }
251}
252
253/** Not a decode failure: the reader named a subject this pull request never handed out. */
254export class GitHubSubjectScopeError extends Schema.TaggedError<GitHubSubjectScopeError>()(
255 "GitHubSubjectScopeError",
256 {
257 cwd: Schema.String,
258 operation: Schema.String,
259 },
260) {
261 override get message(): string {
262 return "The named subject did not belong to the named pull request.";
263 }
264}
265
266/** GitHub answered successfully, but approving every returned workflow would be unsafe. */
267export class GitHubWorkflowApprovalRefusedError extends Schema.TaggedError<GitHubWorkflowApprovalRefusedError>()(
268 "GitHubWorkflowApprovalRefusedError",
269 {
270 cwd: Schema.String,
271 number: Schema.Int,
272 reason: Schema.Literals(["head-list-truncated", "head-not-unique", "run-list-truncated"]),
273 observedCount: Schema.Int,
274 limit: Schema.Int,
275 },
276) {
277 override get message(): string {
278 if (this.reason === "head-list-truncated") {
279 return `GitHub returned more than ${this.limit} pull requests for this head branch.`;
280 }
281 if (this.reason === "head-not-unique") {
282 return `The head revision matched ${this.observedCount} pull requests instead of uniquely matching #${this.number}.`;
283 }
284 return `GitHub returned more than ${this.limit} workflow runs awaiting approval.`;
285 }
286}
287
288/** GitHub omitted the immutable head identity needed to scope an approval safely. */
289export class GitHubWorkflowApprovalHeadUnavailableError extends Schema.TaggedError<GitHubWorkflowApprovalHeadUnavailableError>()(
290 "GitHubWorkflowApprovalHeadUnavailableError",
291 {
292 cwd: Schema.String,
293 number: Schema.Int,
294 },
295) {
296 override get message(): string {
297 return `GitHub did not report a complete head revision for #${this.number}.`;
298 }
299}
300
301/** The pull request moved after its approval candidates were read. */
302export class GitHubWorkflowApprovalHeadChangedError extends Schema.TaggedError<GitHubWorkflowApprovalHeadChangedError>()(
303 "GitHubWorkflowApprovalHeadChangedError",
304 {
305 cwd: Schema.String,
306 number: Schema.Int,
307 },
308) {
309 override get message(): string {
310 return `The head revision of #${this.number} changed before its workflows could be approved.`;
311 }
312}
313
314export class GitHubBranchDeletionRefusedError extends Schema.TaggedError<GitHubBranchDeletionRefusedError>()(
315 "GitHubBranchDeletionRefusedError",
316 {
317 number: Schema.Int,
318 reason: Schema.Literals([
319 "open-pr",
320 "stack",
321 "source-unavailable",
322 "unsafe-branch",
323 "head-changed",
324 ]),
325 },
326) {
327 override get message(): string {
328 switch (this.reason) {
329 case "open-pr":
330 return `Close or merge pull request #${this.number} before deleting its source branch.`;
331 case "stack":
332 return "Branch deletion is not supported for a stack.";
333 case "source-unavailable":
334 return `GitHub did not report a complete source branch for pull request #${this.number}.`;
335 case "unsafe-branch":
336 return "The source branch is a default or base branch and cannot be deleted here.";
337 case "head-changed":
338 return "The source branch has new commits and was not deleted.";
339 }
340 }
341}
342
343export type GitHubPullRequestApiError =
344 | GitHubBranchDeletionRefusedError
345 | GitHubStackActionError
346 | GitHubApi.GitHubApiError
347 | GitHubPullRequestReadError
348 | GitHubDiffCursorError
349 | GitHubDiffCommitError
350 | GitHubDiffRevisionsUnavailableError
351 | GitHubDiffFileContentsUnavailableError
352 | GitHubRepositorySelectorError
353 | GitHubSubjectScopeError
354 | GitHubWorkflowApprovalRefusedError
355 | GitHubWorkflowApprovalHeadUnavailableError
356 | GitHubWorkflowApprovalHeadChangedError
357 | SourceControlRateLimit.SourceControlRateLimitPausedError
358 | GitHubViewerLoginUnavailableError
359 | GitHubPullRequestUpdatedAtUnavailableError;
360
361/** A large pull request can produce a multi-megabyte patch; past this it is truncated. */
362const DIFF_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
363const DIFF_TIMEOUT = Duration.seconds(60);
364/** Pierre expansion is for source files, not blobs large enough to stall a review surface. */
365const DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024;
366
367/** What `gh pr list --state` asked a repository's own list for, without search. */
368const LIST_STATES: Record<PullRequestListState, ReadonlyArray<"OPEN" | "CLOSED" | "MERGED">> = {
369 all: ["OPEN", "CLOSED", "MERGED"],
370 open: ["OPEN"],
371 // Closed includes merged here, the way GitHub's own list reads it; merged rows are filtered out
372 // locally by `matchesUnsortedListing`.
373 closed: ["CLOSED", "MERGED"],
374 merged: ["MERGED"],
375};
376
377interface GitHubPullRequestListPage {
378 readonly items: ReadonlyArray<GitHubPullRequestListItem>;
379 readonly rawCount: number;
380 readonly endCursor: string | null;
381}
382
383/** A search-free fallback may scan older rows for local filters, but never the whole repository. */
384const PULL_REQUEST_FALLBACK_MAX_ROWS = 1_000;
385
386/** What the files API serves at most in one response, which is what one slice is made of. */
387const DIFF_FILES_PAGE_SIZE = 100;
388/**
389 * How many hundred-file pages of viewed state one read will walk. A point of the hourly GraphQL
390 * budget per page, against a change request nobody reviews in one sitting past the first few
391 * hundred files: beyond this the read stops and says it was cut short.
392 */
393const FILES_VIEWED_MAX_PAGES = 5;
394
395/**
396 * How many pull requests' node ids are remembered at once. A long-lived server sees far more of
397 * them than a reader ever has open, and least recently used rather than first in: a listing
398 * walking cold pull requests must not evict the review being ticked through.
399 */
400export const NODE_ID_CACHE_CAPACITY = 128;
401
402/**
403 * Pages of review threads to follow before the conversation is reported as truncated. GitHub
404 * serves a hundred threads a page, so this is a thousand threads — past anything a pull request
405 * a person is reading has, and short of walking a repository-sized conversation forever.
406 */
407const REVIEW_THREAD_PAGES = 10;
408
409/** Pages of a hundred check contexts to walk for one head before the rollup says it was cut. */
410const CHECK_CONTEXT_PAGES = 10;
411
412export interface GitHubPullRequestListBatch {
413 readonly items: ReadonlyArray<GitHubPullRequestListItem>;
414 readonly truncated: boolean;
415 /** False for a page GitHub would not search, which came back in `gh`'s own order instead. */
416 readonly continues: boolean;
417}
418
419export interface GitHubPullRequestStat {
420 readonly repository: string;
421 readonly number: number;
422 readonly additions: number;
423 readonly deletions: number;
424}
425
426/**
427 * Aliased lookups per request, and requests at once. Measured over a hundred rows: one request
428 * carrying all hundred takes ~5.2s, four of twenty-five in parallel ~2.1s.
429 */
430const STAT_ALIASES_PER_REQUEST = 25;
431const STAT_REQUEST_CONCURRENCY = 4;
432/**
433 * How long a summary read waits for company. The background sync asks for every linked pull
434 * request at once, and each read reaches the resolver after its own cache check, so a batch
435 * needs a moment longer than one scheduler tick to gather them.
436 */
437const SUMMARY_BATCH_WINDOW = "10 millis";
438
439class PullRequestSummaryRead extends Request.Class<
440 {
441 readonly cwd: string;
442 readonly repository: string;
443 readonly host: string;
444 readonly number: number;
445 },
446 ProviderChangeRequestSummary,
447 GitHubPullRequestApiError
448> {}
449
450class PullRequestWatchFingerprintRead extends Request.Class<
451 {
452 readonly cwd: string;
453 readonly repository: string;
454 readonly host: string;
455 readonly number: number;
456 },
457 GitHubPullRequestWatchFingerprint | null,
458 GitHubPullRequestApiError
459> {}
460
461export interface GitHubPullRequestSearchBatch {
462 /** Rows across every repository asked for, newest update first, each naming its own. */
463 readonly items: ReadonlyArray<GitHubPullRequestSearchItem>;
464 readonly truncated: boolean;
465}
466
467export interface GitHubPullRequestDiffSlice {
468 readonly patch: string;
469 /** Files in this slice had their hunks withheld, as opposed to there being more slices. */
470 readonly truncated: boolean;
471 /** Where the next slice starts, or null once the patch is whole. */
472 readonly nextCursor: string | null;
473 /** GitHub's own counts for the files whose hunks it withheld from this slice. */
474 readonly omittedFileStats?: ReadonlyArray<PullRequestOmittedFileStat>;
475}
476
477export interface GitHubPullRequestFilesViewed {
478 readonly files: ReadonlyArray<PullRequestFileViewed>;
479 /** GitHub had more files than the page budget below would read. */
480 readonly truncated: boolean;
481}
482
483export class GitHubPullRequestApi extends Context.Service<
484 GitHubPullRequestApi,
485 {
486 readonly withVerifiedCredential: <A, E, R>(
487 input: { readonly cwd: string; readonly host: string },
488 use: (identity: {
489 readonly accountId: string;
490 readonly viewer: string;
491 readonly credentialFingerprint: string;
492 }) => Effect.Effect<A, E, R>,
493 ) => Effect.Effect<A, E | GitHubPullRequestApiError, R>;
494 readonly getRoutingIdentity: (input: {
495 readonly cwd: string;
496 readonly host: string;
497 }) => Effect.Effect<
498 { readonly accountId: string; readonly viewer: string },
499 GitHubPullRequestApiError
500 >;
501 readonly getViewerLogin: (input: {
502 readonly cwd: string;
503 readonly host: string;
504 }) => Effect.Effect<string, GitHubPullRequestApiError>;
505
506 readonly listPullRequests: (input: {
507 readonly cwd: string;
508 readonly repository: string;
509 readonly host: string;
510 readonly state: PullRequestListState;
511 readonly involvement: PullRequestInvolvement;
512 readonly viewer: string;
513 readonly limit: number;
514 /** Free text for `--search`, matched as one literal phrase. */
515 readonly query?: string | undefined;
516 /** Where to carry on from, as a `updated:` qualifier on the same search. */
517 readonly cursor?: ProviderListCursor | undefined;
518 /** Further narrowings, as qualifiers on the search and as a local pass on the fallback. */
519 readonly filters?: PullRequestListFilters | undefined;
520 }) => Effect.Effect<GitHubPullRequestListBatch, GitHubPullRequestApiError>;
521
522 /**
523 * The same listing for a whole host in one search. `limit` is the size of the slice across
524 * all of the repositories rather than per repository, because that is what a search answers:
525 * the newest rows of the lot, which is exactly the page.
526 */
527 readonly searchPullRequests: (input: {
528 /** Any checkout on the host; the search names its repositories itself. */
529 readonly cwd: string;
530 readonly host: string;
531 readonly repositories: ReadonlyArray<string>;
532 readonly state: PullRequestListState;
533 readonly involvement: PullRequestInvolvement;
534 readonly viewer: string;
535 readonly limit: number;
536 readonly query?: string | undefined;
537 readonly cursor?: ProviderListCursor | undefined;
538 readonly filters?: PullRequestListFilters | undefined;
539 }) => Effect.Effect<GitHubPullRequestSearchBatch, GitHubPullRequestApiError>;
540
541 /** The line counts the search leaves out, for rows already on the page. */
542 readonly listPullRequestStats: (input: {
543 readonly cwd: string;
544 readonly host: string;
545 readonly changeRequests: ReadonlyArray<{
546 readonly repository: string;
547 readonly number: number;
548 }>;
549 }) => Effect.Effect<ReadonlyArray<GitHubPullRequestStat>, GitHubPullRequestApiError>;
550
551 readonly getPullRequestSummary: (input: {
552 readonly cwd: string;
553 readonly repository: string;
554 readonly host: string;
555 readonly number: number;
556 }) => Effect.Effect<ProviderChangeRequestSummary, GitHubPullRequestApiError>;
557 /**
558 * What a watch compares between passes, batched like summaries. Null when GitHub gave no
559 * answer for this pull request, so the watch reads it in full instead.
560 */
561 readonly getPullRequestWatchFingerprint: (input: {
562 readonly cwd: string;
563 readonly repository: string;
564 readonly host: string;
565 readonly number: number;
566 }) => Effect.Effect<GitHubPullRequestWatchFingerprint | null, GitHubPullRequestApiError>;
567
568 readonly revalidateChecks: Effect.Success<typeof makeChecksRevalidator>;
569
570 readonly getPullRequestDetail: (input: {
571 readonly cwd: string;
572 readonly repository: string;
573 readonly host: string;
574 readonly number: number;
575 }) => Effect.Effect<GitHubPullRequestCore, GitHubPullRequestApiError>;
576
577 readonly getPullRequestPreview: (input: {
578 readonly cwd: string;
579 readonly repository: string;
580 readonly host: string;
581 readonly number: number;
582 }) => Effect.Effect<
583 Omit<PullRequestPreview, "projectId" | "repository">,
584 GitHubPullRequestApiError
585 >;
586
587 readonly listWorkflowRunsRequiringApproval: (input: {
588 readonly cwd: string;
589 readonly repository: string;
590 readonly host: string;
591 readonly number: number;
592 readonly headSha: string;
593 readonly headBranch: string;
594 readonly headRepositoryOwner: string;
595 readonly isCrossRepository: true;
596 }) => Effect.Effect<ReadonlyArray<GitHubWorkflowRunApproval>, GitHubPullRequestApiError>;
597
598 /**
599 * The host-native stack this pull request is in, or null when it is in none — which is also
600 * the answer for a host that refuses the stacks preview altogether.
601 */
602 readonly getPullRequestStack: (input: {
603 readonly cwd: string;
604 readonly includeDetails?: boolean;
605 readonly repository: string;
606 readonly host: string;
607 readonly number: number;
608 }) => Effect.Effect<GitHubPullRequestStack | null, GitHubPullRequestApiError>;
609
610 readonly getPullRequestActivity: (input: {
611 readonly cwd: string;
612 readonly repository: string;
613 readonly host: string;
614 readonly number: number;
615 }) => Effect.Effect<GitHubPullRequestActivity, GitHubPullRequestApiError>;
616
617 readonly getPullRequestDiff: (input: {
618 readonly cwd: string;
619 readonly repository: string;
620 readonly host: string;
621 readonly number: number;
622 /** Absent asks for the first slice; anything else is a cursor a slice handed back. */
623 readonly cursor?: string | undefined;
624 /** One commit's own changes, rather than everything the pull request carries. */
625 readonly commit?: string | undefined;
626 }) => Effect.Effect<GitHubPullRequestDiffSlice, GitHubPullRequestApiError>;
627
628 readonly getPullRequestDiffFileContents: (input: {
629 readonly cwd: string;
630 readonly repository: string;
631 readonly host: string;
632 readonly number: number;
633 readonly commit?: string | undefined;
634 readonly changeType: "change" | "rename-pure" | "rename-changed" | "new" | "deleted";
635 readonly oldPath: string;
636 readonly newPath: string;
637 }) => Effect.Effect<
638 { readonly oldContents: string; readonly newContents: string },
639 GitHubPullRequestApiError
640 >;
641
642 /**
643 * Which files of the pull request the signed-in account has cleared, and which of those have
644 * been pushed to since. Read apart from the patch because GitHub only reports it over GraphQL.
645 */
646 readonly getPullRequestFilesViewed: (input: {
647 readonly cwd: string;
648 readonly repository: string;
649 readonly host: string;
650 readonly number: number;
651 }) => Effect.Effect<GitHubPullRequestFilesViewed, GitHubPullRequestApiError>;
652
653 /**
654 * Clears files, or puts them back, as one request. GitHub takes a single path per mutation,
655 * so a burst is batched with aliases into one document rather than one subprocess per press.
656 */
657 readonly setPullRequestFilesViewed: (input: {
658 readonly cwd: string;
659 readonly repository: string;
660 readonly host: string;
661 readonly number: number;
662 readonly files: ReadonlyArray<{ readonly path: string; readonly viewed: boolean }>;
663 }) => Effect.Effect<void, GitHubPullRequestApiError>;
664
665 readonly listReviewThreadComments: (input: {
666 readonly cwd: string;
667 readonly repository: string;
668 readonly host: string;
669 readonly number: number;
670 }) => Effect.Effect<GitHubReviewThreadComments, GitHubPullRequestApiError>;
671
672 /** One request for a listing's authors, since no `gh` JSON field reports an avatar. */
673 readonly listActorAvatars: (input: {
674 readonly cwd: string;
675 readonly repository: string;
676 readonly host: string;
677 readonly ids: ReadonlyArray<string>;
678 }) => Effect.Effect<ReadonlyMap<string, string>, GitHubPullRequestApiError>;
679
680 readonly getReviewThreadComments: (input: {
681 readonly cwd: string;
682 readonly repository: string;
683 readonly host: string;
684 readonly number: number;
685 readonly threadId: string;
686 readonly cursor: string;
687 }) => Effect.Effect<PullRequestThreadCommentsResult, GitHubPullRequestApiError>;
688
689 /** The viewer's standing on its own, for deciding a write without reading the whole detail. */
690 readonly getViewerAccess: (input: {
691 readonly cwd: string;
692 readonly repository: string;
693 readonly host: string;
694 readonly number: number;
695 /** Manual action checks may use the quota held back from automatic reads. */
696 readonly allowReserve?: boolean | undefined;
697 }) => Effect.Effect<GitHubViewerAccess & GitHubRepositoryAccess, GitHubPullRequestApiError>;
698
699 /** Who this pull request may be sent to, and who it has already been sent to. */
700 readonly listReviewerCandidates: (input: {
701 readonly cwd: string;
702 readonly repository: string;
703 readonly host: string;
704 readonly number: number;
705 }) => Effect.Effect<PullRequestReviewerCandidateList, GitHubPullRequestApiError>;
706
707 readonly setReviewerRequest: (input: {
708 readonly cwd: string;
709 readonly repository: string;
710 readonly host: string;
711 readonly number: number;
712 readonly reviewers: ReadonlyArray<{
713 readonly id: string;
714 readonly kind: PullRequestReviewerKind;
715 }>;
716 /** False deletes the same collection a request posts to, which takes the request back. */
717 readonly requested: boolean;
718 }) => Effect.Effect<void, GitHubPullRequestApiError>;
719
720 /** The repository's labels, and which of them this pull request already wears. */
721 readonly listLabelCandidates: (input: {
722 readonly cwd: string;
723 readonly repository: string;
724 readonly host: string;
725 readonly number: number;
726 }) => Effect.Effect<PullRequestLabelCandidateList, GitHubPullRequestApiError>;
727
728 readonly setLabels: (input: {
729 readonly cwd: string;
730 readonly repository: string;
731 readonly host: string;
732 readonly number: number;
733 readonly labels: ReadonlyArray<string>;
734 /** False takes each label off; true adds each to whatever is already there. */
735 readonly applied: boolean;
736 }) => Effect.Effect<void, GitHubPullRequestApiError>;
737
738 readonly runPullRequestAction: (input: {
739 readonly cwd: string;
740 readonly repository: string;
741 readonly host: string;
742 readonly number: number;
743 readonly action: PullRequestAction;
744 readonly stackNumber?: number;
745 readonly expectedStackHeads?: ReadonlyArray<PullRequestStackHead>;
746 readonly removeAgentCreditsOnMerge?: boolean;
747 readonly mergeMethod?: PullRequestMergeMethod;
748 readonly updateMethod?: PullRequestUpdateMethod;
749 }) => Effect.Effect<void, GitHubPullRequestApiError>;
750
751 readonly commentOnPullRequest: (input: {
752 readonly cwd: string;
753 readonly repository: string;
754 readonly host: string;
755 readonly number: number;
756 readonly body: string;
757 }) => Effect.Effect<void, GitHubPullRequestApiError>;
758
759 readonly submitReview: (input: {
760 readonly cwd: string;
761 readonly repository: string;
762 readonly host: string;
763 readonly number: number;
764 readonly verdict: PullRequestReviewVerdict;
765 readonly body: string;
766 readonly comments: ReadonlyArray<PullRequestReviewCommentDraft>;
767 }) => Effect.Effect<void, GitHubPullRequestApiError>;
768
769 readonly replyToReviewThread: (input: {
770 readonly cwd: string;
771 readonly repository: string;
772 readonly host: string;
773 readonly threadId: string;
774 readonly body: string;
775 }) => Effect.Effect<void, GitHubPullRequestApiError>;
776
777 readonly setReviewThreadResolution: (input: {
778 readonly cwd: string;
779 readonly repository: string;
780 readonly host: string;
781 readonly threadId: string;
782 readonly resolved: boolean;
783 }) => Effect.Effect<void, GitHubPullRequestApiError>;
784
785 /**
786 * Adds a reaction to a remark, or takes it back. `subjectId` is any node GitHub calls
787 * reactable — a comment, a review, or the pull request itself, which is looked up here
788 * because nothing in the conversation names it. A given `subjectId` is confirmed to belong
789 * to this pull request before the mutation runs, since nothing else ties the two together.
790 */
791 readonly setReaction: (input: {
792 readonly cwd: string;
793 readonly repository: string;
794 readonly host: string;
795 readonly number: number;
796 readonly subjectId?: string | undefined;
797 readonly content: PullRequestReactionContent;
798 readonly reacted: boolean;
799 }) => Effect.Effect<void, GitHubPullRequestApiError>;
800
801 /** Rewrites the pull request's own words, leaving whichever of the two was not given. */
802 readonly updatePullRequest: (input: {
803 readonly cwd: string;
804 readonly repository: string;
805 readonly host: string;
806 readonly number: number;
807 readonly title?: string | undefined;
808 readonly body?: string | undefined;
809 }) => Effect.Effect<void, GitHubPullRequestApiError>;
810
811 /**
812 * Rewrites a remark. `commentId` is trusted to be whatever node it names, so it is confirmed
813 * to belong to this pull request before the mutation runs, the way a reaction subject is.
814 * Whether the remark is the reader's to rewrite is GitHub's own answer, not one asked here.
815 */
816 readonly updateComment: (input: {
817 readonly cwd: string;
818 readonly repository: string;
819 readonly host: string;
820 readonly number: number;
821 readonly commentId: string;
822 readonly kind: "issue-comment" | "review-comment";
823 readonly body: string;
824 }) => Effect.Effect<void, GitHubPullRequestApiError>;
825 }
826>()("@t3tools/source-control-github/server/GitHubPullRequestApi") {}
827
828/**
829 * The GraphQL API takes owner and name as separate arguments, so `owner/repo` is split here.
830 * The host is not read off the identity: it travels alongside it, because the identity a
831 * project records is the path below its host and never names the host itself.
832 */
833function parseRepositorySelector(value: string): {
834 readonly owner: string;
835 readonly name: string;
836} {
837 const parts = value.trim().split("/").filter(Boolean);
838 return { name: parts.at(-1) ?? "", owner: parts.at(-2) ?? "" };
839}
840
841/**
842 * The page a diff cursor names, or null for anything this walk cannot have issued. The cursor
843 * arrives from the reader as a string and goes straight into a request path, so it is parsed
844 * rather than trusted; the length bound keeps a page number out of exponential notation.
845 */
846function diffCursorPage(cursor: string): number | null {
847 return /^[1-9][0-9]{0,6}$/.test(cursor) ? Number(cursor) : null;
848}
849
850/**
851 * A commit sha arrives from the reader and goes straight into a request path, so it is checked
852 * rather than trusted: hexadecimal only, from the shortest abbreviation a host prints up to a
853 * whole sha.
854 */
855function isCommitSha(value: string): boolean {
856 return /^[0-9a-f]{7,64}$/i.test(value);
857}
858
859/**
860 * The reader's own words as one literal phrase of a GitHub search query. Quoting is the whole
861 * defence: outside quotes GitHub reads `is:merged` as a qualifier and `label:x` as another, so
862 * text typed into a search box could widen the very listing it is meant to narrow — inside them
863 * it is only text. The two characters that could end the phrase early are therefore escaped
864 * first, which GitHub reads back as themselves; an unbalanced quote is dropped instead, which
865 * would let everything after it out of the phrase.
866 *
867 * The phrase is one argv element, so nothing in it can become a flag of its own either.
868 */
869function searchPhrase(query: string): string {
870 return `"${query.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
871}
872
873/** GitHub's own spelling of a review state, which is not the contract's. */
874const REVIEW_QUALIFIERS = {
875 approved: "approved",
876 "changes-requested": "changes_requested",
877 "review-required": "required",
878 none: "none",
879} as const;
880
881/**
882 * The extra narrowings as GitHub search qualifiers. Values a reader typed are quoted, and the
883 * one character that could end the quoted value early is dropped rather than escaped: no GitHub
884 * label or login holds a double quote, so there is nothing to preserve and everything to lose.
885 */
886function qualifierValue(value: string): string {
887 return `"${value.replaceAll('"', "").trim()}"`;
888}
889
890function filterQualifiers(
891 filters: PullRequestListFilters | undefined,
892 viewer: string,
893): ReadonlyArray<string> {
894 if (filters === undefined) return [];
895 return [
896 // One qualifier per group, its names joined by commas — GitHub's own OR.
897 ...(filters.labels ?? []).flatMap((group) =>
898 group.length === 0 ? [] : [`label:${group.map(qualifierValue).join(",")}`],
899 ),
900 ...(filters.excludedLabels ?? []).map((label) => `-label:${qualifierValue(label)}`),
901 ...(filters.author === undefined
902 ? []
903 : [`author:${qualifierValue(resolvePullRequestAuthorFilter(filters.author, viewer))}`]),
904 ...(filters.draft === undefined ? [] : [`draft:${filters.draft === "only"}`]),
905 ...(filters.review === undefined ? [] : [`review:${REVIEW_QUALIFIERS[filters.review]}`]),
906 ...(filters.checks === undefined
907 ? []
908 : [`status:${filters.checks === "passing" ? "success" : "failure"}`]),
909 ];
910}
911
912/**
913 * The same narrowings over a row that has already arrived, for the search-free fallback. Every
914 * listed row now carries its own `checksState`, so `checks` is judged the way `review` is: by
915 * equality against the row's field. Unlike `review`, `checks` has no `"none"` value to catch an
916 * absent state on purpose — a row with no checks configured, or whose checks are still `pending`,
917 * equals neither `"passing"` nor `"failing"` and so fails both, the same as a row search would
918 * not have surfaced for `status:success` or `status:failure`.
919 */
920function matchesFilters(
921 item: GitHubPullRequestListItem,
922 filters: PullRequestListFilters | undefined,
923 viewer: string,
924): boolean {
925 if (filters === undefined) return true;
926 const labels = new Set(item.labels.map((label) => label.name.trim().toLowerCase()));
927 const holds = (label: string) => labels.has(label.trim().toLowerCase());
928 return (
929 (filters.draft === undefined || item.isDraft === (filters.draft === "only")) &&
930 (filters.review === undefined ||
931 (filters.review === "none"
932 ? item.reviewDecision === null
933 : item.reviewDecision === filters.review)) &&
934 (filters.checks === undefined || item.checksState === filters.checks) &&
935 (filters.labels === undefined || filters.labels.every((group) => group.some(holds))) &&
936 (filters.excludedLabels === undefined || !filters.excludedLabels.some(holds)) &&
937 (filters.author === undefined ||
938 item.author?.login.toLowerCase() ===
939 resolvePullRequestAuthorFilter(filters.author, viewer).toLowerCase())
940 );
941}
942
943/** The search-free fallback is wider than the request, so narrow its decoded rows locally. */
944function matchesUnsortedListing(
945 item: GitHubPullRequestListItem,
946 input: {
947 readonly state: PullRequestListState;
948 readonly involvement: PullRequestInvolvement;
949 readonly viewer: string;
950 readonly filters?: PullRequestListFilters | undefined;
951 },
952): boolean {
953 const matchesState = input.state === "all" || item.state === input.state;
954 const viewer = input.viewer.toLowerCase();
955 const matchesInvolvement =
956 input.involvement === "all" ||
957 (input.involvement === "authored"
958 ? item.author?.login.toLowerCase() === viewer
959 : item.hasTeamReviewRequest ||
960 item.reviewRequestLogins.some((login) => login.toLowerCase() === viewer));
961 return matchesState && matchesInvolvement && matchesFilters(item, input.filters, input.viewer);
962}
963
964/** What a repository selector may hold before it goes into a search as itself. */
965const SEARCH_REPOSITORY = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
966
967/**
968 * A listing as one GitHub search, across one repository or several: the only way to read a
969 * whole host in one request, and the only order (`updated`) a continuation can carry on from.
970 *
971 * `--state closed` in GitHub's own list includes merged pull requests, so the Closed tab is
972 * `is:closed is:unmerged` here.
973 *
974 * Null where a repository is not `owner/name`. A name is written into the query as itself, and a
975 * name holding a space could otherwise end the `repo:` qualifier and start a qualifier of its
976 * own — so an unaddressable one refuses the whole read rather than being escaped into something
977 * GitHub might still read.
978 */
979function searchQuery(input: {
980 readonly repositories: ReadonlyArray<string>;
981 readonly state: PullRequestListState;
982 readonly involvement: PullRequestInvolvement;
983 readonly viewer: string;
984 readonly query?: string | undefined;
985 readonly cursor?: ProviderListCursor | undefined;
986 readonly filters?: PullRequestListFilters | undefined;
987}): string | null {
988 if (input.repositories.length === 0) return null;
989 const repositories = input.repositories.map((repository) => repository.trim());
990 if (!repositories.every((repository) => SEARCH_REPOSITORY.test(repository))) return null;
991 const query = input.query?.trim() ?? "";
992 return [
993 "is:pr",
994 // "all" is every state, which `is:pr` already is.
995 ...(input.state === "open" ? ["is:open"] : []),
996 ...(input.state === "closed" ? ["is:closed", "is:unmerged"] : []),
997 ...(input.state === "merged" ? ["is:merged"] : []),
998 ...(input.involvement === "authored" ? [`author:${input.viewer}`] : []),
999 ...(input.involvement === "reviewing" ? [`review-requested:${input.viewer}`] : []),
1000 ...(query.length === 0 ? [] : [searchPhrase(query)]),
1001 // Inclusive, and de-duplicated by the caller, for the reason the per-repository read gives.
1002 ...(input.cursor === undefined ? [] : [`updated:<=${input.cursor.updatedBefore}`]),
1003 ...filterQualifiers(input.filters, input.viewer),
1004 // The order the page reads its rows in, and the only order a continuation can carry on from.
1005 "sort:updated-desc",
1006 ...repositories.map((repository) => `repo:${repository}`),
1007 ].join(" ");
1008}
1009
1010const MERGE_MESSAGE_GRAPHQL_QUERY = `
1011query PullRequestMergeMessage($owner: String!, $name: String!, $number: Int!, $method: PullRequestMergeMethod!) {
1012 repository(owner: $owner, name: $name) {
1013 pullRequest(number: $number) {
1014 isMergeQueueEnabled
1015 headRefOid
1016 viewerMergeBodyText(mergeType: $method)
1017 }
1018 }
1019}`;
1020
1021/** The two revisions a file is expanded between: a pull request's base and head, or a commit's parent and itself. */
1022const decodeRevisionRefs = Schema.decodeUnknownOption(
1023 Schema.fromJsonString(
1024 Schema.Struct({
1025 sha: Schema.optional(Schema.String),
1026 parents: Schema.optional(Schema.Array(Schema.Struct({ sha: Schema.String }))),
1027 base: Schema.optional(Schema.Struct({ sha: Schema.String })),
1028 head: Schema.optional(Schema.Struct({ sha: Schema.String })),
1029 }),
1030 ),
1031);
1032
1033const decodeMergeMessageResponse = Schema.decodeUnknownResult(
1034 Schema.fromJsonString(
1035 Schema.Struct({
1036 data: Schema.Struct({
1037 repository: Schema.Struct({
1038 pullRequest: Schema.Struct({
1039 isMergeQueueEnabled: Schema.Boolean,
1040 headRefOid: Schema.String,
1041 viewerMergeBodyText: Schema.String,
1042 }),
1043 }),
1044 }),
1045 }),
1046 ),
1047);
1048const decodeMergeMessage = (raw: string) =>
1049 Result.map(decodeMergeMessageResponse(raw), (response) => response.data.repository.pullRequest);
1050
1051/** What `gh pr merge` and `gh pr update-branch` read before they act. */
1052const ACTION_STATE_GRAPHQL_QUERY = `
1053query PullRequestActionState($owner: String!, $name: String!, $number: Int!, $headRef: String!) {
1054 repository(owner: $owner, name: $name) {
1055 pullRequest(number: $number) {
1056 id
1057 headRefOid
1058 isMergeQueueEnabled
1059 mergeStateStatus
1060 baseRef { compare(headRef: $headRef) { behindBy } }
1061 }
1062 }
1063}`;
1064
1065const decodeActionStateResponse = Schema.decodeUnknownResult(
1066 Schema.fromJsonString(
1067 Schema.Struct({
1068 data: Schema.Struct({
1069 repository: Schema.Struct({
1070 pullRequest: Schema.Struct({
1071 id: Schema.String,
1072 headRefOid: Schema.String,
1073 isMergeQueueEnabled: Schema.optional(Schema.Boolean),
1074 mergeStateStatus: Schema.optional(Schema.NullOr(Schema.String)),
1075 baseRef: Schema.optional(
1076 Schema.NullOr(
1077 Schema.Struct({ compare: Schema.NullOr(Schema.Struct({ behindBy: Schema.Int })) }),
1078 ),
1079 ),
1080 }),
1081 }),
1082 }),
1083 }),
1084 ),
1085);
1086const decodeActionState = (raw: string) =>
1087 Result.map(decodeActionStateResponse(raw), (response) => response.data.repository.pullRequest);
1088
1089const MERGE_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($input: MergePullRequestInput!) {
1090 mergePullRequest(input: $input) { clientMutationId }
1091}`;
1092const ENABLE_AUTO_MERGE_GRAPHQL_MUTATION = `mutation($input: EnablePullRequestAutoMergeInput!) {
1093 enablePullRequestAutoMerge(input: $input) { clientMutationId }
1094}`;
1095
1096const BRANCH_TO_DELETE_GRAPHQL_QUERY = `query PullRequestBranchToDelete($owner: String!, $name: String!, $number: Int!) {
1097 repository(owner: $owner, name: $name) {
1098 pullRequest(number: $number) {
1099 state
1100 headRefName
1101 baseRefName
1102 headRefOid
1103 headRepository { id }
1104 }
1105 }
1106}`;
1107const decodeBranchToDelete = Schema.decodeUnknownResult(
1108 Schema.fromJsonString(
1109 Schema.Struct({
1110 data: Schema.Struct({
1111 repository: Schema.Struct({
1112 pullRequest: Schema.Struct({
1113 state: Schema.Literals(["OPEN", "CLOSED", "MERGED"]),
1114 headRefName: Schema.String,
1115 baseRefName: Schema.String,
1116 headRefOid: Schema.String,
1117 headRepository: Schema.NullOr(Schema.Struct({ id: Schema.String })),
1118 }),
1119 }),
1120 }),
1121 }),
1122 ),
1123);
1124const SOURCE_BRANCH_GRAPHQL_QUERY = `query PullRequestSourceBranch($repositoryId: ID!, $ref: String!) {
1125 node(id: $repositoryId) {
1126 ... on Repository {
1127 defaultBranchRef { name }
1128 ref(qualifiedName: $ref) { target { oid } }
1129 }
1130 }
1131}`;
1132const decodeSourceBranch = Schema.decodeUnknownResult(
1133 Schema.fromJsonString(
1134 Schema.Struct({
1135 data: Schema.Struct({
1136 node: Schema.NullOr(
1137 Schema.Struct({
1138 defaultBranchRef: Schema.NullOr(Schema.Struct({ name: Schema.String })),
1139 ref: Schema.NullOr(Schema.Struct({ target: Schema.Struct({ oid: Schema.String }) })),
1140 }),
1141 ),
1142 }),
1143 }),
1144 ),
1145);
1146const DELETE_BRANCH_GRAPHQL_MUTATION = `mutation($input: UpdateRefsInput!) {
1147 updateRefs(input: $input) { clientMutationId }
1148}`;
1149const DISABLE_AUTO_MERGE_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) {
1150 disablePullRequestAutoMerge(input: { pullRequestId: $pullRequestId }) { clientMutationId }
1151}`;
1152const UPDATE_BRANCH_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!, $expectedHeadOid: GitObjectID!, $updateMethod: PullRequestBranchUpdateMethod!) {
1153 updatePullRequestBranch(input: { pullRequestId: $pullRequestId, expectedHeadOid: $expectedHeadOid, updateMethod: $updateMethod }) { clientMutationId }
1154}`;
1155const READY_FOR_REVIEW_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) {
1156 markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) { clientMutationId }
1157}`;
1158const CONVERT_TO_DRAFT_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) {
1159 convertPullRequestToDraft(input: { pullRequestId: $pullRequestId }) { clientMutationId }
1160}`;
1161const CLOSE_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) {
1162 closePullRequest(input: { pullRequestId: $pullRequestId }) { clientMutationId }
1163}`;
1164const REOPEN_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) {
1165 reopenPullRequest(input: { pullRequestId: $pullRequestId }) { clientMutationId }
1166}`;
1167const ADD_COMMENT_GRAPHQL_MUTATION = `mutation($subjectId: ID!, $body: String!) {
1168 addComment(input: { subjectId: $subjectId, body: $body }) { clientMutationId }
1169}`;
1170
1171const GRAPHQL_MERGE_METHODS = {
1172 merge: "MERGE",
1173 squash: "SQUASH",
1174 rebase: "REBASE",
1175} as const satisfies Record<PullRequestMergeMethod, string>;
1176
1177/** States in which `gh pr merge --auto` merges at once instead of arming auto-merge. */
1178const IMMEDIATELY_MERGEABLE = new Set(["CLEAN", "HAS_HOOKS", "UNSTABLE"]);
1179
1180/** The mutations that only need the pull request's node id. */
1181const SIMPLE_ACTION_MUTATIONS = {
1182 "disable-auto-merge": DISABLE_AUTO_MERGE_GRAPHQL_MUTATION,
1183 ready: READY_FOR_REVIEW_GRAPHQL_MUTATION,
1184 draft: CONVERT_TO_DRAFT_GRAPHQL_MUTATION,
1185 close: CLOSE_PULL_REQUEST_GRAPHQL_MUTATION,
1186 reopen: REOPEN_PULL_REQUEST_GRAPHQL_MUTATION,
1187} as const satisfies Partial<Record<PullRequestAction, string>>;
1188
1189/** @public Service construction is part of the canonical Effect module API. */
1190export const make = Effect.gen(function* () {
1191 const api = yield* GitHubApi.GitHubApi;
1192 const sourceControlHost = yield* SourceControlHost.SourceControlHost;
1193 const fileSystem = yield* FileSystem.FileSystem;
1194 const revalidateChecks = yield* makeChecksRevalidator;
1195 const routingIdentities = new Map<
1196 string,
1197 {
1198 at: number;
1199 value: { accountId: string; viewer: string };
1200 }
1201 >();
1202 const identityLocks = new Map<string, { gate: Semaphore.Semaphore; users: number }>();
1203 const decodeRoutingIdentity = Schema.decodeUnknownEffect(
1204 Schema.fromJsonString(
1205 Schema.Struct({
1206 id: PositiveInt,
1207 login: TrimmedNonEmptyString,
1208 }),
1209 ),
1210 );
1211 const captureVerifiedCredential = Effect.fn("GitHubPullRequestApi.captureVerifiedCredential")(
1212 function* (input: { readonly cwd: string; readonly host: string }) {
1213 const unavailable = () => new GitHubViewerLoginUnavailableError({ cwd: input.cwd });
1214 const host = input.host.toLowerCase();
1215 // A missing or signed-out credential keeps its own error, so the page can say which.
1216 const { token, fingerprint: key } = yield* api.credential(host);
1217 const credential = { host, token, credentialFingerprint: key };
1218 // A cold page may ask several times. Wait per credential and check again after the
1219 // first verification; cancellation releases the next waiter without losing its request.
1220 return yield* Effect.acquireUseRelease(
1221 Effect.sync(() => {
1222 const lock = identityLocks.get(key) ?? { gate: Semaphore.makeUnsafe(1), users: 0 };
1223 lock.users++;
1224 identityLocks.set(key, lock);
1225 return lock;
1226 }),
1227 (lock) =>
1228 lock.gate.withPermit(
1229 Effect.gen(function* () {
1230 const now = yield* Clock.currentTimeMillis;
1231 const cached = routingIdentities.get(key);
1232 if (cached !== undefined && now - cached.at < 10 * 60_000)
1233 return { ...credential, ...cached.value };
1234 // Pin this read so an auth switch cannot poison its cache entry.
1235 const response = yield* api
1236 .rest({ host, operation: "getRoutingIdentity", path: "user", allowReserve: true })
1237 .pipe(
1238 Effect.provideService(GitHubApi.PinnedGitHubCredential, credential),
1239 Effect.provideService(SourceControlRateLimit.CredentialScope, key),
1240 );
1241 const identity = yield* decodeRoutingIdentity(response.body).pipe(
1242 Effect.mapError(unavailable),
1243 );
1244 const value = { accountId: String(identity.id), viewer: identity.login };
1245 if (routingIdentities.size >= 128)
1246 routingIdentities.delete(routingIdentities.keys().next().value!);
1247 routingIdentities.set(key, { at: now, value });
1248 return { ...credential, ...value };
1249 }),
1250 ),
1251 (lock) =>
1252 Effect.sync(() => {
1253 lock.users--;
1254 if (lock.users === 0) identityLocks.delete(key);
1255 }),
1256 );
1257 },
1258 );
1259 const withVerifiedCredential: GitHubPullRequestApi["Service"]["withVerifiedCredential"] = (
1260 input,
1261 use,
1262 ) =>
1263 captureVerifiedCredential(input).pipe(
1264 Effect.flatMap(({ host, token, accountId, viewer, credentialFingerprint }) =>
1265 use({ accountId, viewer, credentialFingerprint }).pipe(
1266 Effect.provideService(SourceControlRateLimit.CredentialScope, credentialFingerprint),
1267 Effect.provideService(GitHubApi.PinnedGitHubCredential, {
1268 host,
1269 token,
1270 credentialFingerprint,
1271 }),
1272 ),
1273 ),
1274 );
1275 const getRoutingIdentity: GitHubPullRequestApi["Service"]["getRoutingIdentity"] = (input) =>
1276 captureVerifiedCredential(input).pipe(
1277 Effect.map(({ accountId, viewer }) => ({ accountId, viewer })),
1278 );
1279
1280 /**
1281 * The pull request's own node id, which is what a mutation against the pull request itself is
1282 * addressed by: a reaction on its description, or a rewrite of its words.
1283 *
1284 * A pull request keeps its node id for life, so it is remembered rather than re-read: a reader
1285 * ticking files viewed would otherwise pay a GraphQL round trip per press.
1286 */
1287 const nodeIds = new Map<string, string>();
1288
1289 const pullRequestNodeId = (input: {
1290 readonly cwd: string;
1291 readonly repository: string;
1292 readonly host: string;
1293 readonly number: number;
1294 readonly operation: string;
1295 }): Effect.Effect<string, GitHubPullRequestApiError> => {
1296 const { owner, name } = parseRepositorySelector(input.repository);
1297 const key = `${input.host} ${owner}/${name} ${input.number}`;
1298 const held = nodeIds.get(key);
1299 if (held !== undefined) {
1300 // Put back at the end on every hit, so what falls out is the pull request nobody has looked
1301 // at rather than the one being ticked through: a run of cold reads would otherwise evict the
1302 // open review and make it pay a round trip per press.
1303 nodeIds.delete(key);
1304 nodeIds.set(key, held);
1305 return Effect.succeed(held);
1306 }
1307 return graphqlRead({
1308 cwd: input.cwd,
1309 host: input.host,
1310 operation: input.operation,
1311 allowReserve: true,
1312 variables: { owner, name, number: input.number },
1313 query: PULL_REQUEST_NODE_ID_GRAPHQL_QUERY,
1314 decode: decodePullRequestNodeIdJson,
1315 }).pipe(
1316 Effect.tap((nodeId) =>
1317 Effect.sync(() => {
1318 if (nodeIds.size >= NODE_ID_CACHE_CAPACITY) {
1319 const oldest = nodeIds.keys().next().value;
1320 if (oldest !== undefined) nodeIds.delete(oldest);
1321 }
1322 nodeIds.set(key, nodeId);
1323 }),
1324 ),
1325 );
1326 };
1327
1328 /**
1329 * Whether a client-given subject actually belongs to the pull request the request names. A
1330 * subject id is trusted to be whatever node it names, and that node can hang off any pull
1331 * request on the host — so the mutation itself would write wherever the id actually belongs,
1332 * not wherever the request says it does, unless this confirms the two agree first.
1333 */
1334 const subjectBelongsToPullRequest = (input: {
1335 readonly cwd: string;
1336 readonly repository: string;
1337 readonly host: string;
1338 readonly number: number;
1339 readonly subjectId: string;
1340 readonly operation: string;
1341 }) => {
1342 const { owner, name } = parseRepositorySelector(input.repository);
1343 return graphqlRead({
1344 cwd: input.cwd,
1345 host: input.host,
1346 operation: input.operation,
1347 allowReserve: true,
1348 variables: { owner, name, number: input.number, subjectId: input.subjectId },
1349 query: REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY,
1350 decode: decodeReactionSubjectScopeJson,
1351 });
1352 };
1353
1354 /** A GraphQL mutation whose answer is not read back; a GraphQL error fails it. */
1355 const graphql = (input: {
1356 readonly host: string;
1357 readonly operation: string;
1358 readonly query: string;
1359 readonly variables: Readonly<Record<string, unknown>>;
1360 }) => api.graphql(input).pipe(Effect.asVoid);
1361
1362 const readError = (cwd: string, operation: string, cause: unknown) =>
1363 new GitHubPullRequestReadError({ cwd, operation, cause });
1364
1365 /** Decodes an answer, reporting a failure against the read that made it. */
1366 const decodeWith = <A>(
1367 cwd: string,
1368 operation: string,
1369 decode: (raw: string) => Result.Result<A, unknown>,
1370 raw: string,
1371 ): Effect.Effect<A, GitHubPullRequestReadError> => {
1372 const decoded = decode(raw.trim());
1373 return Result.isSuccess(decoded)
1374 ? Effect.succeed(decoded.success)
1375 : Effect.fail(readError(cwd, operation, decoded.failure));
1376 };
1377
1378 /** A GraphQL read whose answer is decoded, reporting a failure against the read that made it. */
1379 const graphqlRead = <A>(input: {
1380 readonly cwd: string;
1381 readonly host: string;
1382 readonly operation: string;
1383 readonly allowReserve?: boolean | undefined;
1384 readonly variables?: Readonly<Record<string, unknown>>;
1385 readonly query: string;
1386 readonly decode: (raw: string) => Result.Result<A, unknown>;
1387 }): Effect.Effect<A, GitHubPullRequestApiError> =>
1388 api
1389 .graphql({
1390 host: input.host,
1391 operation: input.operation,
1392 query: input.query,
1393 ...(input.variables === undefined ? {} : { variables: input.variables }),
1394 ...(input.allowReserve === true ? { allowReserve: true } : {}),
1395 })
1396 .pipe(Effect.flatMap((raw) => decodeWith(input.cwd, input.operation, input.decode, raw)));
1397
1398 /** A REST read whose JSON answer is decoded the same way. */
1399 const restRead = <A>(input: {
1400 readonly cwd: string;
1401 readonly host: string;
1402 readonly operation: string;
1403 readonly path: string;
1404 readonly decode: (raw: string) => Result.Result<A, unknown>;
1405 }): Effect.Effect<A, GitHubPullRequestApiError> =>
1406 api
1407 .rest({ host: input.host, operation: input.operation, path: input.path })
1408 .pipe(
1409 Effect.flatMap((response) =>
1410 decodeWith(input.cwd, input.operation, input.decode, response.body),
1411 ),
1412 );
1413
1414 /**
1415 * One page of the patch, read from the files API. GitHub refuses a whole diff past 300 changed
1416 * files, and still serves those files' hunks here.
1417 *
1418 * A page is a whole number of files, so each one parses on its own; the caller carries on from
1419 * `nextCursor` for as long as GitHub keeps handing pages back.
1420 *
1421 * A named commit is read from the commit endpoint, which lists the same file entries and pages
1422 * them the same way, only wrapped in an object.
1423 */
1424 const diffFilesPage = (input: {
1425 readonly cwd: string;
1426 readonly repository: string;
1427 readonly host: string;
1428 readonly number: number;
1429 readonly page: number;
1430 readonly commit?: string | undefined;
1431 }): Effect.Effect<GitHubPullRequestDiffSlice, GitHubPullRequestApiError> => {
1432 const { owner, name } = parseRepositorySelector(input.repository);
1433 const paging = `per_page=${DIFF_FILES_PAGE_SIZE}&page=${input.page}`;
1434 return api
1435 .rest({
1436 host: input.host,
1437 operation: "getPullRequestDiff",
1438 path:
1439 input.commit === undefined
1440 ? `repos/${owner}/${name}/pulls/${input.number}/files?${paging}`
1441 : `repos/${owner}/${name}/commits/${input.commit}?${paging}`,
1442 maxResponseBytes: DIFF_MAX_OUTPUT_BYTES,
1443 timeout: DIFF_TIMEOUT,
1444 })
1445 .pipe(
1446 Effect.flatMap((response) => {
1447 // Checked before decoding: a byte-truncated response is a JSON prefix, which would
1448 // fail to parse. Nothing of this page can be shown, and an empty patch would render
1449 // as a change with no files rather than as the failure it is; slices already handed
1450 // over stay with the reader either way.
1451 if (response.truncated) {
1452 return Effect.fail(
1453 readError(
1454 input.cwd,
1455 "getPullRequestDiff",
1456 new Error(`Page ${input.page} of the changed files was too large to read.`),
1457 ),
1458 );
1459 }
1460 return decodeWith(
1461 input.cwd,
1462 "getPullRequestDiff",
1463 input.commit === undefined ? decodePullRequestFilesJson : decodeCommitFilesJson,
1464 response.body,
1465 );
1466 }),
1467 Effect.map((files) => {
1468 // Counted before decoding, so a page whose files all failed to decode still moves on
1469 // rather than pointing the reader back at the page it just read.
1470 const morePages = files.rawCount >= DIFF_FILES_PAGE_SIZE;
1471 return {
1472 patch: files.patch,
1473 truncated: files.truncated,
1474 nextCursor: morePages ? String(input.page + 1) : null,
1475 ...(files.omittedFileStats.length === 0
1476 ? {}
1477 : { omittedFileStats: files.omittedFileStats }),
1478 };
1479 }),
1480 );
1481 };
1482
1483 const getPullRequestDiffFileContents: GitHubPullRequestApi["Service"]["getPullRequestDiffFileContents"] =
1484 (input) =>
1485 Effect.gen(function* () {
1486 if (input.commit !== undefined && !isCommitSha(input.commit)) {
1487 return yield* new GitHubDiffCommitError({ cwd: input.cwd });
1488 }
1489 const { owner, name } = parseRepositorySelector(input.repository);
1490 const refsResponse = yield* api.rest({
1491 host: input.host,
1492 operation: "getPullRequestDiffFileContents",
1493 path:
1494 input.commit === undefined
1495 ? `repos/${owner}/${name}/pulls/${input.number}`
1496 : `repos/${owner}/${name}/commits/${input.commit}`,
1497 timeout: DIFF_TIMEOUT,
1498 });
1499 const refs = decodeRevisionRefs(refsResponse.body);
1500 // A root commit has no parent, which is an absent old revision. Every file in it is new,
1501 // so that is a usable answer whenever the caller does not need the old side.
1502 const baseRef = Option.isSome(refs)
1503 ? input.commit === undefined
1504 ? refs.value.base?.sha
1505 : (refs.value.parents?.[0]?.sha ?? "")
1506 : undefined;
1507 const headRef = Option.isSome(refs)
1508 ? input.commit === undefined
1509 ? refs.value.head?.sha
1510 : refs.value.sha
1511 : undefined;
1512 const rootCommitNewFile =
1513 input.commit !== undefined && input.changeType === "new" && baseRef === "";
1514 if (
1515 headRef === undefined ||
1516 (!rootCommitNewFile && (baseRef === undefined || !isCommitSha(baseRef))) ||
1517 !isCommitSha(headRef)
1518 ) {
1519 return yield* new GitHubDiffRevisionsUnavailableError({
1520 cwd: input.cwd,
1521 number: input.number,
1522 ...(input.commit === undefined ? {} : { commit: input.commit }),
1523 });
1524 }
1525
1526 const readFile = (revision: string, filePath: string) =>
1527 api
1528 .rest({
1529 host: input.host,
1530 operation: "getPullRequestDiffFileContents",
1531 accept: "application/vnd.github.raw+json",
1532 path: `repos/${owner}/${name}/contents/${filePath
1533 .split("/")
1534 .map(encodeURIComponent)
1535 .join("/")}?ref=${encodeURIComponent(revision)}`,
1536 maxResponseBytes: DIFF_FILE_MAX_OUTPUT_BYTES,
1537 timeout: DIFF_TIMEOUT,
1538 })
1539 .pipe(
1540 Effect.flatMap((response) =>
1541 response.truncated || response.body.includes("\0") || response.invalidUtf8
1542 ? Effect.fail(
1543 new GitHubDiffFileContentsUnavailableError({
1544 cwd: input.cwd,
1545 path: filePath,
1546 reason: response.truncated ? "oversized" : "binary",
1547 }),
1548 )
1549 : Effect.succeed(response.body),
1550 ),
1551 );
1552
1553 const [oldContents, newContents] = yield* Effect.all(
1554 [
1555 input.changeType === "new"
1556 ? Effect.succeed("")
1557 : readFile(baseRef ?? "", input.oldPath),
1558 input.changeType === "deleted" ? Effect.succeed("") : readFile(headRef, input.newPath),
1559 ],
1560 { concurrency: 2 },
1561 );
1562 return { oldContents, newContents };
1563 });
1564
1565 /**
1566 * Every check context of the head commit, a page at a time, for a rollup the detail read cut at
1567 * its first hundred. Each page names the head it read, so a push mid-walk fails rather than
1568 * mixing two revisions' checks.
1569 */
1570 type CheckContextsPage = Result.Result.Success<
1571 ReturnType<typeof decodePullRequestCheckContextsJson>
1572 >;
1573 const readAllCheckContexts = (
1574 input: Parameters<GitHubPullRequestApi["Service"]["getPullRequestDetail"]>[0],
1575 allowReserve: boolean,
1576 ) =>
1577 Effect.gen(function* () {
1578 const { owner, name } = parseRepositorySelector(input.repository);
1579 const { pages, truncated } = yield* readGraphQlPages(
1580 (after, previous: ReadonlyArray<CheckContextsPage>) =>
1581 graphqlRead({
1582 cwd: input.cwd,
1583 host: input.host,
1584 operation: "getPullRequestDetail",
1585 allowReserve,
1586 variables: { owner, name, number: input.number, after },
1587 query: pullRequestCheckContextsGraphQlQuery(input.host),
1588 decode: decodePullRequestCheckContextsJson,
1589 }).pipe(
1590 // Each page names the head it read, so a push mid-walk fails rather than mixing
1591 // two revisions' checks.
1592 Effect.filterOrFail(
1593 (page) => previous.length === 0 || page.headSha === previous[0]!.headSha,
1594 () =>
1595 readError(
1596 input.cwd,
1597 "getPullRequestDetail",
1598 new Error("Pull request head changed while reading checks."),
1599 ),
1600 ),
1601 ),
1602 { maxPages: CHECK_CONTEXT_PAGES, nextCursor: (page) => page.nextCursor },
1603 );
1604 return {
1605 headSha: pages[0]?.headSha ?? null,
1606 contexts: pages.flatMap((page) => page.contexts),
1607 truncated,
1608 };
1609 });
1610
1611 const getPullRequestDetail: GitHubPullRequestApi["Service"]["getPullRequestDetail"] = (input) => {
1612 const { owner, name } = parseRepositorySelector(input.repository);
1613 return SourceControlRateLimit.Interactive.pipe(
1614 Effect.flatMap((allowReserve) =>
1615 graphqlRead({
1616 allowReserve,
1617 cwd: input.cwd,
1618 host: input.host,
1619 operation: "getPullRequestDetail",
1620 variables: {
1621 owner,
1622 name,
1623 number: input.number,
1624 headRef: `refs/pull/${input.number}/head`,
1625 },
1626 query: pullRequestCoreGraphQlQuery(input.host),
1627 decode: decodePullRequestCoreJson,
1628 }).pipe(
1629 // Past a hundred checks the first page would let the rest imply success, so the whole
1630 // rollup is walked instead.
1631 Effect.filterOrElse(
1632 (core) => !core.checksTruncated,
1633 (core) =>
1634 readAllCheckContexts(input, allowReserve).pipe(
1635 Effect.filterOrFail(
1636 (all) => all.headSha === core.headSha,
1637 () =>
1638 readError(
1639 input.cwd,
1640 "getPullRequestDetail",
1641 new Error("Pull request head changed while reading checks."),
1642 ),
1643 ),
1644 Effect.map((all) => ({
1645 ...core,
1646 ...pullRequestChecksFromContexts(all.contexts),
1647 checksTruncated: all.truncated,
1648 })),
1649 ),
1650 ),
1651 ),
1652 ),
1653 );
1654 };
1655
1656 const workflowApprovalLimit = 1_000;
1657 const workflowApprovalReadError = (cwd: string, cause: unknown) =>
1658 readError(cwd, "listWorkflowRunsRequiringApproval", cause);
1659
1660 /**
1661 * Every open pull request whose head branch carries this name, up to one past the limit.
1662 * `truncated` says some were never read, which is never enough to call a head unique.
1663 */
1664 const listHeadsByBranch = (input: {
1665 readonly cwd: string;
1666 readonly repository: string;
1667 readonly host: string;
1668 readonly headBranch: string;
1669 }) =>
1670 Effect.gen(function* () {
1671 const { owner, name } = parseRepositorySelector(input.repository);
1672 const countHeads = (pages: ReadonlyArray<{ readonly heads: ReadonlyArray<unknown> }>) =>
1673 pages.reduce((total, page) => total + page.heads.length, 0);
1674 const { pages, truncated } = yield* readGraphQlPages(
1675 (after) =>
1676 graphqlRead({
1677 cwd: input.cwd,
1678 host: input.host,
1679 operation: "listWorkflowRunsRequiringApproval",
1680 allowReserve: true,
1681 variables: { owner, name, head: input.headBranch, after },
1682 query: PULL_REQUEST_HEADS_GRAPHQL_QUERY,
1683 decode: decodePullRequestHeadsJson,
1684 }),
1685 {
1686 nextCursor: (page) => page.nextCursor,
1687 until: (pages) => countHeads(pages) > workflowApprovalLimit,
1688 },
1689 );
1690 return { heads: pages.flatMap((page) => page.heads), truncated };
1691 });
1692
1693 /**
1694 * The runs waiting on a maintainer for this exact head, up to one past the limit. `head_sha` is
1695 * what scopes them; `head_branch` is checked as well, the way `gh run list --branch` did.
1696 */
1697 const listActionRequiredRuns = (input: {
1698 readonly cwd: string;
1699 readonly repository: string;
1700 readonly host: string;
1701 readonly headSha: string;
1702 readonly headBranch: string;
1703 }) =>
1704 Effect.gen(function* () {
1705 // The checks revalidator may have just confirmed every run of this head as current.
1706 const known = yield* KnownWorkflowRuns;
1707 if (known !== null && known.headSha === input.headSha) {
1708 return known.runs.flatMap((run) =>
1709 // A run waiting on a maintainer reports `completed` with an `action_required`
1710 // conclusion; the `status=action_required` query matches either.
1711 (run.conclusion === "action_required" || run.status === "action_required") &&
1712 run.head_branch === input.headBranch
1713 ? [
1714 {
1715 id: run.id,
1716 name: run.name?.trim() || `Workflow run ${run.id}`,
1717 url: run.html_url?.trim() || null,
1718 },
1719 ]
1720 : [],
1721 );
1722 }
1723 const { owner, name } = parseRepositorySelector(input.repository);
1724 const runs: GitHubWorkflowRunApproval[] = [];
1725 for (let page = 1; runs.length <= workflowApprovalLimit; page++) {
1726 const read: GitHubWorkflowRunPage = yield* restRead({
1727 cwd: input.cwd,
1728 host: input.host,
1729 operation: "listWorkflowRunsRequiringApproval",
1730 path: `repos/${owner}/${name}/actions/runs?head_sha=${encodeURIComponent(input.headSha)}&branch=${encodeURIComponent(input.headBranch)}&event=pull_request&status=action_required&per_page=100&page=${page}`,
1731 decode: decodeWorkflowRunsJson,
1732 }).pipe(Effect.mapError((error) => workflowApprovalReadError(input.cwd, error)));
1733 runs.push(...read.runs);
1734 if (read.rawCount < 100) break;
1735 }
1736 return runs;
1737 });
1738
1739 const listWorkflowRunsRequiringApproval: GitHubPullRequestApi["Service"]["listWorkflowRunsRequiringApproval"] =
1740 (input) =>
1741 Effect.all(
1742 [
1743 listHeadsByBranch(input).pipe(
1744 Effect.flatMap(
1745 ({
1746 heads,
1747 truncated,
1748 }): Effect.Effect<
1749 GitHubPullRequestHead,
1750 GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError
1751 > => {
1752 const exactHeads = heads.filter(
1753 (pullRequest) =>
1754 pullRequest.headSha === input.headSha &&
1755 pullRequest.isCrossRepository === true &&
1756 pullRequest.headRepositoryOwner?.toLowerCase() ===
1757 input.headRepositoryOwner.toLowerCase(),
1758 );
1759 if (truncated || heads.length > workflowApprovalLimit) {
1760 return Effect.fail(
1761 new GitHubWorkflowApprovalRefusedError({
1762 cwd: input.cwd,
1763 number: input.number,
1764 reason: "head-list-truncated",
1765 observedCount: heads.length,
1766 limit: workflowApprovalLimit,
1767 }),
1768 );
1769 }
1770 if (exactHeads.length !== 1 || exactHeads[0]?.number !== input.number) {
1771 return Effect.fail(
1772 new GitHubWorkflowApprovalRefusedError({
1773 cwd: input.cwd,
1774 number: input.number,
1775 reason: "head-not-unique",
1776 observedCount: exactHeads.length,
1777 limit: workflowApprovalLimit,
1778 }),
1779 );
1780 }
1781 return Effect.succeed(exactHeads[0]);
1782 },
1783 ),
1784 ),
1785 listActionRequiredRuns(input).pipe(
1786 Effect.flatMap((runs) =>
1787 runs.length > workflowApprovalLimit
1788 ? Effect.fail(
1789 new GitHubWorkflowApprovalRefusedError({
1790 cwd: input.cwd,
1791 number: input.number,
1792 reason: "run-list-truncated",
1793 observedCount: runs.length,
1794 limit: workflowApprovalLimit,
1795 }),
1796 )
1797 : Effect.succeed(runs),
1798 ),
1799 ),
1800 ],
1801 { concurrency: 2 },
1802 ).pipe(Effect.map(([, runs]) => runs));
1803
1804 /** One pull request's summary, through the same aliased query the batch uses. */
1805 const viewPullRequestSummary = (input: PullRequestSummaryRead) => {
1806 const { owner, name } = parseRepositorySelector(input.repository);
1807 return graphqlRead({
1808 cwd: input.cwd,
1809 host: input.host,
1810 operation: "getPullRequestSummary",
1811 variables: { owner, name, number: input.number },
1812 query: pullRequestSummaryGraphQlQuery(input.host === "github.com"),
1813 decode: decodePullRequestSummariesJson,
1814 }).pipe(
1815 Effect.flatMap((summaries) => {
1816 const summary = summaries.get(0);
1817 return summary === undefined
1818 ? Effect.fail(
1819 readError(
1820 input.cwd,
1821 "getPullRequestSummary",
1822 new Error(`GitHub answered nothing for ${input.repository}#${input.number}.`),
1823 ),
1824 )
1825 : Effect.succeed(summary);
1826 }),
1827 );
1828 };
1829
1830 /**
1831 * Summaries asked for together, on one host under one credential, share aliased GraphQL reads
1832 * of twenty-five: the background sync reads every linked pull request each minute, and one
1833 * `gh pr view` apiece is most of what it spends. Whatever the batch cannot answer — a selector
1834 * GraphQL cannot address, a pull request GitHub returned nothing for — is read on its own.
1835 */
1836 const summaryResolver = RequestResolver.makeGrouped<PullRequestSummaryRead, string>({
1837 key: ({ request, context }) =>
1838 JSON.stringify([
1839 request.host.toLowerCase(),
1840 Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null)
1841 ?.credentialFingerprint ?? null,
1842 Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""),
1843 ]),
1844 resolver: (entries) => {
1845 const [first] = entries;
1846 const batchable = entries.filter(
1847 (entry) => buildPullRequestSummariesGraphQlQuery([entry.request]) !== null,
1848 );
1849 // Stack membership rides along where GitHub serves stacks, so the background sync can skip
1850 // the REST stack read for pull requests that are in none.
1851 const document = buildPullRequestSummariesGraphQlQuery(
1852 batchable.map((entry) => entry.request),
1853 first.request.host === "github.com",
1854 );
1855 const batched =
1856 document === null
1857 ? Effect.succeed(new Map<number, GitHubPullRequestSummary>())
1858 : graphqlRead({
1859 cwd: first.request.cwd,
1860 host: first.request.host,
1861 operation: "getPullRequestSummary",
1862 ...document,
1863 decode: decodePullRequestSummariesJson,
1864 });
1865 return batched.pipe(
1866 // A GraphQL error anywhere fails the whole document — one repository gone or out of
1867 // reach — so a batch that could not be read leaves every entry to its own read. A paused
1868 // budget is the exception: reading one at a time would only spend what is being saved.
1869 Effect.catchCauseIf(
1870 (cause) =>
1871 !Cause.hasInterruptsOnly(cause) &&
1872 !Cause.findErrorOption(cause).pipe(
1873 Option.exists((error) => error._tag === "SourceControlRateLimitPausedError"),
1874 ),
1875 (cause) =>
1876 Effect.logDebug("batched pull request summary read failed", { cause }).pipe(
1877 Effect.as(new Map<number, GitHubPullRequestSummary>()),
1878 ),
1879 ),
1880 Effect.flatMap((summaries) => {
1881 const unanswered = entries.filter((entry) => {
1882 const summary = summaries.get(batchable.indexOf(entry));
1883 if (summary === undefined) return true;
1884 entry.completeUnsafe(Exit.succeed(summary));
1885 return false;
1886 });
1887 return Effect.forEach(
1888 unanswered,
1889 (entry) =>
1890 viewPullRequestSummary(entry.request).pipe(
1891 Effect.exit,
1892 Effect.map((exit) => entry.completeUnsafe(exit)),
1893 ),
1894 { concurrency: STAT_REQUEST_CONCURRENCY, discard: true },
1895 );
1896 }),
1897 Effect.catchCause((cause) =>
1898 Effect.sync(() => {
1899 for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause));
1900 }),
1901 ),
1902 );
1903 },
1904 }).pipe(
1905 RequestResolver.setDelay(SUMMARY_BATCH_WINDOW),
1906 RequestResolver.batchN(STAT_ALIASES_PER_REQUEST),
1907 );
1908 const getPullRequestSummary: GitHubPullRequestApi["Service"]["getPullRequestSummary"] = (input) =>
1909 Effect.request(new PullRequestSummaryRead(input), summaryResolver);
1910
1911 // Every watched pull request on a host in one read per pass. A pull request the batch has no
1912 // answer for gets null, and its watch reads it in full; a failed batch fails every entry.
1913 const watchFingerprintResolver = RequestResolver.makeGrouped<
1914 PullRequestWatchFingerprintRead,
1915 string
1916 >({
1917 key: ({ request, context }) =>
1918 JSON.stringify([
1919 request.host.toLowerCase(),
1920 Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null)
1921 ?.credentialFingerprint ?? null,
1922 Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""),
1923 ]),
1924 resolver: (entries) => {
1925 const [first] = entries;
1926 const batchable = entries.filter(
1927 (entry) => buildPullRequestWatchFingerprintsGraphQlQuery([entry.request]) !== null,
1928 );
1929 const document = buildPullRequestWatchFingerprintsGraphQlQuery(
1930 batchable.map((entry) => entry.request),
1931 );
1932 const read =
1933 document === null
1934 ? Effect.succeed(new Map<number, GitHubPullRequestWatchFingerprint>())
1935 : graphqlRead({
1936 cwd: first.request.cwd,
1937 host: first.request.host,
1938 operation: "getPullRequestWatchFingerprint",
1939 ...document,
1940 decode: decodePullRequestWatchFingerprintsJson,
1941 });
1942 return read.pipe(
1943 Effect.map((fingerprints) => {
1944 for (const entry of entries) {
1945 const index = batchable.indexOf(entry);
1946 entry.completeUnsafe(
1947 Exit.succeed(index === -1 ? null : (fingerprints.get(index) ?? null)),
1948 );
1949 }
1950 }),
1951 Effect.catchCause((cause) =>
1952 Effect.sync(() => {
1953 for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause));
1954 }),
1955 ),
1956 );
1957 },
1958 }).pipe(
1959 RequestResolver.setDelay(SUMMARY_BATCH_WINDOW),
1960 RequestResolver.batchN(STAT_ALIASES_PER_REQUEST),
1961 );
1962 const getPullRequestWatchFingerprint: GitHubPullRequestApi["Service"]["getPullRequestWatchFingerprint"] =
1963 (input) => Effect.request(new PullRequestWatchFingerprintRead(input), watchFingerprintResolver);
1964
1965 return GitHubPullRequestApi.of({
1966 withVerifiedCredential,
1967 revalidateChecks,
1968 getRoutingIdentity,
1969 getViewerLogin: (input) =>
1970 getRoutingIdentity(input).pipe(Effect.map((identity) => identity.viewer)),
1971
1972 listPullRequests: (input) => {
1973 const fallbackMaxRows = Math.max(input.limit + 1, PULL_REQUEST_FALLBACK_MAX_ROWS);
1974 const { owner, name } = parseRepositorySelector(input.repository);
1975 const query = searchQuery({ ...input, repositories: [input.repository] });
1976 if (query === null) {
1977 return Effect.fail(
1978 new GitHubRepositorySelectorError({
1979 cwd: input.cwd,
1980 operation: "listPullRequests",
1981 }),
1982 );
1983 }
1984 /** Pages of up to a hundred until `rows` have arrived or GitHub has no more. */
1985 const collect = (
1986 rows: number,
1987 page: (
1988 after: string | null,
1989 rows: number,
1990 ) => Effect.Effect<GitHubPullRequestListPage, GitHubPullRequestApiError>,
1991 ) =>
1992 Effect.gen(function* () {
1993 const countRows = (pages: ReadonlyArray<GitHubPullRequestListPage>) =>
1994 pages.reduce((total, read) => total + read.rawCount, 0);
1995 const { pages, truncated } = yield* readGraphQlPages(
1996 (after, pages: ReadonlyArray<GitHubPullRequestListPage>) =>
1997 page(after, rows - countRows(pages)),
1998 {
1999 nextCursor: (read) => read.endCursor,
2000 until: (pages) => countRows(pages) >= rows,
2001 },
2002 );
2003 // `truncated` also covers a cursor GitHub repeated before `rows` arrived.
2004 return {
2005 items: pages.flatMap((read) => read.items),
2006 rawCount: countRows(pages),
2007 truncated,
2008 };
2009 });
2010 const read = (
2011 continues: boolean,
2012 requestedRows = input.limit + 1,
2013 ): Effect.Effect<GitHubPullRequestListBatch, GitHubPullRequestApiError> =>
2014 collect(requestedRows, (after, rows) =>
2015 continues
2016 ? graphqlRead({
2017 cwd: input.cwd,
2018 host: input.host,
2019 operation: "listPullRequests",
2020 // The reader's own words are in the query.
2021 variables: { q: query, after },
2022 query: pullRequestSearchGraphQlQuery(rows, false, true),
2023 decode: decodePullRequestSearchJson,
2024 })
2025 : graphqlRead({
2026 cwd: input.cwd,
2027 host: input.host,
2028 operation: "listPullRequests",
2029 variables: { owner, name, states: LIST_STATES[input.state], after },
2030 query: pullRequestListGraphQlQuery(rows),
2031 decode: decodePullRequestListJson,
2032 }),
2033 ).pipe(
2034 Effect.flatMap(({ items: rawItems, rawCount, truncated: cutShort }) => {
2035 const items = continues
2036 ? rawItems
2037 : rawItems.filter((item) => matchesUnsortedListing(item, input));
2038 if (
2039 !continues &&
2040 items.length < input.limit &&
2041 rawCount >= requestedRows &&
2042 requestedRows < fallbackMaxRows
2043 ) {
2044 const nextRows = Math.min(requestedRows * 2, fallbackMaxRows);
2045 if (nextRows > requestedRows) return read(false, nextRows);
2046 }
2047 return Effect.succeed({
2048 items: items.slice(0, input.limit),
2049 // One row over the page size is the probe for a next page, and it is
2050 // counted before decoding: a skipped malformed row must not end paging.
2051 truncated:
2052 cutShort ||
2053 (continues
2054 ? rawCount > input.limit
2055 : items.length > input.limit || rawCount >= requestedRows),
2056 continues,
2057 });
2058 }),
2059 );
2060 // GitHub does not index every repository for search, and one it will not search answers
2061 // with no rows rather than with an error — so an empty listing is read again from the
2062 // repository's own list. Those rows come back newest-created first, an order no `updated:`
2063 // qualifier can carry on from, so that page says it cannot be continued and the reader
2064 // reaches the rest of it by asking for a larger page, as every listing used to.
2065 //
2066 // Only ever the first slice: a repository that answered the search once will answer it
2067 // again, so an empty slice under a cursor is a repository that has run out.
2068 // A text search that finds nothing has found nothing: falling back would answer it with the
2069 // repository's whole list, which is every row the reader did not search for. The fallback
2070 // is for a repository the index does not cover, and a listing with no text to match is the
2071 // only place an empty answer can mean that.
2072 // Every filter is a qualifier `matchesFilters` can judge over the fallback's own rows just
2073 // as well as search judges them over its own, so carrying them into the fallback answers
2074 // the same read rather than a wider one. Free text is the one thing the fallback cannot
2075 // judge locally — it lists rows, it does not search their text — so a query still rules
2076 // the fallback out: an empty answer under one is already the answer.
2077 const hasQuery = (input.query?.trim().length ?? 0) > 0;
2078 return read(true).pipe(
2079 Effect.filterOrElse(
2080 (batch) => batch.items.length > 0 || input.cursor !== undefined || hasQuery,
2081 () => read(false),
2082 ),
2083 Effect.flatMap((batch) => {
2084 // Match the search query's host support, and enrich only rows that survived paging.
2085 if (input.host !== "github.com" || batch.items.length === 0) return Effect.succeed(batch);
2086 const chunks: Array<ReadonlyArray<GitHubPullRequestListItem>> = [];
2087 for (let start = 0; start < batch.items.length; start += STAT_ALIASES_PER_REQUEST) {
2088 chunks.push(batch.items.slice(start, start + STAT_ALIASES_PER_REQUEST));
2089 }
2090 return Effect.forEach(
2091 chunks,
2092 (chunk) => {
2093 const document = buildPullRequestStackMembershipsGraphQlQuery(
2094 input.repository,
2095 chunk.map((item) => item.number),
2096 );
2097 if (document === null) return Effect.succeed(chunk);
2098 return graphqlRead({
2099 cwd: input.cwd,
2100 host: input.host,
2101 operation: "listPullRequestStackMemberships",
2102 ...document,
2103 decode: decodePullRequestStackMembershipsJson,
2104 }).pipe(
2105 Effect.map((memberships) =>
2106 chunk.map((item, index) => {
2107 const stack = memberships.get(index);
2108 return stack === undefined ? item : { ...item, stack };
2109 }),
2110 ),
2111 // Optional badges must not take down a listing that already read successfully.
2112 Effect.catch(() =>
2113 Effect.logWarning("Pull request stack membership enrichment failed", {
2114 operation: "listPullRequestStackMemberships",
2115 host: input.host,
2116 rows: chunk.length,
2117 }).pipe(Effect.as(chunk)),
2118 ),
2119 );
2120 },
2121 { concurrency: STAT_REQUEST_CONCURRENCY },
2122 ).pipe(Effect.map((chunks) => ({ ...batch, items: chunks.flat() })));
2123 }),
2124 );
2125 },
2126
2127 searchPullRequests: (input) => {
2128 const query = searchQuery(input);
2129 if (query === null) {
2130 return Effect.fail(
2131 new GitHubRepositorySelectorError({
2132 cwd: input.cwd,
2133 operation: "searchPullRequests",
2134 }),
2135 );
2136 }
2137 // One extra row reveals that the host has more than the slice shows, the way the
2138 // per-repository read does — up to GitHub's own ceiling on a search page, past which
2139 // `hasNextPage` is what says there is more.
2140 const rows = Math.min(input.limit + 1, PULL_REQUEST_SEARCH_MAX_ROWS);
2141 return graphqlRead({
2142 cwd: input.cwd,
2143 host: input.host,
2144 operation: "searchPullRequests",
2145 variables: { q: query, after: null },
2146 query: pullRequestSearchGraphQlQuery(rows, input.host === "github.com"),
2147 decode: decodePullRequestSearchJson,
2148 }).pipe(
2149 Effect.map((batch) => ({
2150 items: batch.items.slice(0, input.limit),
2151 truncated: batch.rawCount > input.limit || batch.hasNextPage,
2152 })),
2153 );
2154 },
2155
2156 listPullRequestStats: (input) => {
2157 const chunks: Array<ReadonlyArray<{ readonly repository: string; readonly number: number }>> =
2158 [];
2159 for (let start = 0; start < input.changeRequests.length; start += STAT_ALIASES_PER_REQUEST) {
2160 chunks.push(input.changeRequests.slice(start, start + STAT_ALIASES_PER_REQUEST));
2161 }
2162 return Effect.forEach(
2163 chunks,
2164 (chunk) => {
2165 const document = buildPullRequestStatsGraphQlQuery(chunk);
2166 if (document === null) {
2167 return Effect.fail(
2168 new GitHubRepositorySelectorError({
2169 cwd: input.cwd,
2170 operation: "listPullRequestStats",
2171 }),
2172 );
2173 }
2174 return graphqlRead({
2175 cwd: input.cwd,
2176 host: input.host,
2177 operation: "listPullRequestStats",
2178 ...document,
2179 decode: decodePullRequestStatsJson,
2180 }).pipe(
2181 Effect.map((stats) =>
2182 chunk.flatMap((changeRequest, index) => {
2183 const stat = stats.get(index);
2184 return stat === undefined ? [] : [{ ...changeRequest, ...stat }];
2185 }),
2186 ),
2187 );
2188 },
2189 { concurrency: STAT_REQUEST_CONCURRENCY },
2190 ).pipe(Effect.map((results) => results.flat()));
2191 },
2192
2193 getPullRequestSummary,
2194 getPullRequestWatchFingerprint,
2195
2196 getPullRequestDetail,
2197 getPullRequestPreview: (input) => {
2198 const { owner, name } = parseRepositorySelector(input.repository);
2199 return graphqlRead({
2200 cwd: input.cwd,
2201 host: input.host,
2202 operation: "getPullRequestPreview",
2203 variables: { owner, name, number: input.number },
2204 query: PULL_REQUEST_PREVIEW_GRAPHQL_QUERY,
2205 decode: decodePullRequestPreviewJson,
2206 });
2207 },
2208 listWorkflowRunsRequiringApproval,
2209
2210 getPullRequestStack: (input) => {
2211 const { owner, name } = parseRepositorySelector(input.repository);
2212 return restRead({
2213 cwd: input.cwd,
2214 host: input.host,
2215 operation: "getPullRequestStack",
2216 path: `repos/${owner}/${name}/stacks?pull_request=${input.number}`,
2217 decode: decodePullRequestStacksJson,
2218 }).pipe(
2219 // @effect-diagnostics-next-line flatMapConditionalToFilterOrFail:off - the fallback needs a non-null stack, which a predicate that also reads includeDetails cannot refine.
2220 Effect.flatMap((stack) => {
2221 if (!input.includeDetails || stack === null) return Effect.succeed(stack);
2222 return restRead({
2223 cwd: input.cwd,
2224 host: input.host,
2225 operation: "getPullRequestStack",
2226 path: `repos/${owner}/${name}/stacks/${stack.number}`,
2227 decode: (raw) => decodePullRequestStacksJson(`[${raw}]`),
2228 });
2229 }),
2230 // Hosts without the stacks preview return 404. Other failures must preserve the
2231 // previously synced stack and let the caller retry.
2232 Effect.catchTags({
2233 GitHubApiNotFoundError: () => Effect.succeed(null),
2234 }),
2235 );
2236 },
2237
2238 getPullRequestActivity: (input) =>
2239 Effect.gen(function* () {
2240 const { owner, name } = parseRepositorySelector(input.repository);
2241 let author: PullRequestActor | null = null;
2242 let commits: ReadonlyArray<PullRequestCommit> = [];
2243 const remarks: PullRequestComment[] = [];
2244 let commentsAfter: string | null = null;
2245 let reviewsAfter: string | null = null;
2246 let withComments = true;
2247 let withReviews = true;
2248 // Both remark lists page on their own; a page asks only for the ones with more to give.
2249 for (let page = 0; page < REVIEW_THREAD_PAGES && (withComments || withReviews); page++) {
2250 const read: GitHubPullRequestActivityPage = yield* graphqlRead({
2251 cwd: input.cwd,
2252 host: input.host,
2253 operation: "getPullRequestActivity",
2254 variables: {
2255 owner,
2256 name,
2257 number: input.number,
2258 head: page === 0,
2259 withComments,
2260 commentsAfter,
2261 withReviews,
2262 reviewsAfter,
2263 },
2264 query: PULL_REQUEST_ACTIVITY_GRAPHQL_QUERY,
2265 decode: decodePullRequestActivityJson,
2266 });
2267 if (page === 0) {
2268 author = read.author ?? null;
2269 commits = read.commits ?? [];
2270 }
2271 remarks.push(...read.remarks);
2272 commentsAfter = read.nextCommentsCursor;
2273 reviewsAfter = read.nextReviewsCursor;
2274 withComments = withComments && commentsAfter !== null;
2275 withReviews = withReviews && reviewsAfter !== null;
2276 }
2277 return {
2278 author,
2279 comments: remarks.toSorted((left, right) =>
2280 left.createdAt.localeCompare(right.createdAt),
2281 ),
2282 commits,
2283 } satisfies GitHubPullRequestActivity;
2284 }),
2285
2286 getPullRequestDiff: (input) => {
2287 const filesPage = (page: number) =>
2288 diffFilesPage({
2289 cwd: input.cwd,
2290 repository: input.repository,
2291 host: input.host,
2292 number: input.number,
2293 page,
2294 ...(input.commit === undefined ? {} : { commit: input.commit }),
2295 });
2296 if (input.commit !== undefined && !isCommitSha(input.commit)) {
2297 return Effect.fail(new GitHubDiffCommitError({ cwd: input.cwd }));
2298 }
2299 // A cursor only ever comes from the files walk, so a reader carrying one is already past
2300 // the point where `gh pr diff` had anything to say.
2301 if (input.cursor !== undefined) {
2302 const page = diffCursorPage(input.cursor);
2303 return page === null
2304 ? Effect.fail(new GitHubDiffCursorError({ cwd: input.cwd }))
2305 : filesPage(page);
2306 }
2307 // `gh pr diff` speaks for the whole pull request and has no way to name one commit of it.
2308 if (input.commit !== undefined) {
2309 return filesPage(1);
2310 }
2311 const { owner, name } = parseRepositorySelector(input.repository);
2312 return api
2313 .rest({
2314 host: input.host,
2315 operation: "getPullRequestDiff",
2316 path: `repos/${owner}/${name}/pulls/${input.number}`,
2317 accept: "application/vnd.github.diff",
2318 maxResponseBytes: DIFF_MAX_OUTPUT_BYTES,
2319 timeout: DIFF_TIMEOUT,
2320 })
2321 .pipe(
2322 Effect.flatMap((response) =>
2323 // A patch cut at a byte boundary ends mid-file, which is neither a whole slice nor
2324 // something the reader can carry on from. The files API can serve the same change a
2325 // whole number of files at a time, so an oversized patch takes that road as well.
2326 response.truncated
2327 ? filesPage(1)
2328 : // One read served the whole patch, so there is no next slice to ask for.
2329 Effect.succeed({ patch: response.body, truncated: false, nextCursor: null }),
2330 ),
2331 // GitHub answers 406 rather than a diff past 300 changed files, so the patch is read
2332 // from the files API instead, a page per call. Only once the direct read has failed: a
2333 // pull request GitHub will serve a diff for must not pay for a second request. A
2334 // fallback that fails too reports the original refusal, which is the one that explains
2335 // the page. Narrowed to a request GitHub answered and refused: a missing credential or
2336 // a rate limit fails the same way for every request.
2337 Effect.catchTags({
2338 GitHubApiResponseError: (error) => filesPage(1).pipe(Effect.mapError(() => error)),
2339 }),
2340 );
2341 },
2342
2343 getPullRequestDiffFileContents,
2344
2345 getReviewThreadComments: (input) => {
2346 const { owner, name } = parseRepositorySelector(input.repository);
2347 return graphqlRead({
2348 cwd: input.cwd,
2349 host: input.host,
2350 operation: "getReviewThreadComments",
2351 variables: {
2352 owner,
2353 name,
2354 number: input.number,
2355 threadId: input.threadId,
2356 cursor: input.cursor,
2357 },
2358 query: REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY,
2359 decode: decodeReviewThreadCommentsJson,
2360 }).pipe(
2361 Effect.flatMap(({ belongsToPullRequest, comments, nextCursor }) =>
2362 belongsToPullRequest
2363 ? Effect.succeed({ comments, nextCursor })
2364 : Effect.fail(
2365 new GitHubSubjectScopeError({
2366 cwd: input.cwd,
2367 operation: "getReviewThreadComments",
2368 }),
2369 ),
2370 ),
2371 );
2372 },
2373
2374 listReviewThreadComments: (input) =>
2375 Effect.gen(function* () {
2376 const { owner, name } = parseRepositorySelector(input.repository);
2377 const threadPage = (
2378 cursor: string | null,
2379 ): Effect.Effect<GitHubReviewThreadPage, GitHubPullRequestApiError> =>
2380 graphqlRead({
2381 cwd: input.cwd,
2382 host: input.host,
2383 operation: "listReviewThreadComments",
2384 variables: { owner, name, number: input.number, cursor },
2385 query: REVIEW_THREADS_GRAPHQL_QUERY,
2386 decode: decodeReviewThreadsJson,
2387 });
2388 const entries: GitHubReviewThreadEntry[] = [];
2389 const avatarsByLogin = new Map<string, string>();
2390 const botLogins = new Set<string>();
2391 const commitStats = new Map<
2392 string,
2393 { readonly additions: number; readonly deletions: number }
2394 >();
2395 let reviewers: ReadonlyArray<PullRequestActor> = [];
2396 let reactions: GitHubReviewThreadPage["reactions"] = [];
2397 const reactionsById = new Map<string, ReadonlyArray<PullRequestReaction>>();
2398 const editedAtById = new Map<string, string>();
2399 let commits: GitHubReviewThreadPage["commits"] = [];
2400 let viewer: GitHubReviewThreadPage["viewer"] = { canUpdate: true, didAuthor: false };
2401 const dismissalsByReviewId = new Map<string, string>();
2402 let dismissalCursor: string | null = null;
2403 let cursor: string | null = null;
2404 let page = 0;
2405 do {
2406 const read: GitHubReviewThreadPage = yield* threadPage(cursor);
2407 entries.push(...read.threads);
2408 for (const login of read.botLogins) botLogins.add(login);
2409 for (const [login, avatarUrl] of read.avatarsByLogin)
2410 avatarsByLogin.set(login, avatarUrl);
2411 // The roster, the commits and the viewer's standing travel with every page, and the
2412 // first one already carries all of them.
2413 if (page === 0) {
2414 reviewers = read.reviewers;
2415 reactions = read.reactions;
2416 for (const [id, entry] of read.reactionsById) reactionsById.set(id, entry);
2417 for (const [id, editedAt] of read.editedAtById) editedAtById.set(id, editedAt);
2418 commits = read.commits;
2419 viewer = read.viewer;
2420 for (const [id, message] of read.dismissalsByReviewId)
2421 dismissalsByReviewId.set(id, message);
2422 dismissalCursor = read.nextDismissalCursor;
2423 for (const [oid, stat] of read.commitStats) commitStats.set(oid, stat);
2424 }
2425 cursor = read.nextCursor;
2426 page += 1;
2427 } while (cursor !== null && page < REVIEW_THREAD_PAGES);
2428
2429 // Almost never entered: the embedded page already holds every dismissal a pull request
2430 // ordinarily accrues. Followed so a review whose event fell past that page still finds
2431 // its reason.
2432 if (dismissalCursor !== null) {
2433 const { pages } = yield* readGraphQlPages(
2434 (cursor) =>
2435 graphqlRead({
2436 cwd: input.cwd,
2437 host: input.host,
2438 operation: "listReviewThreadComments",
2439 variables: { owner, name, number: input.number, cursor },
2440 query: REVIEW_DISMISSALS_GRAPHQL_QUERY,
2441 decode: decodeReviewDismissalsJson,
2442 }),
2443 {
2444 from: dismissalCursor,
2445 maxPages: REVIEW_THREAD_PAGES,
2446 nextCursor: (read) => read.nextCursor,
2447 },
2448 );
2449 for (const read of pages)
2450 for (const [id, message] of read.dismissalsByReviewId)
2451 dismissalsByReviewId.set(id, message);
2452 }
2453
2454 const reviewThreads = entries.map((entry) => ({
2455 ...entry.thread,
2456 commentCount: entry.commentCount,
2457 ...(entry.nextCommentCursor === null
2458 ? {}
2459 : { nextCommentsCursor: entry.nextCommentCursor }),
2460 }));
2461 return {
2462 comments: reviewThreadConversation(reviewThreads),
2463 dismissalsByReviewId,
2464 reviewThreads,
2465 // GitHub's own count of each thread, so the number the page shows is the host's even
2466 // where a bound kept some of the words on GitHub.
2467 commentCount: entries.reduce((total, entry) => total + entry.commentCount, 0),
2468 truncated: cursor !== null || entries.some((entry) => entry.nextCommentCursor !== null),
2469 reviewThreadsTruncated: cursor !== null,
2470 reactions,
2471 reactionsById,
2472 editedAtById,
2473 reviewers,
2474 avatarsByLogin,
2475 botLogins,
2476 commitStats,
2477 commits,
2478 viewer,
2479 };
2480 }),
2481
2482 listActorAvatars: (input) => {
2483 if (input.ids.length === 0) {
2484 return Effect.succeed(new Map<string, string>());
2485 }
2486 return graphqlRead({
2487 cwd: input.cwd,
2488 host: input.host,
2489 operation: "listActorAvatars",
2490 variables: { ids: input.ids },
2491 query: ACTOR_AVATARS_GRAPHQL_QUERY,
2492 decode: decodeActorAvatarsJson,
2493 });
2494 },
2495
2496 getViewerAccess: (input) => {
2497 const { owner, name } = parseRepositorySelector(input.repository);
2498 return graphqlRead({
2499 cwd: input.cwd,
2500 host: input.host,
2501 operation: "getViewerAccess",
2502 ...(input.allowReserve === true ? { allowReserve: true } : {}),
2503 variables: { owner, name, number: input.number },
2504 query: VIEWER_PERMISSIONS_GRAPHQL_QUERY,
2505 decode: decodeViewerPermissionsJson,
2506 });
2507 },
2508
2509 listReviewerCandidates: (input) => {
2510 const { owner, name } = parseRepositorySelector(input.repository);
2511 return graphqlRead({
2512 cwd: input.cwd,
2513 host: input.host,
2514 operation: "listReviewerCandidates",
2515 allowReserve: true,
2516 variables: { owner, name, number: input.number },
2517 query: REVIEWER_CANDIDATES_GRAPHQL_QUERY,
2518 decode: decodeReviewerCandidatesJson,
2519 });
2520 },
2521
2522 setReviewerRequest: (input) => {
2523 const { owner, name } = parseRepositorySelector(input.repository);
2524 // Posting to a login GitHub has already been asked about is what a re-request is, so
2525 // there is nothing to say here about somebody who has reviewed once already.
2526 return api
2527 .rest({
2528 host: input.host,
2529 operation: "setReviewerRequest",
2530 method: input.requested ? "POST" : "DELETE",
2531 path: `repos/${owner}/${name}/pulls/${input.number}/requested_reviewers`,
2532 body: buildReviewerRequest(input.reviewers),
2533 })
2534 .pipe(Effect.asVoid);
2535 },
2536
2537 listLabelCandidates: (input) => {
2538 const { owner, name } = parseRepositorySelector(input.repository);
2539 return graphqlRead({
2540 cwd: input.cwd,
2541 host: input.host,
2542 operation: "listLabelCandidates",
2543 allowReserve: true,
2544 variables: { owner, name, number: input.number },
2545 query: LABEL_CANDIDATES_GRAPHQL_QUERY,
2546 decode: decodeLabelCandidatesJson,
2547 });
2548 },
2549
2550 setLabels: (input) => {
2551 const { owner, name } = parseRepositorySelector(input.repository);
2552 // A pull request is an issue to the labels API. Adding posts a list and leaves what was
2553 // already there; taking off is one delete per label, since the endpoint names one in its
2554 // path. The name goes into the path encoded, because a label may carry a space or a slash.
2555 const issue = `repos/${owner}/${name}/issues/${input.number}/labels`;
2556 if (input.applied) {
2557 return api
2558 .rest({
2559 host: input.host,
2560 operation: "setLabels",
2561 method: "POST",
2562 path: issue,
2563 body: buildLabelRequest(input.labels),
2564 })
2565 .pipe(Effect.asVoid);
2566 }
2567 return Effect.forEach(
2568 input.labels,
2569 (label) =>
2570 api.rest({
2571 host: input.host,
2572 operation: "setLabels",
2573 method: "DELETE",
2574 path: `${issue}/${encodeURIComponent(label)}`,
2575 }),
2576 { concurrency: 1, discard: true },
2577 );
2578 },
2579
2580 runPullRequestAction: (input) => {
2581 if (input.action === "delete-branch") {
2582 return Effect.gen(function* () {
2583 if (input.stackNumber !== undefined) {
2584 return yield* new GitHubBranchDeletionRefusedError({
2585 number: input.number,
2586 reason: "stack",
2587 });
2588 }
2589 const { owner, name } = parseRepositorySelector(input.repository);
2590 const response = yield* graphqlRead({
2591 cwd: input.cwd,
2592 host: input.host,
2593 operation: "deletePullRequestBranch",
2594 allowReserve: true,
2595 query: BRANCH_TO_DELETE_GRAPHQL_QUERY,
2596 variables: { owner, name, number: input.number },
2597 decode: decodeBranchToDelete,
2598 });
2599 const branch = response.data.repository.pullRequest;
2600 if (branch.state === "OPEN") {
2601 return yield* new GitHubBranchDeletionRefusedError({
2602 number: input.number,
2603 reason: "open-pr",
2604 });
2605 }
2606 if (
2607 branch.headRepository === null ||
2608 !branch.headRepository.id ||
2609 !branch.headRefName ||
2610 !branch.headRefOid
2611 ) {
2612 return yield* new GitHubBranchDeletionRefusedError({
2613 number: input.number,
2614 reason: "source-unavailable",
2615 });
2616 }
2617 if (branch.headRefName === branch.baseRefName) {
2618 return yield* new GitHubBranchDeletionRefusedError({
2619 number: input.number,
2620 reason: "unsafe-branch",
2621 });
2622 }
2623 const ref = `refs/heads/${branch.headRefName}`;
2624 const source = yield* graphqlRead({
2625 cwd: input.cwd,
2626 host: input.host,
2627 operation: "deletePullRequestBranch",
2628 allowReserve: true,
2629 query: SOURCE_BRANCH_GRAPHQL_QUERY,
2630 variables: { repositoryId: branch.headRepository.id, ref },
2631 decode: decodeSourceBranch,
2632 });
2633 const repository = source.data.node;
2634 if (repository === null) {
2635 return yield* new GitHubBranchDeletionRefusedError({
2636 number: input.number,
2637 reason: "source-unavailable",
2638 });
2639 }
2640 if (repository.ref === null) return;
2641 if (
2642 repository.defaultBranchRef === null ||
2643 branch.headRefName === repository.defaultBranchRef.name
2644 ) {
2645 return yield* new GitHubBranchDeletionRefusedError({
2646 number: input.number,
2647 reason: "unsafe-branch",
2648 });
2649 }
2650 if (repository.ref.target.oid !== branch.headRefOid) {
2651 return yield* new GitHubBranchDeletionRefusedError({
2652 number: input.number,
2653 reason: "head-changed",
2654 });
2655 }
2656 yield* graphql({
2657 host: input.host,
2658 operation: "deletePullRequestBranch",
2659 query: DELETE_BRANCH_GRAPHQL_MUTATION,
2660 variables: {
2661 input: {
2662 repositoryId: branch.headRepository.id,
2663 refUpdates: [
2664 {
2665 name: ref,
2666 beforeOid: branch.headRefOid,
2667 afterOid: "0000000000000000000000000000000000000000",
2668 },
2669 ],
2670 },
2671 },
2672 });
2673 });
2674 }
2675 if (input.stackNumber !== undefined)
2676 return runGitHubStackAction({ ...input, stackNumber: input.stackNumber }).pipe(
2677 Effect.provideService(GitHubApi.GitHubApi, api),
2678 Effect.provideService(SourceControlHost.SourceControlHost, sourceControlHost),
2679 Effect.provideService(FileSystem.FileSystem, fileSystem),
2680 );
2681 if (input.action === "revert") {
2682 return pullRequestNodeId({ ...input, operation: "revertPullRequest" }).pipe(
2683 Effect.flatMap((pullRequestId) =>
2684 graphql({
2685 host: input.host,
2686 operation: "revertPullRequest",
2687 query: REVERT_PULL_REQUEST_GRAPHQL_MUTATION,
2688 variables: { pullRequestId },
2689 }),
2690 ),
2691 );
2692 }
2693 if (input.action === "approve-workflows") {
2694 const { owner, name } = parseRepositorySelector(input.repository);
2695 return getPullRequestDetail(input).pipe(
2696 Effect.flatMap((detail) => {
2697 if (detail.isCrossRepository !== true) return Effect.void;
2698 if (detail.headSha == null || detail.headRepositoryOwner == null) {
2699 return Effect.fail(
2700 new GitHubWorkflowApprovalHeadUnavailableError({
2701 cwd: input.cwd,
2702 number: input.number,
2703 }),
2704 );
2705 }
2706 const expectedHeadSha = detail.headSha;
2707 const expectedHeadBranch = detail.headBranch;
2708 const expectedHeadRepositoryOwner = detail.headRepositoryOwner;
2709 return listWorkflowRunsRequiringApproval({
2710 ...input,
2711 headSha: expectedHeadSha,
2712 headBranch: expectedHeadBranch,
2713 headRepositoryOwner: expectedHeadRepositoryOwner,
2714 isCrossRepository: true,
2715 }).pipe(
2716 Effect.flatMap((runs) =>
2717 Effect.forEach(
2718 runs,
2719 (run) =>
2720 getPullRequestDetail(input).pipe(
2721 Effect.flatMap((current) => {
2722 if (current.headSha == null || current.headRepositoryOwner == null) {
2723 return Effect.fail(
2724 new GitHubWorkflowApprovalHeadUnavailableError({
2725 cwd: input.cwd,
2726 number: input.number,
2727 }),
2728 );
2729 }
2730 if (
2731 current.isCrossRepository !== true ||
2732 current.headSha !== expectedHeadSha ||
2733 current.headBranch !== expectedHeadBranch ||
2734 current.headRepositoryOwner.toLowerCase() !==
2735 expectedHeadRepositoryOwner.toLowerCase()
2736 ) {
2737 return Effect.fail(
2738 new GitHubWorkflowApprovalHeadChangedError({
2739 cwd: input.cwd,
2740 number: input.number,
2741 }),
2742 );
2743 }
2744 return listWorkflowRunsRequiringApproval({
2745 ...input,
2746 headSha: current.headSha,
2747 headBranch: current.headBranch,
2748 headRepositoryOwner: current.headRepositoryOwner,
2749 isCrossRepository: true,
2750 });
2751 }),
2752 Effect.flatMap((currentRuns) =>
2753 currentRuns.some((current) => current.id === run.id)
2754 ? api
2755 .rest({
2756 host: input.host,
2757 operation: "approveWorkflowRun",
2758 method: "POST",
2759 path: `repos/${owner}/${name}/actions/runs/${run.id}/approve`,
2760 })
2761 .pipe(Effect.asVoid)
2762 : Effect.void,
2763 ),
2764 ),
2765 { concurrency: 1, discard: true },
2766 ),
2767 ),
2768 );
2769 }),
2770 );
2771 }
2772 const action = input.action;
2773 if (action in SIMPLE_ACTION_MUTATIONS) {
2774 return pullRequestNodeId({ ...input, operation: "runPullRequestAction" }).pipe(
2775 Effect.flatMap((pullRequestId) =>
2776 graphql({
2777 host: input.host,
2778 operation: "runPullRequestAction",
2779 query: SIMPLE_ACTION_MUTATIONS[action as keyof typeof SIMPLE_ACTION_MUTATIONS],
2780 variables: { pullRequestId },
2781 }),
2782 ),
2783 );
2784 }
2785 return Effect.gen(function* () {
2786 const { owner, name } = parseRepositorySelector(input.repository);
2787 // Read fresh rather than from the node id cache: merging and updating act on the head
2788 // as it stands now, and the merge queue decides which mutation a merge is.
2789 const state = yield* graphqlRead({
2790 cwd: input.cwd,
2791 host: input.host,
2792 operation: "runPullRequestAction",
2793 allowReserve: true,
2794 query: ACTION_STATE_GRAPHQL_QUERY,
2795 variables: {
2796 owner,
2797 name,
2798 number: input.number,
2799 headRef: `refs/pull/${input.number}/head`,
2800 },
2801 decode: decodeActionState,
2802 });
2803 if (action === "update-branch") {
2804 // Already current is done, the way `gh pr update-branch` reports it.
2805 if (state.baseRef?.compare?.behindBy === 0) return;
2806 // GitHub updates with a merge commit unless asked to rebase, which is its own default.
2807 return yield* graphql({
2808 host: input.host,
2809 operation: "runPullRequestAction",
2810 query: UPDATE_BRANCH_GRAPHQL_MUTATION,
2811 variables: {
2812 pullRequestId: state.id,
2813 expectedHeadOid: state.headRefOid,
2814 updateMethod: input.updateMethod === "rebase" ? "REBASE" : "MERGE",
2815 },
2816 });
2817 }
2818 let body: string | undefined;
2819 let expectedHead: string | undefined;
2820 if (input.removeAgentCreditsOnMerge === true && input.mergeMethod !== "rebase") {
2821 const message = yield* graphqlRead({
2822 cwd: input.cwd,
2823 host: input.host,
2824 operation: "runPullRequestAction",
2825 allowReserve: true,
2826 query: MERGE_MESSAGE_GRAPHQL_QUERY,
2827 variables: {
2828 owner,
2829 name,
2830 number: input.number,
2831 method: input.mergeMethod === "squash" ? "SQUASH" : "MERGE",
2832 },
2833 decode: decodeMergeMessage,
2834 });
2835 // GitHub's merge queue chooses its own message and ignores custom text.
2836 if (!message.isMergeQueueEnabled) {
2837 const cleaned = removeAgentCredits(message.viewerMergeBodyText);
2838 if (cleaned !== message.viewerMergeBodyText) {
2839 body = cleaned;
2840 expectedHead = message.headRefOid;
2841 }
2842 }
2843 }
2844 // A merge queue takes a pull request through auto-merge rather than a direct merge, and
2845 // `--auto` on a pull request that is mergeable right now simply merges it, as `gh` does.
2846 // GitHub stores the strategy with a standing instruction rather than choosing one at
2847 // merge time, so arming still names it.
2848 const auto =
2849 state.isMergeQueueEnabled === true ||
2850 (action === "enable-auto-merge" &&
2851 !IMMEDIATELY_MERGEABLE.has(state.mergeStateStatus?.toUpperCase() ?? ""));
2852 yield* graphql({
2853 host: input.host,
2854 operation: "runPullRequestAction",
2855 query: auto ? ENABLE_AUTO_MERGE_GRAPHQL_MUTATION : MERGE_PULL_REQUEST_GRAPHQL_MUTATION,
2856 variables: {
2857 input: {
2858 pullRequestId: state.id,
2859 mergeMethod: GRAPHQL_MERGE_METHODS[input.mergeMethod ?? "merge"],
2860 ...(expectedHead === undefined ? {} : { expectedHeadOid: expectedHead }),
2861 ...(body === undefined ? {} : { commitBody: body }),
2862 },
2863 },
2864 });
2865 });
2866 },
2867
2868 commentOnPullRequest: (input) =>
2869 pullRequestNodeId({ ...input, operation: "commentOnPullRequest" }).pipe(
2870 Effect.flatMap((subjectId) =>
2871 graphql({
2872 host: input.host,
2873 operation: "commentOnPullRequest",
2874 query: ADD_COMMENT_GRAPHQL_MUTATION,
2875 variables: { subjectId, body: input.body },
2876 }),
2877 ),
2878 ),
2879
2880 submitReview: (input) => {
2881 const { owner, name } = parseRepositorySelector(input.repository);
2882 // The whole review is one request, so nothing is visible to anyone else until the verdict
2883 // is sent.
2884 return api
2885 .rest({
2886 host: input.host,
2887 operation: "submitReview",
2888 method: "POST",
2889 path: `repos/${owner}/${name}/pulls/${input.number}/reviews`,
2890 body: buildReviewSubmission({
2891 verdict: input.verdict,
2892 body: input.body,
2893 comments: input.comments,
2894 }),
2895 })
2896 .pipe(Effect.asVoid);
2897 },
2898
2899 replyToReviewThread: (input) =>
2900 graphql({
2901 host: input.host,
2902 operation: "replyToReviewThread",
2903 query: REVIEW_THREAD_REPLY_GRAPHQL_MUTATION,
2904 variables: { threadId: input.threadId, body: input.body },
2905 }),
2906
2907 getPullRequestFilesViewed: (input) => {
2908 const { owner, name } = parseRepositorySelector(input.repository);
2909 return readGraphQlPages(
2910 (after) =>
2911 graphqlRead({
2912 cwd: input.cwd,
2913 host: input.host,
2914 operation: "getPullRequestFilesViewed",
2915 variables: { owner, name, number: input.number, after },
2916 query: PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY,
2917 decode: decodePullRequestFilesViewedJson,
2918 }),
2919 { maxPages: FILES_VIEWED_MAX_PAGES, nextCursor: (page) => page.nextCursor },
2920 ).pipe(
2921 Effect.map(({ pages, truncated }) => ({
2922 files: pages.flatMap((page) => page.files),
2923 truncated,
2924 })),
2925 );
2926 },
2927
2928 setPullRequestFilesViewed: (input) => {
2929 if (input.files.length === 0) return Effect.void;
2930 return pullRequestNodeId({ ...input, operation: "setPullRequestFilesViewed" }).pipe(
2931 Effect.flatMap((pullRequestId) => {
2932 const mutation = buildSetFilesViewedGraphQlMutation(pullRequestId, input.files);
2933 return mutation === null
2934 ? Effect.void
2935 : graphql({ host: input.host, operation: "setPullRequestFilesViewed", ...mutation });
2936 }),
2937 );
2938 },
2939
2940 setReviewThreadResolution: (input) =>
2941 graphql({
2942 host: input.host,
2943 operation: "setReviewThreadResolution",
2944 query: input.resolved
2945 ? RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION
2946 : UNRESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION,
2947 variables: { threadId: input.threadId },
2948 }),
2949
2950 setReaction: (input) => {
2951 const givenSubjectId = input.subjectId;
2952 const subjectId =
2953 givenSubjectId === undefined
2954 ? pullRequestNodeId({ ...input, operation: "setReaction" })
2955 : subjectBelongsToPullRequest({
2956 ...input,
2957 subjectId: givenSubjectId,
2958 operation: "setReaction",
2959 }).pipe(
2960 Effect.flatMap((belongs) =>
2961 belongs
2962 ? Effect.succeed(givenSubjectId)
2963 : Effect.fail(
2964 new GitHubSubjectScopeError({
2965 cwd: input.cwd,
2966 operation: "setReaction",
2967 }),
2968 ),
2969 ),
2970 );
2971 return subjectId.pipe(
2972 Effect.flatMap((subjectId) =>
2973 graphql({
2974 host: input.host,
2975 operation: "setReaction",
2976 query: input.reacted ? ADD_REACTION_GRAPHQL_MUTATION : REMOVE_REACTION_GRAPHQL_MUTATION,
2977 variables: { subjectId, content: gitHubReactionContent(input.content) },
2978 }),
2979 ),
2980 );
2981 },
2982
2983 updatePullRequest: (input) =>
2984 pullRequestNodeId({ ...input, operation: "updatePullRequest" }).pipe(
2985 Effect.flatMap((pullRequestId) =>
2986 graphql({
2987 host: input.host,
2988 operation: "updatePullRequest",
2989 query: UPDATE_PULL_REQUEST_GRAPHQL_MUTATION,
2990 // A field the caller did not name is left out of the request entirely, so GitHub
2991 // keeps the words that are there rather than being asked for an empty one.
2992 variables: {
2993 pullRequestId,
2994 ...(input.title === undefined ? {} : { title: input.title }),
2995 ...(input.body === undefined ? {} : { body: input.body }),
2996 },
2997 }),
2998 ),
2999 ),
3000
3001 updateComment: (input) =>
3002 subjectBelongsToPullRequest({
3003 cwd: input.cwd,
3004 repository: input.repository,
3005 host: input.host,
3006 number: input.number,
3007 subjectId: input.commentId,
3008 operation: "updateComment",
3009 }).pipe(
3010 Effect.flatMap((belongs) =>
3011 belongs
3012 ? Effect.succeed(input.commentId)
3013 : Effect.fail(
3014 new GitHubSubjectScopeError({
3015 cwd: input.cwd,
3016 operation: "updateComment",
3017 }),
3018 ),
3019 ),
3020 Effect.flatMap((commentId) =>
3021 graphql({
3022 host: input.host,
3023 operation: "updateComment",
3024 query:
3025 input.kind === "issue-comment"
3026 ? UPDATE_ISSUE_COMMENT_GRAPHQL_MUTATION
3027 : UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION,
3028 variables: { commentId, body: input.body },
3029 }),
3030 ),
3031 ),
3032 });
3033});
3034
3035export const layer = Layer.effect(GitHubPullRequestApi, make);