| 1 | import { removeAgentCredits } from "@t3tools/source-control-core/server/mergeMessage"; |
| 2 | import { KnownWorkflowRuns, makeChecksRevalidator } from "./gitHubConditionalChecks.ts"; |
| 3 | import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts"; |
| 4 | import * as Cause from "effect/Cause"; |
| 5 | import * as Context from "effect/Context"; |
| 6 | import * as Clock from "effect/Clock"; |
| 7 | import * as Duration from "effect/Duration"; |
| 8 | import * as Effect from "effect/Effect"; |
| 9 | import * as FileSystem from "effect/FileSystem"; |
| 10 | import * as Layer from "effect/Layer"; |
| 11 | import * as Exit from "effect/Exit"; |
| 12 | import * as Option from "effect/Option"; |
| 13 | import * as Request from "effect/Request"; |
| 14 | import * as RequestResolver from "effect/RequestResolver"; |
| 15 | import * as Result from "effect/Result"; |
| 16 | import * as Schema from "effect/Schema"; |
| 17 | import * as Semaphore from "effect/Semaphore"; |
| 18 | import { |
| 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 | |
| 45 | import * as GitHubApi from "./GitHubApi.ts"; |
| 46 | import { readGraphQlPages } from "./githubGraphQl.ts"; |
| 47 | import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; |
| 48 | import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; |
| 49 | import { |
| 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"; |
| 131 | import 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 | */ |
| 140 | export 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. */ |
| 154 | export 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. */ |
| 166 | export 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. */ |
| 180 | export 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. */ |
| 192 | export 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. */ |
| 204 | export 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. */ |
| 220 | export 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 | */ |
| 241 | export 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. */ |
| 254 | export 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. */ |
| 267 | export 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. */ |
| 289 | export 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. */ |
| 302 | export 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 | |
| 314 | export 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 | |
| 343 | export 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. */ |
| 362 | const DIFF_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; |
| 363 | const DIFF_TIMEOUT = Duration.seconds(60); |
| 364 | /** Pierre expansion is for source files, not blobs large enough to stall a review surface. */ |
| 365 | const DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024; |
| 366 | |
| 367 | /** What `gh pr list --state` asked a repository's own list for, without search. */ |
| 368 | const 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 | |
| 377 | interface 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. */ |
| 384 | const 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. */ |
| 387 | const 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 | */ |
| 393 | const 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 | */ |
| 400 | export 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 | */ |
| 407 | const REVIEW_THREAD_PAGES = 10; |
| 408 | |
| 409 | /** Pages of a hundred check contexts to walk for one head before the rollup says it was cut. */ |
| 410 | const CHECK_CONTEXT_PAGES = 10; |
| 411 | |
| 412 | export 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 | |
| 419 | export 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 | */ |
| 430 | const STAT_ALIASES_PER_REQUEST = 25; |
| 431 | const 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 | */ |
| 437 | const SUMMARY_BATCH_WINDOW = "10 millis"; |
| 438 | |
| 439 | class 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 | |
| 450 | class 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 | |
| 461 | export 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 | |
| 467 | export 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 | |
| 477 | export 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 | |
| 483 | export 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 | */ |
| 833 | function 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 | */ |
| 846 | function 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 | */ |
| 855 | function 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 | */ |
| 869 | function 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. */ |
| 874 | const 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 | */ |
| 886 | function qualifierValue(value: string): string { |
| 887 | return `"${value.replaceAll('"', "").trim()}"`; |
| 888 | } |
| 889 | |
| 890 | function 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 | */ |
| 920 | function 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. */ |
| 944 | function 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. */ |
| 965 | const 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 | */ |
| 979 | function 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 | |
| 1010 | const MERGE_MESSAGE_GRAPHQL_QUERY = ` |
| 1011 | query 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. */ |
| 1022 | const 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 | |
| 1033 | const 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 | ); |
| 1048 | const 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. */ |
| 1052 | const ACTION_STATE_GRAPHQL_QUERY = ` |
| 1053 | query 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 | |
| 1065 | const 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 | ); |
| 1086 | const decodeActionState = (raw: string) => |
| 1087 | Result.map(decodeActionStateResponse(raw), (response) => response.data.repository.pullRequest); |
| 1088 | |
| 1089 | const MERGE_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($input: MergePullRequestInput!) { |
| 1090 | mergePullRequest(input: $input) { clientMutationId } |
| 1091 | }`; |
| 1092 | const ENABLE_AUTO_MERGE_GRAPHQL_MUTATION = `mutation($input: EnablePullRequestAutoMergeInput!) { |
| 1093 | enablePullRequestAutoMerge(input: $input) { clientMutationId } |
| 1094 | }`; |
| 1095 | |
| 1096 | const 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 | }`; |
| 1107 | const 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 | ); |
| 1124 | const 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 | }`; |
| 1132 | const 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 | ); |
| 1146 | const DELETE_BRANCH_GRAPHQL_MUTATION = `mutation($input: UpdateRefsInput!) { |
| 1147 | updateRefs(input: $input) { clientMutationId } |
| 1148 | }`; |
| 1149 | const DISABLE_AUTO_MERGE_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { |
| 1150 | disablePullRequestAutoMerge(input: { pullRequestId: $pullRequestId }) { clientMutationId } |
| 1151 | }`; |
| 1152 | const UPDATE_BRANCH_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!, $expectedHeadOid: GitObjectID!, $updateMethod: PullRequestBranchUpdateMethod!) { |
| 1153 | updatePullRequestBranch(input: { pullRequestId: $pullRequestId, expectedHeadOid: $expectedHeadOid, updateMethod: $updateMethod }) { clientMutationId } |
| 1154 | }`; |
| 1155 | const READY_FOR_REVIEW_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { |
| 1156 | markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) { clientMutationId } |
| 1157 | }`; |
| 1158 | const CONVERT_TO_DRAFT_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { |
| 1159 | convertPullRequestToDraft(input: { pullRequestId: $pullRequestId }) { clientMutationId } |
| 1160 | }`; |
| 1161 | const CLOSE_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { |
| 1162 | closePullRequest(input: { pullRequestId: $pullRequestId }) { clientMutationId } |
| 1163 | }`; |
| 1164 | const REOPEN_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { |
| 1165 | reopenPullRequest(input: { pullRequestId: $pullRequestId }) { clientMutationId } |
| 1166 | }`; |
| 1167 | const ADD_COMMENT_GRAPHQL_MUTATION = `mutation($subjectId: ID!, $body: String!) { |
| 1168 | addComment(input: { subjectId: $subjectId, body: $body }) { clientMutationId } |
| 1169 | }`; |
| 1170 | |
| 1171 | const 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. */ |
| 1178 | const IMMEDIATELY_MERGEABLE = new Set(["CLEAN", "HAS_HOOKS", "UNSTABLE"]); |
| 1179 | |
| 1180 | /** The mutations that only need the pull request's node id. */ |
| 1181 | const 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. */ |
| 1190 | export 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 | |
| 3035 | export const layer = Layer.effect(GitHubPullRequestApi, make); |