| 1 | import { SourceControlProviderKind } from "@t3tools/contracts"; |
| 2 | import { removeAgentCredits } from "@t3tools/source-control-core/server/mergeMessage"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import type { |
| 5 | PullRequestActor, |
| 6 | PullRequestCapabilities, |
| 7 | PullRequestCheck, |
| 8 | PullRequestReaction, |
| 9 | PullRequestViewerPermissions, |
| 10 | } from "@t3tools/contracts"; |
| 11 | |
| 12 | import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; |
| 13 | import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; |
| 14 | import { |
| 15 | PullRequestProviderError, |
| 16 | type PullRequestProviderFailure, |
| 17 | type ProviderChangeRequestActivity, |
| 18 | type ProviderChangeRequestDetail, |
| 19 | type PullRequestProviderApi, |
| 20 | type ProviderRepositoryRef, |
| 21 | } from "@t3tools/source-control-core/server/PullRequestProvider"; |
| 22 | import type { GitHubViewerAccess, GitHubWorkflowRunApproval } from "./gitHubPullRequestJson.ts"; |
| 23 | import { definition } from "../client/definition.ts"; |
| 24 | |
| 25 | const CAPABILITIES: PullRequestCapabilities = { |
| 26 | diff: true, |
| 27 | comment: true, |
| 28 | actions: [...definition.changeRequestActions], |
| 29 | mergeMethods: ["merge", "squash", "rebase"], |
| 30 | deleteBranch: true, |
| 31 | updateMethods: ["merge", "rebase"], |
| 32 | search: true, |
| 33 | reactions: true, |
| 34 | viewedFiles: "host", |
| 35 | review: { |
| 36 | inlineComment: true, |
| 37 | reply: true, |
| 38 | resolve: true, |
| 39 | verdicts: ["comment", "approve", "request-changes"], |
| 40 | }, |
| 41 | reviewers: { request: true, listCandidates: true }, |
| 42 | edit: { changeRequest: true, comment: true }, |
| 43 | stacks: true, |
| 44 | stackActions: true, |
| 45 | labels: true, |
| 46 | }; |
| 47 | |
| 48 | /** |
| 49 | * What the signed-in account may do here, from the three things GitHub says about it. |
| 50 | * |
| 51 | * Merging needs a role that can push, which is the one thing a stranger on an open-source |
| 52 | * repository never has. The other four actions go by `viewerCanUpdate`, because the author of a |
| 53 | * pull request may close it, reopen it and move it in and out of draft with no more than read |
| 54 | * access on the repository it was opened against. |
| 55 | * |
| 56 | * Commenting and reviewing are not gated at all: read access is enough to say something and |
| 57 | * enough to approve or ask for changes, which is what open-source review consists of. Resolving a |
| 58 | * conversation is the exception — GitHub allows it to whoever can write, and to the author of the |
| 59 | * pull request the conversation is on. |
| 60 | * |
| 61 | * Asking somebody else for a review needs write access, which is the one thing here an author |
| 62 | * cannot do on their own pull request: GitHub shows an outside contributor the reviewer control |
| 63 | * and refuses the request behind it. |
| 64 | */ |
| 65 | export function gitHubViewerPermissions(access: GitHubViewerAccess): PullRequestViewerPermissions { |
| 66 | return { |
| 67 | ...(access.canWrite ? { stackRebase: true } : {}), |
| 68 | actions: [ |
| 69 | ...(access.canDeleteBranch === true ? (["delete-branch"] as const) : []), |
| 70 | // Arming a merge and taking the arming back are the merge, deferred: whoever may not |
| 71 | // merge here may not leave an instruction to merge later either. |
| 72 | ...(access.canWrite |
| 73 | ? ([ |
| 74 | "merge", |
| 75 | "enable-auto-merge", |
| 76 | "disable-auto-merge", |
| 77 | "revert", |
| 78 | "approve-workflows", |
| 79 | ] as const) |
| 80 | : []), |
| 81 | ...(access.canUpdate ? (["ready", "draft", "close", "reopen"] as const) : []), |
| 82 | // Whether this viewer may update the branch is GitHub's own answer, read with the |
| 83 | // comparison; without it the action is offered to nobody rather than to everybody. |
| 84 | ...(access.canUpdateBranch === true ? (["update-branch"] as const) : []), |
| 85 | ], |
| 86 | comment: true, |
| 87 | resolve: access.canWrite || access.didAuthor, |
| 88 | // Anyone may review a pull request they can see, except their own: GitHub refuses an author's |
| 89 | // approval and their request for changes ("Can not approve your own pull request"), and |
| 90 | // leaves them commenting, which is what an author has to say about their own change anyway. |
| 91 | verdicts: access.didAuthor ? (["comment"] as const) : CAPABILITIES.review.verdicts, |
| 92 | requestReviewers: access.canWrite, |
| 93 | ...(access.canUpdateBranch === true ? { updateMethods: CAPABILITIES.updateMethods } : {}), |
| 94 | // Triage is the one role that labels without writing, which is what triage is for. |
| 95 | labels: access.canTriage, |
| 96 | }; |
| 97 | } |
| 98 | |
| 99 | /** The tags that mean GitHub is out of reach for this account, rather than one request failing. */ |
| 100 | export function gitHubProviderFailure( |
| 101 | error: GitHubPullRequestApi.GitHubPullRequestApiError, |
| 102 | ): PullRequestProviderFailure { |
| 103 | switch (error._tag) { |
| 104 | case "GitHubCliMissingError": |
| 105 | return { reason: "missing-tool" }; |
| 106 | case "GitHubNotSignedInError": |
| 107 | case "GitHubHostDisabledError": |
| 108 | case "GitHubApiAuthenticationError": |
| 109 | return { reason: "unauthenticated" }; |
| 110 | case "GitHubApiRateLimitError": |
| 111 | return { |
| 112 | reason: "rate-limited", |
| 113 | ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }), |
| 114 | }; |
| 115 | case "SourceControlRateLimitPausedError": |
| 116 | return { reason: "rate-limited", retryAt: error.retryAt }; |
| 117 | case "GitHubApiNotFoundError": |
| 118 | return { reason: "not-found" }; |
| 119 | default: |
| 120 | return { reason: "failed" }; |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | /** |
| 125 | * `gh pr view --json` reports no avatar for anyone, so the ones the GraphQL read collected are |
| 126 | * applied here by login. An actor already carrying one keeps it. |
| 127 | * |
| 128 | * A login GitHub did not answer for falls back to the picture every GitHub install serves at |
| 129 | * `/<login>.png`. The lookup is one more request per repository and can be refused — a rate |
| 130 | * limit, a slow host — and a face that comes and goes between two loads of the same page reads |
| 131 | * as a bug in the page rather than as a request that failed quietly. |
| 132 | */ |
| 133 | function withAvatar( |
| 134 | actor: PullRequestActor | null, |
| 135 | avatarsByLogin: ReadonlyMap<string, string>, |
| 136 | host: string, |
| 137 | botLogins?: ReadonlySet<string>, |
| 138 | ): PullRequestActor | null { |
| 139 | if (actor === null) return actor; |
| 140 | if (botLogins?.has(actor.login)) actor = { ...actor, isBot: true }; |
| 141 | if (actor.avatarUrl !== null) return actor; |
| 142 | const avatarUrl = avatarsByLogin.get(actor.login) ?? loginAvatarUrl(actor.login, host); |
| 143 | return avatarUrl === null ? actor : { ...actor, avatarUrl }; |
| 144 | } |
| 145 | |
| 146 | function withWorkflowApprovals( |
| 147 | checks: ReadonlyArray<PullRequestCheck>, |
| 148 | runs: ReadonlyArray<GitHubWorkflowRunApproval>, |
| 149 | unavailable: boolean, |
| 150 | ): ReadonlyArray<PullRequestCheck> { |
| 151 | const representedRunIds = new Set<number>(); |
| 152 | for (const check of checks) { |
| 153 | if (check.status !== "action-required" || check.url === null) continue; |
| 154 | const id = check.url.match(/\/actions\/runs\/(\d+)(?:\/|$)/)?.[1]; |
| 155 | if (id !== undefined) representedRunIds.add(Number(id)); |
| 156 | } |
| 157 | const approvalChecks = runs |
| 158 | .filter((run) => !representedRunIds.has(run.id)) |
| 159 | .map((run): PullRequestCheck => ({ |
| 160 | name: run.name, |
| 161 | status: "action-required", |
| 162 | description: "A maintainer must approve this workflow before it can run.", |
| 163 | url: run.url, |
| 164 | })); |
| 165 | return [ |
| 166 | ...checks, |
| 167 | ...approvalChecks, |
| 168 | ...(unavailable |
| 169 | ? [ |
| 170 | { |
| 171 | name: "Workflow approval status", |
| 172 | status: "action-required" as const, |
| 173 | description: "GitHub could not determine whether workflows are awaiting approval.", |
| 174 | url: null, |
| 175 | }, |
| 176 | ] |
| 177 | : []), |
| 178 | ]; |
| 179 | } |
| 180 | |
| 181 | /** |
| 182 | * Null for anything that is not a plain user login: an app posts as `dependabot[bot]`, which |
| 183 | * names no page, and a guessed URL that 404s is worse than the initials it would replace. |
| 184 | */ |
| 185 | export function loginAvatarUrl(login: string, host: string): string | null { |
| 186 | return /^[a-z0-9][a-z0-9-]{0,38}$/iu.test(login) ? `https://${host}/${login}.png?size=80` : null; |
| 187 | } |
| 188 | |
| 189 | /** True where markdown would render nothing: whitespace, or only HTML comments. */ |
| 190 | const rendersEmpty = (body: string): boolean => |
| 191 | body.replace(/<!--[\s\S]*?-->/g, "").trim().length === 0; |
| 192 | |
| 193 | export const make = Effect.gen(function* () { |
| 194 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 195 | |
| 196 | const fail = (operation: string) => (error: GitHubPullRequestApi.GitHubPullRequestApiError) => |
| 197 | new PullRequestProviderError({ |
| 198 | provider: SourceControlProviderKind.make("github"), |
| 199 | operation, |
| 200 | ...gitHubProviderFailure(error), |
| 201 | detail: error.message, |
| 202 | cause: error, |
| 203 | }); |
| 204 | |
| 205 | const readChecks = (input: ProviderRepositoryRef & { readonly number: number }) => |
| 206 | cli.getPullRequestDetail(input).pipe( |
| 207 | Effect.flatMap((pullRequest) => |
| 208 | (pullRequest.state !== "open" || pullRequest.isCrossRepository !== true |
| 209 | ? Effect.succeed({ |
| 210 | runs: [] as ReadonlyArray<GitHubWorkflowRunApproval>, |
| 211 | unavailable: false, |
| 212 | }) |
| 213 | : pullRequest.headSha == null || pullRequest.headRepositoryOwner == null |
| 214 | ? Effect.succeed({ |
| 215 | runs: [] as ReadonlyArray<GitHubWorkflowRunApproval>, |
| 216 | unavailable: true, |
| 217 | }) |
| 218 | : cli |
| 219 | .listWorkflowRunsRequiringApproval({ |
| 220 | ...input, |
| 221 | headSha: pullRequest.headSha, |
| 222 | headBranch: pullRequest.headBranch, |
| 223 | headRepositoryOwner: pullRequest.headRepositoryOwner, |
| 224 | isCrossRepository: true, |
| 225 | }) |
| 226 | .pipe( |
| 227 | Effect.matchEffect({ |
| 228 | onFailure: (error) => |
| 229 | error._tag === "GitHubApiRateLimitError" || |
| 230 | error._tag === "SourceControlRateLimitPausedError" |
| 231 | ? Effect.fail(error) |
| 232 | : Effect.succeed({ |
| 233 | runs: [] as ReadonlyArray<GitHubWorkflowRunApproval>, |
| 234 | unavailable: true, |
| 235 | }), |
| 236 | onSuccess: (runs) => Effect.succeed({ runs, unavailable: false }), |
| 237 | }), |
| 238 | ) |
| 239 | ).pipe( |
| 240 | Effect.map((workflowApprovals) => ({ |
| 241 | ...pullRequest, |
| 242 | author: withAvatar(pullRequest.author, new Map(), input.host), |
| 243 | checks: withWorkflowApprovals( |
| 244 | pullRequest.checks, |
| 245 | workflowApprovals.runs, |
| 246 | workflowApprovals.unavailable, |
| 247 | ), |
| 248 | ...(workflowApprovals.unavailable |
| 249 | ? {} |
| 250 | : { workflowApprovalsRequired: workflowApprovals.runs.length }), |
| 251 | })), |
| 252 | ), |
| 253 | ), |
| 254 | ); |
| 255 | |
| 256 | const provider: PullRequestProviderApi = { |
| 257 | kind: SourceControlProviderKind.make("github"), |
| 258 | capabilities: CAPABILITIES, |
| 259 | mergeMessageRewrite: removeAgentCredits, |
| 260 | getRoutingIdentity: (input) => |
| 261 | cli.getRoutingIdentity(input).pipe(Effect.mapError(fail("routeIdentity"))), |
| 262 | withVerifiedCredential: (input, use) => |
| 263 | cli |
| 264 | .withVerifiedCredential(input, (identity) => use(identity).pipe(Effect.result)) |
| 265 | .pipe(Effect.mapError(fail("routeIdentity")), Effect.flatMap(Effect.fromResult)), |
| 266 | |
| 267 | getViewer: (input) => |
| 268 | cli |
| 269 | .getViewerLogin({ cwd: input.cwd, host: input.host ?? "github.com" }) |
| 270 | .pipe(Effect.mapError(fail("getViewer"))), |
| 271 | |
| 272 | listChangeRequests: (input) => |
| 273 | cli |
| 274 | .listPullRequests({ |
| 275 | cwd: input.cwd, |
| 276 | repository: input.repository, |
| 277 | host: input.host, |
| 278 | state: input.state, |
| 279 | involvement: input.involvement, |
| 280 | viewer: input.viewer, |
| 281 | limit: input.limit, |
| 282 | query: input.query, |
| 283 | cursor: input.cursor, |
| 284 | filters: input.filters, |
| 285 | }) |
| 286 | .pipe( |
| 287 | Effect.mapError(fail("listChangeRequests")), |
| 288 | Effect.flatMap((page) => |
| 289 | cli |
| 290 | .listActorAvatars({ |
| 291 | cwd: input.cwd, |
| 292 | repository: input.repository, |
| 293 | host: input.host, |
| 294 | ids: [...new Set(page.items.flatMap((item) => item.authorId ?? []))], |
| 295 | }) |
| 296 | // A listing without faces is still a listing, so a failed lookup falls back to |
| 297 | // the initials rather than taking the rows down with it. |
| 298 | .pipe( |
| 299 | Effect.orElseSucceed(() => new Map<string, string>()), |
| 300 | Effect.map((avatarsByLogin) => ({ |
| 301 | ...page, |
| 302 | items: page.items.map((item) => ({ |
| 303 | ...item, |
| 304 | author: withAvatar(item.author, avatarsByLogin, input.host), |
| 305 | })), |
| 306 | })), |
| 307 | ), |
| 308 | ), |
| 309 | ), |
| 310 | |
| 311 | /** |
| 312 | * The same listing for a whole host in one search. The avatar lookup the per-repository read |
| 313 | * needs is not here: a search reports an author's picture itself, so a face costs no request |
| 314 | * of its own — `withAvatar` still stands behind it for the login GitHub answered nothing for. |
| 315 | */ |
| 316 | listChangeRequestsAcross: (input) => |
| 317 | cli |
| 318 | .searchPullRequests({ |
| 319 | cwd: input.cwd, |
| 320 | host: input.host, |
| 321 | repositories: input.repositories, |
| 322 | state: input.state, |
| 323 | involvement: input.involvement, |
| 324 | viewer: input.viewer, |
| 325 | limit: input.limit, |
| 326 | query: input.query, |
| 327 | cursor: input.cursor, |
| 328 | filters: input.filters, |
| 329 | }) |
| 330 | .pipe( |
| 331 | Effect.mapError(fail("listChangeRequestsAcross")), |
| 332 | Effect.map((batch) => ({ |
| 333 | truncated: batch.truncated, |
| 334 | items: batch.items.map((item) => ({ |
| 335 | ...item, |
| 336 | author: withAvatar(item.author, new Map<string, string>(), input.host), |
| 337 | })), |
| 338 | })), |
| 339 | ), |
| 340 | |
| 341 | listChangeRequestStats: (input) => |
| 342 | cli |
| 343 | .listPullRequestStats({ |
| 344 | cwd: input.cwd, |
| 345 | host: input.host, |
| 346 | changeRequests: input.changeRequests, |
| 347 | }) |
| 348 | .pipe(Effect.mapError(fail("listChangeRequestStats"))), |
| 349 | |
| 350 | getChangeRequestSummary: (input) => |
| 351 | cli.getPullRequestSummary(input).pipe( |
| 352 | // `gh pr view` names the author without an avatar; the login-shaped URL every user |
| 353 | // has stands in, without the second request the listing spends on it. |
| 354 | Effect.map((summary) => ({ |
| 355 | ...summary, |
| 356 | ...(summary.author === undefined |
| 357 | ? {} |
| 358 | : { author: withAvatar(summary.author, new Map(), input.host) }), |
| 359 | })), |
| 360 | Effect.mapError(fail("getChangeRequestSummary")), |
| 361 | ), |
| 362 | |
| 363 | getChangeRequestStack: (input) => |
| 364 | cli.getPullRequestStack(input).pipe(Effect.mapError(fail("getChangeRequestStack"))), |
| 365 | |
| 366 | getChangeRequestWatchFingerprint: (input) => |
| 367 | cli |
| 368 | .getPullRequestWatchFingerprint(input) |
| 369 | .pipe(Effect.mapError(fail("getChangeRequestWatchFingerprint"))), |
| 370 | |
| 371 | getChangeRequestPreview: (input) => |
| 372 | cli.getPullRequestPreview(input).pipe(Effect.mapError(fail("getChangeRequestPreview"))), |
| 373 | |
| 374 | getChangeRequestChecks: (input) => |
| 375 | cli.revalidateChecks(input, readChecks(input)).pipe( |
| 376 | Effect.map(({ state, checks }) => ({ state, checks })), |
| 377 | Effect.mapError(fail("getChangeRequestChecks")), |
| 378 | ), |
| 379 | |
| 380 | getChangeRequest: (input) => |
| 381 | readChecks(input).pipe( |
| 382 | Effect.map((pullRequest): ProviderChangeRequestDetail => ({ |
| 383 | ...pullRequest, |
| 384 | author: withAvatar(pullRequest.author, new Map<string, string>(), input.host), |
| 385 | reviewers: [ |
| 386 | ...pullRequest.reviewRequestLogins.map((login) => ({ |
| 387 | login, |
| 388 | name: null, |
| 389 | avatarUrl: null, |
| 390 | })), |
| 391 | ...pullRequest.reviewRequestTeams, |
| 392 | ], |
| 393 | mergeCapabilities: pullRequest.viewerAccess.mergeCapabilities, |
| 394 | viewerPermissions: gitHubViewerPermissions({ |
| 395 | ...pullRequest.viewerAccess, |
| 396 | canUpdateBranch: pullRequest.comparison?.viewerCanUpdate === true, |
| 397 | }), |
| 398 | baseComparison: |
| 399 | pullRequest.comparison === null || pullRequest.comparison.behindBy === null |
| 400 | ? "unknown" |
| 401 | : pullRequest.comparison.behindBy > 0 |
| 402 | ? "behind" |
| 403 | : "up-to-date", |
| 404 | ...(pullRequest.comparison?.behindBy == null |
| 405 | ? {} |
| 406 | : { behindBy: pullRequest.comparison.behindBy }), |
| 407 | })), |
| 408 | Effect.mapError(fail("getChangeRequest")), |
| 409 | ), |
| 410 | |
| 411 | getChangeRequestActivity: (input) => |
| 412 | Effect.all( |
| 413 | [ |
| 414 | cli.getPullRequestActivity(input), |
| 415 | // Line comments live on review threads, which `gh pr view --json` cannot reach. A |
| 416 | // GraphQL hiccup degrades to a truncated conversation rather than blanking activity. |
| 417 | cli.listReviewThreadComments(input).pipe( |
| 418 | Effect.orElseSucceed(() => ({ |
| 419 | comments: [], |
| 420 | dismissalsByReviewId: new Map<string, string>(), |
| 421 | reactions: [], |
| 422 | reactionsById: new Map<string, ReadonlyArray<PullRequestReaction>>(), |
| 423 | editedAtById: new Map<string, string>(), |
| 424 | reviewThreads: [], |
| 425 | commentCount: 0, |
| 426 | truncated: true, |
| 427 | reviewThreadsTruncated: true, |
| 428 | reviewers: [], |
| 429 | avatarsByLogin: new Map<string, string>(), |
| 430 | botLogins: new Set<string>(), |
| 431 | commitStats: new Map< |
| 432 | string, |
| 433 | { readonly additions: number; readonly deletions: number } |
| 434 | >(), |
| 435 | commits: [], |
| 436 | viewer: { canUpdate: true, didAuthor: false }, |
| 437 | })), |
| 438 | ), |
| 439 | ], |
| 440 | { concurrency: 2 }, |
| 441 | ).pipe( |
| 442 | Effect.mapError(fail("getChangeRequestActivity")), |
| 443 | Effect.map(([pullRequest, reviewThreads]): ProviderChangeRequestActivity => ({ |
| 444 | author: withAvatar( |
| 445 | pullRequest.author, |
| 446 | reviewThreads.avatarsByLogin, |
| 447 | input.host, |
| 448 | reviewThreads.botLogins, |
| 449 | ), |
| 450 | reviewers: reviewThreads.reviewers, |
| 451 | reactions: reviewThreads.reactions, |
| 452 | commits: (reviewThreads.commits.length > 0 |
| 453 | ? reviewThreads.commits |
| 454 | : pullRequest.commits |
| 455 | ).map((commit) => ({ |
| 456 | ...commit, |
| 457 | ...reviewThreads.commitStats.get(commit.oid), |
| 458 | authors: commit.authors?.map( |
| 459 | (author) => |
| 460 | withAvatar( |
| 461 | author, |
| 462 | reviewThreads.avatarsByLogin, |
| 463 | input.host, |
| 464 | reviewThreads.botLogins, |
| 465 | ) ?? author, |
| 466 | ), |
| 467 | })), |
| 468 | comments: [...pullRequest.comments, ...reviewThreads.comments] |
| 469 | .map((comment) => ({ |
| 470 | ...comment, |
| 471 | // GitHub keeps the dismissal reason on the timeline event, not on the review, |
| 472 | // so a dismissed review with nothing visible of its own reads its words from |
| 473 | // there. "Visible" and not "empty": bot reviews often carry only an HTML |
| 474 | // marker comment, which markdown renders as nothing. |
| 475 | body: |
| 476 | comment.kind === "review" && |
| 477 | comment.reviewState?.toUpperCase() === "DISMISSED" && |
| 478 | rendersEmpty(comment.body) |
| 479 | ? (reviewThreads.dismissalsByReviewId.get(comment.id) ?? comment.body) |
| 480 | : comment.body, |
| 481 | author: withAvatar( |
| 482 | comment.author, |
| 483 | reviewThreads.avatarsByLogin, |
| 484 | input.host, |
| 485 | reviewThreads.botLogins, |
| 486 | ), |
| 487 | // A comment out of `gh pr view --json` carries none of its own: that read |
| 488 | // reports no reaction at all, so they arrive from the GraphQL page by node id. |
| 489 | reactions: comment.reactions ?? reviewThreads.reactionsById.get(comment.id) ?? [], |
| 490 | editedAt: reviewThreads.editedAtById.get(comment.id) ?? comment.editedAt ?? null, |
| 491 | })) |
| 492 | .toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)), |
| 493 | // `gh pr view --json comments,reviews` follows GitHub's cursors itself, so those two |
| 494 | // are always whole and only the thread walk can stop short of the host. |
| 495 | commentCount: pullRequest.comments.length + reviewThreads.commentCount, |
| 496 | commentsTruncated: reviewThreads.truncated, |
| 497 | reviewThreadsTruncated: reviewThreads.reviewThreadsTruncated, |
| 498 | reviewThreads: reviewThreads.reviewThreads.map((thread) => ({ |
| 499 | ...thread, |
| 500 | comments: thread.comments.map((comment) => ({ |
| 501 | ...comment, |
| 502 | author: withAvatar( |
| 503 | comment.author, |
| 504 | reviewThreads.avatarsByLogin, |
| 505 | input.host, |
| 506 | reviewThreads.botLogins, |
| 507 | ), |
| 508 | })), |
| 509 | })), |
| 510 | })), |
| 511 | ), |
| 512 | |
| 513 | getReviewThreadComments: (input) => |
| 514 | cli.getReviewThreadComments(input).pipe(Effect.mapError(fail("getReviewThreadComments"))), |
| 515 | |
| 516 | getViewerPermissions: (input) => { |
| 517 | const lightAccess = cli |
| 518 | .getViewerAccess({ ...input, allowReserve: true }) |
| 519 | .pipe(Effect.map((access) => gitHubViewerPermissions(access))); |
| 520 | if (input.includeUpdateBranch === false) { |
| 521 | return lightAccess.pipe(Effect.mapError(fail("getViewerPermissions"))); |
| 522 | } |
| 523 | // The core detail already carries the viewer's access, the merge settings, and the base |
| 524 | // comparison, so one read usually answers what used to take three. When that heavier read |
| 525 | // fails, the light access read still answers, withholding only update-branch. |
| 526 | return cli.getPullRequestDetail(input).pipe( |
| 527 | Effect.provideService(SourceControlRateLimit.Interactive, true), |
| 528 | Effect.map((pullRequest) => |
| 529 | gitHubViewerPermissions({ |
| 530 | ...pullRequest.viewerAccess, |
| 531 | canUpdateBranch: |
| 532 | pullRequest.state === "open" && pullRequest.comparison?.viewerCanUpdate === true, |
| 533 | }), |
| 534 | ), |
| 535 | Effect.catchIf( |
| 536 | (error) => |
| 537 | error._tag !== "GitHubApiRateLimitError" && |
| 538 | error._tag !== "SourceControlRateLimitPausedError", |
| 539 | () => lightAccess, |
| 540 | ), |
| 541 | Effect.mapError(fail("getViewerPermissions")), |
| 542 | ); |
| 543 | }, |
| 544 | |
| 545 | getDiff: (input) => cli.getPullRequestDiff(input).pipe(Effect.mapError(fail("getDiff"))), |
| 546 | |
| 547 | getDiffFileContents: (input) => |
| 548 | cli.getPullRequestDiffFileContents(input).pipe(Effect.mapError(fail("getDiffFileContents"))), |
| 549 | |
| 550 | getFilesViewed: (input) => |
| 551 | cli.getPullRequestFilesViewed(input).pipe(Effect.mapError(fail("getFilesViewed"))), |
| 552 | |
| 553 | setFilesViewed: (input) => |
| 554 | cli.setPullRequestFilesViewed(input).pipe(Effect.mapError(fail("setFilesViewed"))), |
| 555 | |
| 556 | listReviewerCandidates: (input) => |
| 557 | cli.listReviewerCandidates(input).pipe(Effect.mapError(fail("listReviewerCandidates"))), |
| 558 | |
| 559 | setReviewerRequest: (input) => |
| 560 | cli |
| 561 | .setReviewerRequest({ |
| 562 | cwd: input.cwd, |
| 563 | repository: input.repository, |
| 564 | host: input.host, |
| 565 | number: input.number, |
| 566 | reviewers: input.reviewers, |
| 567 | requested: input.requested, |
| 568 | }) |
| 569 | .pipe(Effect.mapError(fail("setReviewerRequest"))), |
| 570 | |
| 571 | listLabelCandidates: (input) => |
| 572 | cli.listLabelCandidates(input).pipe(Effect.mapError(fail("listLabelCandidates"))), |
| 573 | |
| 574 | setLabels: (input) => |
| 575 | cli |
| 576 | .setLabels({ |
| 577 | cwd: input.cwd, |
| 578 | repository: input.repository, |
| 579 | host: input.host, |
| 580 | number: input.number, |
| 581 | labels: input.labels, |
| 582 | applied: input.applied, |
| 583 | }) |
| 584 | .pipe(Effect.mapError(fail("setLabels"))), |
| 585 | |
| 586 | runAction: (input) => |
| 587 | cli |
| 588 | .runPullRequestAction({ |
| 589 | cwd: input.cwd, |
| 590 | repository: input.repository, |
| 591 | host: input.host, |
| 592 | number: input.number, |
| 593 | action: input.action, |
| 594 | ...(input.removeAgentCreditsOnMerge === true ? { removeAgentCreditsOnMerge: true } : {}), |
| 595 | ...(input.stackNumber === undefined ? {} : { stackNumber: input.stackNumber }), |
| 596 | ...(input.expectedStackHeads === undefined |
| 597 | ? {} |
| 598 | : { expectedStackHeads: input.expectedStackHeads }), |
| 599 | ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), |
| 600 | ...(input.updateMethod === undefined ? {} : { updateMethod: input.updateMethod }), |
| 601 | }) |
| 602 | .pipe(Effect.mapError(fail("runAction"))), |
| 603 | |
| 604 | updateChangeRequest: (input) => |
| 605 | cli |
| 606 | .updatePullRequest({ |
| 607 | cwd: input.cwd, |
| 608 | repository: input.repository, |
| 609 | host: input.host, |
| 610 | number: input.number, |
| 611 | ...(input.title === undefined ? {} : { title: input.title }), |
| 612 | ...(input.body === undefined ? {} : { body: input.body }), |
| 613 | }) |
| 614 | .pipe(Effect.mapError(fail("updateChangeRequest"))), |
| 615 | |
| 616 | comment: (input) => cli.commentOnPullRequest(input).pipe(Effect.mapError(fail("comment"))), |
| 617 | |
| 618 | updateComment: (input) => |
| 619 | cli |
| 620 | .updateComment({ |
| 621 | cwd: input.cwd, |
| 622 | repository: input.repository, |
| 623 | host: input.host, |
| 624 | number: input.number, |
| 625 | commentId: input.commentId, |
| 626 | kind: input.kind, |
| 627 | body: input.body, |
| 628 | }) |
| 629 | .pipe(Effect.mapError(fail("updateComment"))), |
| 630 | |
| 631 | submitReview: (input) => cli.submitReview(input).pipe(Effect.mapError(fail("submitReview"))), |
| 632 | |
| 633 | replyToThread: (input) => |
| 634 | cli |
| 635 | .replyToReviewThread({ |
| 636 | cwd: input.cwd, |
| 637 | repository: input.repository, |
| 638 | host: input.host, |
| 639 | threadId: input.threadId, |
| 640 | body: input.body, |
| 641 | }) |
| 642 | .pipe(Effect.mapError(fail("replyToThread"))), |
| 643 | |
| 644 | setReaction: (input) => |
| 645 | cli |
| 646 | .setReaction({ |
| 647 | cwd: input.cwd, |
| 648 | repository: input.repository, |
| 649 | host: input.host, |
| 650 | number: input.number, |
| 651 | ...(input.subjectId === undefined ? {} : { subjectId: input.subjectId }), |
| 652 | content: input.content, |
| 653 | reacted: input.reacted, |
| 654 | }) |
| 655 | .pipe(Effect.mapError(fail("setReaction"))), |
| 656 | |
| 657 | setThreadResolution: (input) => |
| 658 | cli |
| 659 | .setReviewThreadResolution({ |
| 660 | cwd: input.cwd, |
| 661 | repository: input.repository, |
| 662 | host: input.host, |
| 663 | threadId: input.threadId, |
| 664 | resolved: input.resolved, |
| 665 | }) |
| 666 | .pipe(Effect.mapError(fail("setThreadResolution"))), |
| 667 | }; |
| 668 | |
| 669 | return provider; |
| 670 | }); |