1import {
2 canonicalRepositoryKey,
3 isSshRemoteUrl,
4 sourceControlRepositorySelector,
5} from "@t3tools/shared/sourceControl";
6import { normalizeGitRemoteUrl } from "@t3tools/shared/git";
7import * as Cache from "effect/Cache";
8import * as Cause from "effect/Cause";
9import * as Clock from "effect/Clock";
10import * as Context from "effect/Context";
11import * as DateTime from "effect/DateTime";
12import * as Duration from "effect/Duration";
13import * as Effect from "effect/Effect";
14import * as Exit from "effect/Exit";
15import * as Layer from "effect/Layer";
16import * as PubSub from "effect/PubSub";
17import * as Option from "effect/Option";
18import * as Schema from "effect/Schema";
19import type * as Scope from "effect/Scope";
20import * as Stream from "effect/Stream";
21import * as SubscriptionRef from "effect/SubscriptionRef";
22import {
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";
79import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
80import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl";
81
82import * as MergebotQueue from "../mergebot/MergebotQueue.ts";
83import {
84 mergebotChecks,
85 mergebotMergeability,
86 mergebotWatchFingerprint,
87} from "../mergebot/pullRequestRead.ts";
88import * as ProjectService from "../project/ProjectService.ts";
89import * as ServerSettings from "../serverSettings.ts";
90import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts";
91import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";
92import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
93import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
94import {
95 type ProviderChangeRequest,
96 type ProviderListCursor,
97 type ProviderChangeRequestWatchFingerprint,
98 type PullRequestProviderApi,
99 PullRequestProviderError,
100} from "@t3tools/source-control-core/server/PullRequestProvider";
101import * as PullRequestReadCache from "./PullRequestReadCache.ts";
102import * as PullRequestProviderRegistry from "./PullRequestProviderRegistry.ts";
103import * as ViewedFiles from "./pullRequestViewedFiles.ts";
104
105export interface PullRequestMergeEvent extends PullRequestRef {
106 readonly mergedAt: string;
107}
108
109type PullRequestReading = Pick<PullRequestSummary, "state" | "updatedAt" | "observedAt">;
110
111/** Whether `next` is a newer reading of a pull request than `current`. Merged is final. */
112function 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 */
129const 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 */
136const 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 */
144const 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 */
155const 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 */
162const DETAIL_CACHE_TTL = Duration.seconds(60);
163const MERGED_DETAIL_CACHE_TTL = Duration.minutes(10);
164const 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 */
170const 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. */
172const REPORTED_STATE_DEDUPE_MS = 5 * 60 * 1_000;
173const DIFF_CACHE_TTL = Duration.seconds(60);
174/** A commit is content-addressed, so its own diff cannot change under its key. */
175const 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. */
177const 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 */
183const FILES_VIEWED_CACHE_TTL = Duration.seconds(15);
184/** A diff can stay interactive while its next cached value is fetched off the critical path. */
185const DIFF_STALE_WINDOW = Duration.minutes(10);
186/** How long an expired diff may stay held before it is dropped unread. */
187const DIFF_SWEEP_INTERVAL = Duration.minutes(1);
188/** How long one host's signed-in login is believed without asking its CLI again. */
189const VIEWER_CACHE_TTL = Duration.minutes(10);
190const SEARCH_VISIBILITY_TTL = Duration.minutes(10);
191const STALE_DETAIL_WINDOW = Duration.minutes(10);
192const isPullRequestProviderError = Schema.is(PullRequestProviderError);
193const LIST_CACHE_CAPACITY = 64;
194const LIST_STATS_CACHE_CAPACITY = 32;
195const DETAIL_CACHE_CAPACITY = 128;
196const DIFF_CACHE_CAPACITY = 128;
197// Each diff cache can retain at most 64 MiB of patch text, counting UTF-16 storage.
198const MAX_CACHED_DIFF_PATCH_BYTES = 512 * 1024;
199const canCacheDiff = (value: PullRequestDiffResult) =>
200 value.patch.length * 2 <= MAX_CACHED_DIFF_PATCH_BYTES;
201const FILES_VIEWED_CACHE_CAPACITY = 128;
202const VIEWER_CACHE_CAPACITY = 32;
203
204export type PullRequestError = PullRequestUnavailableError | PullRequestOperationError;
205
206const 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.
211const credentialNamespace = Symbol("pullRequestCredentialNamespace");
212type CredentialRef = PullRequestRef & { readonly [credentialNamespace]?: string };
213
214export 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. */
338const 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 */
349const 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 */
376const REVIEWER_REQUEST_REFUSAL = "You need write access on this repository to ask for a review.";
377const 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. */
380export 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 */
405interface 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
421interface 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. */
431interface 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 */
445const 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
448function 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 */
463function 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 */
476function 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 */
503function 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. */
518function 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 */
527function 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
537function 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
545function 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
559function 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.
711const 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
716export 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
3794export const layer = Layer.effect(PullRequestService, make);