| 1 | import { |
| 2 | canonicalRepositoryKey, |
| 3 | isSshRemoteUrl, |
| 4 | sourceControlRepositorySelector, |
| 5 | } from "@t3tools/shared/sourceControl"; |
| 6 | import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; |
| 7 | import * as Cache from "effect/Cache"; |
| 8 | import * as Cause from "effect/Cause"; |
| 9 | import * as Clock from "effect/Clock"; |
| 10 | import * as Context from "effect/Context"; |
| 11 | import * as DateTime from "effect/DateTime"; |
| 12 | import * as Duration from "effect/Duration"; |
| 13 | import * as Effect from "effect/Effect"; |
| 14 | import * as Exit from "effect/Exit"; |
| 15 | import * as Layer from "effect/Layer"; |
| 16 | import * as PubSub from "effect/PubSub"; |
| 17 | import * as Option from "effect/Option"; |
| 18 | import * as Schema from "effect/Schema"; |
| 19 | import type * as Scope from "effect/Scope"; |
| 20 | import * as Stream from "effect/Stream"; |
| 21 | import * as SubscriptionRef from "effect/SubscriptionRef"; |
| 22 | import { |
| 23 | PullRequestOperationError, |
| 24 | isMergebotRepository, |
| 25 | PullRequestUnavailableError, |
| 26 | pullRequestHostOf, |
| 27 | pullRequestProviderRequirement, |
| 28 | resolvePullRequestAuthorFilter, |
| 29 | type OrchestrationProjectShell, |
| 30 | type ProjectId, |
| 31 | type PullRequestAction, |
| 32 | type PullRequestActionInput, |
| 33 | type PullRequestActivity, |
| 34 | type PullRequestCommentInput, |
| 35 | type PullRequestCommentUpdateInput, |
| 36 | type PullRequestDetail, |
| 37 | type PullRequestPreview, |
| 38 | type PullRequestChecks, |
| 39 | type PullRequestDiffFileContentsInput, |
| 40 | type PullRequestDiffFileContentsResult, |
| 41 | type PullRequestDiffStat, |
| 42 | type PullRequestDiffInput, |
| 43 | type PullRequestFilesViewedResult, |
| 44 | type PullRequestDiffResult, |
| 45 | type PullRequestInvalidateInput, |
| 46 | type PullRequestReportStateInput, |
| 47 | type PullRequestListEntry, |
| 48 | type PullRequestListFilters, |
| 49 | type PullRequestListInput, |
| 50 | type PullRequestListProjectError, |
| 51 | type PullRequestListResult, |
| 52 | type PullRequestListStatsInput, |
| 53 | type PullRequestListStatsResult, |
| 54 | type PullRequestProviderSummary, |
| 55 | type PullRequestReactionInput, |
| 56 | type PullRequestRef, |
| 57 | type PullRequestRoutingResult, |
| 58 | type PullRequestRoutingIdentityInput, |
| 59 | type PullRequestRoutingIdentityResult, |
| 60 | type PullRequestReviewVerdict, |
| 61 | type PullRequestReviewerCandidateList, |
| 62 | type PullRequestReviewerRequestInput, |
| 63 | type PullRequestLabelCandidateList, |
| 64 | type PullRequestLabelChangeInput, |
| 65 | type PullRequestSetFilesViewedInput, |
| 66 | type PullRequestState, |
| 67 | type PullRequestSubmitReviewInput, |
| 68 | PullRequestStack, |
| 69 | PullRequestSummary, |
| 70 | type PullRequestThreadReplyInput, |
| 71 | type PullRequestThreadResolutionInput, |
| 72 | type PullRequestThreadCommentsInput, |
| 73 | type PullRequestThreadCommentsResult, |
| 74 | type PullRequestUpdateInput, |
| 75 | type SourceControlProviderInfo, |
| 76 | SourceControlProviderKind, |
| 77 | type ThreadPullRequestKey, |
| 78 | } from "@t3tools/contracts"; |
| 79 | import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; |
| 80 | import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; |
| 81 | |
| 82 | import * as MergebotQueue from "../mergebot/MergebotQueue.ts"; |
| 83 | import { |
| 84 | mergebotChecks, |
| 85 | mergebotMergeability, |
| 86 | mergebotWatchFingerprint, |
| 87 | } from "../mergebot/pullRequestRead.ts"; |
| 88 | import * as ProjectService from "../project/ProjectService.ts"; |
| 89 | import * as ServerSettings from "../serverSettings.ts"; |
| 90 | import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; |
| 91 | import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; |
| 92 | import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; |
| 93 | import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; |
| 94 | import { |
| 95 | type ProviderChangeRequest, |
| 96 | type ProviderListCursor, |
| 97 | type ProviderChangeRequestWatchFingerprint, |
| 98 | type PullRequestProviderApi, |
| 99 | PullRequestProviderError, |
| 100 | } from "@t3tools/source-control-core/server/PullRequestProvider"; |
| 101 | import * as PullRequestReadCache from "./PullRequestReadCache.ts"; |
| 102 | import * as PullRequestProviderRegistry from "./PullRequestProviderRegistry.ts"; |
| 103 | import * as ViewedFiles from "./pullRequestViewedFiles.ts"; |
| 104 | |
| 105 | export interface PullRequestMergeEvent extends PullRequestRef { |
| 106 | readonly mergedAt: string; |
| 107 | } |
| 108 | |
| 109 | type PullRequestReading = Pick<PullRequestSummary, "state" | "updatedAt" | "observedAt">; |
| 110 | |
| 111 | /** Whether `next` is a newer reading of a pull request than `current`. Merged is final. */ |
| 112 | function supersedesReading(current: PullRequestReading | undefined, next: PullRequestReading) { |
| 113 | if (current === undefined) return true; |
| 114 | if (current.state === "merged" && next.state !== "merged") return false; |
| 115 | if (next.updatedAt !== current.updatedAt) return next.updatedAt > current.updatedAt; |
| 116 | return (next.observedAt ?? -Infinity) >= (current.observedAt ?? -Infinity); |
| 117 | } |
| 118 | |
| 119 | /** |
| 120 | * Rows per repository when the client does not ask for a page size, and rows per slice when a |
| 121 | * listing is carried on from a cursor. |
| 122 | * |
| 123 | * 99 and not 100, because every provider asks its host for one row over this to probe for a next |
| 124 | * page: 99 requests 100, which is exactly what a page of GitHub's API serves — GraphQL refuses |
| 125 | * `first` over 100 with EXCESSIVE_PAGINATION and REST clamps `per_page` to it — and what GitLab |
| 126 | * caps `per_page` at. Asking for 100 here would request 101 and buy a whole second round trip for |
| 127 | * one row (measured: `gh pr list --limit 100` makes 1 HTTP request, `--limit 101` makes 2). |
| 128 | */ |
| 129 | const DEFAULT_REPOSITORY_LIST_LIMIT = 99; |
| 130 | /** |
| 131 | * Repositories read at once. Each one is a CLI process that spends nearly all its wall clock |
| 132 | * waiting on the host, so the useful ceiling is far above the core count; measured over 12 |
| 133 | * repositories on this listing's own command, 4 took ~12.7s, 8 ~8.9s and 12 ~4.9s, with 16 and 24 |
| 134 | * no faster because 12 already reads every repository in one wave. |
| 135 | */ |
| 136 | const REPOSITORY_CONCURRENCY = 12; |
| 137 | /** |
| 138 | * Repositories named in one read across a host. Measured against GitHub's search: six hundred |
| 139 | * `repo:` qualifiers in one query — 14.7KB of it — were all still honoured, and the answer took |
| 140 | * the same three to six seconds at twelve repositories as at four hundred. A hundred is well |
| 141 | * inside that and past the size of a workspace anyone opens, so a larger one reads in a handful |
| 142 | * of searches rather than in a request per repository. |
| 143 | */ |
| 144 | const REPOSITORY_SEARCH_CHUNK = 100; |
| 145 | |
| 146 | /** |
| 147 | * Every read leaves the process — a CLI per repository, against hosts whose limits are low |
| 148 | * (GitHub's search API allows ~30 requests a minute) — so answers are shared for a short |
| 149 | * while and concurrent identical reads share one request. The windows sit near the clients' |
| 150 | * own stale times: long enough that two people opening the same page cost one round trip, |
| 151 | * short enough that "cached" and "fresh" never need telling apart on screen. Reads that |
| 152 | * must not share — the refresh button, a client reloading after its own action — go through |
| 153 | * `invalidate` rather than a flag on the read, so an ordinary read can never opt out. |
| 154 | */ |
| 155 | const LIST_CACHE_TTL = Duration.seconds(30); |
| 156 | /** |
| 157 | * Each connected client re-reads the pull request it shows on focus, on a timer, and after every |
| 158 | * turn, so detail, activity, and preview answers are shared for the clients' own minute of |
| 159 | * staleness. A merged change request cannot change again and is held for ten. A closed one is |
| 160 | * not: it can reopen, and a watch started on it reads it to learn whether it did. |
| 161 | */ |
| 162 | const DETAIL_CACHE_TTL = Duration.seconds(60); |
| 163 | const MERGED_DETAIL_CACHE_TTL = Duration.minutes(10); |
| 164 | const detailTimeToLive = (state: PullRequestState | undefined) => |
| 165 | state === "merged" ? MERGED_DETAIL_CACHE_TTL : DETAIL_CACHE_TTL; |
| 166 | /** |
| 167 | * Checks and the watch fingerprint stay short: they are how a running check's result reaches the |
| 168 | * page and a watched pull request's change reaches its watch. |
| 169 | */ |
| 170 | const CHECKS_CACHE_TTL = Duration.seconds(15); |
| 171 | /** How long a repeated reported state is skipped, so a wrong one cannot hide a real change for long. */ |
| 172 | const REPORTED_STATE_DEDUPE_MS = 5 * 60 * 1_000; |
| 173 | const DIFF_CACHE_TTL = Duration.seconds(60); |
| 174 | /** A commit is content-addressed, so its own diff cannot change under its key. */ |
| 175 | const COMMIT_DIFF_CACHE_TTL = Duration.minutes(10); |
| 176 | /** Sized like the client's own stale time; a row's counts move only when somebody pushes. */ |
| 177 | const LIST_STATS_CACHE_TTL = Duration.seconds(60); |
| 178 | /** |
| 179 | * Short, and with no stale window behind it: this is the reader's own bookkeeping, and the |
| 180 | * press that changes it is the same press the page is already showing optimistically. Held at |
| 181 | * all only so opening a change request on two devices costs one read. |
| 182 | */ |
| 183 | const FILES_VIEWED_CACHE_TTL = Duration.seconds(15); |
| 184 | /** A diff can stay interactive while its next cached value is fetched off the critical path. */ |
| 185 | const DIFF_STALE_WINDOW = Duration.minutes(10); |
| 186 | /** How long an expired diff may stay held before it is dropped unread. */ |
| 187 | const DIFF_SWEEP_INTERVAL = Duration.minutes(1); |
| 188 | /** How long one host's signed-in login is believed without asking its CLI again. */ |
| 189 | const VIEWER_CACHE_TTL = Duration.minutes(10); |
| 190 | const SEARCH_VISIBILITY_TTL = Duration.minutes(10); |
| 191 | const STALE_DETAIL_WINDOW = Duration.minutes(10); |
| 192 | const isPullRequestProviderError = Schema.is(PullRequestProviderError); |
| 193 | const LIST_CACHE_CAPACITY = 64; |
| 194 | const LIST_STATS_CACHE_CAPACITY = 32; |
| 195 | const DETAIL_CACHE_CAPACITY = 128; |
| 196 | const DIFF_CACHE_CAPACITY = 128; |
| 197 | // Each diff cache can retain at most 64 MiB of patch text, counting UTF-16 storage. |
| 198 | const MAX_CACHED_DIFF_PATCH_BYTES = 512 * 1024; |
| 199 | const canCacheDiff = (value: PullRequestDiffResult) => |
| 200 | value.patch.length * 2 <= MAX_CACHED_DIFF_PATCH_BYTES; |
| 201 | const FILES_VIEWED_CACHE_CAPACITY = 128; |
| 202 | const VIEWER_CACHE_CAPACITY = 32; |
| 203 | |
| 204 | export type PullRequestError = PullRequestUnavailableError | PullRequestOperationError; |
| 205 | |
| 206 | const routingCredential = Context.Reference<{ |
| 207 | readonly credentialFingerprint: string; |
| 208 | readonly viewer: string; |
| 209 | } | null>("t3/PullRequestService/routingCredential", { defaultValue: () => null }); |
| 210 | // Internal only: the client cannot choose its cache's credential namespace. |
| 211 | const credentialNamespace = Symbol("pullRequestCredentialNamespace"); |
| 212 | type CredentialRef = PullRequestRef & { readonly [credentialNamespace]?: string }; |
| 213 | |
| 214 | export class PullRequestService extends Context.Service< |
| 215 | PullRequestService, |
| 216 | { |
| 217 | readonly list: ( |
| 218 | input: PullRequestListInput, |
| 219 | ) => Effect.Effect<PullRequestListResult, PullRequestError>; |
| 220 | readonly listStats: ( |
| 221 | input: PullRequestListStatsInput, |
| 222 | ) => Effect.Effect<PullRequestListStatsResult, PullRequestError>; |
| 223 | readonly routing: ( |
| 224 | input: PullRequestRef, |
| 225 | ) => Effect.Effect<PullRequestRoutingResult, PullRequestError>; |
| 226 | readonly routingIdentity: ( |
| 227 | input: PullRequestRoutingIdentityInput, |
| 228 | ) => Effect.Effect<PullRequestRoutingIdentityResult, PullRequestError>; |
| 229 | readonly withRoutingCredential: <A, E, R>( |
| 230 | input: PullRequestRef, |
| 231 | operation: Effect.Effect<A, E, R>, |
| 232 | ) => Effect.Effect<A, E | PullRequestError, R>; |
| 233 | readonly summary: ( |
| 234 | input: PullRequestRef, |
| 235 | options?: { readonly recoverTransientFailure?: boolean }, |
| 236 | ) => Effect.Effect<PullRequestSummary, PullRequestError>; |
| 237 | /** |
| 238 | * The host-native stack the pull request belongs to, or null when it is not in one or the |
| 239 | * host keeps no such object. Cached like a summary; a stack changes about as often. |
| 240 | */ |
| 241 | readonly stack: ( |
| 242 | input: PullRequestRef, |
| 243 | options?: { readonly includeDetails?: boolean }, |
| 244 | ) => Effect.Effect<PullRequestStack | null, PullRequestError>; |
| 245 | readonly subscribeMerges: Effect.Effect< |
| 246 | Stream.Stream<PullRequestMergeEvent>, |
| 247 | never, |
| 248 | Scope.Scope |
| 249 | >; |
| 250 | /** |
| 251 | * Pull requests a detail read saw for the first time or in a new state (opened, closed, |
| 252 | * merged), so thread links can catch up before the next sync sweep. Best effort. |
| 253 | */ |
| 254 | readonly subscribeStateChanges: Effect.Effect< |
| 255 | Stream.Stream<ThreadPullRequestKey>, |
| 256 | never, |
| 257 | Scope.Scope |
| 258 | >; |
| 259 | /** |
| 260 | * The state a read routed to another environment saw. A new one reaches |
| 261 | * `subscribeStateChanges`, so the host is asked again; the report itself is never trusted. |
| 262 | */ |
| 263 | readonly reportState: ( |
| 264 | input: PullRequestReportStateInput, |
| 265 | ) => Effect.Effect<void, PullRequestError>; |
| 266 | readonly subscribeRefreshes: Stream.Stream<number>; |
| 267 | readonly refreshAfterTurn: (projectId: ProjectId) => Effect.Effect<void>; |
| 268 | readonly detail: (input: PullRequestRef) => Effect.Effect<PullRequestDetail, PullRequestError>; |
| 269 | readonly preview: ( |
| 270 | input: PullRequestRef, |
| 271 | ) => Effect.Effect<PullRequestPreview, PullRequestError>; |
| 272 | readonly checks: ( |
| 273 | input: PullRequestRef, |
| 274 | ) => Effect.Effect<PullRequestChecks | null, PullRequestError>; |
| 275 | /** |
| 276 | * What a pull request watch compares between passes, so it reads detail and activity only |
| 277 | * when something moved. Null when the host has no fingerprint or gave none for this one. |
| 278 | */ |
| 279 | readonly watchFingerprint: ( |
| 280 | input: PullRequestRef, |
| 281 | ) => Effect.Effect<ProviderChangeRequestWatchFingerprint | null, PullRequestError>; |
| 282 | readonly activity: ( |
| 283 | input: PullRequestRef, |
| 284 | ) => Effect.Effect<PullRequestActivity, PullRequestError>; |
| 285 | readonly threadComments: ( |
| 286 | input: PullRequestThreadCommentsInput, |
| 287 | ) => Effect.Effect<PullRequestThreadCommentsResult, PullRequestError>; |
| 288 | readonly diff: ( |
| 289 | input: PullRequestDiffInput, |
| 290 | ) => Effect.Effect<PullRequestDiffResult, PullRequestError>; |
| 291 | readonly diffFileContents: ( |
| 292 | input: PullRequestDiffFileContentsInput, |
| 293 | ) => Effect.Effect<PullRequestDiffFileContentsResult, PullRequestError>; |
| 294 | readonly filesViewed: ( |
| 295 | input: PullRequestRef, |
| 296 | ) => Effect.Effect<PullRequestFilesViewedResult, PullRequestError>; |
| 297 | readonly setFilesViewed: ( |
| 298 | input: PullRequestSetFilesViewedInput, |
| 299 | ) => Effect.Effect<void, PullRequestError>; |
| 300 | readonly runAction: (input: PullRequestActionInput) => Effect.Effect<void, PullRequestError>; |
| 301 | readonly update: (input: PullRequestUpdateInput) => Effect.Effect<void, PullRequestError>; |
| 302 | readonly comment: (input: PullRequestCommentInput) => Effect.Effect<void, PullRequestError>; |
| 303 | readonly updateComment: ( |
| 304 | input: PullRequestCommentUpdateInput, |
| 305 | ) => Effect.Effect<void, PullRequestError>; |
| 306 | readonly submitReview: ( |
| 307 | input: PullRequestSubmitReviewInput, |
| 308 | ) => Effect.Effect<void, PullRequestError>; |
| 309 | readonly replyToThread: ( |
| 310 | input: PullRequestThreadReplyInput, |
| 311 | ) => Effect.Effect<void, PullRequestError>; |
| 312 | readonly setThreadResolution: ( |
| 313 | input: PullRequestThreadResolutionInput, |
| 314 | ) => Effect.Effect<void, PullRequestError>; |
| 315 | readonly setReaction: ( |
| 316 | input: PullRequestReactionInput, |
| 317 | ) => Effect.Effect<void, PullRequestError>; |
| 318 | readonly reviewerCandidates: ( |
| 319 | input: PullRequestRef, |
| 320 | ) => Effect.Effect<PullRequestReviewerCandidateList, PullRequestError>; |
| 321 | readonly requestReviewers: ( |
| 322 | input: PullRequestReviewerRequestInput, |
| 323 | ) => Effect.Effect<void, PullRequestError>; |
| 324 | readonly labelCandidates: ( |
| 325 | input: PullRequestRef, |
| 326 | ) => Effect.Effect<PullRequestLabelCandidateList, PullRequestError>; |
| 327 | readonly setLabels: ( |
| 328 | input: PullRequestLabelChangeInput, |
| 329 | ) => Effect.Effect<void, PullRequestError>; |
| 330 | readonly invalidate: ( |
| 331 | input: PullRequestInvalidateInput, |
| 332 | options?: { readonly notifyReaders?: boolean }, |
| 333 | ) => Effect.Effect<void>; |
| 334 | } |
| 335 | >()("t3/pullRequest/PullRequestService") {} |
| 336 | |
| 337 | /** What a verdict is called when refusing it, so the sentence reads as an action. */ |
| 338 | const VERDICT_LABELS: Record<PullRequestReviewVerdict, string> = { |
| 339 | comment: "review", |
| 340 | approve: "approve", |
| 341 | "request-changes": "request changes on", |
| 342 | }; |
| 343 | |
| 344 | /** |
| 345 | * Why an action is refused to this viewer, said as the access it would take rather than as the |
| 346 | * refusal the host would have answered with. Merging is the one that needs write and nothing |
| 347 | * else; the other four are also the author's to take, whatever access they have. |
| 348 | */ |
| 349 | const ACTION_ACCESS_REFUSALS: Record<PullRequestAction, string> = { |
| 350 | merge: "You need write access on this repository to merge.", |
| 351 | ready: |
| 352 | "You need write access on this repository, or to have opened this change request, to mark it ready for review.", |
| 353 | draft: |
| 354 | "You need write access on this repository, or to have opened this change request, to return it to a draft.", |
| 355 | close: |
| 356 | "You need write access on this repository, or to have opened this change request, to close it.", |
| 357 | "update-branch": |
| 358 | "You need write access on this repository, or to have opened this change request, to update its branch.", |
| 359 | reopen: |
| 360 | "You need write access on this repository, or to have opened this change request, to reopen it.", |
| 361 | "enable-auto-merge": |
| 362 | "You need write access on this repository to have it merged for you once it is ready.", |
| 363 | "disable-auto-merge": |
| 364 | "You need write access on this repository to stop it being merged for you once it is ready.", |
| 365 | revert: "You need write access on this repository to open a revert pull request.", |
| 366 | "delete-branch": "You need write access on the source repository to delete its branch.", |
| 367 | "approve-workflows": |
| 368 | "You need write access on this repository to approve workflows from a fork pull request.", |
| 369 | }; |
| 370 | |
| 371 | /** |
| 372 | * Why asking for a review is refused, and why the menu behind it is too. Write access is what the |
| 373 | * hosts that state anything about this want; the ones that state nothing grant it, so this |
| 374 | * sentence is only ever the answer where a host said no. |
| 375 | */ |
| 376 | const REVIEWER_REQUEST_REFUSAL = "You need write access on this repository to ask for a review."; |
| 377 | const LABEL_CHANGE_REFUSAL = "You need triage access on this repository to change its labels."; |
| 378 | |
| 379 | /** A project this page can read: its remote is on a host with an implementation. */ |
| 380 | export interface SupportedProject { |
| 381 | readonly cursorKey: string; |
| 382 | readonly project: OrchestrationProjectShell; |
| 383 | readonly api: PullRequestProviderApi; |
| 384 | readonly repository: string; |
| 385 | /** The host the repository lives on, which is the account boundary rather than the kind. */ |
| 386 | readonly host: string; |
| 387 | /** |
| 388 | * The identity's canonical key, which is what this environment's own records are keyed by. |
| 389 | * Unique where `repository` is not: Azure's is a bare name that repeats across an organisation. |
| 390 | */ |
| 391 | readonly remote: string; |
| 392 | /** |
| 393 | * The host's own key for this checkout's repository (`api.repositoryKey`), normalized, or null |
| 394 | * where `owner/name` on the host identifies it. When set, only a reference whose key matches |
| 395 | * is served by this checkout. |
| 396 | */ |
| 397 | readonly repositoryKey: string | null; |
| 398 | } |
| 399 | |
| 400 | /** |
| 401 | * What the workspace has, split by whether this build can read it. Hosts with no |
| 402 | * implementation are counted rather than dropped, so their projects are explained in the |
| 403 | * provider list instead of quietly missing from the page. |
| 404 | */ |
| 405 | interface WorkspaceProjects { |
| 406 | readonly supported: ReadonlyArray<SupportedProject>; |
| 407 | /** Keyed by host, as the readable ones are: an unimplemented host is its own switcher entry. */ |
| 408 | readonly unimplemented: ReadonlyMap< |
| 409 | string, |
| 410 | { readonly kind: SourceControlProviderKind; readonly projectCount: number } |
| 411 | >; |
| 412 | /** |
| 413 | * Every checkout on a host, including the ones the listing de-duplicated away. Asking who is |
| 414 | * signed in is a question about the host rather than about a repository, and any checkout can |
| 415 | * answer it — so a broken worktree is not allowed to take the host down with it just because |
| 416 | * it happened to be the one the listing kept. |
| 417 | */ |
| 418 | readonly viewerRoots: ReadonlyMap<string, ReadonlyArray<string>>; |
| 419 | } |
| 420 | |
| 421 | interface RepositoryBatch { |
| 422 | /** Which repository this slice came from, which is what a cursor for it is filed under. */ |
| 423 | readonly key: string; |
| 424 | readonly entries: ReadonlyArray<PullRequestListEntry>; |
| 425 | readonly errors: ReadonlyArray<PullRequestListProjectError>; |
| 426 | readonly truncated: boolean; |
| 427 | readonly nextCursor: string | null; |
| 428 | } |
| 429 | |
| 430 | /** What the providers are told, plus the part only the service acts on. */ |
| 431 | interface ListCursor extends ProviderListCursor { |
| 432 | /** |
| 433 | * The rows already handed over at exactly `updatedBefore`. The next read asks for that instant |
| 434 | * inclusively, so these are what keeps it from sending them a second time. |
| 435 | */ |
| 436 | readonly seenAt: ReadonlyArray<number>; |
| 437 | } |
| 438 | |
| 439 | /** |
| 440 | * A continuation as it travels through the page and back. Written out rather than encoded because |
| 441 | * it comes back from a client and has to be believed or refused on sight: everything a host is |
| 442 | * given is either a timestamp of this shape or a number of this length, which is what lets a |
| 443 | * provider drop it into a filter without checking it again. |
| 444 | */ |
| 445 | const LIST_CURSOR_PATTERN = |
| 446 | /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2}))\|(\d{1,9})\|(\d{1,9}(?:,\d{1,9})*)?$/; |
| 447 | |
| 448 | function parseListCursor(raw: string): ListCursor | null { |
| 449 | const match = LIST_CURSOR_PATTERN.exec(raw); |
| 450 | if (match === null) return null; |
| 451 | const seenAt = match[3]; |
| 452 | return { |
| 453 | updatedBefore: match[1]!, |
| 454 | delivered: Number(match[2]), |
| 455 | seenAt: seenAt === undefined ? [] : seenAt.split(",").map(Number), |
| 456 | }; |
| 457 | } |
| 458 | |
| 459 | /** |
| 460 | * How a listing tells two repositories apart. The host is part of it because the same |
| 461 | * `owner/repo` exists on github.com and on an Enterprise install, and they are two repositories. |
| 462 | */ |
| 463 | function listCursorKey(host: string, repository: string): string { |
| 464 | return `${host} ${repository.toLowerCase()}`; |
| 465 | } |
| 466 | |
| 467 | /** |
| 468 | * Where a repository carries on, worked out from the slice just handed over. The boundary is the |
| 469 | * instant of the oldest row in it: the next read asks for that instant and everything before it, |
| 470 | * and names the rows already sent at it so none of them arrives twice. |
| 471 | * |
| 472 | * The names carry over when the boundary has not moved. A slice that ends on the same instant it |
| 473 | * began on has to keep the earlier rows excluded as well as its own, or the read after it would |
| 474 | * hand them over again. |
| 475 | */ |
| 476 | function nextListCursor( |
| 477 | previous: ListCursor | undefined, |
| 478 | /** What the host handed over, before the rows already sent were dropped from it. */ |
| 479 | fetched: ReadonlyArray<ProviderChangeRequest>, |
| 480 | /** What is being sent on, which is what the count of delivered rows is about. */ |
| 481 | delivered: ReadonlyArray<ProviderChangeRequest>, |
| 482 | /** A provider may consume malformed offset-paged rows that never appear in `delivered`. */ |
| 483 | cursorAdvance = delivered.length, |
| 484 | ): string | null { |
| 485 | // The host had nothing at all, so there is no row to carry on from — and repeating the cursor |
| 486 | // that produced the empty slice would ask the same question forever. |
| 487 | if (fetched.length === 0) return null; |
| 488 | // Taken from what the host answered rather than from what survived de-duplication: a slice can |
| 489 | // be entirely rows already sent — a hundred change requests touched in the same second is one |
| 490 | // repository's boring afternoon — and reading "nothing new" as "nothing left" would end the |
| 491 | // walk on the instant it was stuck on, with everything older unreachable for good. |
| 492 | const oldest = fetched.reduce((left, right) => (right.updatedAt < left.updatedAt ? right : left)); |
| 493 | return listCursorAt(previous, oldest.updatedAt, fetched, cursorAdvance); |
| 494 | } |
| 495 | |
| 496 | /** |
| 497 | * The same cursor against a boundary chosen elsewhere, which is what a slice read across several |
| 498 | * repositories at once needs: every repository in it is read up to the oldest row of the whole |
| 499 | * slice, including the ones that contributed nothing to it — their rows are simply all older, and |
| 500 | * a repository that carried on from its own oldest row would be right about where it stopped and |
| 501 | * silent about the ones that never appeared. |
| 502 | */ |
| 503 | function listCursorAt( |
| 504 | previous: ListCursor | undefined, |
| 505 | boundary: string, |
| 506 | /** This repository's own rows in the slice, before the ones already sent were dropped. */ |
| 507 | fetched: ReadonlyArray<ProviderChangeRequest>, |
| 508 | deliveredCount: number, |
| 509 | ): string { |
| 510 | const seenAt = [ |
| 511 | ...(previous?.updatedBefore === boundary ? previous.seenAt : []), |
| 512 | ...fetched.filter((item) => item.updatedAt === boundary).map((item) => item.number), |
| 513 | ]; |
| 514 | return `${boundary}|${(previous?.delivered ?? 0) + deliveredCount}|${seenAt.join(",")}`; |
| 515 | } |
| 516 | |
| 517 | /** A host that cannot be read at all, as opposed to one request that failed. */ |
| 518 | function isProviderUnusable(error: PullRequestProviderError): boolean { |
| 519 | return error.reason === "missing-tool" || error.reason === "unauthenticated"; |
| 520 | } |
| 521 | |
| 522 | /** |
| 523 | * Why a host is not readable, told as the thing to do about it. A host that is simply not set up |
| 524 | * says so in the same words the whole-page state uses, rather than repeating whatever its tool |
| 525 | * printed — "HTTP 401" names the symptom, not the fix. |
| 526 | */ |
| 527 | function providerDetail(error: PullRequestProviderError): string { |
| 528 | if (!isProviderUnusable(error)) return error.detail; |
| 529 | return ( |
| 530 | pullRequestProviderRequirement( |
| 531 | error.provider, |
| 532 | error.reason === "missing-tool" ? "cli-missing" : "cli-unauthenticated", |
| 533 | ) ?? error.detail |
| 534 | ); |
| 535 | } |
| 536 | |
| 537 | function toUnavailableError(error: PullRequestProviderError): PullRequestUnavailableError { |
| 538 | return new PullRequestUnavailableError({ |
| 539 | reason: error.reason === "missing-tool" ? "cli-missing" : "cli-unauthenticated", |
| 540 | provider: error.provider, |
| 541 | cause: error, |
| 542 | }); |
| 543 | } |
| 544 | |
| 545 | function toPullRequestError( |
| 546 | operation: string, |
| 547 | ): (error: PullRequestProviderError) => PullRequestError { |
| 548 | return (error) => |
| 549 | isProviderUnusable(error) |
| 550 | ? toUnavailableError(error) |
| 551 | : new PullRequestOperationError({ |
| 552 | operation, |
| 553 | detail: error.detail, |
| 554 | ...(error.reason === "not-found" ? { reason: "not-found" as const } : {}), |
| 555 | cause: error, |
| 556 | }); |
| 557 | } |
| 558 | |
| 559 | function withRateLimitBackoff( |
| 560 | api: PullRequestProviderApi, |
| 561 | host: string, |
| 562 | limits: SourceControlRateLimit.SourceControlRateLimit["Service"], |
| 563 | options?: { readonly viewerAllowsPause: boolean }, |
| 564 | ): PullRequestProviderApi { |
| 565 | const key = { provider: api.kind, host }; |
| 566 | const protect = <A>( |
| 567 | operation: string, |
| 568 | effect: Effect.Effect<A, PullRequestProviderError>, |
| 569 | allowPaused: boolean, |
| 570 | ) => |
| 571 | limits.check(key, allowPaused ? { allowPaused: true } : undefined).pipe( |
| 572 | Effect.mapError( |
| 573 | (error) => |
| 574 | new PullRequestProviderError({ |
| 575 | provider: api.kind, |
| 576 | operation, |
| 577 | reason: "rate-limited", |
| 578 | detail: error.detail, |
| 579 | retryAt: error.retryAt, |
| 580 | cause: error, |
| 581 | }), |
| 582 | ), |
| 583 | Effect.flatMap((lease) => |
| 584 | effect.pipe( |
| 585 | Effect.provideService(SourceControlRateLimit.Interactive, allowPaused), |
| 586 | Effect.tap(() => limits.recordSuccess({ ...key, lease })), |
| 587 | Effect.tapError((error) => |
| 588 | error.reason === "rate-limited" |
| 589 | ? limits.recordRateLimit({ |
| 590 | ...key, |
| 591 | lease, |
| 592 | ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }), |
| 593 | }) |
| 594 | : Effect.void, |
| 595 | ), |
| 596 | ), |
| 597 | ), |
| 598 | ); |
| 599 | const wrap = |
| 600 | <Args extends ReadonlyArray<unknown>, A>( |
| 601 | operation: string, |
| 602 | call: (...args: Args) => Effect.Effect<A, PullRequestProviderError>, |
| 603 | allowPaused = false, |
| 604 | ) => |
| 605 | (...args: Args) => |
| 606 | protect(operation, call(...args), allowPaused); |
| 607 | const interactive = <Args extends ReadonlyArray<unknown>, A>( |
| 608 | operation: string, |
| 609 | call: (...args: Args) => Effect.Effect<A, PullRequestProviderError>, |
| 610 | ) => wrap(operation, call, true); |
| 611 | |
| 612 | const wrapped = { |
| 613 | kind: api.kind, |
| 614 | capabilities: api.capabilities, |
| 615 | ...(api.mergeMessageRewrite === undefined |
| 616 | ? {} |
| 617 | : { mergeMessageRewrite: api.mergeMessageRewrite }), |
| 618 | ...(api.repositoryKey === undefined ? {} : { repositoryKey: api.repositoryKey }), |
| 619 | // Refused during a pause like any other read, except for the caller that asks for the |
| 620 | // bypass: a lookup that failed is not held, so letting every background read through would |
| 621 | // spawn this host's CLI on each of them and re-extend the pause it was already in. |
| 622 | getViewer: |
| 623 | options?.viewerAllowsPause === true |
| 624 | ? interactive("getViewer", api.getViewer) |
| 625 | : wrap("getViewer", api.getViewer), |
| 626 | ...(api.getRoutingIdentity === undefined ? {} : { getRoutingIdentity: api.getRoutingIdentity }), |
| 627 | ...(api.withVerifiedCredential === undefined |
| 628 | ? {} |
| 629 | : { withVerifiedCredential: api.withVerifiedCredential }), |
| 630 | listChangeRequests: wrap("listChangeRequests", api.listChangeRequests), |
| 631 | ...(api.listChangeRequestsAcross === undefined |
| 632 | ? {} |
| 633 | : { |
| 634 | listChangeRequestsAcross: wrap("listChangeRequestsAcross", api.listChangeRequestsAcross), |
| 635 | }), |
| 636 | ...(api.listChangeRequestStats === undefined |
| 637 | ? {} |
| 638 | : { |
| 639 | listChangeRequestStats: wrap("listChangeRequestStats", api.listChangeRequestStats), |
| 640 | }), |
| 641 | getChangeRequest: wrap("getChangeRequest", api.getChangeRequest), |
| 642 | ...(api.getChangeRequestPreview === undefined |
| 643 | ? {} |
| 644 | : { getChangeRequestPreview: wrap("getChangeRequestPreview", api.getChangeRequestPreview) }), |
| 645 | ...(api.getChangeRequestChecks === undefined |
| 646 | ? {} |
| 647 | : { getChangeRequestChecks: wrap("getChangeRequestChecks", api.getChangeRequestChecks) }), |
| 648 | ...(api.getChangeRequestSummary === undefined |
| 649 | ? {} |
| 650 | : { |
| 651 | getChangeRequestSummary: wrap("getChangeRequestSummary", api.getChangeRequestSummary), |
| 652 | }), |
| 653 | ...(api.getChangeRequestStack === undefined |
| 654 | ? {} |
| 655 | : { getChangeRequestStack: wrap("getChangeRequestStack", api.getChangeRequestStack) }), |
| 656 | ...(api.getChangeRequestWatchFingerprint === undefined |
| 657 | ? {} |
| 658 | : { |
| 659 | getChangeRequestWatchFingerprint: wrap( |
| 660 | "getChangeRequestWatchFingerprint", |
| 661 | api.getChangeRequestWatchFingerprint, |
| 662 | ), |
| 663 | }), |
| 664 | getChangeRequestActivity: wrap("getChangeRequestActivity", api.getChangeRequestActivity), |
| 665 | ...(api.getReviewThreadComments === undefined |
| 666 | ? {} |
| 667 | : { |
| 668 | getReviewThreadComments: wrap("getReviewThreadComments", api.getReviewThreadComments), |
| 669 | }), |
| 670 | getViewerPermissions: interactive("getViewerPermissions", api.getViewerPermissions), |
| 671 | getDiff: wrap("getDiff", api.getDiff), |
| 672 | ...(api.getDiffFileContents === undefined |
| 673 | ? {} |
| 674 | : { getDiffFileContents: wrap("getDiffFileContents", api.getDiffFileContents) }), |
| 675 | ...(api.getFilesViewed === undefined |
| 676 | ? {} |
| 677 | : { getFilesViewed: wrap("getFilesViewed", api.getFilesViewed) }), |
| 678 | ...(api.setFilesViewed === undefined |
| 679 | ? {} |
| 680 | : { setFilesViewed: interactive("setFilesViewed", api.setFilesViewed) }), |
| 681 | ...(api.getFileRevisions === undefined |
| 682 | ? {} |
| 683 | : { getFileRevisions: wrap("getFileRevisions", api.getFileRevisions) }), |
| 684 | runAction: interactive("runAction", api.runAction), |
| 685 | ...(api.updateChangeRequest === undefined |
| 686 | ? {} |
| 687 | : { |
| 688 | updateChangeRequest: interactive("updateChangeRequest", api.updateChangeRequest), |
| 689 | }), |
| 690 | comment: interactive("comment", api.comment), |
| 691 | ...(api.updateComment === undefined |
| 692 | ? {} |
| 693 | : { updateComment: interactive("updateComment", api.updateComment) }), |
| 694 | submitReview: interactive("submitReview", api.submitReview), |
| 695 | listReviewerCandidates: interactive("listReviewerCandidates", api.listReviewerCandidates), |
| 696 | setReviewerRequest: interactive("setReviewerRequest", api.setReviewerRequest), |
| 697 | ...(api.listLabelCandidates === undefined |
| 698 | ? {} |
| 699 | : { listLabelCandidates: interactive("listLabelCandidates", api.listLabelCandidates) }), |
| 700 | ...(api.setLabels === undefined ? {} : { setLabels: interactive("setLabels", api.setLabels) }), |
| 701 | replyToThread: interactive("replyToThread", api.replyToThread), |
| 702 | setReaction: interactive("setReaction", api.setReaction), |
| 703 | setThreadResolution: interactive("setThreadResolution", api.setThreadResolution), |
| 704 | }; |
| 705 | // Optional provider methods must be forwarded too; returning the interface alone permits omissions. |
| 706 | return wrapped satisfies PullRequestProviderApi & |
| 707 | Record<Exclude<keyof PullRequestProviderApi, keyof typeof wrapped>, never>; |
| 708 | } |
| 709 | |
| 710 | // Capture before the provider read so a slow response keeps its original freshness through caches. |
| 711 | const observeRead = Effect.fnUntraced(function* <A, E, R>(read: Effect.Effect<A, E, R>) { |
| 712 | const observedAt = yield* Clock.currentTimeMillis; |
| 713 | return { value: yield* read, observedAt }; |
| 714 | }); |
| 715 | |
| 716 | export const make = Effect.gen(function* () { |
| 717 | const mergedPullRequests = yield* PubSub.sliding<PullRequestMergeEvent>(64); |
| 718 | const stateChanges = yield* PubSub.sliding<ThreadPullRequestKey>(64); |
| 719 | const pullRequestRefreshes = yield* SubscriptionRef.make(0); |
| 720 | const registry = yield* PullRequestProviderRegistry.PullRequestProviderRegistry; |
| 721 | const mergebot = yield* MergebotQueue.MergebotQueue; |
| 722 | const serverSettings = yield* ServerSettings.ServerSettingsService; |
| 723 | const projects = yield* ProjectService.ProjectService; |
| 724 | const repositoryIdentities = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; |
| 725 | const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; |
| 726 | const rateLimits = yield* SourceControlRateLimit.SourceControlRateLimit; |
| 727 | const filesViewedStore = yield* PullRequestFilesViewed.PullRequestFilesViewedRepository; |
| 728 | const readCache = yield* PullRequestReadCache.PullRequestReadCache; |
| 729 | |
| 730 | const refineUnknownProjectKinds = ( |
| 731 | projects: ReadonlyArray<OrchestrationProjectShell>, |
| 732 | filter: Pick<PullRequestListInput, "projectId" | "host">, |
| 733 | ) => { |
| 734 | type RefinementCandidate = { |
| 735 | readonly project: OrchestrationProjectShell; |
| 736 | readonly provider: SourceControlProviderInfo; |
| 737 | readonly remoteName: string; |
| 738 | readonly remoteUrl: string; |
| 739 | }; |
| 740 | const refinements = new Map<string, RefinementCandidate[]>(); |
| 741 | for (const project of projects) { |
| 742 | if (filter.projectId !== undefined && project.id !== filter.projectId) continue; |
| 743 | const identity = project.repositoryIdentity; |
| 744 | if ( |
| 745 | (identity?.provider !== "unknown" && |
| 746 | !(identity?.provider === "forgejo" && isSshRemoteUrl(identity.locator.remoteUrl))) || |
| 747 | sourceControlRepositorySelector(project.repositoryIdentity) === null |
| 748 | ) |
| 749 | continue; |
| 750 | const host = pullRequestHostOf(identity, SourceControlProviderKind.make("unknown")); |
| 751 | // A legacy identity has no canonical host until its provider is refined, so it must reach |
| 752 | // the refinement before a host filter can decide whether it belongs in the result. |
| 753 | if ( |
| 754 | filter.host !== undefined && |
| 755 | host !== "unknown" && |
| 756 | host !== filter.host.toLowerCase() && |
| 757 | pullRequestHostOf(identity, SourceControlProviderKind.make("forgejo")) !== |
| 758 | filter.host.toLowerCase() && |
| 759 | !isSshRemoteUrl(identity.locator.remoteUrl) |
| 760 | ) { |
| 761 | continue; |
| 762 | } |
| 763 | const { remoteName, remoteUrl } = identity.locator; |
| 764 | const provider = detectSourceControlProviderFromRemoteUrl(remoteUrl); |
| 765 | if (provider !== null) { |
| 766 | const candidates = refinements.get(provider.baseUrl); |
| 767 | const candidate = { project, provider, remoteName, remoteUrl }; |
| 768 | if (candidates === undefined) refinements.set(provider.baseUrl, [candidate]); |
| 769 | else candidates.push(candidate); |
| 770 | } |
| 771 | } |
| 772 | |
| 773 | return Effect.forEach( |
| 774 | refinements, |
| 775 | ([baseUrl, candidates]) => |
| 776 | Effect.firstSuccessOf( |
| 777 | candidates.map(({ project, provider, remoteName, remoteUrl }) => |
| 778 | Effect.suspend(() => |
| 779 | sourceControlProviders.resolveHandle({ |
| 780 | cwd: project.workspaceRoot, |
| 781 | context: { |
| 782 | provider: |
| 783 | provider.kind === "forgejo" |
| 784 | ? { ...provider, kind: SourceControlProviderKind.make("unknown") } |
| 785 | : provider, |
| 786 | remoteName, |
| 787 | remoteUrl, |
| 788 | ...(filter.host !== undefined && isSshRemoteUrl(remoteUrl) |
| 789 | ? { requestedHost: filter.host } |
| 790 | : {}), |
| 791 | }, |
| 792 | }), |
| 793 | ).pipe( |
| 794 | Effect.flatMap((handle) => { |
| 795 | const refined = handle.context?.provider; |
| 796 | return refined === undefined || refined.kind === "unknown" |
| 797 | ? Effect.fail(undefined) |
| 798 | : Effect.succeed(refined); |
| 799 | }), |
| 800 | ), |
| 801 | ), |
| 802 | ).pipe( |
| 803 | Effect.map((provider) => [baseUrl, provider] as const), |
| 804 | Effect.orElseSucceed(() => [baseUrl, null] as const), |
| 805 | ), |
| 806 | { concurrency: REPOSITORY_CONCURRENCY }, |
| 807 | ).pipe(Effect.map((resolved) => new Map(resolved))); |
| 808 | }; |
| 809 | |
| 810 | const listWorkspaceProjects = ( |
| 811 | filter: Pick<PullRequestListInput, "projectId" | "projectIds" | "host">, |
| 812 | ): Effect.Effect<WorkspaceProjects, PullRequestError> => |
| 813 | (filter.projectId === undefined |
| 814 | ? projects.listShells( |
| 815 | filter.projectIds === undefined ? undefined : { projectIds: filter.projectIds }, |
| 816 | ) |
| 817 | : projects.getShell(filter.projectId).pipe(Effect.map(Option.toArray)) |
| 818 | ).pipe( |
| 819 | Effect.mapError( |
| 820 | (error) => |
| 821 | new PullRequestOperationError({ |
| 822 | operation: "listProjects", |
| 823 | detail: "The project list could not be read.", |
| 824 | cause: error, |
| 825 | }), |
| 826 | ), |
| 827 | Effect.flatMap((projects) => |
| 828 | Effect.forEach( |
| 829 | projects, |
| 830 | (project) => |
| 831 | project.repositoryIdentity != null |
| 832 | ? Effect.succeed(project) |
| 833 | : repositoryIdentities |
| 834 | .resolve(project.workspaceRoot) |
| 835 | .pipe(Effect.map((repositoryIdentity) => ({ ...project, repositoryIdentity }))), |
| 836 | { concurrency: REPOSITORY_CONCURRENCY }, |
| 837 | ), |
| 838 | ), |
| 839 | Effect.flatMap((projects) => |
| 840 | refineUnknownProjectKinds(projects, filter).pipe( |
| 841 | Effect.map((refinedProviders) => ({ refinedProviders, projects })), |
| 842 | ), |
| 843 | ), |
| 844 | Effect.map(({ refinedProviders, projects }) => { |
| 845 | const supported: SupportedProject[] = []; |
| 846 | const unimplemented = new Map< |
| 847 | string, |
| 848 | { kind: SourceControlProviderKind; projectCount: number } |
| 849 | >(); |
| 850 | const viewerRoots = new Map<string, string[]>(); |
| 851 | const seen = new Set<string>(); |
| 852 | for (const project of projects) { |
| 853 | if (filter.projectId !== undefined && project.id !== filter.projectId) continue; |
| 854 | if (filter.projectIds !== undefined && !filter.projectIds.includes(project.id)) continue; |
| 855 | const identity = project.repositoryIdentity; |
| 856 | let kind = identity?.provider as SourceControlProviderKind | undefined; |
| 857 | const repository = sourceControlRepositorySelector(project.repositoryIdentity); |
| 858 | if (!identity || kind === undefined || repository === null) continue; |
| 859 | // Worktrees of one repository are separate projects; reading the remote once keeps |
| 860 | // the page from repeating every change request per local checkout. The host is part |
| 861 | // of the key, so the same `owner/repo` on two hosts stays two repositories. |
| 862 | let refinedProvider: SourceControlProviderInfo | null | undefined; |
| 863 | if ( |
| 864 | kind === "unknown" || |
| 865 | (kind === "forgejo" && isSshRemoteUrl(identity.locator.remoteUrl)) |
| 866 | ) { |
| 867 | const provider = detectSourceControlProviderFromRemoteUrl(identity.locator.remoteUrl); |
| 868 | refinedProvider = provider === null ? null : refinedProviders.get(provider.baseUrl); |
| 869 | kind = refinedProvider?.kind ?? kind; |
| 870 | } |
| 871 | const host = |
| 872 | refinedProvider?.kind === "forgejo" |
| 873 | ? new URL(refinedProvider.baseUrl).host.toLowerCase() |
| 874 | : pullRequestHostOf(identity, kind); |
| 875 | if (filter.host !== undefined && host !== filter.host.toLowerCase()) { |
| 876 | continue; |
| 877 | } |
| 878 | const api = registry.get(kind); |
| 879 | // Recorded before the de-duplication below, so the viewer lookup keeps the alternates |
| 880 | // the listing is about to drop. |
| 881 | if (api !== null) { |
| 882 | const roots = viewerRoots.get(host); |
| 883 | if (roots === undefined) viewerRoots.set(host, [project.workspaceRoot]); |
| 884 | else if (!roots.includes(project.workspaceRoot)) roots.push(project.workspaceRoot); |
| 885 | } |
| 886 | const repositoryKey = |
| 887 | api?.repositoryKey?.({ canonicalKey: identity.canonicalKey }) ?? null; |
| 888 | const key = listCursorKey(host, repositoryKey ?? repository); |
| 889 | if (seen.has(key)) continue; |
| 890 | seen.add(key); |
| 891 | if (api === null) { |
| 892 | const counted = unimplemented.get(host); |
| 893 | if (counted === undefined) unimplemented.set(host, { kind, projectCount: 1 }); |
| 894 | else counted.projectCount += 1; |
| 895 | continue; |
| 896 | } |
| 897 | supported.push({ |
| 898 | cursorKey: key, |
| 899 | project, |
| 900 | api: withRateLimitBackoff(api, host, rateLimits), |
| 901 | repository, |
| 902 | host, |
| 903 | remote: repositoryKey ?? normalizeGitRemoteUrl(`https://${host}/${repository}`), |
| 904 | repositoryKey: |
| 905 | repositoryKey === null ? null : canonicalRepositoryKey(repositoryKey.toLowerCase()), |
| 906 | }); |
| 907 | } |
| 908 | return { supported, unimplemented, viewerRoots }; |
| 909 | }), |
| 910 | ); |
| 911 | |
| 912 | /** |
| 913 | * The project whose checkout and credentials serve a reference. The project's own |
| 914 | * repository is the default; a reference that names a `host` may instead point at any |
| 915 | * repository on that host. Prefer its own checkout; providers with explicit repository |
| 916 | * targeting can fall back to another checkout on the host. Providers with explicit host |
| 917 | * targeting can also use the selected project's directory for supported public hosts. |
| 918 | * Azure derives its organization from the checkout, so it requires a matching repository. |
| 919 | */ |
| 920 | const requireProject = (ref: PullRequestRef): Effect.Effect<SupportedProject, PullRequestError> => |
| 921 | listWorkspaceProjects({ projectId: ref.projectId }).pipe( |
| 922 | Effect.flatMap(({ supported }): Effect.Effect<SupportedProject, PullRequestError> => { |
| 923 | const own = supported[0]; |
| 924 | const repository = ref.repository.trim(); |
| 925 | const host = ref.host?.trim().toLowerCase(); |
| 926 | if (own !== undefined && own.repository.toLowerCase() === repository.toLowerCase()) { |
| 927 | // Hostless references only ever meant the project's own repository, and a hosted one |
| 928 | // naming it still is; either way the project serves itself. |
| 929 | if (host === undefined || host === own.host) return Effect.succeed(own); |
| 930 | } |
| 931 | if (host === undefined) { |
| 932 | if (own === undefined) { |
| 933 | return Effect.fail(new PullRequestUnavailableError({ reason: "provider-unsupported" })); |
| 934 | } |
| 935 | // The repository travels through the client, so it is checked against the project's |
| 936 | // own remote rather than being handed to a provider verbatim. |
| 937 | return Effect.fail( |
| 938 | new PullRequestOperationError({ |
| 939 | operation: "resolveRepository", |
| 940 | detail: "The change request does not belong to the selected project.", |
| 941 | }), |
| 942 | ); |
| 943 | } |
| 944 | const repositoryKey = canonicalRepositoryKey(`${host}/${repository}`.toLowerCase()); |
| 945 | // Azure SSH and legacy clone hosts differ from the browser URL's host. Compare |
| 946 | // the complete repository identity before narrowing those checkouts by host. |
| 947 | return listWorkspaceProjects( |
| 948 | repositoryKey.startsWith("dev.azure.com/") ? {} : { host }, |
| 949 | ).pipe( |
| 950 | Effect.flatMap(({ supported }) => { |
| 951 | const onHost = supported.filter((candidate) => candidate.host === host); |
| 952 | const route = |
| 953 | supported.find( |
| 954 | (candidate) => |
| 955 | candidate.repositoryKey !== null && candidate.repositoryKey === repositoryKey, |
| 956 | ) ?? |
| 957 | onHost.find( |
| 958 | (candidate) => |
| 959 | candidate.repositoryKey === null && |
| 960 | candidate.repository.toLowerCase() === repository.toLowerCase(), |
| 961 | ) ?? |
| 962 | onHost.find((candidate) => candidate.repositoryKey === null); |
| 963 | if (route === undefined) { |
| 964 | return Effect.gen(function* () { |
| 965 | // These providers target the repository explicitly and authenticate by host; |
| 966 | // a linked PR in a non-repository project still needs its snapshot refreshed. |
| 967 | // GitLab still selects its host from the checkout or CLI configuration. |
| 968 | const provider = ["github.com", "bitbucket.org", "codeberg.org"].includes(host) |
| 969 | ? detectSourceControlProviderFromRemoteUrl(`https://${host}/${repository}.git`) |
| 970 | : null; |
| 971 | const api = provider === null ? null : registry.get(provider.kind); |
| 972 | if (api !== null) { |
| 973 | const selected = yield* projects.getShell(ref.projectId).pipe( |
| 974 | Effect.mapError( |
| 975 | (cause) => |
| 976 | new PullRequestOperationError({ |
| 977 | operation: "resolveRepository", |
| 978 | detail: "The selected project could not be read.", |
| 979 | cause, |
| 980 | }), |
| 981 | ), |
| 982 | ); |
| 983 | if (Option.isSome(selected)) { |
| 984 | return { |
| 985 | cursorKey: listCursorKey(host, repository), |
| 986 | project: selected.value, |
| 987 | api: withRateLimitBackoff(api, host, rateLimits), |
| 988 | repository, |
| 989 | host, |
| 990 | remote: normalizeGitRemoteUrl(`https://${host}/${repository}`), |
| 991 | repositoryKey: null, |
| 992 | }; |
| 993 | } |
| 994 | } |
| 995 | return yield* new PullRequestUnavailableError({ reason: "provider-unsupported" }); |
| 996 | }); |
| 997 | } |
| 998 | return Effect.succeed( |
| 999 | route.repositoryKey !== null || |
| 1000 | route.repository.toLowerCase() === repository.toLowerCase() |
| 1001 | ? route |
| 1002 | : { |
| 1003 | ...route, |
| 1004 | repository, |
| 1005 | remote: normalizeGitRemoteUrl(`https://${host}/${repository}`), |
| 1006 | }, |
| 1007 | ); |
| 1008 | }), |
| 1009 | ); |
| 1010 | }), |
| 1011 | ); |
| 1012 | |
| 1013 | const isWorkProject = (project: SupportedProject) => |
| 1014 | project.api.kind === "github" && |
| 1015 | project.host === "github.com" && |
| 1016 | isMergebotRepository(mergebot.configuration ?? undefined, project.repository); |
| 1017 | |
| 1018 | const canonicalRef = Effect.fn("PullRequestService.canonicalRef")(function* < |
| 1019 | I extends PullRequestRef, |
| 1020 | >(input: I) { |
| 1021 | const project = yield* requireProject(input); |
| 1022 | return { |
| 1023 | ...input, |
| 1024 | projectId: project.project.id, |
| 1025 | host: project.host, |
| 1026 | repository: project.repository, |
| 1027 | }; |
| 1028 | }); |
| 1029 | |
| 1030 | /** |
| 1031 | * What the signed-in account may do with this change request, asked of the host itself. Every |
| 1032 | * write goes through it: the page hides what a viewer may not do, and a request that arrived |
| 1033 | * without passing through the page — or after the access behind it was withdrawn — must not be |
| 1034 | * handed to a provider on the client's word. Read freshly for that reason, rather than taken |
| 1035 | * from whatever the detail said when the page loaded. |
| 1036 | */ |
| 1037 | const viewerPermissionsOf = ( |
| 1038 | project: SupportedProject, |
| 1039 | ref: PullRequestRef, |
| 1040 | operation: string, |
| 1041 | includeUpdateBranch = false, |
| 1042 | ) => |
| 1043 | project.api |
| 1044 | .getViewerPermissions({ |
| 1045 | cwd: project.project.workspaceRoot, |
| 1046 | repository: project.repository, |
| 1047 | host: project.host, |
| 1048 | number: ref.number, |
| 1049 | includeUpdateBranch, |
| 1050 | }) |
| 1051 | .pipe(Effect.mapError(toPullRequestError(operation))); |
| 1052 | |
| 1053 | /** |
| 1054 | * The cursors the page sent back, read once before any host is asked anything. Null where the |
| 1055 | * page sent none, which is the listing read from its newest row. |
| 1056 | */ |
| 1057 | const decodeCursors = ( |
| 1058 | cursors: PullRequestListInput["cursors"], |
| 1059 | ): Effect.Effect<ReadonlyMap<string, ListCursor> | null, PullRequestError> => { |
| 1060 | if (cursors === undefined) return Effect.succeed(null); |
| 1061 | const decoded = new Map<string, ListCursor>(); |
| 1062 | for (const [key, raw] of Object.entries(cursors)) { |
| 1063 | const cursor = parseListCursor(raw); |
| 1064 | if (cursor === null) { |
| 1065 | return Effect.fail( |
| 1066 | new PullRequestOperationError({ |
| 1067 | operation: "list", |
| 1068 | detail: "The list could not be carried on from where it left off.", |
| 1069 | }), |
| 1070 | ); |
| 1071 | } |
| 1072 | decoded.set(key, cursor); |
| 1073 | } |
| 1074 | return Effect.succeed(decoded); |
| 1075 | }; |
| 1076 | |
| 1077 | /** |
| 1078 | * One viewer lookup per host, tried across that host's workspaces so a single broken checkout |
| 1079 | * cannot hide every healthy repository on it. Per host and not per provider kind: two GitHub |
| 1080 | * hosts are two accounts, and the wrong login would misattribute every review request. |
| 1081 | * |
| 1082 | * Its failure doubles as the answer to "is this host set up", which is what the provider |
| 1083 | * switcher shows. |
| 1084 | */ |
| 1085 | type ResolvedViewer = { |
| 1086 | readonly host: string; |
| 1087 | readonly kind: SourceControlProviderKind; |
| 1088 | readonly viewer: string | null; |
| 1089 | readonly error: PullRequestProviderError | null; |
| 1090 | }; |
| 1091 | // Who is signed in moves on the timescale of `gh auth login`, not of a page visit, yet every |
| 1092 | // list read was asking each host's CLI again — a subprocess and a network round trip per host |
| 1093 | // per read, three reads per page. Only a success is believed for a while: a failure is the |
| 1094 | // "is this host set up" answer the provider switcher shows, and holding it would keep saying |
| 1095 | // signed-out after the reader has signed in. |
| 1096 | const viewersByHost = new Map<string, { readonly at: number; readonly result: ResolvedViewer }>(); |
| 1097 | const viewerFlights = yield* Cache.makeWith( |
| 1098 | (key: string): Effect.Effect<ResolvedViewer> => { |
| 1099 | const [host, kind, roots] = JSON.parse(key) as [ |
| 1100 | string, |
| 1101 | SourceControlProviderKind, |
| 1102 | ReadonlyArray<string>, |
| 1103 | ]; |
| 1104 | const registered = registry.get(kind); |
| 1105 | if (registered === null) { |
| 1106 | return Effect.die(new Error(`Missing pull request provider: ${kind}`)); |
| 1107 | } |
| 1108 | // Let through a pause: a press the reader is waiting on has to be answered, and the |
| 1109 | // callers that are not that press are held back at the gate below instead, before they |
| 1110 | // reach this lookup at all. |
| 1111 | const api = withRateLimitBackoff(registered, host, rateLimits, { viewerAllowsPause: true }); |
| 1112 | return Effect.firstSuccessOf(roots.map((cwd) => api.getViewer({ cwd, host }))).pipe( |
| 1113 | Effect.map((viewer) => ({ |
| 1114 | host, |
| 1115 | kind, |
| 1116 | viewer: viewer as string | null, |
| 1117 | error: null as PullRequestProviderError | null, |
| 1118 | })), |
| 1119 | Effect.tap((result) => |
| 1120 | Effect.map(Clock.currentTimeMillis, (at) => viewersByHost.set(host, { at, result })), |
| 1121 | ), |
| 1122 | Effect.catch((error) => |
| 1123 | Effect.succeed({ |
| 1124 | host, |
| 1125 | kind, |
| 1126 | viewer: null, |
| 1127 | error, |
| 1128 | }), |
| 1129 | ), |
| 1130 | ); |
| 1131 | }, |
| 1132 | { |
| 1133 | capacity: VIEWER_CACHE_CAPACITY, |
| 1134 | // The host-wide success map holds the real ten-minute answer. This short entry exists to |
| 1135 | // keep simultaneous cold page reads on one in-flight lookup; failures remain retryable. |
| 1136 | timeToLive: (exit) => |
| 1137 | Exit.isSuccess(exit) && exit.value.error === null ? Duration.seconds(1) : Duration.zero, |
| 1138 | }, |
| 1139 | ); |
| 1140 | |
| 1141 | const resolveViewers = ( |
| 1142 | projects: ReadonlyArray<SupportedProject>, |
| 1143 | viewerRoots: WorkspaceProjects["viewerRoots"], |
| 1144 | options?: { readonly allowPaused: boolean }, |
| 1145 | ) => |
| 1146 | Effect.forEach( |
| 1147 | [...new Set(projects.map(({ host }) => host))], |
| 1148 | (host) => |
| 1149 | Effect.flatMap(Clock.currentTimeMillis, (now): Effect.Effect<ResolvedViewer> => { |
| 1150 | const held = viewersByHost.get(host); |
| 1151 | if (held !== undefined && now - held.at <= Duration.toMillis(VIEWER_CACHE_TTL)) { |
| 1152 | return Effect.succeed(held.result); |
| 1153 | } |
| 1154 | const forHost = projects.filter((project) => project.host === host); |
| 1155 | const api = forHost[0]!.api; |
| 1156 | // Every checkout on the host, not just the ones that survived de-duplication: one |
| 1157 | // unreadable worktree would otherwise report the whole host as signed out. |
| 1158 | const roots = |
| 1159 | viewerRoots.get(host) ?? forHost.map(({ project }) => project.workspaceRoot); |
| 1160 | // Nothing about the caller is in the key. A listing and a press for the same host and |
| 1161 | // roots are the same lookup, and putting them on separate flights would spawn two of |
| 1162 | // this host's CLIs on a cold page load, which is the coalescing this exists for. |
| 1163 | const key = JSON.stringify([host, api.kind, [...new Set(roots)].sort()]); |
| 1164 | if (options?.allowPaused === true) return Cache.get(viewerFlights, key); |
| 1165 | // The pause is checked here rather than inside the lookup, so that it holds back the |
| 1166 | // callers nobody is waiting on without splitting the flight they share with a press. |
| 1167 | // A failed lookup is held nowhere, so letting a background read through would spawn |
| 1168 | // this host's CLI on every refresh for as long as the pause lasted, and re-extend it. |
| 1169 | return rateLimits.check({ provider: api.kind, host }).pipe( |
| 1170 | Effect.flatMap(() => Cache.get(viewerFlights, key)), |
| 1171 | Effect.catch((error) => |
| 1172 | Effect.succeed<ResolvedViewer>({ |
| 1173 | host, |
| 1174 | kind: api.kind, |
| 1175 | viewer: null, |
| 1176 | error: new PullRequestProviderError({ |
| 1177 | provider: api.kind, |
| 1178 | operation: "getViewer", |
| 1179 | reason: "rate-limited", |
| 1180 | detail: error.detail, |
| 1181 | retryAt: error.retryAt, |
| 1182 | cause: error, |
| 1183 | }), |
| 1184 | }), |
| 1185 | ), |
| 1186 | ); |
| 1187 | }), |
| 1188 | { concurrency: REPOSITORY_CONCURRENCY }, |
| 1189 | ); |
| 1190 | |
| 1191 | /** |
| 1192 | * The narrowings a row can be judged by from its own fields, applied here rather than trusted |
| 1193 | * to the host. Only GitHub is asked to narrow a listing for itself; every other provider |
| 1194 | * answers unnarrowed, and without this pass a draft filter or a label filter would be sent, |
| 1195 | * accepted and quietly ignored. Idempotent for the hosts that did narrow. |
| 1196 | * |
| 1197 | * `checks` is absent because no listed row carries its check state: that one filter is the |
| 1198 | * host's alone, and a row nobody narrowed stays rather than being guessed at. |
| 1199 | */ |
| 1200 | const matchesRowFilters = ( |
| 1201 | item: ProviderChangeRequest, |
| 1202 | filters: PullRequestListFilters | undefined, |
| 1203 | viewer: string, |
| 1204 | ): boolean => { |
| 1205 | if (filters === undefined) return true; |
| 1206 | const labels = new Set(item.labels.map((label) => label.name.trim().toLowerCase())); |
| 1207 | const holds = (label: string) => labels.has(label.trim().toLowerCase()); |
| 1208 | return ( |
| 1209 | (filters.draft === undefined || item.isDraft === (filters.draft === "only")) && |
| 1210 | // Judged on the provider row rather than the entry, because the two absences mean |
| 1211 | // different things and the entry keeps only one of them: `null` is a host that summarises |
| 1212 | // its reviews saying there is no decision yet, which is what "none" asks for, while |
| 1213 | // `undefined` is a host that does not summarise at all — an unjudgeable row, left alone |
| 1214 | // the way an unreadable check state is. |
| 1215 | (filters.review === undefined || |
| 1216 | item.reviewDecision === undefined || |
| 1217 | (filters.review === "none" |
| 1218 | ? item.reviewDecision === null |
| 1219 | : item.reviewDecision === filters.review)) && |
| 1220 | (filters.labels === undefined || filters.labels.every((group) => group.some(holds))) && |
| 1221 | (filters.excludedLabels === undefined || !filters.excludedLabels.some(holds)) && |
| 1222 | (filters.author === undefined || |
| 1223 | item.author?.login.toLowerCase() === |
| 1224 | resolvePullRequestAuthorFilter(filters.author, viewer).toLowerCase()) |
| 1225 | ); |
| 1226 | }; |
| 1227 | |
| 1228 | const toEntry = (input: { |
| 1229 | readonly project: SupportedProject; |
| 1230 | readonly item: ProviderChangeRequest; |
| 1231 | readonly viewer: string; |
| 1232 | readonly observedAt: number; |
| 1233 | }): PullRequestListEntry => { |
| 1234 | const viewer = input.viewer.toLowerCase(); |
| 1235 | return { |
| 1236 | ...(input.item.stack === undefined ? {} : { stack: input.item.stack }), |
| 1237 | provider: input.project.api.kind, |
| 1238 | host: input.project.host, |
| 1239 | projectId: input.project.project.id, |
| 1240 | projectTitle: input.project.project.title, |
| 1241 | repository: input.project.repository, |
| 1242 | number: input.item.number, |
| 1243 | title: input.item.title, |
| 1244 | url: input.item.url, |
| 1245 | author: input.item.author, |
| 1246 | headBranch: input.item.headBranch, |
| 1247 | baseBranch: input.item.baseBranch, |
| 1248 | state: input.item.state, |
| 1249 | isDraft: input.item.isDraft, |
| 1250 | mergeability: input.item.mergeability, |
| 1251 | additions: input.item.additions, |
| 1252 | deletions: input.item.deletions, |
| 1253 | createdAt: input.item.createdAt, |
| 1254 | updatedAt: input.item.updatedAt, |
| 1255 | observedAt: input.observedAt, |
| 1256 | ...(input.item.checksState === undefined || input.item.checksState === null |
| 1257 | ? {} |
| 1258 | : { checksState: input.item.checksState }), |
| 1259 | viewerReviewRequested: |
| 1260 | input.item.author?.login.toLowerCase() !== viewer && |
| 1261 | input.item.reviewRequestLogins.some((login) => login.toLowerCase() === viewer), |
| 1262 | labels: input.item.labels, |
| 1263 | ...(input.item.reviewDecision === undefined || input.item.reviewDecision === null |
| 1264 | ? {} |
| 1265 | : { reviewDecision: input.item.reviewDecision }), |
| 1266 | }; |
| 1267 | }; |
| 1268 | |
| 1269 | // A repository that has appeared in a host search is known to be indexed there. Empty |
| 1270 | // authored/reviewing searches for that same repository are therefore real empty answers, not |
| 1271 | // a reason to issue the two-command per-repository fallback again. |
| 1272 | const searchVisibleAt = new Map<string, number>(); |
| 1273 | const searchVisibilityKey = (host: string, repository: string) => |
| 1274 | `${host}\n${repository.trim().toLowerCase()}`; |
| 1275 | |
| 1276 | const listUncached: PullRequestService["Service"]["list"] = (input) => |
| 1277 | Effect.gen(function* () { |
| 1278 | const involvement = input.involvement ?? "all"; |
| 1279 | // Refused whole rather than per repository: a cursor is only ever a value this service |
| 1280 | // issued, so one that does not read as one means the page is sending something it made up, |
| 1281 | // and reading part of the listing under that assumption would quietly lose rows. |
| 1282 | const continuation = yield* decodeCursors(input.cursors); |
| 1283 | const { supported, unimplemented, viewerRoots } = yield* listWorkspaceProjects(input); |
| 1284 | const workProjects = supported.filter(isWorkProject); |
| 1285 | const projects = supported.filter((project) => !isWorkProject(project)); |
| 1286 | const workErrors = workProjects.map(({ project }) => ({ |
| 1287 | projectId: project.id, |
| 1288 | projectTitle: project.title, |
| 1289 | message: "Open this repository in Mergebot to browse its pull requests.", |
| 1290 | })); |
| 1291 | const projectCounts = new Map<string, number>(); |
| 1292 | for (const { host } of projects) { |
| 1293 | projectCounts.set(host, (projectCounts.get(host) ?? 0) + 1); |
| 1294 | } |
| 1295 | |
| 1296 | const viewerResults = yield* resolveViewers(projects, viewerRoots); |
| 1297 | const viewers: Record<string, string> = {}; |
| 1298 | for (const result of viewerResults) { |
| 1299 | if (result.viewer !== null) viewers[result.host] = result.viewer; |
| 1300 | } |
| 1301 | |
| 1302 | // One summary per host, which is what the viewer lookup already answers for: two GitHub |
| 1303 | // hosts sign in separately, so collapsing them by kind would report one as the other. |
| 1304 | const providers: ReadonlyArray<PullRequestProviderSummary> = [ |
| 1305 | ...viewerResults.map((result) => { |
| 1306 | const capabilities = projects.find((project) => project.host === result.host)?.api |
| 1307 | .capabilities; |
| 1308 | return { |
| 1309 | host: result.host, |
| 1310 | kind: result.kind, |
| 1311 | searchesOnHost: capabilities?.search ?? false, |
| 1312 | actions: capabilities?.actions ?? [], |
| 1313 | projectCount: projectCounts.get(result.host) ?? 1, |
| 1314 | configured: result.viewer !== null, |
| 1315 | detail: result.error === null ? null : providerDetail(result.error), |
| 1316 | }; |
| 1317 | }), |
| 1318 | ...[...unimplemented].map(([host, { kind, projectCount }]) => ({ |
| 1319 | host, |
| 1320 | kind, |
| 1321 | searchesOnHost: false, |
| 1322 | actions: [], |
| 1323 | projectCount, |
| 1324 | configured: false, |
| 1325 | detail: "This host cannot be browsed here yet.", |
| 1326 | })), |
| 1327 | ]; |
| 1328 | |
| 1329 | // A continued listing reads only the repositories it was asked to carry on with: every |
| 1330 | // other one is already on the page, and reading it again is the whole cost this is here to |
| 1331 | // avoid. The host summaries above stay over the whole workspace, because the switcher they |
| 1332 | // fill is about the workspace rather than about this slice. |
| 1333 | const selected = |
| 1334 | continuation === null |
| 1335 | ? projects |
| 1336 | : projects.filter(({ cursorKey }) => continuation.has(cursorKey)); |
| 1337 | const readable = selected.filter(({ host }) => viewers[host] !== undefined); |
| 1338 | // A host that could not be read still has projects, and they are absent from the list. |
| 1339 | // Reporting them keeps "N repositories were unavailable" honest instead of dropping them. |
| 1340 | const unreadable = selected |
| 1341 | .filter(({ host }) => viewers[host] === undefined) |
| 1342 | .map(({ project, repository }) => ({ |
| 1343 | projectId: project.id, |
| 1344 | projectTitle: project.title, |
| 1345 | message: `${repository} could not be read.`, |
| 1346 | })); |
| 1347 | if (readable.length === 0) { |
| 1348 | // No host this request covers can be read, so it is not a per-project problem. An |
| 1349 | // unusable host is preferred as the reported cause because it names the fix; a host |
| 1350 | // that merely failed reports as a failed operation rather than as a signed-out CLI, |
| 1351 | // which would send the reader to `auth login` over a transient error. |
| 1352 | // |
| 1353 | // Only the hosts this request was actually going to read: a continuation that named |
| 1354 | // nothing has asked for nothing, and a host it never mentioned being signed out is no |
| 1355 | // reason to refuse it. |
| 1356 | const errors = viewerResults.flatMap((result) => |
| 1357 | result.error === null || !selected.some(({ host }) => host === result.host) |
| 1358 | ? [] |
| 1359 | : [result.error], |
| 1360 | ); |
| 1361 | const blocking = errors.find(isProviderUnusable) ?? errors[0]; |
| 1362 | if (blocking) { |
| 1363 | return yield* toPullRequestError("list")(blocking); |
| 1364 | } |
| 1365 | |
| 1366 | return { |
| 1367 | viewers: viewers as PullRequestListResult["viewers"], |
| 1368 | providers, |
| 1369 | entries: [], |
| 1370 | errors: workErrors, |
| 1371 | truncated: false, |
| 1372 | nextCursors: {}, |
| 1373 | }; |
| 1374 | } |
| 1375 | |
| 1376 | const limit = input.limit ?? DEFAULT_REPOSITORY_LIST_LIMIT; |
| 1377 | const cursorOf = (project: SupportedProject): ListCursor | undefined => |
| 1378 | continuation?.get(project.cursorKey); |
| 1379 | |
| 1380 | /** |
| 1381 | * One repository asked on its own. What every host without a search across repositories |
| 1382 | * does, and what a batched read falls back to for a repository it could not answer for. |
| 1383 | */ |
| 1384 | const readRepository = (project: SupportedProject): Effect.Effect<RepositoryBatch> => { |
| 1385 | { |
| 1386 | const viewer = viewers[project.host]!; |
| 1387 | const key = project.cursorKey; |
| 1388 | const cursor = cursorOf(project); |
| 1389 | return project.api |
| 1390 | .listChangeRequests({ |
| 1391 | cwd: project.project.workspaceRoot, |
| 1392 | repository: project.repository, |
| 1393 | host: project.host, |
| 1394 | state: input.state, |
| 1395 | involvement, |
| 1396 | viewer, |
| 1397 | limit, |
| 1398 | // Each host matches this its own way, and one that cannot match text at all |
| 1399 | // answers unnarrowed rather than failing. |
| 1400 | query: input.query, |
| 1401 | filters: input.filters, |
| 1402 | // Only the two fields a host can act on: which rows have already been sent at the |
| 1403 | // boundary instant is this service's business, not a provider's. |
| 1404 | ...(cursor === undefined |
| 1405 | ? {} |
| 1406 | : { |
| 1407 | cursor: { updatedBefore: cursor.updatedBefore, delivered: cursor.delivered }, |
| 1408 | }), |
| 1409 | }) |
| 1410 | .pipe( |
| 1411 | observeRead, |
| 1412 | Effect.map(({ value: page, observedAt }): RepositoryBatch => { |
| 1413 | // The boundary instant was asked for inclusively, so the rows already sent at it |
| 1414 | // come back with the slice. Dropping them here rather than asking for strictly |
| 1415 | // older is what keeps their neighbours at the same instant from being skipped. |
| 1416 | const items = |
| 1417 | cursor === undefined |
| 1418 | ? page.items |
| 1419 | : page.items.filter( |
| 1420 | (item) => |
| 1421 | item.updatedAt !== cursor.updatedBefore || |
| 1422 | !cursor.seenAt.includes(item.number), |
| 1423 | ); |
| 1424 | return { |
| 1425 | key, |
| 1426 | entries: items |
| 1427 | .filter((item) => matchesRowFilters(item, input.filters, viewer)) |
| 1428 | .map((item) => toEntry({ project, item, viewer, observedAt })), |
| 1429 | errors: [], |
| 1430 | truncated: page.truncated, |
| 1431 | nextCursor: |
| 1432 | page.continues && page.truncated |
| 1433 | ? nextListCursor(cursor, page.items, items, page.cursorAdvance) |
| 1434 | : null, |
| 1435 | }; |
| 1436 | }), |
| 1437 | // One unreachable repository must not blank the page. A host-level failure is |
| 1438 | // already reported through `providers`, so it degrades the same way here. |
| 1439 | Effect.orElseSucceed((): RepositoryBatch => ({ |
| 1440 | key, |
| 1441 | entries: [], |
| 1442 | errors: [ |
| 1443 | { |
| 1444 | projectId: project.project.id, |
| 1445 | projectTitle: project.project.title, |
| 1446 | message: `${project.repository} could not be read.`, |
| 1447 | }, |
| 1448 | ], |
| 1449 | truncated: false, |
| 1450 | nextCursor: null, |
| 1451 | })), |
| 1452 | ); |
| 1453 | } |
| 1454 | }; |
| 1455 | |
| 1456 | /** |
| 1457 | * One host's repositories in one read. The slice is the newest `limit` rows across all of |
| 1458 | * them, so it is split back up by repository here: the page still reports per project, and |
| 1459 | * each repository still carries on from a cursor of its own. |
| 1460 | * |
| 1461 | * A read that fails is read the long way instead. The batch is an optimisation, and a host |
| 1462 | * that could not answer one question about twelve repositories should not report twelve |
| 1463 | * repositories as unreadable before anyone has asked it about them one at a time. |
| 1464 | */ |
| 1465 | const readTogether = ( |
| 1466 | chunk: ReadonlyArray<SupportedProject>, |
| 1467 | ): Effect.Effect<ReadonlyArray<RepositoryBatch>> => { |
| 1468 | const first = chunk[0]!; |
| 1469 | const readAcross = first.api.listChangeRequestsAcross; |
| 1470 | const separately = () => |
| 1471 | Effect.forEach(chunk, readRepository, { concurrency: REPOSITORY_CONCURRENCY }); |
| 1472 | if (readAcross === undefined) return separately(); |
| 1473 | const viewer = viewers[first.host]!; |
| 1474 | const cursor = cursorOf(first); |
| 1475 | return readAcross({ |
| 1476 | cwd: first.project.workspaceRoot, |
| 1477 | host: first.host, |
| 1478 | repositories: chunk.map((project) => project.repository), |
| 1479 | state: input.state, |
| 1480 | involvement, |
| 1481 | viewer, |
| 1482 | limit, |
| 1483 | query: input.query, |
| 1484 | filters: input.filters, |
| 1485 | ...(cursor === undefined |
| 1486 | ? {} |
| 1487 | : { cursor: { updatedBefore: cursor.updatedBefore, delivered: cursor.delivered } }), |
| 1488 | }).pipe( |
| 1489 | observeRead, |
| 1490 | Effect.flatMap(({ value: page, observedAt }) => |
| 1491 | Effect.flatMap(Clock.currentTimeMillis, (now) => { |
| 1492 | const rows = new Map<string, Array<ProviderChangeRequest>>(); |
| 1493 | for (const [key, visibleAt] of searchVisibleAt) { |
| 1494 | if (now - visibleAt > Duration.toMillis(SEARCH_VISIBILITY_TTL)) { |
| 1495 | searchVisibleAt.delete(key); |
| 1496 | } |
| 1497 | } |
| 1498 | for (const item of page.items) { |
| 1499 | const key = item.repository.trim().toLowerCase(); |
| 1500 | const held = rows.get(key); |
| 1501 | if (held === undefined) rows.set(key, [item]); |
| 1502 | else held.push(item); |
| 1503 | searchVisibleAt.set(searchVisibilityKey(first.host, item.repository), now); |
| 1504 | } |
| 1505 | // The oldest row of the whole slice, which is how far every repository in it has now |
| 1506 | // been read — including the ones that contributed nothing to it. |
| 1507 | const boundary = page.items.reduce<string | null>( |
| 1508 | (oldest, item) => |
| 1509 | oldest === null || item.updatedAt < oldest ? item.updatedAt : oldest, |
| 1510 | null, |
| 1511 | ); |
| 1512 | return Effect.forEach( |
| 1513 | chunk, |
| 1514 | (project): Effect.Effect<RepositoryBatch> => { |
| 1515 | const fetched = rows.get(project.repository.trim().toLowerCase()) ?? []; |
| 1516 | // GitHub does not index every repository for search — a renamed one answers for |
| 1517 | // its old name with silence rather than with an error — so a repository the |
| 1518 | // search said nothing at all about is read on its own, once, before it is |
| 1519 | // believed. Only on its first slice: after that it has a boundary to carry on |
| 1520 | // from, and silence past one means the rows are older rather than absent. That |
| 1521 | // keeps a search-invisible repository from disappearing on a busy host, at the |
| 1522 | // price of one request per repository with nothing in the first slice — which |
| 1523 | // run together, and only there. |
| 1524 | const lastVisible = searchVisibleAt.get( |
| 1525 | searchVisibilityKey(project.host, project.repository), |
| 1526 | ); |
| 1527 | const searchIsKnownVisible = |
| 1528 | !page.truncated && |
| 1529 | lastVisible !== undefined && |
| 1530 | now - lastVisible <= Duration.toMillis(SEARCH_VISIBILITY_TTL); |
| 1531 | if ( |
| 1532 | fetched.length === 0 && |
| 1533 | cursorOf(project) === undefined && |
| 1534 | !searchIsKnownVisible |
| 1535 | ) { |
| 1536 | return readRepository(project); |
| 1537 | } |
| 1538 | const cursorHere = cursorOf(project); |
| 1539 | const items = |
| 1540 | cursorHere === undefined |
| 1541 | ? fetched |
| 1542 | : fetched.filter( |
| 1543 | (item) => |
| 1544 | item.updatedAt !== cursorHere.updatedBefore || |
| 1545 | !cursorHere.seenAt.includes(item.number), |
| 1546 | ); |
| 1547 | return Effect.succeed({ |
| 1548 | key: project.cursorKey, |
| 1549 | entries: items |
| 1550 | .filter((item) => matchesRowFilters(item, input.filters, viewer)) |
| 1551 | .map((item) => toEntry({ project, item, viewer, observedAt })), |
| 1552 | errors: [], |
| 1553 | truncated: page.truncated, |
| 1554 | nextCursor: |
| 1555 | page.truncated && boundary !== null |
| 1556 | ? listCursorAt(cursorHere, boundary, fetched, items.length) |
| 1557 | : null, |
| 1558 | }); |
| 1559 | }, |
| 1560 | { concurrency: REPOSITORY_CONCURRENCY }, |
| 1561 | ); |
| 1562 | }), |
| 1563 | ), |
| 1564 | Effect.catch(separately), |
| 1565 | ); |
| 1566 | }; |
| 1567 | |
| 1568 | // A host with a search across repositories is asked once for all of them; everyone else is |
| 1569 | // asked once each. Repositories standing at different points of the same listing are |
| 1570 | // different questions, so they are grouped by the boundary they carry on from. |
| 1571 | const together = new Map<string, Array<SupportedProject>>(); |
| 1572 | const separate: Array<SupportedProject> = []; |
| 1573 | for (const project of readable) { |
| 1574 | if (project.api.listChangeRequestsAcross === undefined) { |
| 1575 | separate.push(project); |
| 1576 | continue; |
| 1577 | } |
| 1578 | const key = `${project.host}\n${cursorOf(project)?.updatedBefore ?? ""}`; |
| 1579 | const group = together.get(key); |
| 1580 | if (group === undefined) together.set(key, [project]); |
| 1581 | else group.push(project); |
| 1582 | } |
| 1583 | const reads: Array<Effect.Effect<ReadonlyArray<RepositoryBatch>>> = separate.map((project) => |
| 1584 | readRepository(project).pipe(Effect.map((batch) => [batch])), |
| 1585 | ); |
| 1586 | for (const group of together.values()) { |
| 1587 | for (let start = 0; start < group.length; start += REPOSITORY_SEARCH_CHUNK) { |
| 1588 | reads.push(readTogether(group.slice(start, start + REPOSITORY_SEARCH_CHUNK))); |
| 1589 | } |
| 1590 | } |
| 1591 | const batches = (yield* Effect.all(reads, { concurrency: REPOSITORY_CONCURRENCY })).flat(); |
| 1592 | |
| 1593 | const nextCursors: Record<string, string> = {}; |
| 1594 | for (const batch of batches) { |
| 1595 | if (batch.nextCursor !== null) nextCursors[batch.key] = batch.nextCursor; |
| 1596 | } |
| 1597 | |
| 1598 | return { |
| 1599 | viewers: viewers as PullRequestListResult["viewers"], |
| 1600 | providers, |
| 1601 | entries: batches |
| 1602 | .flatMap((batch) => batch.entries) |
| 1603 | .toSorted((left, right) => right.updatedAt.localeCompare(left.updatedAt)), |
| 1604 | errors: [...workErrors, ...unreadable, ...batches.flatMap((batch) => batch.errors)], |
| 1605 | truncated: batches.some((batch) => batch.truncated), |
| 1606 | nextCursors, |
| 1607 | }; |
| 1608 | }); |
| 1609 | |
| 1610 | /** |
| 1611 | * Who this project's host says the reader is. Shared with the listing's own lookup — the same |
| 1612 | * ten-minute answer per host — so a page that has already listed anything pays nothing for it, |
| 1613 | * and a host that cannot say leaves it null rather than failing the read it decorates. |
| 1614 | */ |
| 1615 | const viewerOf = (project: SupportedProject): Effect.Effect<string | null> => |
| 1616 | routingCredential.pipe( |
| 1617 | Effect.flatMap((credential) => |
| 1618 | credential !== null |
| 1619 | ? Effect.succeed(credential.viewer) |
| 1620 | : resolveViewers([project], new Map()).pipe( |
| 1621 | Effect.map(([resolved]) => resolved?.viewer ?? null), |
| 1622 | ), |
| 1623 | ), |
| 1624 | ); |
| 1625 | |
| 1626 | const routingIdentity: PullRequestService["Service"]["routingIdentity"] = Effect.fn( |
| 1627 | "PullRequestService.routingIdentity", |
| 1628 | )(function* (input) { |
| 1629 | const host = input.host.toLowerCase(); |
| 1630 | const { supported } = yield* listWorkspaceProjects({ host }); |
| 1631 | const project = supported.find( |
| 1632 | (candidate) => registry.get(candidate.api.kind)?.getRoutingIdentity !== undefined, |
| 1633 | ); |
| 1634 | const api = project === undefined ? null : registry.get(project.api.kind); |
| 1635 | if (project === undefined || api?.getRoutingIdentity === undefined) { |
| 1636 | return yield* new PullRequestUnavailableError({ reason: "provider-unsupported" }); |
| 1637 | } |
| 1638 | const identity = yield* api |
| 1639 | .getRoutingIdentity({ |
| 1640 | cwd: project.project.workspaceRoot, |
| 1641 | host, |
| 1642 | }) |
| 1643 | .pipe(Effect.mapError(toPullRequestError("routeIdentity"))); |
| 1644 | // Only GitHub reports a routing identity, and the contract names it. |
| 1645 | return { ...identity, host, provider: "github" as const }; |
| 1646 | }); |
| 1647 | |
| 1648 | const withRoutingCredential: PullRequestService["Service"]["withRoutingCredential"] = ( |
| 1649 | input, |
| 1650 | operation, |
| 1651 | ) => |
| 1652 | Effect.gen(function* () { |
| 1653 | if (input.expectedAccountId === undefined) return yield* operation; |
| 1654 | const rejected = () => |
| 1655 | new PullRequestOperationError({ |
| 1656 | operation: "routeIdentity", |
| 1657 | detail: "The GitHub account could not be verified before starting the operation.", |
| 1658 | }); |
| 1659 | const project = yield* requireProject(input).pipe(Effect.mapError(rejected)); |
| 1660 | const api = registry.get(project.api.kind); |
| 1661 | if ( |
| 1662 | api?.withVerifiedCredential === undefined || |
| 1663 | input.host?.toLowerCase() !== project.host.toLowerCase() |
| 1664 | ) { |
| 1665 | return yield* rejected(); |
| 1666 | } |
| 1667 | const result = yield* api |
| 1668 | .withVerifiedCredential( |
| 1669 | { cwd: project.project.workspaceRoot, host: project.host }, |
| 1670 | (identity) => |
| 1671 | identity.accountId === input.expectedAccountId |
| 1672 | ? operation.pipe(Effect.provideService(routingCredential, identity), Effect.result) |
| 1673 | : Effect.fail(rejected()), |
| 1674 | ) |
| 1675 | .pipe(Effect.catchTags({ PullRequestProviderError: () => Effect.fail(rejected()) })); |
| 1676 | return yield* Effect.fromResult(result); |
| 1677 | }); |
| 1678 | |
| 1679 | const routing = Effect.fn("PullRequestService.routing")(function* (input: PullRequestRef) { |
| 1680 | const project = yield* requireProject(input); |
| 1681 | const api = registry.get(project.api.kind); |
| 1682 | if (api?.getRoutingIdentity === undefined) { |
| 1683 | return yield* new PullRequestUnavailableError({ reason: "provider-unsupported" }); |
| 1684 | } |
| 1685 | const identity = yield* api |
| 1686 | .getRoutingIdentity({ |
| 1687 | cwd: project.project.workspaceRoot, |
| 1688 | host: project.host, |
| 1689 | }) |
| 1690 | .pipe(Effect.mapError(toPullRequestError("routeIdentity"))); |
| 1691 | if (!identity.viewer.trim() || !identity.accountId.trim()) { |
| 1692 | return yield* new PullRequestOperationError({ |
| 1693 | operation: "routeIdentity", |
| 1694 | detail: "The signed-in account could not be verified.", |
| 1695 | }); |
| 1696 | } |
| 1697 | return { |
| 1698 | host: project.host, |
| 1699 | provider: project.api.kind, |
| 1700 | ...identity, |
| 1701 | projectTitle: project.project.title, |
| 1702 | workspaceRoot: project.project.workspaceRoot, |
| 1703 | }; |
| 1704 | }); |
| 1705 | |
| 1706 | const summaryUncached: PullRequestService["Service"]["summary"] = (input) => |
| 1707 | requireProject(input).pipe( |
| 1708 | Effect.flatMap((project) => { |
| 1709 | const providerInput = { |
| 1710 | cwd: project.project.workspaceRoot, |
| 1711 | repository: project.repository, |
| 1712 | host: project.host, |
| 1713 | number: input.number, |
| 1714 | }; |
| 1715 | const read = |
| 1716 | project.api.getChangeRequestSummary === undefined |
| 1717 | ? project.api.getChangeRequest(providerInput) |
| 1718 | : project.api.getChangeRequestSummary(providerInput); |
| 1719 | return read.pipe( |
| 1720 | Effect.mapError(toPullRequestError("summary")), |
| 1721 | observeRead, |
| 1722 | Effect.map(({ value: changeRequest, observedAt }): PullRequestSummary => ({ |
| 1723 | provider: project.api.kind, |
| 1724 | projectId: project.project.id, |
| 1725 | repository: project.repository, |
| 1726 | number: changeRequest.number, |
| 1727 | title: changeRequest.title, |
| 1728 | url: changeRequest.url, |
| 1729 | state: changeRequest.state, |
| 1730 | headBranch: changeRequest.headBranch, |
| 1731 | baseBranch: changeRequest.baseBranch, |
| 1732 | closedAt: changeRequest.closedAt ?? null, |
| 1733 | mergedAt: changeRequest.mergedAt ?? null, |
| 1734 | updatedAt: changeRequest.updatedAt, |
| 1735 | observedAt, |
| 1736 | ...(changeRequest.isDraft === undefined ? {} : { isDraft: changeRequest.isDraft }), |
| 1737 | ...(changeRequest.author === undefined ? {} : { author: changeRequest.author }), |
| 1738 | ...(changeRequest.additions === undefined |
| 1739 | ? {} |
| 1740 | : { additions: changeRequest.additions }), |
| 1741 | ...(changeRequest.deletions === undefined |
| 1742 | ? {} |
| 1743 | : { deletions: changeRequest.deletions }), |
| 1744 | ...(changeRequest.changedFiles === undefined |
| 1745 | ? {} |
| 1746 | : { changedFiles: changeRequest.changedFiles }), |
| 1747 | ...(changeRequest.reviewDecision === undefined |
| 1748 | ? {} |
| 1749 | : { reviewDecision: changeRequest.reviewDecision }), |
| 1750 | ...(changeRequest.checksState === undefined |
| 1751 | ? {} |
| 1752 | : { checksState: changeRequest.checksState }), |
| 1753 | ...(changeRequest.mergeability === undefined |
| 1754 | ? {} |
| 1755 | : { mergeability: changeRequest.mergeability }), |
| 1756 | ...(changeRequest.stack === undefined ? {} : { stack: changeRequest.stack }), |
| 1757 | })), |
| 1758 | ); |
| 1759 | }), |
| 1760 | ); |
| 1761 | |
| 1762 | const stackUncached: PullRequestService["Service"]["stack"] = (input, options) => |
| 1763 | requireProject(input).pipe( |
| 1764 | Effect.flatMap((project) => { |
| 1765 | const read = project.api.getChangeRequestStack; |
| 1766 | if (read === undefined) return Effect.succeed(null); |
| 1767 | return read({ |
| 1768 | cwd: project.project.workspaceRoot, |
| 1769 | repository: project.repository, |
| 1770 | host: project.host, |
| 1771 | number: input.number, |
| 1772 | includeDetails: options?.includeDetails !== false, |
| 1773 | }).pipe( |
| 1774 | Effect.mapError(toPullRequestError("stack")), |
| 1775 | Effect.map((stack): PullRequestStack | null => |
| 1776 | stack === null |
| 1777 | ? null |
| 1778 | : { |
| 1779 | id: stack.id, |
| 1780 | number: stack.number, |
| 1781 | url: stack.url, |
| 1782 | base: stack.base, |
| 1783 | layers: stack.layers.map((layer) => ({ |
| 1784 | ...layer, |
| 1785 | number: layer.number, |
| 1786 | headBranch: layer.headBranch, |
| 1787 | state: layer.state, |
| 1788 | })), |
| 1789 | }, |
| 1790 | ), |
| 1791 | ); |
| 1792 | }), |
| 1793 | ); |
| 1794 | |
| 1795 | const detailUncached: PullRequestService["Service"]["detail"] = (input) => |
| 1796 | requireProject(input).pipe( |
| 1797 | Effect.flatMap((project) => |
| 1798 | Effect.all( |
| 1799 | [ |
| 1800 | project.api |
| 1801 | .getChangeRequest({ |
| 1802 | cwd: project.project.workspaceRoot, |
| 1803 | repository: project.repository, |
| 1804 | host: project.host, |
| 1805 | number: input.number, |
| 1806 | }) |
| 1807 | .pipe(Effect.mapError(toPullRequestError("detail")), observeRead), |
| 1808 | viewerOf(project), |
| 1809 | ], |
| 1810 | { concurrency: 2 }, |
| 1811 | ).pipe( |
| 1812 | Effect.map(([{ value: changeRequest, observedAt }, viewer]): PullRequestDetail => ({ |
| 1813 | provider: project.api.kind, |
| 1814 | capabilities: project.api.capabilities, |
| 1815 | projectId: project.project.id, |
| 1816 | projectTitle: project.project.title, |
| 1817 | workspaceRoot: project.project.workspaceRoot, |
| 1818 | repository: project.repository, |
| 1819 | number: changeRequest.number, |
| 1820 | title: changeRequest.title, |
| 1821 | body: changeRequest.body, |
| 1822 | url: changeRequest.url, |
| 1823 | author: changeRequest.author, |
| 1824 | state: changeRequest.state, |
| 1825 | isDraft: changeRequest.isDraft, |
| 1826 | mergeability: changeRequest.mergeability, |
| 1827 | additions: changeRequest.additions, |
| 1828 | deletions: changeRequest.deletions, |
| 1829 | changedFiles: changeRequest.changedFiles, |
| 1830 | headBranch: changeRequest.headBranch, |
| 1831 | ...(changeRequest.headRepositoryNameWithOwner === undefined |
| 1832 | ? {} |
| 1833 | : { headRepositoryNameWithOwner: changeRequest.headRepositoryNameWithOwner }), |
| 1834 | ...(changeRequest.headSha ? { headSha: changeRequest.headSha } : {}), |
| 1835 | baseBranch: changeRequest.baseBranch, |
| 1836 | createdAt: changeRequest.createdAt, |
| 1837 | updatedAt: changeRequest.updatedAt, |
| 1838 | observedAt, |
| 1839 | mergedAt: changeRequest.mergedAt, |
| 1840 | closedAt: changeRequest.closedAt, |
| 1841 | reviewers: changeRequest.reviewers, |
| 1842 | labels: changeRequest.labels, |
| 1843 | checks: changeRequest.checks, |
| 1844 | mergeCapabilities: changeRequest.mergeCapabilities, |
| 1845 | viewerPermissions: changeRequest.viewerPermissions, |
| 1846 | ...(viewer === null || viewer.trim().length === 0 ? {} : { viewer }), |
| 1847 | ...(changeRequest.baseComparison === undefined |
| 1848 | ? {} |
| 1849 | : { baseComparison: changeRequest.baseComparison }), |
| 1850 | ...(changeRequest.behindBy === undefined ? {} : { behindBy: changeRequest.behindBy }), |
| 1851 | ...(changeRequest.autoMergeEnabled === undefined |
| 1852 | ? {} |
| 1853 | : { autoMergeEnabled: changeRequest.autoMergeEnabled }), |
| 1854 | ...(changeRequest.autoMergeMethod === undefined |
| 1855 | ? {} |
| 1856 | : { autoMergeMethod: changeRequest.autoMergeMethod }), |
| 1857 | ...(changeRequest.workflowApprovalsRequired === undefined |
| 1858 | ? {} |
| 1859 | : { workflowApprovalsRequired: changeRequest.workflowApprovalsRequired }), |
| 1860 | })), |
| 1861 | ), |
| 1862 | ), |
| 1863 | ); |
| 1864 | |
| 1865 | const previewFields = (value: PullRequestPreview): PullRequestPreview => ({ |
| 1866 | projectId: value.projectId, |
| 1867 | repository: value.repository, |
| 1868 | number: value.number, |
| 1869 | title: value.title, |
| 1870 | url: value.url, |
| 1871 | author: value.author, |
| 1872 | state: value.state, |
| 1873 | isDraft: value.isDraft, |
| 1874 | createdAt: value.createdAt, |
| 1875 | }); |
| 1876 | const previewUncached: PullRequestService["Service"]["preview"] = (input) => |
| 1877 | requireProject(input).pipe( |
| 1878 | Effect.flatMap((project) => |
| 1879 | (project.api.getChangeRequestPreview ?? project.api.getChangeRequest)({ |
| 1880 | cwd: project.project.workspaceRoot, |
| 1881 | repository: project.repository, |
| 1882 | host: project.host, |
| 1883 | number: input.number, |
| 1884 | }).pipe( |
| 1885 | Effect.mapError(toPullRequestError("preview")), |
| 1886 | Effect.map((value) => |
| 1887 | previewFields({ |
| 1888 | ...value, |
| 1889 | projectId: project.project.id, |
| 1890 | repository: project.repository, |
| 1891 | }), |
| 1892 | ), |
| 1893 | ), |
| 1894 | ), |
| 1895 | ); |
| 1896 | |
| 1897 | const activityUncached: PullRequestService["Service"]["activity"] = (input) => |
| 1898 | requireProject(input).pipe( |
| 1899 | Effect.flatMap((project) => |
| 1900 | project.api |
| 1901 | .getChangeRequestActivity({ |
| 1902 | cwd: project.project.workspaceRoot, |
| 1903 | repository: project.repository, |
| 1904 | host: project.host, |
| 1905 | number: input.number, |
| 1906 | }) |
| 1907 | .pipe( |
| 1908 | Effect.mapError(toPullRequestError("activity")), |
| 1909 | Effect.map((activity): PullRequestActivity => ({ |
| 1910 | ...(activity.author === undefined ? {} : { author: activity.author }), |
| 1911 | ...(activity.reviewers === undefined ? {} : { reviewers: activity.reviewers }), |
| 1912 | comments: activity.comments, |
| 1913 | commentCount: activity.commentCount, |
| 1914 | commentsTruncated: activity.commentsTruncated, |
| 1915 | ...(activity.reviewThreadsTruncated === undefined |
| 1916 | ? {} |
| 1917 | : { reviewThreadsTruncated: activity.reviewThreadsTruncated }), |
| 1918 | reviewThreads: activity.reviewThreads, |
| 1919 | commits: activity.commits, |
| 1920 | ...(activity.reactions === undefined ? {} : { reactions: activity.reactions }), |
| 1921 | })), |
| 1922 | ), |
| 1923 | ), |
| 1924 | ); |
| 1925 | |
| 1926 | const threadComments: PullRequestService["Service"]["threadComments"] = (input) => |
| 1927 | requireProject(input).pipe( |
| 1928 | Effect.flatMap( |
| 1929 | (project): Effect.Effect<PullRequestThreadCommentsResult, PullRequestError> => { |
| 1930 | const read = project.api.getReviewThreadComments; |
| 1931 | if (read === undefined) { |
| 1932 | return Effect.fail( |
| 1933 | new PullRequestOperationError({ |
| 1934 | operation: "threadComments", |
| 1935 | detail: "This host does not page review thread comments.", |
| 1936 | }), |
| 1937 | ); |
| 1938 | } |
| 1939 | return read({ |
| 1940 | cwd: project.project.workspaceRoot, |
| 1941 | repository: project.repository, |
| 1942 | host: project.host, |
| 1943 | number: input.number, |
| 1944 | threadId: input.threadId, |
| 1945 | cursor: input.cursor, |
| 1946 | }).pipe(Effect.mapError(toPullRequestError("threadComments"))); |
| 1947 | }, |
| 1948 | ), |
| 1949 | ); |
| 1950 | |
| 1951 | const diffUncached: PullRequestService["Service"]["diff"] = (input) => |
| 1952 | requireProject(input).pipe( |
| 1953 | Effect.flatMap((project) => |
| 1954 | project.api.capabilities.diff |
| 1955 | ? project.api |
| 1956 | .getDiff({ |
| 1957 | cwd: project.project.workspaceRoot, |
| 1958 | repository: project.repository, |
| 1959 | host: project.host, |
| 1960 | number: input.number, |
| 1961 | ...(input.cursor === undefined ? {} : { cursor: input.cursor }), |
| 1962 | ...(input.commit === undefined ? {} : { commit: input.commit }), |
| 1963 | }) |
| 1964 | .pipe(Effect.mapError(toPullRequestError("diff"))) |
| 1965 | : Effect.fail( |
| 1966 | new PullRequestOperationError({ |
| 1967 | operation: "diff", |
| 1968 | detail: "This host cannot provide a diff for a change request.", |
| 1969 | }), |
| 1970 | ), |
| 1971 | ), |
| 1972 | ); |
| 1973 | |
| 1974 | const diffFileContents: PullRequestService["Service"]["diffFileContents"] = (input) => |
| 1975 | requireProject(input).pipe( |
| 1976 | Effect.flatMap((project) => { |
| 1977 | const read = project.api.getDiffFileContents; |
| 1978 | return project.api.capabilities.diff && read |
| 1979 | ? read({ |
| 1980 | cwd: project.project.workspaceRoot, |
| 1981 | repository: project.repository, |
| 1982 | host: project.host, |
| 1983 | number: input.number, |
| 1984 | ...(input.commit === undefined ? {} : { commit: input.commit }), |
| 1985 | changeType: input.changeType, |
| 1986 | oldPath: input.oldPath, |
| 1987 | newPath: input.newPath, |
| 1988 | }).pipe(Effect.mapError(toPullRequestError("diffFileContents"))) |
| 1989 | : Effect.fail( |
| 1990 | new PullRequestOperationError({ |
| 1991 | operation: "diffFileContents", |
| 1992 | detail: "This host cannot expand unchanged pull request lines.", |
| 1993 | }), |
| 1994 | ); |
| 1995 | }), |
| 1996 | ); |
| 1997 | |
| 1998 | const context = yield* Effect.context<never>(); |
| 1999 | const scope = yield* Effect.scope; |
| 2000 | /** Runs a refresh as its own fiber, for the reads that answer from a held value first. */ |
| 2001 | const runFork = Effect.runForkWith(context); |
| 2002 | |
| 2003 | /** |
| 2004 | * Who the host says the reader is, for the paths whose rows are keyed by it. A lookup that |
| 2005 | * failed is refused rather than answered as the unnamed reader: a momentarily signed-out CLI |
| 2006 | * would otherwise hide every tick this reader has made and file the next press under rows that |
| 2007 | * are orphaned once it recovers. The reader is waiting on every one of these paths, so the |
| 2008 | * lookup is let through a host's backoff rather than turning a pause into a refusal, and only |
| 2009 | * here, where the bypass is bounded by what the reader does. |
| 2010 | */ |
| 2011 | const requiredViewerOf = ( |
| 2012 | project: SupportedProject, |
| 2013 | operation: string, |
| 2014 | ): Effect.Effect<string | null, PullRequestError> => |
| 2015 | resolveViewers([project], new Map(), { allowPaused: true }).pipe( |
| 2016 | Effect.flatMap(([resolved]) => { |
| 2017 | const error = resolved?.error ?? null; |
| 2018 | return error === null |
| 2019 | ? Effect.succeed(resolved?.viewer ?? null) |
| 2020 | : Effect.fail(toPullRequestError(operation)(error)); |
| 2021 | }), |
| 2022 | ); |
| 2023 | |
| 2024 | const setFilesViewed: PullRequestService["Service"]["setFilesViewed"] = (input) => |
| 2025 | canonicalRef(input).pipe( |
| 2026 | Effect.flatMap((ref) => |
| 2027 | viewedFiles.setFilesViewed(input).pipe( |
| 2028 | // Deliberately not `invalidatedByMutation`: ticking a file off says nothing about the |
| 2029 | // change request, and dropping a 300-file diff on every checkbox is the whole cost of |
| 2030 | // the feature. Only this reader's own bookkeeping is forgotten. |
| 2031 | Effect.tap(() => Effect.sync(() => bumpFilesViewedEpoch(ref))), |
| 2032 | ), |
| 2033 | ), |
| 2034 | ); |
| 2035 | |
| 2036 | const runAction = (input: PullRequestActionInput): Effect.Effect<string, PullRequestError> => |
| 2037 | requireProject(input).pipe( |
| 2038 | Effect.flatMap((project): Effect.Effect<string, PullRequestError> => { |
| 2039 | if (input.action === "delete-branch" && project.api.capabilities.deleteBranch !== true) { |
| 2040 | return Effect.fail( |
| 2041 | new PullRequestOperationError({ |
| 2042 | operation: "runAction", |
| 2043 | detail: "This host cannot delete a change request's source branch.", |
| 2044 | }), |
| 2045 | ); |
| 2046 | } |
| 2047 | if ( |
| 2048 | input.stackNumber !== undefined && |
| 2049 | (project.api.capabilities.stackActions !== true || |
| 2050 | !["merge", "update-branch"].includes(input.action) || |
| 2051 | input.expectedStackHeads === undefined || |
| 2052 | (input.action === "update-branch" && input.updateMethod !== "rebase")) |
| 2053 | ) { |
| 2054 | return Effect.fail( |
| 2055 | new PullRequestOperationError({ |
| 2056 | operation: "runAction", |
| 2057 | detail: "This stack action is not supported or has no expected head revision.", |
| 2058 | }), |
| 2059 | ); |
| 2060 | } |
| 2061 | // The surface hides what a host cannot do, and this refuses it as well: a request that |
| 2062 | // reached here anyway must not be handed to a provider that never claimed the action. |
| 2063 | if (!project.api.capabilities.actions.includes(input.action)) { |
| 2064 | return Effect.fail( |
| 2065 | new PullRequestOperationError({ |
| 2066 | operation: "runAction", |
| 2067 | detail: `This host cannot ${input.action} a change request.`, |
| 2068 | }), |
| 2069 | ); |
| 2070 | } |
| 2071 | // A strategy the host does not offer must be refused rather than passed on: every |
| 2072 | // provider maps an unrecognised method to its own default, so asking Azure DevOps to |
| 2073 | // rebase would quietly merge instead of failing. |
| 2074 | if ( |
| 2075 | input.mergeMethod !== undefined && |
| 2076 | !project.api.capabilities.mergeMethods.includes(input.mergeMethod) |
| 2077 | ) { |
| 2078 | return Effect.fail( |
| 2079 | new PullRequestOperationError({ |
| 2080 | operation: "runAction", |
| 2081 | detail: `This host cannot merge with the ${input.mergeMethod} strategy.`, |
| 2082 | }), |
| 2083 | ); |
| 2084 | } |
| 2085 | // The same for the way a stale branch is brought up to date: a host that only merges |
| 2086 | // must not be asked to rebase and left to pick something else. |
| 2087 | if ( |
| 2088 | input.updateMethod !== undefined && |
| 2089 | !(project.api.capabilities.updateMethods ?? []).includes(input.updateMethod) |
| 2090 | ) { |
| 2091 | return Effect.fail( |
| 2092 | new PullRequestOperationError({ |
| 2093 | operation: "runAction", |
| 2094 | detail: `This host cannot update a branch by ${input.updateMethod}.`, |
| 2095 | }), |
| 2096 | ); |
| 2097 | } |
| 2098 | // What the host can do and what this account may ask of it are two questions, and both |
| 2099 | // have to say yes. The second is asked last, because it costs a request and the checks |
| 2100 | // above do not. |
| 2101 | const operation = viewerPermissionsOf( |
| 2102 | project, |
| 2103 | input, |
| 2104 | "runAction", |
| 2105 | input.action === "update-branch", |
| 2106 | ).pipe( |
| 2107 | Effect.flatMap((viewer): Effect.Effect<string, PullRequestError> => { |
| 2108 | const stackRebase = input.stackNumber !== undefined && input.action === "update-branch"; |
| 2109 | if ( |
| 2110 | stackRebase ? viewer.stackRebase !== true : !viewer.actions.includes(input.action) |
| 2111 | ) { |
| 2112 | return Effect.fail( |
| 2113 | new PullRequestOperationError({ |
| 2114 | operation: "runAction", |
| 2115 | detail: ACTION_ACCESS_REFUSALS[input.action], |
| 2116 | }), |
| 2117 | ); |
| 2118 | } |
| 2119 | if ( |
| 2120 | !stackRebase && |
| 2121 | input.updateMethod !== undefined && |
| 2122 | !(viewer.updateMethods ?? []).includes(input.updateMethod) |
| 2123 | ) { |
| 2124 | return Effect.fail( |
| 2125 | new PullRequestOperationError({ |
| 2126 | operation: "runAction", |
| 2127 | detail: ACTION_ACCESS_REFUSALS["update-branch"], |
| 2128 | }), |
| 2129 | ); |
| 2130 | } |
| 2131 | const mergeSettings = |
| 2132 | project.api.mergeMessageRewrite !== undefined && |
| 2133 | input.stackNumber === undefined && |
| 2134 | (input.action === "merge" || input.action === "enable-auto-merge") |
| 2135 | ? serverSettings.getSettings.pipe( |
| 2136 | Effect.map( |
| 2137 | (settings) => |
| 2138 | resolveProjectSettings(settings, project.project.id).settings |
| 2139 | .removeAgentCreditsOnMerge, |
| 2140 | ), |
| 2141 | Effect.mapError( |
| 2142 | () => |
| 2143 | new PullRequestOperationError({ |
| 2144 | operation: "runAction", |
| 2145 | detail: "Could not read merge settings.", |
| 2146 | }), |
| 2147 | ), |
| 2148 | ) |
| 2149 | : Effect.succeed(false); |
| 2150 | return mergeSettings.pipe( |
| 2151 | Effect.flatMap((removeAgentCreditsOnMerge) => |
| 2152 | project.api |
| 2153 | .runAction({ |
| 2154 | cwd: project.project.workspaceRoot, |
| 2155 | repository: project.repository, |
| 2156 | host: project.host, |
| 2157 | number: input.number, |
| 2158 | action: input.action, |
| 2159 | ...(removeAgentCreditsOnMerge ? { removeAgentCreditsOnMerge: true } : {}), |
| 2160 | ...(input.stackNumber === undefined ? {} : { stackNumber: input.stackNumber }), |
| 2161 | ...(input.expectedStackHeads === undefined |
| 2162 | ? {} |
| 2163 | : { expectedStackHeads: input.expectedStackHeads }), |
| 2164 | ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), |
| 2165 | ...(input.updateMethod === undefined |
| 2166 | ? {} |
| 2167 | : { updateMethod: input.updateMethod }), |
| 2168 | }) |
| 2169 | .pipe( |
| 2170 | // Once the authorized provider action starts, a failure may leave partial |
| 2171 | // remote updates. Validation and permission failures above changed nothing. |
| 2172 | Effect.ensuring( |
| 2173 | input.stackNumber === undefined |
| 2174 | ? Effect.void |
| 2175 | : refreshAfterTurn(project.project.id), |
| 2176 | ), |
| 2177 | Effect.mapError(toPullRequestError("runAction")), |
| 2178 | Effect.as( |
| 2179 | project.repositoryKey !== null ? input.repository.trim() : project.repository, |
| 2180 | ), |
| 2181 | ), |
| 2182 | ), |
| 2183 | ); |
| 2184 | }), |
| 2185 | ); |
| 2186 | return input.action === "delete-branch" && project.api.withVerifiedCredential !== undefined |
| 2187 | ? project.api |
| 2188 | .withVerifiedCredential( |
| 2189 | { cwd: project.project.workspaceRoot, host: project.host }, |
| 2190 | () => operation.pipe(Effect.result), |
| 2191 | ) |
| 2192 | .pipe( |
| 2193 | Effect.mapError(toPullRequestError("runAction")), |
| 2194 | Effect.flatMap(Effect.fromResult), |
| 2195 | ) |
| 2196 | : operation; |
| 2197 | }), |
| 2198 | ); |
| 2199 | |
| 2200 | const comment: PullRequestService["Service"]["comment"] = (input) => |
| 2201 | // The contract keeps the body verbatim because it is markdown, so the "did the user |
| 2202 | // actually write something" check lives here. |
| 2203 | (input.body.trim().length === 0 |
| 2204 | ? Effect.fail( |
| 2205 | new PullRequestOperationError({ |
| 2206 | operation: "comment", |
| 2207 | detail: "A comment cannot be empty.", |
| 2208 | }), |
| 2209 | ) |
| 2210 | : requireProject(input) |
| 2211 | ).pipe( |
| 2212 | Effect.flatMap((project): Effect.Effect<void, PullRequestError> => { |
| 2213 | if (!project.api.capabilities.comment) { |
| 2214 | return Effect.fail( |
| 2215 | new PullRequestOperationError({ |
| 2216 | operation: "comment", |
| 2217 | detail: "This host cannot post a comment on a change request.", |
| 2218 | }), |
| 2219 | ); |
| 2220 | } |
| 2221 | return viewerPermissionsOf(project, input, "comment").pipe( |
| 2222 | Effect.flatMap((viewer): Effect.Effect<void, PullRequestError> => { |
| 2223 | if (!viewer.comment) { |
| 2224 | return Effect.fail( |
| 2225 | new PullRequestOperationError({ |
| 2226 | operation: "comment", |
| 2227 | detail: |
| 2228 | "You need write access on this repository to comment on a change request.", |
| 2229 | }), |
| 2230 | ); |
| 2231 | } |
| 2232 | return project.api |
| 2233 | .comment({ |
| 2234 | cwd: project.project.workspaceRoot, |
| 2235 | repository: project.repository, |
| 2236 | host: project.host, |
| 2237 | number: input.number, |
| 2238 | body: input.body, |
| 2239 | }) |
| 2240 | .pipe(Effect.mapError(toPullRequestError("comment"))); |
| 2241 | }), |
| 2242 | ); |
| 2243 | }), |
| 2244 | ); |
| 2245 | |
| 2246 | /** |
| 2247 | * Rewriting the change request's own words, and rewriting a remark, are both left to the host to |
| 2248 | * allow or refuse. Neither is a question a permission read answers: every host lets the person |
| 2249 | * who wrote something rewrite it whatever access they have otherwise, and none of them reports |
| 2250 | * that as a permission — so a check here could only guess, and a wrong guess takes the control |
| 2251 | * away from the one person certain to be allowed. |
| 2252 | */ |
| 2253 | const update: PullRequestService["Service"]["update"] = (input) => |
| 2254 | requireProject(input).pipe( |
| 2255 | Effect.flatMap((project): Effect.Effect<void, PullRequestError> => { |
| 2256 | const rewrite = project.api.updateChangeRequest; |
| 2257 | if (project.api.capabilities.edit?.changeRequest !== true || rewrite === undefined) { |
| 2258 | return Effect.fail( |
| 2259 | new PullRequestOperationError({ |
| 2260 | operation: "update", |
| 2261 | detail: "This host cannot rewrite a change request.", |
| 2262 | }), |
| 2263 | ); |
| 2264 | } |
| 2265 | if (input.title === undefined && input.body === undefined) { |
| 2266 | return Effect.fail( |
| 2267 | new PullRequestOperationError({ |
| 2268 | operation: "update", |
| 2269 | detail: "Nothing was changed.", |
| 2270 | }), |
| 2271 | ); |
| 2272 | } |
| 2273 | return rewrite({ |
| 2274 | cwd: project.project.workspaceRoot, |
| 2275 | repository: project.repository, |
| 2276 | host: project.host, |
| 2277 | number: input.number, |
| 2278 | ...(input.title === undefined ? {} : { title: input.title }), |
| 2279 | ...(input.body === undefined ? {} : { body: input.body }), |
| 2280 | }).pipe(Effect.mapError(toPullRequestError("update"))); |
| 2281 | }), |
| 2282 | ); |
| 2283 | |
| 2284 | const updateComment: PullRequestService["Service"]["updateComment"] = (input) => |
| 2285 | (input.body.trim().length === 0 |
| 2286 | ? Effect.fail( |
| 2287 | new PullRequestOperationError({ |
| 2288 | operation: "updateComment", |
| 2289 | detail: "A comment cannot be empty.", |
| 2290 | }), |
| 2291 | ) |
| 2292 | : requireProject(input) |
| 2293 | ).pipe( |
| 2294 | Effect.flatMap((project): Effect.Effect<void, PullRequestError> => { |
| 2295 | const rewrite = project.api.updateComment; |
| 2296 | if (project.api.capabilities.edit?.comment !== true || rewrite === undefined) { |
| 2297 | return Effect.fail( |
| 2298 | new PullRequestOperationError({ |
| 2299 | operation: "updateComment", |
| 2300 | detail: "This host cannot rewrite a comment.", |
| 2301 | }), |
| 2302 | ); |
| 2303 | } |
| 2304 | return rewrite({ |
| 2305 | cwd: project.project.workspaceRoot, |
| 2306 | repository: project.repository, |
| 2307 | host: project.host, |
| 2308 | number: input.number, |
| 2309 | commentId: input.commentId, |
| 2310 | kind: input.kind, |
| 2311 | body: input.body, |
| 2312 | }).pipe(Effect.mapError(toPullRequestError("updateComment"))); |
| 2313 | }), |
| 2314 | ); |
| 2315 | |
| 2316 | const submitReview: PullRequestService["Service"]["submitReview"] = (input) => |
| 2317 | requireProject(input).pipe( |
| 2318 | Effect.flatMap((project): Effect.Effect<void, PullRequestError> => { |
| 2319 | const review = project.api.capabilities.review; |
| 2320 | const refuse = (detail: string) => |
| 2321 | Effect.fail(new PullRequestOperationError({ operation: "submitReview", detail })); |
| 2322 | // The surface hides what a host cannot do, and this refuses it as well: a request that |
| 2323 | // reached here anyway must not be handed to a provider that never claimed it. |
| 2324 | if (!review.verdicts.includes(input.verdict)) { |
| 2325 | return refuse(`This host cannot ${VERDICT_LABELS[input.verdict]} a change request.`); |
| 2326 | } |
| 2327 | if (input.comments.length > 0 && !review.inlineComment) { |
| 2328 | return refuse("This host cannot comment on a line of a change request."); |
| 2329 | } |
| 2330 | // A verdict with nothing attached to it is a request every host rejects, and doing so |
| 2331 | // here says which of the two is missing rather than reporting the host's refusal. |
| 2332 | if ( |
| 2333 | input.verdict !== "approve" && |
| 2334 | input.body.trim().length === 0 && |
| 2335 | input.comments.length === 0 |
| 2336 | ) { |
| 2337 | return refuse("A review needs a summary or at least one comment."); |
| 2338 | } |
| 2339 | return viewerPermissionsOf(project, input, "submitReview").pipe( |
| 2340 | Effect.flatMap((viewer): Effect.Effect<void, PullRequestError> => { |
| 2341 | if (!viewer.verdicts.includes(input.verdict)) { |
| 2342 | return refuse( |
| 2343 | `You need write access on this repository to ${ |
| 2344 | VERDICT_LABELS[input.verdict] |
| 2345 | } a change request.`, |
| 2346 | ); |
| 2347 | } |
| 2348 | if (input.comments.length > 0 && !viewer.comment) { |
| 2349 | return refuse( |
| 2350 | "You need write access on this repository to comment on a line of a change request.", |
| 2351 | ); |
| 2352 | } |
| 2353 | return project.api |
| 2354 | .submitReview({ |
| 2355 | cwd: project.project.workspaceRoot, |
| 2356 | repository: project.repository, |
| 2357 | host: project.host, |
| 2358 | number: input.number, |
| 2359 | verdict: input.verdict, |
| 2360 | body: input.body, |
| 2361 | comments: input.comments, |
| 2362 | }) |
| 2363 | .pipe(Effect.mapError(toPullRequestError("submitReview"))); |
| 2364 | }), |
| 2365 | ); |
| 2366 | }), |
| 2367 | ); |
| 2368 | |
| 2369 | const replyToThread: PullRequestService["Service"]["replyToThread"] = (input) => |
| 2370 | (input.body.trim().length === 0 |
| 2371 | ? Effect.fail( |
| 2372 | new PullRequestOperationError({ |
| 2373 | operation: "replyToThread", |
| 2374 | detail: "A reply cannot be empty.", |
| 2375 | }), |
| 2376 | ) |
| 2377 | : requireProject(input) |
| 2378 | ).pipe( |
| 2379 | Effect.flatMap((project): Effect.Effect<void, PullRequestError> => { |
| 2380 | if (!project.api.capabilities.review.reply) { |
| 2381 | return Effect.fail( |
| 2382 | new PullRequestOperationError({ |
| 2383 | operation: "replyToThread", |
| 2384 | detail: "This host cannot reply to a review conversation.", |
| 2385 | }), |
| 2386 | ); |
| 2387 | } |
| 2388 | return viewerPermissionsOf(project, input, "replyToThread").pipe( |
| 2389 | Effect.flatMap((viewer): Effect.Effect<void, PullRequestError> => { |
| 2390 | if (!viewer.comment) { |
| 2391 | return Effect.fail( |
| 2392 | new PullRequestOperationError({ |
| 2393 | operation: "replyToThread", |
| 2394 | detail: |
| 2395 | "You need write access on this repository to reply to a review conversation.", |
| 2396 | }), |
| 2397 | ); |
| 2398 | } |
| 2399 | return project.api |
| 2400 | .replyToThread({ |
| 2401 | cwd: project.project.workspaceRoot, |
| 2402 | repository: project.repository, |
| 2403 | host: project.host, |
| 2404 | number: input.number, |
| 2405 | threadId: input.threadId, |
| 2406 | body: input.body, |
| 2407 | }) |
| 2408 | .pipe(Effect.mapError(toPullRequestError("replyToThread"))); |
| 2409 | }), |
| 2410 | ); |
| 2411 | }), |
| 2412 | ); |
| 2413 | |
| 2414 | const setThreadResolution: PullRequestService["Service"]["setThreadResolution"] = (input) => |
| 2415 | requireProject(input).pipe( |
| 2416 | Effect.flatMap((project): Effect.Effect<void, PullRequestError> => { |
| 2417 | if (!project.api.capabilities.review.resolve) { |
| 2418 | return Effect.fail( |
| 2419 | new PullRequestOperationError({ |
| 2420 | operation: "setThreadResolution", |
| 2421 | detail: "This host cannot resolve a review conversation.", |
| 2422 | }), |
| 2423 | ); |
| 2424 | } |
| 2425 | return viewerPermissionsOf(project, input, "setThreadResolution").pipe( |
| 2426 | Effect.flatMap((viewer): Effect.Effect<void, PullRequestError> => { |
| 2427 | if (!viewer.resolve) { |
| 2428 | return Effect.fail( |
| 2429 | new PullRequestOperationError({ |
| 2430 | operation: "setThreadResolution", |
| 2431 | detail: |
| 2432 | "You need write access on this repository, or to have opened this change request, to resolve a review conversation.", |
| 2433 | }), |
| 2434 | ); |
| 2435 | } |
| 2436 | return project.api |
| 2437 | .setThreadResolution({ |
| 2438 | cwd: project.project.workspaceRoot, |
| 2439 | repository: project.repository, |
| 2440 | host: project.host, |
| 2441 | number: input.number, |
| 2442 | threadId: input.threadId, |
| 2443 | resolved: input.resolved, |
| 2444 | }) |
| 2445 | .pipe(Effect.mapError(toPullRequestError("setThreadResolution"))); |
| 2446 | }), |
| 2447 | ); |
| 2448 | }), |
| 2449 | ); |
| 2450 | |
| 2451 | /** |
| 2452 | * Reacting is gated on the host alone. Every host with reactions takes one from whoever can read |
| 2453 | * the change request, so there is no access left to check that reading it has not already |
| 2454 | * settled. |
| 2455 | */ |
| 2456 | const setReaction: PullRequestService["Service"]["setReaction"] = (input) => |
| 2457 | requireProject(input).pipe( |
| 2458 | Effect.flatMap((project): Effect.Effect<void, PullRequestError> => { |
| 2459 | if (project.api.capabilities.reactions !== true) { |
| 2460 | return Effect.fail( |
| 2461 | new PullRequestOperationError({ |
| 2462 | operation: "setReaction", |
| 2463 | detail: "This host has no reactions.", |
| 2464 | }), |
| 2465 | ); |
| 2466 | } |
| 2467 | return project.api |
| 2468 | .setReaction({ |
| 2469 | cwd: project.project.workspaceRoot, |
| 2470 | repository: project.repository, |
| 2471 | host: project.host, |
| 2472 | number: input.number, |
| 2473 | ...(input.subjectId === undefined ? {} : { subjectId: input.subjectId }), |
| 2474 | content: input.content, |
| 2475 | reacted: input.reacted, |
| 2476 | }) |
| 2477 | .pipe(Effect.mapError(toPullRequestError("setReaction"))); |
| 2478 | }), |
| 2479 | ); |
| 2480 | |
| 2481 | /** |
| 2482 | * Who may be asked is only ever wanted by somebody about to ask, because the menu it fills is |
| 2483 | * the one the request is made from. So the same permission guards both: a page that could open |
| 2484 | * the menu without it would offer a list whose every press was going to be turned down. |
| 2485 | */ |
| 2486 | const reviewerCandidates: PullRequestService["Service"]["reviewerCandidates"] = (input) => |
| 2487 | requireProject(input).pipe( |
| 2488 | Effect.flatMap( |
| 2489 | (project): Effect.Effect<PullRequestReviewerCandidateList, PullRequestError> => { |
| 2490 | if (!project.api.capabilities.reviewers.listCandidates) { |
| 2491 | return Effect.fail( |
| 2492 | new PullRequestOperationError({ |
| 2493 | operation: "reviewerCandidates", |
| 2494 | detail: "This host cannot say who may review a change request.", |
| 2495 | }), |
| 2496 | ); |
| 2497 | } |
| 2498 | return viewerPermissionsOf(project, input, "reviewerCandidates").pipe( |
| 2499 | Effect.flatMap( |
| 2500 | (viewer): Effect.Effect<PullRequestReviewerCandidateList, PullRequestError> => |
| 2501 | viewer.requestReviewers |
| 2502 | ? project.api |
| 2503 | .listReviewerCandidates({ |
| 2504 | cwd: project.project.workspaceRoot, |
| 2505 | repository: project.repository, |
| 2506 | host: project.host, |
| 2507 | number: input.number, |
| 2508 | }) |
| 2509 | .pipe(Effect.mapError(toPullRequestError("reviewerCandidates"))) |
| 2510 | : Effect.fail( |
| 2511 | new PullRequestOperationError({ |
| 2512 | operation: "reviewerCandidates", |
| 2513 | detail: REVIEWER_REQUEST_REFUSAL, |
| 2514 | }), |
| 2515 | ), |
| 2516 | ), |
| 2517 | ); |
| 2518 | }, |
| 2519 | ), |
| 2520 | ); |
| 2521 | |
| 2522 | const requestReviewers: PullRequestService["Service"]["requestReviewers"] = (input) => |
| 2523 | requireProject(input).pipe( |
| 2524 | Effect.flatMap((project): Effect.Effect<void, PullRequestError> => { |
| 2525 | if (!project.api.capabilities.reviewers.request) { |
| 2526 | return Effect.fail( |
| 2527 | new PullRequestOperationError({ |
| 2528 | operation: "requestReviewers", |
| 2529 | detail: "This host cannot ask somebody for a review.", |
| 2530 | }), |
| 2531 | ); |
| 2532 | } |
| 2533 | return viewerPermissionsOf(project, input, "requestReviewers").pipe( |
| 2534 | Effect.flatMap((viewer): Effect.Effect<void, PullRequestError> => { |
| 2535 | if (!viewer.requestReviewers) { |
| 2536 | return Effect.fail( |
| 2537 | new PullRequestOperationError({ |
| 2538 | operation: "requestReviewers", |
| 2539 | detail: REVIEWER_REQUEST_REFUSAL, |
| 2540 | }), |
| 2541 | ); |
| 2542 | } |
| 2543 | return project.api |
| 2544 | .setReviewerRequest({ |
| 2545 | cwd: project.project.workspaceRoot, |
| 2546 | repository: project.repository, |
| 2547 | host: project.host, |
| 2548 | number: input.number, |
| 2549 | reviewers: input.reviewers, |
| 2550 | requested: input.requested, |
| 2551 | }) |
| 2552 | .pipe(Effect.mapError(toPullRequestError("requestReviewers"))); |
| 2553 | }), |
| 2554 | ); |
| 2555 | }), |
| 2556 | ); |
| 2557 | |
| 2558 | /** |
| 2559 | * The labels, like the reviewer candidates, are wanted only by somebody about to change them, |
| 2560 | * so the same permission guards the list and the change. |
| 2561 | */ |
| 2562 | const labelCandidates: PullRequestService["Service"]["labelCandidates"] = (input) => |
| 2563 | requireProject(input).pipe( |
| 2564 | Effect.flatMap((project): Effect.Effect<PullRequestLabelCandidateList, PullRequestError> => { |
| 2565 | const list = project.api.listLabelCandidates; |
| 2566 | if (project.api.capabilities.labels !== true || list === undefined) { |
| 2567 | return Effect.fail( |
| 2568 | new PullRequestOperationError({ |
| 2569 | operation: "labelCandidates", |
| 2570 | detail: "This host cannot change the labels on a change request.", |
| 2571 | }), |
| 2572 | ); |
| 2573 | } |
| 2574 | return viewerPermissionsOf(project, input, "labelCandidates").pipe( |
| 2575 | Effect.flatMap( |
| 2576 | (viewer): Effect.Effect<PullRequestLabelCandidateList, PullRequestError> => |
| 2577 | viewer.labels === false |
| 2578 | ? Effect.fail( |
| 2579 | new PullRequestOperationError({ |
| 2580 | operation: "labelCandidates", |
| 2581 | detail: LABEL_CHANGE_REFUSAL, |
| 2582 | }), |
| 2583 | ) |
| 2584 | : list({ |
| 2585 | cwd: project.project.workspaceRoot, |
| 2586 | repository: project.repository, |
| 2587 | host: project.host, |
| 2588 | number: input.number, |
| 2589 | }).pipe(Effect.mapError(toPullRequestError("labelCandidates"))), |
| 2590 | ), |
| 2591 | ); |
| 2592 | }), |
| 2593 | ); |
| 2594 | |
| 2595 | const setLabels: PullRequestService["Service"]["setLabels"] = (input) => |
| 2596 | requireProject(input).pipe( |
| 2597 | Effect.flatMap((project): Effect.Effect<void, PullRequestError> => { |
| 2598 | const change = project.api.setLabels; |
| 2599 | if (project.api.capabilities.labels !== true || change === undefined) { |
| 2600 | return Effect.fail( |
| 2601 | new PullRequestOperationError({ |
| 2602 | operation: "setLabels", |
| 2603 | detail: "This host cannot change the labels on a change request.", |
| 2604 | }), |
| 2605 | ); |
| 2606 | } |
| 2607 | return viewerPermissionsOf(project, input, "setLabels").pipe( |
| 2608 | Effect.flatMap((viewer): Effect.Effect<void, PullRequestError> => |
| 2609 | viewer.labels === false |
| 2610 | ? Effect.fail( |
| 2611 | new PullRequestOperationError({ |
| 2612 | operation: "setLabels", |
| 2613 | detail: LABEL_CHANGE_REFUSAL, |
| 2614 | }), |
| 2615 | ) |
| 2616 | : change({ |
| 2617 | cwd: project.project.workspaceRoot, |
| 2618 | repository: project.repository, |
| 2619 | host: project.host, |
| 2620 | number: input.number, |
| 2621 | labels: input.labels, |
| 2622 | applied: input.applied, |
| 2623 | }).pipe(Effect.mapError(toPullRequestError("setLabels"))), |
| 2624 | ), |
| 2625 | ); |
| 2626 | }), |
| 2627 | ); |
| 2628 | |
| 2629 | /** |
| 2630 | * The line counts for rows already on the page, which the listing left out because on GitHub |
| 2631 | * they cost more than everything else on the row put together. |
| 2632 | * |
| 2633 | * One read per host rather than per row, and only for a host whose listing defers them; a row |
| 2634 | * whose host answered with the counts in the first place is not here to be asked about. A ref |
| 2635 | * that names no project this workspace has, or a repository that is not the one the project's |
| 2636 | * remote points at, is dropped rather than refused: it is one row's two numbers, and the page |
| 2637 | * that asked has already moved on. |
| 2638 | */ |
| 2639 | const listStatsUncached: PullRequestService["Service"]["listStats"] = (input) => |
| 2640 | Effect.gen(function* () { |
| 2641 | if (input.refs.length === 0) return { stats: [] }; |
| 2642 | const { supported } = yield* listWorkspaceProjects({}); |
| 2643 | const byProject = new Map(supported.map((project) => [project.project.id, project])); |
| 2644 | const wanted = new Map< |
| 2645 | string, |
| 2646 | { readonly project: SupportedProject; readonly number: number } |
| 2647 | >(); |
| 2648 | for (const ref of input.refs) { |
| 2649 | const project = byProject.get(ref.projectId); |
| 2650 | // The repository travels through the client, so it is checked against the project's own |
| 2651 | // remote rather than being handed to a provider verbatim. |
| 2652 | if ( |
| 2653 | project === undefined || |
| 2654 | project.api.listChangeRequestStats === undefined || |
| 2655 | project.repository.toLowerCase() !== ref.repository.trim().toLowerCase() |
| 2656 | ) { |
| 2657 | continue; |
| 2658 | } |
| 2659 | wanted.set(`${project.project.id} ${ref.number}`, { project, number: ref.number }); |
| 2660 | } |
| 2661 | const byHost = new Map<string, Array<{ project: SupportedProject; number: number }>>(); |
| 2662 | for (const entry of wanted.values()) { |
| 2663 | const held = byHost.get(entry.project.host); |
| 2664 | if (held === undefined) byHost.set(entry.project.host, [entry]); |
| 2665 | else held.push(entry); |
| 2666 | } |
| 2667 | const stats = yield* Effect.forEach( |
| 2668 | [...byHost.values()], |
| 2669 | (entries) => { |
| 2670 | const first = entries[0]!; |
| 2671 | const readStats = first.project.api.listChangeRequestStats; |
| 2672 | if (readStats === undefined) |
| 2673 | return Effect.succeed<ReadonlyArray<PullRequestDiffStat>>([]); |
| 2674 | const projectsByRepository = new Map( |
| 2675 | entries.map((entry) => [ |
| 2676 | `${entry.project.repository.toLowerCase()} ${entry.number}`, |
| 2677 | entry.project, |
| 2678 | ]), |
| 2679 | ); |
| 2680 | return readStats({ |
| 2681 | cwd: first.project.project.workspaceRoot, |
| 2682 | host: first.project.host, |
| 2683 | changeRequests: entries.map((entry) => ({ |
| 2684 | repository: entry.project.repository, |
| 2685 | number: entry.number, |
| 2686 | })), |
| 2687 | }).pipe( |
| 2688 | Effect.map((read) => |
| 2689 | read.flatMap((stat): ReadonlyArray<PullRequestDiffStat> => { |
| 2690 | const project = projectsByRepository.get( |
| 2691 | `${stat.repository.toLowerCase()} ${stat.number}`, |
| 2692 | ); |
| 2693 | return project === undefined |
| 2694 | ? [] |
| 2695 | : [ |
| 2696 | { |
| 2697 | projectId: project.project.id, |
| 2698 | repository: project.repository, |
| 2699 | number: stat.number, |
| 2700 | additions: stat.additions, |
| 2701 | deletions: stat.deletions, |
| 2702 | }, |
| 2703 | ]; |
| 2704 | }), |
| 2705 | ), |
| 2706 | // A row without its counts is a row the page already draws without them, so a host |
| 2707 | // that could not answer costs the numbers rather than the answer. |
| 2708 | Effect.orElseSucceed((): ReadonlyArray<PullRequestDiffStat> => []), |
| 2709 | ); |
| 2710 | }, |
| 2711 | { concurrency: REPOSITORY_CONCURRENCY }, |
| 2712 | ); |
| 2713 | return { stats: stats.flat() }; |
| 2714 | }); |
| 2715 | |
| 2716 | const revalidate = <A, E>(read: Effect.Effect<A, E>) => |
| 2717 | Effect.context<never>().pipe( |
| 2718 | Effect.flatMap((caller) => |
| 2719 | Effect.sync(() => runFork(Effect.ignore(read).pipe(Effect.provideContext(caller)))), |
| 2720 | ), |
| 2721 | ); |
| 2722 | |
| 2723 | /** |
| 2724 | * The diff is not live-polled and is expensive enough to keep its stale-while-revalidate path. |
| 2725 | * Explicit refreshes and mutations still strand held values through the reference epoch. |
| 2726 | */ |
| 2727 | const staleDiff = (() => { |
| 2728 | const staleMs = Duration.toMillis(DIFF_STALE_WINDOW); |
| 2729 | const held = new Map<string, { readonly at: number; readonly value: PullRequestDiffResult }>(); |
| 2730 | const record = (key: string, value: PullRequestDiffResult) => |
| 2731 | Effect.map(Clock.currentTimeMillis, (at) => { |
| 2732 | held.delete(key); |
| 2733 | if (!canCacheDiff(value)) return; |
| 2734 | if (held.size >= DIFF_CACHE_CAPACITY) { |
| 2735 | const oldest = held.keys().next().value; |
| 2736 | if (oldest !== undefined) held.delete(oldest); |
| 2737 | } |
| 2738 | held.set(key, { at, value }); |
| 2739 | armDiffSweep(); |
| 2740 | }); |
| 2741 | const serve = <E>(key: string, read: Effect.Effect<PullRequestDiffResult, E>) => { |
| 2742 | const recorded = read.pipe(Effect.tap((value) => record(key, value))); |
| 2743 | return Effect.flatMap(Clock.currentTimeMillis, (now) => { |
| 2744 | const snapshot = held.get(key); |
| 2745 | if (snapshot === undefined || now - snapshot.at > staleMs) return recorded; |
| 2746 | // Run as its own fiber rather than a child: the caller is answered and gone before the |
| 2747 | // refresh lands. The read still coalesces on the cache key, so ten stale reads in one |
| 2748 | // window cost one host request — and a failed refresh costs nothing but the retry. |
| 2749 | return revalidate(recorded).pipe(Effect.as(snapshot.value)); |
| 2750 | }); |
| 2751 | }; |
| 2752 | /** Drops what `serve` would already refuse, and says how many values are still held. */ |
| 2753 | const dropExpired = Effect.map(Clock.currentTimeMillis, (now) => { |
| 2754 | for (const [key, snapshot] of held) { |
| 2755 | if (now - snapshot.at > staleMs) held.delete(key); |
| 2756 | } |
| 2757 | return held.size; |
| 2758 | }); |
| 2759 | return { serve, dropExpired }; |
| 2760 | })(); |
| 2761 | |
| 2762 | const makeLastGoodRead = <A>(capacity: number) => { |
| 2763 | const held = new Map<string, { readonly at: number; readonly value: A }>(); |
| 2764 | const record = (key: string, value: A) => |
| 2765 | Effect.map(Clock.currentTimeMillis, (at) => { |
| 2766 | held.delete(key); |
| 2767 | if (held.size >= capacity) { |
| 2768 | const oldest = held.keys().next().value; |
| 2769 | if (oldest !== undefined) held.delete(oldest); |
| 2770 | } |
| 2771 | held.set(key, { at, value }); |
| 2772 | }); |
| 2773 | const read = (key: string, effect: Effect.Effect<A, PullRequestError>) => |
| 2774 | effect.pipe( |
| 2775 | Effect.tap((value) => record(key, value)), |
| 2776 | Effect.catchTags({ |
| 2777 | PullRequestOperationError: (error) => { |
| 2778 | if (!isPullRequestProviderError(error.cause)) { |
| 2779 | return Effect.fail(error); |
| 2780 | } |
| 2781 | const provider = error.cause; |
| 2782 | if (provider.reason !== "failed" && provider.reason !== "rate-limited") { |
| 2783 | return Effect.fail(error); |
| 2784 | } |
| 2785 | return Effect.flatMap(Clock.currentTimeMillis, (now) => { |
| 2786 | const snapshot = held.get(key); |
| 2787 | if ( |
| 2788 | snapshot === undefined || |
| 2789 | now - snapshot.at > Duration.toMillis(STALE_DETAIL_WINDOW) |
| 2790 | ) { |
| 2791 | return Effect.fail(error); |
| 2792 | } |
| 2793 | return Effect.logWarning("using recent pull request data after a failed refresh", { |
| 2794 | operation: error.operation, |
| 2795 | reason: provider.reason, |
| 2796 | }).pipe(Effect.as(snapshot.value)); |
| 2797 | }); |
| 2798 | }, |
| 2799 | }), |
| 2800 | ); |
| 2801 | /** |
| 2802 | * A change request already read does not wait on the host again. `reuse` answers from |
| 2803 | * what we hold and spends nothing — title, author, and state barely move, and a linked |
| 2804 | * thread already names the change request. `revalidate` answers the same way and |
| 2805 | * refreshes behind it, so line counts and the rest can change in place. |
| 2806 | */ |
| 2807 | const serveHeld = ( |
| 2808 | key: string, |
| 2809 | effect: Effect.Effect<A, PullRequestError>, |
| 2810 | mode: "reuse" | "revalidate", |
| 2811 | ) => { |
| 2812 | const snapshot = held.get(key); |
| 2813 | if (snapshot === undefined) return read(key, effect); |
| 2814 | if (mode === "reuse") return Effect.succeed(snapshot.value); |
| 2815 | return revalidate(read(key, effect)).pipe(Effect.as(snapshot.value)); |
| 2816 | }; |
| 2817 | return { peek: (key: string) => held.get(key)?.value, read, record, serveHeld }; |
| 2818 | }; |
| 2819 | const lastGoodSummary = makeLastGoodRead<PullRequestSummary>(DETAIL_CACHE_CAPACITY); |
| 2820 | const lastGoodDetail = makeLastGoodRead<PullRequestDetail>(DETAIL_CACHE_CAPACITY); |
| 2821 | |
| 2822 | // Epochs are the invalidation mechanism: a key carries its scope's epoch, so bumping the |
| 2823 | // epoch strands every entry made under the old one — no enumerating a cache whose keys |
| 2824 | // (cursors, commits) nothing holds a list of. The counter is shared and monotonic so a |
| 2825 | // scope re-entering `refEpochs` after eviction can never mint a key an old entry still has. |
| 2826 | let epochCounter = 0; |
| 2827 | let listingsEpoch = 0; |
| 2828 | const refEpochs = new Map<string, number>(); |
| 2829 | const projectEpochs = new Map<ProjectId, number>(); |
| 2830 | let projectEpochFloor = 0; |
| 2831 | const REF_EPOCH_CAPACITY = 2_048; |
| 2832 | const refScope = (ref: PullRequestRef) => |
| 2833 | JSON.stringify([ |
| 2834 | ref.projectId, |
| 2835 | ref.host?.toLowerCase() ?? "", |
| 2836 | ref.repository.toLowerCase(), |
| 2837 | ref.number, |
| 2838 | ]); |
| 2839 | /** |
| 2840 | * The project epoch a pull request was first read merged under. A merged pull request cannot |
| 2841 | * change again, so a turn in its project no longer moves its key; otherwise every reader showing |
| 2842 | * it would read the host again after every turn. A refresh or a mutation still moves it. |
| 2843 | */ |
| 2844 | const mergedEpochs = new Map<string, number>(); |
| 2845 | const refEpoch = (ref: PullRequestRef) => { |
| 2846 | const scope = refScope(ref); |
| 2847 | return Math.max( |
| 2848 | mergedEpochs.get(scope) ?? projectEpochs.get(ref.projectId) ?? projectEpochFloor, |
| 2849 | refEpochs.get(scope) ?? 0, |
| 2850 | ); |
| 2851 | }; |
| 2852 | const noteMerged = (ref: PullRequestRef) => { |
| 2853 | const scope = refScope(ref); |
| 2854 | if (mergedEpochs.has(scope)) return; |
| 2855 | if (mergedEpochs.size >= REF_EPOCH_CAPACITY) { |
| 2856 | const oldest = mergedEpochs.keys().next().value; |
| 2857 | if (oldest !== undefined) mergedEpochs.delete(oldest); |
| 2858 | } |
| 2859 | mergedEpochs.set(scope, projectEpochs.get(ref.projectId) ?? projectEpochFloor); |
| 2860 | }; |
| 2861 | // Keys carry the reference back out of the cache loader, so the slot layout is shared with |
| 2862 | // `refOfCacheKey` rather than read positionally at every loader. |
| 2863 | const refCacheKey = (ref: CredentialRef) => |
| 2864 | JSON.stringify([ |
| 2865 | refEpoch(ref), |
| 2866 | ref.projectId, |
| 2867 | ref.host?.toLowerCase() ?? null, |
| 2868 | ref.repository.toLowerCase(), |
| 2869 | ref.number, |
| 2870 | ref.expectedAccountId ?? null, |
| 2871 | ref[credentialNamespace] ?? null, |
| 2872 | ]); |
| 2873 | const refOfCacheKey = (key: string): PullRequestRef => { |
| 2874 | const [, projectId, host, repository, number, expectedAccountId, fingerprint] = JSON.parse( |
| 2875 | key, |
| 2876 | ) as [number, string, string | null, string, number, string | null, string | null]; |
| 2877 | return { |
| 2878 | projectId, |
| 2879 | ...(host === null ? {} : { host }), |
| 2880 | ...(expectedAccountId === null ? {} : { expectedAccountId }), |
| 2881 | ...(fingerprint === null ? {} : { [credentialNamespace]: fingerprint }), |
| 2882 | repository, |
| 2883 | number, |
| 2884 | } as PullRequestRef; |
| 2885 | }; |
| 2886 | // Counts belong to a PR, not a filtered page. Background reads and filter changes reuse |
| 2887 | // them; explicit refreshes, mutations, and turns strand old and in-flight results. |
| 2888 | const statsCacheKey = (key: string) => JSON.stringify([listingsEpoch, key]); |
| 2889 | const recentStats = new Map< |
| 2890 | string, |
| 2891 | { readonly at: number; readonly value: PullRequestDiffStat } |
| 2892 | >(); |
| 2893 | const recordStats = (key: string, value: PullRequestDiffStat, at: number) => { |
| 2894 | recentStats.delete(key); |
| 2895 | recentStats.set(key, { at, value }); |
| 2896 | if (recentStats.size > REF_EPOCH_CAPACITY) { |
| 2897 | const oldest = recentStats.keys().next().value; |
| 2898 | if (oldest !== undefined) recentStats.delete(oldest); |
| 2899 | } |
| 2900 | }; |
| 2901 | const bumpEpoch = (epochs: Map<string, number>, ref: PullRequestRef) => { |
| 2902 | const scope = refScope(ref); |
| 2903 | if (!epochs.has(scope) && epochs.size >= REF_EPOCH_CAPACITY) { |
| 2904 | const oldest = epochs.keys().next().value; |
| 2905 | if (oldest !== undefined) epochs.delete(oldest); |
| 2906 | } |
| 2907 | epochs.set(scope, ++epochCounter); |
| 2908 | }; |
| 2909 | const bumpRefEpoch = (ref: PullRequestRef) => bumpEpoch(refEpochs, ref); |
| 2910 | // Its own scope, so a press forgets the reader's ticks and nothing else. The read's key |
| 2911 | // carries both epochs, which is what makes an ordinary refresh re-ask for these too. |
| 2912 | const filesViewedEpochs = new Map<string, number>(); |
| 2913 | const filesViewedEpoch = (ref: PullRequestRef) => filesViewedEpochs.get(refScope(ref)) ?? 0; |
| 2914 | const bumpFilesViewedEpoch = (ref: PullRequestRef) => bumpEpoch(filesViewedEpochs, ref); |
| 2915 | |
| 2916 | /** Bumped by a whole-workspace refresh, the one drop no single reference's epoch covers. */ |
| 2917 | let everyFileRevisionEpoch = 0; |
| 2918 | // Built after the epochs because it reads two of them: taken as an argument any higher, |
| 2919 | // `refEpoch` would be read while its `const` was still in its dead zone and this would throw. |
| 2920 | const viewedFiles = ViewedFiles.make({ |
| 2921 | filesViewedStore, |
| 2922 | requireProject, |
| 2923 | requiredViewerOf, |
| 2924 | toPullRequestError, |
| 2925 | runFork, |
| 2926 | refEpoch, |
| 2927 | fileRevisionsEpoch: () => everyFileRevisionEpoch, |
| 2928 | }); |
| 2929 | |
| 2930 | /** The positional filter slot of a cache key, back as the record `listUncached` takes. */ |
| 2931 | const filtersOfKey = ( |
| 2932 | slots: ReadonlyArray< |
| 2933 | string | ReadonlyArray<string> | ReadonlyArray<ReadonlyArray<string>> | null |
| 2934 | >, |
| 2935 | ): PullRequestListFilters => { |
| 2936 | const [draft, review, checks, author, labels, excludedLabels] = slots; |
| 2937 | return { |
| 2938 | ...(typeof draft === "string" ? { draft: draft as "only" | "hide" } : {}), |
| 2939 | ...(typeof review === "string" ? { review: review as PullRequestListFilters["review"] } : {}), |
| 2940 | ...(typeof checks === "string" ? { checks: checks as PullRequestListFilters["checks"] } : {}), |
| 2941 | ...(typeof author === "string" ? { author } : {}), |
| 2942 | ...(Array.isArray(labels) ? { labels: labels as ReadonlyArray<ReadonlyArray<string>> } : {}), |
| 2943 | ...(Array.isArray(excludedLabels) ? { excludedLabels } : {}), |
| 2944 | }; |
| 2945 | }; |
| 2946 | |
| 2947 | const persistedRead = Effect.fn("PullRequestService.persistedRead")(function* <A>( |
| 2948 | input: CredentialRef, |
| 2949 | operation: string, |
| 2950 | codec: Schema.Codec<A, string>, |
| 2951 | read: Effect.Effect<A, PullRequestError>, |
| 2952 | ) { |
| 2953 | const project = yield* requireProject(input); |
| 2954 | const key = [ |
| 2955 | operation, |
| 2956 | project.api.kind, |
| 2957 | project.host.toLowerCase(), |
| 2958 | project.repository.toLowerCase(), |
| 2959 | project.project.id, |
| 2960 | project.project.workspaceRoot, |
| 2961 | String(input.number), |
| 2962 | input.expectedAccountId ?? "", |
| 2963 | input[credentialNamespace] ?? "", |
| 2964 | ] |
| 2965 | .map(encodeURIComponent) |
| 2966 | .join(":"); |
| 2967 | const lookup = yield* Effect.cached(read); |
| 2968 | const encodedRead = lookup.pipe( |
| 2969 | Effect.flatMap((value) => |
| 2970 | Schema.encodeEffect(codec)(value).pipe( |
| 2971 | Effect.mapError( |
| 2972 | (cause) => |
| 2973 | new PullRequestOperationError({ |
| 2974 | operation: "cache", |
| 2975 | detail: "Could not encode PR cache data.", |
| 2976 | cause, |
| 2977 | }), |
| 2978 | ), |
| 2979 | ), |
| 2980 | ), |
| 2981 | ); |
| 2982 | const payload = yield* readCache.get(key, encodedRead, [ |
| 2983 | `project:${input.projectId}`, |
| 2984 | refScope(input), |
| 2985 | ]); |
| 2986 | const decoded = yield* Schema.decodeEffect(codec)(payload).pipe(Effect.option); |
| 2987 | return Option.isSome(decoded) ? decoded.value : yield* lookup; |
| 2988 | }); |
| 2989 | const summaryCodec = Schema.fromJsonString(PullRequestSummary); |
| 2990 | const stackCodec = Schema.fromJsonString(Schema.NullOr(PullRequestStack)); |
| 2991 | |
| 2992 | const summary: PullRequestService["Service"]["summary"] = (input, options) => { |
| 2993 | const key = refCacheKey(input); |
| 2994 | const cached = persistedRead(input, "summary", summaryCodec, summaryUncached(input)); |
| 2995 | const held = lastGoodSummary.peek(key); |
| 2996 | return held !== undefined && |
| 2997 | input.allowStale !== false && |
| 2998 | (options?.recoverTransientFailure !== false || held.state === "merged") |
| 2999 | ? Effect.succeed(held) |
| 3000 | : cached.pipe( |
| 3001 | Effect.tap((value) => |
| 3002 | shouldReplaceHeldSummary(key, value) ? lastGoodSummary.record(key, value) : Effect.void, |
| 3003 | ), |
| 3004 | ); |
| 3005 | }; |
| 3006 | |
| 3007 | const stack: PullRequestService["Service"]["stack"] = (input, options) => |
| 3008 | persistedRead( |
| 3009 | input, |
| 3010 | `stack:${options?.includeDetails !== false}`, |
| 3011 | stackCodec, |
| 3012 | stackUncached(input, options), |
| 3013 | ); |
| 3014 | |
| 3015 | // Keys serialize positionally and parse back in the lookup, so the cache is the only holder |
| 3016 | // of in-flight state: concurrent identical reads coalesce on the key into one host request. |
| 3017 | // The continuation cursors are part of the key, entries sorted so one continuation is one |
| 3018 | // key however its record was assembled — a further slice is its own answer, cached like any. |
| 3019 | const listCache = yield* Cache.makeWith( |
| 3020 | (key: string) => { |
| 3021 | // The parse undoes this module's own serialization, so the shapes are known exactly; |
| 3022 | // the cast restores the branded field types JSON cannot carry. |
| 3023 | const [ |
| 3024 | , |
| 3025 | state, |
| 3026 | involvement, |
| 3027 | filters, |
| 3028 | projectId, |
| 3029 | projectIds, |
| 3030 | host, |
| 3031 | limit, |
| 3032 | query, |
| 3033 | cursorEntries, |
| 3034 | ] = JSON.parse(key) as [ |
| 3035 | number, |
| 3036 | string, |
| 3037 | string | null, |
| 3038 | ReadonlyArray<string | ReadonlyArray<string> | null> | null, |
| 3039 | string | null, |
| 3040 | ReadonlyArray<string> | null, |
| 3041 | string | null, |
| 3042 | number | null, |
| 3043 | string | null, |
| 3044 | ReadonlyArray<[string, string]> | null, |
| 3045 | ]; |
| 3046 | return listUncached({ |
| 3047 | state, |
| 3048 | ...(involvement === null ? {} : { involvement }), |
| 3049 | ...(filters === null ? {} : { filters: filtersOfKey(filters) }), |
| 3050 | ...(projectId === null ? {} : { projectId }), |
| 3051 | ...(projectIds === null ? {} : { projectIds }), |
| 3052 | ...(host === null ? {} : { host }), |
| 3053 | ...(limit === null ? {} : { limit }), |
| 3054 | ...(query === null ? {} : { query }), |
| 3055 | ...(cursorEntries === null ? {} : { cursors: Object.fromEntries(cursorEntries) }), |
| 3056 | } as PullRequestListInput); |
| 3057 | }, |
| 3058 | { |
| 3059 | capacity: LIST_CACHE_CAPACITY, |
| 3060 | timeToLive: (exit) => (Exit.isSuccess(exit) ? LIST_CACHE_TTL : Duration.zero), |
| 3061 | }, |
| 3062 | ); |
| 3063 | const list: PullRequestService["Service"]["list"] = (input) => { |
| 3064 | const key = JSON.stringify([ |
| 3065 | listingsEpoch, |
| 3066 | input.state, |
| 3067 | input.involvement ?? null, |
| 3068 | // Positional so two identical filter sets key alike however their record was assembled. |
| 3069 | input.filters === undefined |
| 3070 | ? null |
| 3071 | : [ |
| 3072 | input.filters.draft ?? null, |
| 3073 | input.filters.review ?? null, |
| 3074 | input.filters.checks ?? null, |
| 3075 | input.filters.author ?? null, |
| 3076 | input.filters.labels ?? null, |
| 3077 | input.filters.excludedLabels ?? null, |
| 3078 | ], |
| 3079 | input.projectId ?? null, |
| 3080 | // Sorted so the same narrowing keys alike however the caller ordered it. |
| 3081 | input.projectIds === undefined ? null : [...input.projectIds].sort(), |
| 3082 | input.host ?? null, |
| 3083 | input.limit ?? null, |
| 3084 | input.query ?? null, |
| 3085 | input.cursors === undefined |
| 3086 | ? null |
| 3087 | : Object.entries(input.cursors).toSorted(([left], [right]) => left.localeCompare(right)), |
| 3088 | ]); |
| 3089 | // A replacement reader can join a lookup still finishing its previous reader's cancellation. |
| 3090 | return Cache.get(listCache, key).pipe( |
| 3091 | Effect.catchCause((cause) => |
| 3092 | Cause.hasInterruptsOnly(cause) ? Cache.get(listCache, key) : Effect.failCause(cause), |
| 3093 | ), |
| 3094 | ); |
| 3095 | }; |
| 3096 | |
| 3097 | const checksCache = yield* Cache.makeWith( |
| 3098 | (key: string) => { |
| 3099 | const input = refOfCacheKey(key); |
| 3100 | return requireProject(input).pipe( |
| 3101 | Effect.flatMap((project) => |
| 3102 | project.api.getChangeRequestChecks === undefined |
| 3103 | ? Effect.succeed(null) |
| 3104 | : project.api |
| 3105 | .getChangeRequestChecks({ |
| 3106 | cwd: project.project.workspaceRoot, |
| 3107 | repository: project.repository, |
| 3108 | host: project.host, |
| 3109 | number: input.number, |
| 3110 | }) |
| 3111 | .pipe(Effect.mapError(toPullRequestError("checks"))), |
| 3112 | ), |
| 3113 | ); |
| 3114 | }, |
| 3115 | { |
| 3116 | capacity: DETAIL_CACHE_CAPACITY, |
| 3117 | timeToLive: (exit) => (Exit.isSuccess(exit) ? CHECKS_CACHE_TTL : Duration.zero), |
| 3118 | }, |
| 3119 | ); |
| 3120 | |
| 3121 | const watchFingerprintCache = yield* Cache.makeWith( |
| 3122 | (key: string) => { |
| 3123 | const input = refOfCacheKey(key); |
| 3124 | return requireProject(input).pipe( |
| 3125 | Effect.flatMap((project) => |
| 3126 | project.api.getChangeRequestWatchFingerprint === undefined |
| 3127 | ? Effect.succeed(null) |
| 3128 | : project.api |
| 3129 | .getChangeRequestWatchFingerprint({ |
| 3130 | cwd: project.project.workspaceRoot, |
| 3131 | repository: project.repository, |
| 3132 | host: project.host, |
| 3133 | number: input.number, |
| 3134 | }) |
| 3135 | .pipe(Effect.mapError(toPullRequestError("watchFingerprint"))), |
| 3136 | ), |
| 3137 | ); |
| 3138 | }, |
| 3139 | { |
| 3140 | capacity: DETAIL_CACHE_CAPACITY, |
| 3141 | timeToLive: (exit) => (Exit.isSuccess(exit) ? CHECKS_CACHE_TTL : Duration.zero), |
| 3142 | }, |
| 3143 | ); |
| 3144 | |
| 3145 | const detailCache = yield* Cache.makeWith( |
| 3146 | (key: string) => { |
| 3147 | const statsKey = statsCacheKey(key); |
| 3148 | return detailUncached(refOfCacheKey(key)).pipe( |
| 3149 | Effect.tap( |
| 3150 | Effect.fn("PullRequestService.recordDetailStats")(function* (value: PullRequestDetail) { |
| 3151 | recordStats( |
| 3152 | statsKey, |
| 3153 | { |
| 3154 | projectId: value.projectId, |
| 3155 | repository: value.repository, |
| 3156 | number: value.number, |
| 3157 | additions: value.additions, |
| 3158 | deletions: value.deletions, |
| 3159 | }, |
| 3160 | yield* Clock.currentTimeMillis, |
| 3161 | ); |
| 3162 | }), |
| 3163 | ), |
| 3164 | ); |
| 3165 | }, |
| 3166 | { |
| 3167 | capacity: DETAIL_CACHE_CAPACITY, |
| 3168 | timeToLive: (exit) => |
| 3169 | Exit.isSuccess(exit) ? detailTimeToLive(exit.value.state) : Duration.zero, |
| 3170 | }, |
| 3171 | ); |
| 3172 | const summaryFromDetail = ( |
| 3173 | detail: PullRequestDetail, |
| 3174 | previous: PullRequestSummary | undefined, |
| 3175 | ): PullRequestSummary => ({ |
| 3176 | // Detail does not carry review/check summaries. Keep the last summary observation. |
| 3177 | ...previous, |
| 3178 | provider: detail.provider, |
| 3179 | projectId: detail.projectId, |
| 3180 | repository: detail.repository, |
| 3181 | number: detail.number, |
| 3182 | title: detail.title, |
| 3183 | url: detail.url, |
| 3184 | state: detail.state, |
| 3185 | isDraft: detail.isDraft, |
| 3186 | author: detail.author, |
| 3187 | additions: detail.additions, |
| 3188 | deletions: detail.deletions, |
| 3189 | changedFiles: detail.changedFiles, |
| 3190 | mergeability: detail.mergeability, |
| 3191 | headBranch: detail.headBranch, |
| 3192 | baseBranch: detail.baseBranch, |
| 3193 | closedAt: detail.closedAt, |
| 3194 | mergedAt: detail.mergedAt, |
| 3195 | updatedAt: detail.updatedAt, |
| 3196 | observedAt: detail.observedAt, |
| 3197 | }); |
| 3198 | const shouldReplaceHeldSummary = (key: string, next: PullRequestSummary) => |
| 3199 | supersedesReading(lastGoodSummary.peek(key), next); |
| 3200 | // The newest state a detail read found per pull request, whatever cache epoch or credential |
| 3201 | // it read under. Summary reads stay out: they would hide a change from the detail read after. |
| 3202 | const detailStates = new Map<string, PullRequestReading>(); |
| 3203 | /** |
| 3204 | * Announces a pull request a detail read sees first or in a new state, so a client showing a |
| 3205 | * fresh merge brings the thread's link and its settlement along, rather than leaving them to |
| 3206 | * the next sync sweep. |
| 3207 | */ |
| 3208 | const noteDetailReading = (ref: PullRequestRef, next: PullRequestSummary) => |
| 3209 | Effect.suspend(() => { |
| 3210 | const scope = refScope(ref); |
| 3211 | const current = detailStates.get(scope); |
| 3212 | if (!supersedesReading(current, next)) return Effect.void; |
| 3213 | detailStates.delete(scope); |
| 3214 | if (detailStates.size >= REF_EPOCH_CAPACITY) { |
| 3215 | const oldest = detailStates.keys().next().value; |
| 3216 | if (oldest !== undefined) detailStates.delete(oldest); |
| 3217 | } |
| 3218 | detailStates.set(scope, { |
| 3219 | state: next.state, |
| 3220 | updatedAt: next.updatedAt, |
| 3221 | observedAt: next.observedAt, |
| 3222 | }); |
| 3223 | return current?.state !== next.state && ref.host !== undefined |
| 3224 | ? PubSub.publish(stateChanges, { |
| 3225 | host: ref.host, |
| 3226 | repository: ref.repository, |
| 3227 | number: ref.number, |
| 3228 | }) |
| 3229 | : Effect.void; |
| 3230 | }); |
| 3231 | // The last state each routed read reported, kept apart from `detailStates`: an unverified report |
| 3232 | // must never outrank a reading this environment made itself. |
| 3233 | const reportedStates = new Map< |
| 3234 | string, |
| 3235 | { readonly state: PullRequestState; readonly atMs: number } |
| 3236 | >(); |
| 3237 | const reportState: PullRequestService["Service"]["reportState"] = ({ reference, state }) => |
| 3238 | Effect.all([canonicalRef(reference), Clock.currentTimeMillis]).pipe( |
| 3239 | Effect.flatMap(([ref, nowMs]) => |
| 3240 | Effect.suspend(() => { |
| 3241 | const scope = refScope(ref); |
| 3242 | const reported = reportedStates.get(scope); |
| 3243 | if (reported?.state === state && nowMs - reported.atMs < REPORTED_STATE_DEDUPE_MS) |
| 3244 | return Effect.void; |
| 3245 | reportedStates.delete(scope); |
| 3246 | if (reportedStates.size >= REF_EPOCH_CAPACITY) { |
| 3247 | const oldest = reportedStates.keys().next().value; |
| 3248 | if (oldest !== undefined) reportedStates.delete(oldest); |
| 3249 | } |
| 3250 | reportedStates.set(scope, { state, atMs: nowMs }); |
| 3251 | return ref.host === undefined |
| 3252 | ? Effect.void |
| 3253 | : PubSub.publish(stateChanges, { |
| 3254 | host: ref.host, |
| 3255 | repository: ref.repository, |
| 3256 | number: ref.number, |
| 3257 | }); |
| 3258 | }), |
| 3259 | ), |
| 3260 | ); |
| 3261 | const detail: PullRequestService["Service"]["detail"] = (input) => { |
| 3262 | const key = refCacheKey(input); |
| 3263 | // Record the summary from a host or cache read, not the stale value |
| 3264 | // `serveHeld` returns immediately. Skip the write when that read is older |
| 3265 | // than a later strict summary — display reuse would otherwise keep the |
| 3266 | // regression and never ask the host again. |
| 3267 | const read = Cache.get(detailCache, key).pipe( |
| 3268 | Effect.tap((value) => { |
| 3269 | if (value.state === "merged") noteMerged(input); |
| 3270 | const summary = summaryFromDetail(value, lastGoodSummary.peek(key)); |
| 3271 | return noteDetailReading(input, summary).pipe( |
| 3272 | Effect.andThen( |
| 3273 | shouldReplaceHeldSummary(key, summary) |
| 3274 | ? lastGoodSummary.record(key, summary) |
| 3275 | : Effect.void, |
| 3276 | ), |
| 3277 | ); |
| 3278 | }), |
| 3279 | ); |
| 3280 | return input.allowStale === false |
| 3281 | ? read.pipe(Effect.tap((value) => lastGoodDetail.record(key, value))) |
| 3282 | : lastGoodDetail.serveHeld(key, read, "revalidate"); |
| 3283 | }; |
| 3284 | |
| 3285 | const activityCache = yield* Cache.makeWith( |
| 3286 | (key: string) => { |
| 3287 | return activityUncached(refOfCacheKey(key)); |
| 3288 | }, |
| 3289 | { |
| 3290 | capacity: DETAIL_CACHE_CAPACITY, |
| 3291 | // Activity carries no state of its own; the detail or summary read under the same key does. |
| 3292 | timeToLive: (exit, key) => |
| 3293 | Exit.isSuccess(exit) ? detailTimeToLive(lastGoodSummary.peek(key)?.state) : Duration.zero, |
| 3294 | }, |
| 3295 | ); |
| 3296 | |
| 3297 | const previewCache = yield* Cache.makeWith( |
| 3298 | (key: string) => { |
| 3299 | return previewUncached(refOfCacheKey(key)); |
| 3300 | }, |
| 3301 | { |
| 3302 | capacity: DETAIL_CACHE_CAPACITY, |
| 3303 | timeToLive: (exit) => |
| 3304 | Exit.isSuccess(exit) ? detailTimeToLive(exit.value.state) : Duration.zero, |
| 3305 | }, |
| 3306 | ); |
| 3307 | const preview: PullRequestService["Service"]["preview"] = (input) => { |
| 3308 | const key = refCacheKey(input); |
| 3309 | return Cache.getSuccess(detailCache, key).pipe( |
| 3310 | Effect.flatMap( |
| 3311 | Option.match({ |
| 3312 | onNone: () => Cache.get(previewCache, key), |
| 3313 | onSome: (detail) => Effect.succeed(previewFields(detail)), |
| 3314 | }), |
| 3315 | ), |
| 3316 | ); |
| 3317 | }; |
| 3318 | const activity: PullRequestService["Service"]["activity"] = (input) => { |
| 3319 | const key = refCacheKey(input); |
| 3320 | return Cache.get(activityCache, key); |
| 3321 | }; |
| 3322 | |
| 3323 | const diffCache = yield* Cache.makeWith( |
| 3324 | (key: string) => { |
| 3325 | const [reference, cursor, commit] = JSON.parse(key) as [string, string | null, string | null]; |
| 3326 | return diffUncached({ |
| 3327 | ...refOfCacheKey(reference), |
| 3328 | ...(cursor === null ? {} : { cursor }), |
| 3329 | ...(commit === null ? {} : { commit }), |
| 3330 | } as PullRequestDiffInput); |
| 3331 | }, |
| 3332 | { |
| 3333 | capacity: DIFF_CACHE_CAPACITY, |
| 3334 | timeToLive: (exit, key) => { |
| 3335 | if (!Exit.isSuccess(exit)) return Duration.zero; |
| 3336 | const commit = (JSON.parse(key) as ReadonlyArray<unknown>)[2]; |
| 3337 | return commit === null ? DIFF_CACHE_TTL : COMMIT_DIFF_CACHE_TTL; |
| 3338 | }, |
| 3339 | }, |
| 3340 | ); |
| 3341 | /** |
| 3342 | * Both diff holders drop an expired value only when its own key is read again, and a diff's key |
| 3343 | * moves with every push, so a closed page's patches would stay held until the server restarted. |
| 3344 | * One pending sweep at a time, armed in the same step that leaves a value held and re-armed |
| 3345 | * while anything still is, so an idle server keeps no timer. |
| 3346 | */ |
| 3347 | let diffSweepPending = false; |
| 3348 | // Forked from the service's own context, in one step with the flag: a caller's interruption |
| 3349 | // cannot strand the flag, and its request is not kept alive by the wait. |
| 3350 | const armDiffSweep = () => { |
| 3351 | if (diffSweepPending) return; |
| 3352 | diffSweepPending = true; |
| 3353 | runFork(Effect.forkIn(sweepDiffs, scope)); |
| 3354 | }; |
| 3355 | const sweepDiffs: Effect.Effect<void> = Effect.sleep(DIFF_SWEEP_INTERVAL).pipe( |
| 3356 | Effect.andThen( |
| 3357 | Effect.suspend(() => { |
| 3358 | // Cleared before the sweep, so a read that settles during it arms the next one itself. |
| 3359 | diffSweepPending = false; |
| 3360 | return staleDiff.dropExpired; |
| 3361 | }), |
| 3362 | ), |
| 3363 | // The cache drops its expired entries as its keys are walked. |
| 3364 | Effect.flatMap((stale) => |
| 3365 | Effect.map(Cache.keys(diffCache), (keys) => stale + Array.from(keys).length), |
| 3366 | ), |
| 3367 | Effect.map((held) => { |
| 3368 | if (held > 0) armDiffSweep(); |
| 3369 | }), |
| 3370 | ); |
| 3371 | const diff: PullRequestService["Service"]["diff"] = (input) => { |
| 3372 | const key = JSON.stringify([ |
| 3373 | refCacheKey(input), |
| 3374 | input.cursor ?? null, |
| 3375 | input.commit ?? null, |
| 3376 | input.commit === undefined |
| 3377 | ? (lastGoodSummary.peek(refCacheKey(input))?.updatedAt ?? null) |
| 3378 | : null, |
| 3379 | ]); |
| 3380 | const read = Cache.get(diffCache, key).pipe( |
| 3381 | Effect.tap((value) => |
| 3382 | canCacheDiff(value) |
| 3383 | ? Effect.void |
| 3384 | : Cache.getSuccess(diffCache, key).pipe( |
| 3385 | Effect.flatMap((current) => |
| 3386 | Option.isSome(current) && current.value === value |
| 3387 | ? Cache.invalidate(diffCache, key) |
| 3388 | : Effect.void, |
| 3389 | ), |
| 3390 | Effect.uninterruptible, |
| 3391 | ), |
| 3392 | ), |
| 3393 | // However the read ends, it may have left a value behind in the cache. |
| 3394 | Effect.ensuring(Effect.sync(armDiffSweep)), |
| 3395 | ); |
| 3396 | return staleDiff.serve(key, read); |
| 3397 | }; |
| 3398 | |
| 3399 | const filesViewedCache = yield* Cache.makeWith( |
| 3400 | (key: string) => { |
| 3401 | const [referenceKey] = JSON.parse(key) as [string, number]; |
| 3402 | return viewedFiles.filesViewed(refOfCacheKey(referenceKey)); |
| 3403 | }, |
| 3404 | { |
| 3405 | capacity: FILES_VIEWED_CACHE_CAPACITY, |
| 3406 | timeToLive: (exit) => (Exit.isSuccess(exit) ? FILES_VIEWED_CACHE_TTL : Duration.zero), |
| 3407 | }, |
| 3408 | ); |
| 3409 | // Canonicalised before it is keyed: both epochs are bumped against the remote's own spelling, |
| 3410 | // so a reference keyed as the client spelled it would never see a refresh or a press. |
| 3411 | const filesViewed: PullRequestService["Service"]["filesViewed"] = (input) => |
| 3412 | canonicalRef(input).pipe( |
| 3413 | Effect.flatMap((ref) => |
| 3414 | Cache.get(filesViewedCache, JSON.stringify([refCacheKey(ref), filesViewedEpoch(ref)])), |
| 3415 | ), |
| 3416 | ); |
| 3417 | |
| 3418 | const listStatsCache = yield* Cache.makeWith( |
| 3419 | (key: string) => { |
| 3420 | const [, refs] = JSON.parse(key) as [number, ReadonlyArray<[string, string, number, number]>]; |
| 3421 | return listStatsUncached({ |
| 3422 | refs: refs.map(([projectId, repository, number]) => ({ projectId, repository, number })), |
| 3423 | } as unknown as PullRequestListStatsInput).pipe( |
| 3424 | Effect.flatMap((result) => |
| 3425 | Clock.currentTimeMillis.pipe(Effect.map((at) => ({ result, at }))), |
| 3426 | ), |
| 3427 | ); |
| 3428 | }, |
| 3429 | { |
| 3430 | capacity: LIST_STATS_CACHE_CAPACITY, |
| 3431 | timeToLive: (exit) => (Exit.isSuccess(exit) ? LIST_STATS_CACHE_TTL : Duration.zero), |
| 3432 | }, |
| 3433 | ); |
| 3434 | const statsBatchKey = (refs: Iterable<PullRequestRef>) => |
| 3435 | JSON.stringify([ |
| 3436 | listingsEpoch, |
| 3437 | [...refs] |
| 3438 | .map((ref) => [ref.projectId, ref.repository, ref.number, refEpoch(ref)] as const) |
| 3439 | .toSorted((left, right) => |
| 3440 | `${left[0]} ${left[1]} ${left[2]}`.localeCompare(`${right[0]} ${right[1]} ${right[2]}`), |
| 3441 | ), |
| 3442 | ]); |
| 3443 | // Exact batches share in-flight reads; overlapping pages reuse each row already fetched. |
| 3444 | const listStats: PullRequestService["Service"]["listStats"] = Effect.fn( |
| 3445 | "PullRequestService.listStats", |
| 3446 | )(function* (input: PullRequestListStatsInput) { |
| 3447 | if (input.refs.length === 0) return { stats: [] }; |
| 3448 | const now = yield* Clock.currentTimeMillis; |
| 3449 | const held: PullRequestDiffStat[] = []; |
| 3450 | const missing = new Map<string, PullRequestRef>(); |
| 3451 | for (const ref of input.refs) { |
| 3452 | const key = statsCacheKey(refCacheKey(ref)); |
| 3453 | const cached = recentStats.get(key); |
| 3454 | if (cached !== undefined && now - cached.at < Duration.toMillis(LIST_STATS_CACHE_TTL)) { |
| 3455 | held.push(cached.value); |
| 3456 | } else { |
| 3457 | missing.set(key, ref); |
| 3458 | } |
| 3459 | } |
| 3460 | if (missing.size === 0) return { stats: held }; |
| 3461 | const key = statsBatchKey(missing.values()); |
| 3462 | const { result, at } = yield* Cache.get(listStatsCache, key).pipe( |
| 3463 | Effect.catchCause((cause) => |
| 3464 | Cause.hasInterruptsOnly(cause) ? Cache.get(listStatsCache, key) : Effect.failCause(cause), |
| 3465 | ), |
| 3466 | ); |
| 3467 | for (const [key, ref] of missing) { |
| 3468 | const stat = result.stats.find( |
| 3469 | (stat) => |
| 3470 | stat.projectId === ref.projectId && |
| 3471 | stat.repository.toLowerCase() === ref.repository.toLowerCase() && |
| 3472 | stat.number === ref.number, |
| 3473 | ); |
| 3474 | if (stat !== undefined) recordStats(key, stat, at); |
| 3475 | } |
| 3476 | return { stats: [...held, ...result.stats] }; |
| 3477 | }); |
| 3478 | |
| 3479 | const invalidate: PullRequestService["Service"]["invalidate"] = Effect.fn( |
| 3480 | "PullRequestService.invalidate", |
| 3481 | )(function* (input, options) { |
| 3482 | const reference = input.reference; |
| 3483 | if (input.filesViewedOnly === true) { |
| 3484 | return yield* reference === undefined |
| 3485 | ? Cache.invalidateAll(filesViewedCache) |
| 3486 | : canonicalRef(reference).pipe( |
| 3487 | Effect.flatMap((ref) => Effect.sync(() => bumpFilesViewedEpoch(ref))), |
| 3488 | Effect.ignore, |
| 3489 | ); |
| 3490 | } |
| 3491 | if (reference !== undefined) { |
| 3492 | yield* canonicalRef(reference).pipe( |
| 3493 | Effect.flatMap((ref) => |
| 3494 | readCache |
| 3495 | .invalidate(refScope(ref)) |
| 3496 | .pipe(Effect.andThen(Effect.sync(() => bumpRefEpoch(ref)))), |
| 3497 | ), |
| 3498 | Effect.ignore, |
| 3499 | ); |
| 3500 | } else { |
| 3501 | listingsEpoch = ++epochCounter; |
| 3502 | everyFileRevisionEpoch = ++epochCounter; |
| 3503 | viewersByHost.clear(); |
| 3504 | yield* Cache.invalidateAll(viewerFlights); |
| 3505 | } |
| 3506 | if (options?.notifyReaders) { |
| 3507 | yield* SubscriptionRef.set(pullRequestRefreshes, ++epochCounter); |
| 3508 | } |
| 3509 | }); |
| 3510 | |
| 3511 | const refreshAfterTurn: PullRequestService["Service"]["refreshAfterTurn"] = (projectId) => |
| 3512 | Effect.suspend(() => { |
| 3513 | listingsEpoch = ++epochCounter; |
| 3514 | projectEpochs.delete(projectId); |
| 3515 | if (projectEpochs.size >= REF_EPOCH_CAPACITY) { |
| 3516 | const oldest = projectEpochs.keys().next().value; |
| 3517 | if (oldest !== undefined) { |
| 3518 | projectEpochFloor = projectEpochs.get(oldest)!; |
| 3519 | projectEpochs.delete(oldest); |
| 3520 | } |
| 3521 | } |
| 3522 | projectEpochs.set(projectId, listingsEpoch); |
| 3523 | return readCache |
| 3524 | .invalidate(`project:${projectId}`) |
| 3525 | .pipe(Effect.andThen(SubscriptionRef.set(pullRequestRefreshes, listingsEpoch))); |
| 3526 | }); |
| 3527 | |
| 3528 | // Invalidate before notifying every client so mounted readers immediately fetch the edit. |
| 3529 | const invalidatedByMutation = |
| 3530 | <I extends PullRequestRef>( |
| 3531 | method: (input: I) => Effect.Effect<void, PullRequestError>, |
| 3532 | ): ((input: I) => Effect.Effect<void, PullRequestError>) => |
| 3533 | (input) => |
| 3534 | Effect.gen(function* () { |
| 3535 | const ref = yield* canonicalRef(input); |
| 3536 | yield* readCache.invalidate(refScope(ref)).pipe( |
| 3537 | Effect.andThen(method(input)), |
| 3538 | Effect.ensuring(readCache.invalidate(refScope(ref))), |
| 3539 | Effect.tap(() => |
| 3540 | Effect.sync(() => { |
| 3541 | bumpRefEpoch(ref); |
| 3542 | listingsEpoch = ++epochCounter; |
| 3543 | }), |
| 3544 | ), |
| 3545 | ); |
| 3546 | yield* SubscriptionRef.set(pullRequestRefreshes, listingsEpoch); |
| 3547 | }); |
| 3548 | const runActionAndInvalidate: PullRequestService["Service"]["runAction"] = Effect.fn( |
| 3549 | "PullRequestService.runActionAndInvalidate", |
| 3550 | )(function* (input) { |
| 3551 | const ref = yield* canonicalRef(input); |
| 3552 | yield* readCache.invalidate(refScope(ref)); |
| 3553 | const repository = yield* runAction(input).pipe( |
| 3554 | Effect.ensuring(readCache.invalidate(refScope(ref))), |
| 3555 | ); |
| 3556 | bumpRefEpoch({ ...ref, repository }); |
| 3557 | listingsEpoch = ++epochCounter; |
| 3558 | yield* SubscriptionRef.set(pullRequestRefreshes, listingsEpoch); |
| 3559 | if (input.action === "merge") { |
| 3560 | // A successful merge action can merely enqueue the PR or enable auto-merge. |
| 3561 | const confirmed = yield* summaryUncached({ ...input, repository }).pipe( |
| 3562 | Effect.catch((error) => |
| 3563 | Effect.logWarning("failed to confirm pull request merge", { error }).pipe( |
| 3564 | Effect.as(null), |
| 3565 | ), |
| 3566 | ), |
| 3567 | ); |
| 3568 | if (confirmed?.state !== "merged") return; |
| 3569 | yield* PubSub.publish(mergedPullRequests, { |
| 3570 | projectId: input.projectId, |
| 3571 | repository, |
| 3572 | number: input.number, |
| 3573 | mergedAt: DateTime.formatIso(yield* DateTime.now), |
| 3574 | }); |
| 3575 | } |
| 3576 | }); |
| 3577 | |
| 3578 | const credentialCached = |
| 3579 | <I extends PullRequestRef, Args extends ReadonlyArray<unknown>, A, E>( |
| 3580 | read: (input: I, ...args: Args) => Effect.Effect<A, E>, |
| 3581 | ) => |
| 3582 | (input: I, ...args: Args) => |
| 3583 | Effect.gen(function* () { |
| 3584 | const ref = yield* canonicalRef(input); |
| 3585 | const credential = yield* routingCredential; |
| 3586 | return yield* read( |
| 3587 | credential === null |
| 3588 | ? ref |
| 3589 | : { ...ref, [credentialNamespace]: credential.credentialFingerprint }, |
| 3590 | ...args, |
| 3591 | ); |
| 3592 | }); |
| 3593 | |
| 3594 | const withWorkRead = |
| 3595 | <I extends PullRequestRef, Args extends ReadonlyArray<unknown>, A>( |
| 3596 | normal: (input: I, ...args: Args) => Effect.Effect<A, PullRequestError>, |
| 3597 | work: (input: I) => Effect.Effect<A, PullRequestError>, |
| 3598 | ) => |
| 3599 | (input: I, ...args: Args) => |
| 3600 | Effect.gen(function* () { |
| 3601 | if (!isMergebotRepository(mergebot.configuration ?? undefined, input.repository)) |
| 3602 | return yield* normal(input, ...args); |
| 3603 | const project = yield* requireProject(input); |
| 3604 | if (!isWorkProject(project)) return yield* normal(input, ...args); |
| 3605 | return yield* work({ |
| 3606 | ...input, |
| 3607 | projectId: project.project.id, |
| 3608 | repository: project.repository, |
| 3609 | host: project.host, |
| 3610 | }); |
| 3611 | }); |
| 3612 | |
| 3613 | const workDetails = Effect.fn("PullRequestService.mergebotDetails")(function* ( |
| 3614 | input: PullRequestRef, |
| 3615 | ) { |
| 3616 | const value = yield* mergebot.readPullRequest(input); |
| 3617 | if (value === null) |
| 3618 | return yield* new PullRequestOperationError({ |
| 3619 | operation: "mergebot", |
| 3620 | detail: "Mergebot is unavailable. Try again after it reconnects.", |
| 3621 | }); |
| 3622 | return value; |
| 3623 | }); |
| 3624 | |
| 3625 | const unsupportedWorkRead = <I extends PullRequestRef, Args extends ReadonlyArray<unknown>, A>( |
| 3626 | operation: string, |
| 3627 | normal: (input: I, ...args: Args) => Effect.Effect<A, PullRequestError>, |
| 3628 | ) => |
| 3629 | withWorkRead( |
| 3630 | normal, |
| 3631 | () => |
| 3632 | new PullRequestOperationError({ |
| 3633 | operation, |
| 3634 | detail: "Open this pull request in Mergebot to continue.", |
| 3635 | }), |
| 3636 | ); |
| 3637 | |
| 3638 | const workSummary = Effect.fn("PullRequestService.mergebotSummary")(function* ( |
| 3639 | input: PullRequestRef, |
| 3640 | ): Effect.fn.Return<PullRequestSummary, PullRequestError> { |
| 3641 | const value = yield* workDetails(input); |
| 3642 | return { |
| 3643 | projectId: input.projectId, |
| 3644 | repository: input.repository, |
| 3645 | number: value.number, |
| 3646 | provider: SourceControlProviderKind.make("github"), |
| 3647 | title: value.title, |
| 3648 | url: value.url, |
| 3649 | state: value.lifecycle, |
| 3650 | headBranch: value.headBranch, |
| 3651 | baseBranch: value.baseBranch, |
| 3652 | isDraft: value.draft, |
| 3653 | updatedAt: value.updatedAt, |
| 3654 | observedAt: value.observedAt, |
| 3655 | author: value.author, |
| 3656 | ...(value.mergedAt === undefined ? {} : { mergedAt: value.mergedAt }), |
| 3657 | ...(value.closedAt === undefined ? {} : { closedAt: value.closedAt }), |
| 3658 | ...(value.additions === null ? {} : { additions: value.additions }), |
| 3659 | ...(value.deletions === null ? {} : { deletions: value.deletions }), |
| 3660 | ...(value.changedFilesCount === null ? {} : { changedFiles: value.changedFilesCount }), |
| 3661 | mergeability: mergebotMergeability(value), |
| 3662 | ...(value.ciStatus === "passed" |
| 3663 | ? { checksState: "passing" as const } |
| 3664 | : value.ciStatus === "failed" |
| 3665 | ? { checksState: "failing" as const } |
| 3666 | : value.ciStatus === "pending" |
| 3667 | ? { checksState: "pending" as const } |
| 3668 | : {}), |
| 3669 | }; |
| 3670 | }); |
| 3671 | |
| 3672 | yield* mergebot.streamChanges.pipe( |
| 3673 | Stream.runForEach((change) => |
| 3674 | Effect.gen(function* () { |
| 3675 | const numbers = |
| 3676 | change.number === undefined |
| 3677 | ? ((yield* mergebot.read(change))?.pullRequests?.map((pr) => pr.number) ?? []) |
| 3678 | : [change.number]; |
| 3679 | yield* PubSub.publishAll( |
| 3680 | stateChanges, |
| 3681 | numbers.map((number) => ({ host: "github.com", repository: change.repository, number })), |
| 3682 | ); |
| 3683 | }), |
| 3684 | ), |
| 3685 | Effect.forkScoped, |
| 3686 | ); |
| 3687 | |
| 3688 | return PullRequestService.of({ |
| 3689 | routing, |
| 3690 | routingIdentity, |
| 3691 | withRoutingCredential, |
| 3692 | list: (input) => |
| 3693 | mergebot.configuration === null |
| 3694 | ? list(input) |
| 3695 | : listWorkspaceProjects(input).pipe( |
| 3696 | Effect.flatMap(({ supported }) => |
| 3697 | supported.some(isWorkProject) ? listUncached(input) : list(input), |
| 3698 | ), |
| 3699 | ), |
| 3700 | listStats: (input) => |
| 3701 | Effect.gen(function* () { |
| 3702 | const refs = (yield* Effect.forEach(input.refs, (ref) => |
| 3703 | canonicalRef(ref).pipe(Effect.option), |
| 3704 | )).flatMap(Option.toArray); |
| 3705 | const ordinary: PullRequestRef[] = []; |
| 3706 | const workStats: PullRequestDiffStat[] = []; |
| 3707 | for (const ref of refs) { |
| 3708 | if (!isWorkProject(yield* requireProject(ref))) { |
| 3709 | ordinary.push(ref); |
| 3710 | continue; |
| 3711 | } |
| 3712 | const value = yield* workDetails(ref); |
| 3713 | if (value.additions !== null && value.deletions !== null) |
| 3714 | workStats.push({ |
| 3715 | projectId: ref.projectId, |
| 3716 | repository: ref.repository, |
| 3717 | number: ref.number, |
| 3718 | additions: value.additions, |
| 3719 | deletions: value.deletions, |
| 3720 | }); |
| 3721 | } |
| 3722 | const result = yield* listStats({ ...input, refs: ordinary }); |
| 3723 | return { stats: [...workStats, ...result.stats] }; |
| 3724 | }), |
| 3725 | summary: withWorkRead(credentialCached(summary), workSummary), |
| 3726 | stack: unsupportedWorkRead("stack", credentialCached(stack)), |
| 3727 | subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe( |
| 3728 | Effect.map((subscription) => Stream.fromSubscription(subscription)), |
| 3729 | ), |
| 3730 | subscribeStateChanges: PubSub.subscribe(stateChanges).pipe( |
| 3731 | Effect.map((subscription) => Stream.fromSubscription(subscription)), |
| 3732 | ), |
| 3733 | reportState, |
| 3734 | subscribeRefreshes: SubscriptionRef.changes(pullRequestRefreshes).pipe( |
| 3735 | Stream.filter((revision) => revision > 0), |
| 3736 | ), |
| 3737 | refreshAfterTurn, |
| 3738 | detail: unsupportedWorkRead("detail", credentialCached(detail)), |
| 3739 | checks: withWorkRead( |
| 3740 | credentialCached((input) => Cache.get(checksCache, refCacheKey(input))), |
| 3741 | (input) => |
| 3742 | workDetails(input).pipe( |
| 3743 | Effect.map((value) => ({ state: value.lifecycle, checks: mergebotChecks(value) })), |
| 3744 | ), |
| 3745 | ), |
| 3746 | watchFingerprint: withWorkRead( |
| 3747 | credentialCached((input) => Cache.get(watchFingerprintCache, refCacheKey(input))), |
| 3748 | (input) => workDetails(input).pipe(Effect.map(mergebotWatchFingerprint)), |
| 3749 | ), |
| 3750 | activity: unsupportedWorkRead("activity", credentialCached(activity)), |
| 3751 | preview: withWorkRead(credentialCached(preview), (input) => |
| 3752 | workDetails(input).pipe( |
| 3753 | Effect.map((value) => ({ |
| 3754 | projectId: input.projectId, |
| 3755 | repository: input.repository, |
| 3756 | number: value.number, |
| 3757 | title: value.title, |
| 3758 | url: value.url, |
| 3759 | author: value.author, |
| 3760 | state: value.lifecycle, |
| 3761 | isDraft: value.draft, |
| 3762 | createdAt: value.createdAt, |
| 3763 | })), |
| 3764 | ), |
| 3765 | ), |
| 3766 | threadComments: unsupportedWorkRead("threadComments", threadComments), |
| 3767 | diff: unsupportedWorkRead("diff", credentialCached(diff)), |
| 3768 | diffFileContents: unsupportedWorkRead("diffFileContents", diffFileContents), |
| 3769 | filesViewed: unsupportedWorkRead("filesViewed", credentialCached(filesViewed)), |
| 3770 | setFilesViewed: unsupportedWorkRead("setFilesViewed", setFilesViewed), |
| 3771 | runAction: unsupportedWorkRead("runAction", runActionAndInvalidate), |
| 3772 | update: unsupportedWorkRead("update", invalidatedByMutation(update)), |
| 3773 | comment: unsupportedWorkRead("comment", invalidatedByMutation(comment)), |
| 3774 | updateComment: unsupportedWorkRead("updateComment", invalidatedByMutation(updateComment)), |
| 3775 | submitReview: unsupportedWorkRead("submitReview", invalidatedByMutation(submitReview)), |
| 3776 | replyToThread: unsupportedWorkRead("replyToThread", invalidatedByMutation(replyToThread)), |
| 3777 | setThreadResolution: unsupportedWorkRead( |
| 3778 | "setThreadResolution", |
| 3779 | invalidatedByMutation(setThreadResolution), |
| 3780 | ), |
| 3781 | setReaction: unsupportedWorkRead("setReaction", invalidatedByMutation(setReaction)), |
| 3782 | // The candidate list is deliberately read fresh per menu-open, so it stays uncached. |
| 3783 | reviewerCandidates: unsupportedWorkRead("reviewerCandidates", reviewerCandidates), |
| 3784 | requestReviewers: unsupportedWorkRead( |
| 3785 | "requestReviewers", |
| 3786 | invalidatedByMutation(requestReviewers), |
| 3787 | ), |
| 3788 | labelCandidates: unsupportedWorkRead("labelCandidates", labelCandidates), |
| 3789 | setLabels: unsupportedWorkRead("setLabels", invalidatedByMutation(setLabels)), |
| 3790 | invalidate, |
| 3791 | }); |
| 3792 | }); |
| 3793 | |
| 3794 | export const layer = Layer.effect(PullRequestService, make); |