2551 lines · 107.3 KB · TypeScript
RawDownload
import * as Cache from "effect/Cache";import * as Clock from "effect/Clock";import * as Context from "effect/Context";import * as DateTime from "effect/DateTime";import * as Duration from "effect/Duration";import * as Effect from "effect/Effect";import * as Exit from "effect/Exit";import * as Layer from "effect/Layer";import * as PubSub from "effect/PubSub";import * as Schema from "effect/Schema";import type * as Scope from "effect/Scope";import * as Stream from "effect/Stream";import * as SubscriptionRef from "effect/SubscriptionRef";import {  PullRequestOperationError,  PullRequestUnavailableError,  pullRequestHostOf,  pullRequestProviderRequirement,  resolvePullRequestAuthorFilter,  type OrchestrationProjectShell,  type PullRequestAction,  type PullRequestActionInput,  type PullRequestActivity,  type PullRequestCommentInput,  type PullRequestCommentUpdateInput,  type PullRequestDetail,  type PullRequestDiffFileContentsInput,  type PullRequestDiffFileContentsResult,  type PullRequestDiffStat,  type PullRequestDiffInput,  type PullRequestDiffResult,  type PullRequestInvalidateInput,  type PullRequestListEntry,  type PullRequestListFilters,  type PullRequestListInput,  type PullRequestListProjectError,  type PullRequestListResult,  type PullRequestListStatsInput,  type PullRequestListStatsResult,  type PullRequestProviderSummary,  type PullRequestReactionInput,  type PullRequestRef,  type PullRequestReviewVerdict,  type PullRequestReviewerCandidateList,  type PullRequestReviewerRequestInput,  type PullRequestLabelCandidateList,  type PullRequestLabelChangeInput,  type PullRequestSubmitReviewInput,  type PullRequestSummary,  type PullRequestThreadReplyInput,  type PullRequestThreadResolutionInput,  type PullRequestThreadCommentsInput,  type PullRequestThreadCommentsResult,  type PullRequestUpdateInput,  type SourceControlProviderInfo,  type SourceControlProviderKind,} from "@t3tools/contracts";import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts";import {  type ProviderChangeRequest,  type ProviderListCursor,  type PullRequestProviderApi,  PullRequestProviderError,} from "./PullRequestProvider.ts";import { PullRequestProviderRegistry } from "./PullRequestProviderRegistry.ts"; export interface PullRequestMergeEvent extends PullRequestRef {  readonly mergedAt: string;} /** * Rows per repository when the client does not ask for a page size, and rows per slice when a * listing is carried on from a cursor. * * 99 and not 100, because every provider asks its host for one row over this to probe for a next * page: 99 requests 100, which is exactly what a page of GitHub's API serves — GraphQL refuses * `first` over 100 with EXCESSIVE_PAGINATION and REST clamps `per_page` to it — and what GitLab * caps `per_page` at. Asking for 100 here would request 101 and buy a whole second round trip for * one row (measured: `gh pr list --limit 100` makes 1 HTTP request, `--limit 101` makes 2). */const DEFAULT_REPOSITORY_LIST_LIMIT = 99;/** * Repositories read at once. Each one is a CLI process that spends nearly all its wall clock * waiting on the host, so the useful ceiling is far above the core count; measured over 12 * repositories on this listing's own command, 4 took ~12.7s, 8 ~8.9s and 12 ~4.9s, with 16 and 24 * no faster because 12 already reads every repository in one wave. */const REPOSITORY_CONCURRENCY = 12;/** * Repositories named in one read across a host. Measured against GitHub's search: six hundred * `repo:` qualifiers in one query — 14.7KB of it — were all still honoured, and the answer took * the same three to six seconds at twelve repositories as at four hundred. A hundred is well * inside that and past the size of a workspace anyone opens, so a larger one reads in a handful * of searches rather than in a request per repository. */const REPOSITORY_SEARCH_CHUNK = 100; /** * Every read leaves the process — a CLI per repository, against hosts whose limits are low * (GitHub's search API allows ~30 requests a minute) — so answers are shared for a short * while and concurrent identical reads share one request. The windows sit near the clients' * own stale times: long enough that two people opening the same page cost one round trip, * short enough that "cached" and "fresh" never need telling apart on screen. Reads that * must not share — the refresh button, a client reloading after its own action — go through * `invalidate` rather than a flag on the read, so an ordinary read can never opt out. */const LIST_CACHE_TTL = Duration.seconds(30);const SUMMARY_CACHE_TTL = Duration.seconds(60);const DETAIL_CACHE_TTL = Duration.seconds(15);const DIFF_CACHE_TTL = Duration.seconds(60);/** A commit is content-addressed, so its own diff cannot change under its key. */const COMMIT_DIFF_CACHE_TTL = Duration.minutes(10);/** Sized like the client's own stale time; a row's counts move only when somebody pushes. */const LIST_STATS_CACHE_TTL = Duration.seconds(60);/** A diff can stay interactive while its next cached value is fetched off the critical path. */const DIFF_STALE_WINDOW = Duration.minutes(10);/** How long one host's signed-in login is believed without asking its CLI again. */const VIEWER_CACHE_TTL = Duration.minutes(10);const SEARCH_VISIBILITY_TTL = Duration.minutes(10);const STALE_DETAIL_WINDOW = Duration.minutes(10);const isPullRequestProviderError = Schema.is(PullRequestProviderError);const LIST_CACHE_CAPACITY = 64;const LIST_STATS_CACHE_CAPACITY = 32;const DETAIL_CACHE_CAPACITY = 128;const DIFF_CACHE_CAPACITY = 128;const VIEWER_CACHE_CAPACITY = 32; export type PullRequestError = PullRequestUnavailableError | PullRequestOperationError; export class PullRequestService extends Context.Service<  PullRequestService,  {    readonly list: (      input: PullRequestListInput,    ) => Effect.Effect<PullRequestListResult, PullRequestError>;    readonly listStats: (      input: PullRequestListStatsInput,    ) => Effect.Effect<PullRequestListStatsResult, PullRequestError>;    readonly summary: (      input: PullRequestRef,      options?: { readonly recoverTransientFailure?: boolean },    ) => Effect.Effect<PullRequestSummary, PullRequestError>;    readonly subscribeMerges: Effect.Effect<      Stream.Stream<PullRequestMergeEvent>,      never,      Scope.Scope    >;    readonly subscribeRefreshes: Stream.Stream<number>;    readonly refreshAfterTurn: Effect.Effect<void>;    readonly detail: (input: PullRequestRef) => Effect.Effect<PullRequestDetail, PullRequestError>;    readonly activity: (      input: PullRequestRef,    ) => Effect.Effect<PullRequestActivity, PullRequestError>;    readonly threadComments: (      input: PullRequestThreadCommentsInput,    ) => Effect.Effect<PullRequestThreadCommentsResult, PullRequestError>;    readonly diff: (      input: PullRequestDiffInput,    ) => Effect.Effect<PullRequestDiffResult, PullRequestError>;    readonly diffFileContents: (      input: PullRequestDiffFileContentsInput,    ) => Effect.Effect<PullRequestDiffFileContentsResult, PullRequestError>;    readonly runAction: (input: PullRequestActionInput) => Effect.Effect<void, PullRequestError>;    readonly update: (input: PullRequestUpdateInput) => Effect.Effect<void, PullRequestError>;    readonly comment: (input: PullRequestCommentInput) => Effect.Effect<void, PullRequestError>;    readonly updateComment: (      input: PullRequestCommentUpdateInput,    ) => Effect.Effect<void, PullRequestError>;    readonly submitReview: (      input: PullRequestSubmitReviewInput,    ) => Effect.Effect<void, PullRequestError>;    readonly replyToThread: (      input: PullRequestThreadReplyInput,    ) => Effect.Effect<void, PullRequestError>;    readonly setThreadResolution: (      input: PullRequestThreadResolutionInput,    ) => Effect.Effect<void, PullRequestError>;    readonly setReaction: (      input: PullRequestReactionInput,    ) => Effect.Effect<void, PullRequestError>;    readonly reviewerCandidates: (      input: PullRequestRef,    ) => Effect.Effect<PullRequestReviewerCandidateList, PullRequestError>;    readonly requestReviewers: (      input: PullRequestReviewerRequestInput,    ) => Effect.Effect<void, PullRequestError>;    readonly labelCandidates: (      input: PullRequestRef,    ) => Effect.Effect<PullRequestLabelCandidateList, PullRequestError>;    readonly setLabels: (      input: PullRequestLabelChangeInput,    ) => Effect.Effect<void, PullRequestError>;    readonly invalidate: (input: PullRequestInvalidateInput) => Effect.Effect<void>;  }>()("t3/pullRequest/PullRequestService") {} /** What a verdict is called when refusing it, so the sentence reads as an action. */const VERDICT_LABELS: Record<PullRequestReviewVerdict, string> = {  comment: "review",  approve: "approve",  "request-changes": "request changes on",}; /** * Why an action is refused to this viewer, said as the access it would take rather than as the * refusal the host would have answered with. Merging is the one that needs write and nothing * else; the other four are also the author's to take, whatever access they have. */const ACTION_ACCESS_REFUSALS: Record<PullRequestAction, string> = {  merge: "You need write access on this repository to merge.",  ready:    "You need write access on this repository, or to have opened this change request, to mark it ready for review.",  draft:    "You need write access on this repository, or to have opened this change request, to return it to a draft.",  close:    "You need write access on this repository, or to have opened this change request, to close it.",  "update-branch":    "You need write access on this repository, or to have opened this change request, to update its branch.",  reopen:    "You need write access on this repository, or to have opened this change request, to reopen it.",  "enable-auto-merge":    "You need write access on this repository to have it merged for you once it is ready.",  "disable-auto-merge":    "You need write access on this repository to stop it being merged for you once it is ready.",  revert: "You need write access on this repository to open a revert pull request.",  "approve-workflows":    "You need write access on this repository to approve workflows from a fork pull request.",}; /** * Why asking for a review is refused, and why the menu behind it is too. Write access is what the * hosts that state anything about this want; the ones that state nothing grant it, so this * sentence is only ever the answer where a host said no. */const REVIEWER_REQUEST_REFUSAL = "You need write access on this repository to ask for a review.";const LABEL_CHANGE_REFUSAL = "You need triage access on this repository to change its labels."; /** A project this page can read: its remote is on a host with an implementation. */interface SupportedProject {  readonly project: OrchestrationProjectShell;  readonly api: PullRequestProviderApi;  readonly repository: string;  /** The host the repository lives on, which is the account boundary rather than the kind. */  readonly host: string;} /** * What the workspace has, split by whether this build can read it. Hosts with no * implementation are counted rather than dropped, so their projects are explained in the * provider list instead of quietly missing from the page. */interface WorkspaceProjects {  readonly supported: ReadonlyArray<SupportedProject>;  /** Keyed by host, as the readable ones are: an unimplemented host is its own switcher entry. */  readonly unimplemented: ReadonlyMap<    string,    { readonly kind: SourceControlProviderKind; readonly projectCount: number }  >;  /**   * Every checkout on a host, including the ones the listing de-duplicated away. Asking who is   * signed in is a question about the host rather than about a repository, and any checkout can   * answer it — so a broken worktree is not allowed to take the host down with it just because   * it happened to be the one the listing kept.   */  readonly viewerRoots: ReadonlyMap<string, ReadonlyArray<string>>;} interface RepositoryBatch {  /** Which repository this slice came from, which is what a cursor for it is filed under. */  readonly key: string;  readonly entries: ReadonlyArray<PullRequestListEntry>;  readonly errors: ReadonlyArray<PullRequestListProjectError>;  readonly truncated: boolean;  readonly nextCursor: string | null;} /** What the providers are told, plus the part only the service acts on. */interface ListCursor extends ProviderListCursor {  /**   * The rows already handed over at exactly `updatedBefore`. The next read asks for that instant   * inclusively, so these are what keeps it from sending them a second time.   */  readonly seenAt: ReadonlyArray<number>;} /** * A continuation as it travels through the page and back. Written out rather than encoded because * it comes back from a client and has to be believed or refused on sight: everything a host is * given is either a timestamp of this shape or a number of this length, which is what lets a * provider drop it into a filter without checking it again. */const LIST_CURSOR_PATTERN =  /^(\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})*)?$/; function parseListCursor(raw: string): ListCursor | null {  const match = LIST_CURSOR_PATTERN.exec(raw);  if (match === null) return null;  const seenAt = match[3];  return {    updatedBefore: match[1]!,    delivered: Number(match[2]),    seenAt: seenAt === undefined ? [] : seenAt.split(",").map(Number),  };} /** * How a listing tells two repositories apart. The host is part of it because the same * `owner/repo` exists on github.com and on an Enterprise install, and they are two repositories. */function listCursorKey(host: string, repository: string): string {  return `${host} ${repository.toLowerCase()}`;} /** * Where a repository carries on, worked out from the slice just handed over. The boundary is the * instant of the oldest row in it: the next read asks for that instant and everything before it, * and names the rows already sent at it so none of them arrives twice. * * The names carry over when the boundary has not moved. A slice that ends on the same instant it * began on has to keep the earlier rows excluded as well as its own, or the read after it would * hand them over again. */function nextListCursor(  previous: ListCursor | undefined,  /** What the host handed over, before the rows already sent were dropped from it. */  fetched: ReadonlyArray<ProviderChangeRequest>,  /** What is being sent on, which is what the count of delivered rows is about. */  delivered: ReadonlyArray<ProviderChangeRequest>,  /** A provider may consume malformed offset-paged rows that never appear in `delivered`. */  cursorAdvance = delivered.length,): string | null {  // The host had nothing at all, so there is no row to carry on from — and repeating the cursor  // that produced the empty slice would ask the same question forever.  if (fetched.length === 0) return null;  // Taken from what the host answered rather than from what survived de-duplication: a slice can  // be entirely rows already sent — a hundred change requests touched in the same second is one  // repository's boring afternoon — and reading "nothing new" as "nothing left" would end the  // walk on the instant it was stuck on, with everything older unreachable for good.  const oldest = fetched.reduce((left, right) => (right.updatedAt < left.updatedAt ? right : left));  return listCursorAt(previous, oldest.updatedAt, fetched, cursorAdvance);} /** * The same cursor against a boundary chosen elsewhere, which is what a slice read across several * repositories at once needs: every repository in it is read up to the oldest row of the whole * slice, including the ones that contributed nothing to it — their rows are simply all older, and * a repository that carried on from its own oldest row would be right about where it stopped and * silent about the ones that never appeared. */function listCursorAt(  previous: ListCursor | undefined,  boundary: string,  /** This repository's own rows in the slice, before the ones already sent were dropped. */  fetched: ReadonlyArray<ProviderChangeRequest>,  deliveredCount: number,): string {  const seenAt = [    ...(previous?.updatedBefore === boundary ? previous.seenAt : []),    ...fetched.filter((item) => item.updatedAt === boundary).map((item) => item.number),  ];  return `${boundary}|${(previous?.delivered ?? 0) + deliveredCount}|${seenAt.join(",")}`;} /** A host that cannot be read at all, as opposed to one request that failed. */function isProviderUnusable(error: PullRequestProviderError): boolean {  return error.reason === "missing-tool" || error.reason === "unauthenticated";} /** * Why a host is not readable, told as the thing to do about it. A host that is simply not set up * says so in the same words the whole-page state uses, rather than repeating whatever its tool * printed — "HTTP 401" names the symptom, not the fix. */function providerDetail(error: PullRequestProviderError): string {  if (!isProviderUnusable(error)) return error.detail;  return (    pullRequestProviderRequirement(      error.provider,      error.reason === "missing-tool" ? "cli-missing" : "cli-unauthenticated",    ) ?? error.detail  );} function toUnavailableError(error: PullRequestProviderError): PullRequestUnavailableError {  return new PullRequestUnavailableError({    reason: error.reason === "missing-tool" ? "cli-missing" : "cli-unauthenticated",    provider: error.provider,    cause: error,  });} function toPullRequestError(  operation: string,): (error: PullRequestProviderError) => PullRequestError {  return (error) =>    isProviderUnusable(error)      ? toUnavailableError(error)      : new PullRequestOperationError({ operation, detail: error.detail, cause: error });} function withRateLimitBackoff(  api: PullRequestProviderApi,  host: string,  limits: SourceControlRateLimit.SourceControlRateLimit["Service"],): PullRequestProviderApi {  const key = { provider: api.kind, host };  const protect = <A>(    operation: string,    effect: Effect.Effect<A, PullRequestProviderError>,    allowPaused: boolean,  ) =>    limits.check(key, allowPaused ? { allowPaused: true } : undefined).pipe(      Effect.mapError(        (error) =>          new PullRequestProviderError({            provider: api.kind,            operation,            reason: "rate-limited",            detail: error.detail,            retryAt: error.retryAt,            cause: error,          }),      ),      Effect.flatMap((lease) =>        effect.pipe(          Effect.tap(() => limits.recordSuccess({ ...key, lease })),          Effect.tapError((error) =>            error.reason === "rate-limited"              ? limits.recordRateLimit({                  ...key,                  lease,                  ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }),                })              : Effect.void,          ),        ),      ),    );  const wrap =    <Args extends ReadonlyArray<unknown>, A>(      operation: string,      call: (...args: Args) => Effect.Effect<A, PullRequestProviderError>,      allowPaused = false,    ) =>    (...args: Args) =>      protect(operation, call(...args), allowPaused);  const interactive = <Args extends ReadonlyArray<unknown>, A>(    operation: string,    call: (...args: Args) => Effect.Effect<A, PullRequestProviderError>,  ) => wrap(operation, call, true);   return {    kind: api.kind,    capabilities: api.capabilities,    getViewer: wrap("getViewer", api.getViewer),    listChangeRequests: wrap("listChangeRequests", api.listChangeRequests),    ...(api.listChangeRequestsAcross === undefined      ? {}      : {          listChangeRequestsAcross: wrap("listChangeRequestsAcross", api.listChangeRequestsAcross),        }),    ...(api.listChangeRequestStats === undefined      ? {}      : {          listChangeRequestStats: wrap("listChangeRequestStats", api.listChangeRequestStats),        }),    getChangeRequest: wrap("getChangeRequest", api.getChangeRequest),    ...(api.getChangeRequestSummary === undefined      ? {}      : {          getChangeRequestSummary: wrap("getChangeRequestSummary", api.getChangeRequestSummary),        }),    getChangeRequestActivity: wrap("getChangeRequestActivity", api.getChangeRequestActivity),    ...(api.getReviewThreadComments === undefined      ? {}      : {          getReviewThreadComments: wrap("getReviewThreadComments", api.getReviewThreadComments),        }),    getViewerPermissions: interactive("getViewerPermissions", api.getViewerPermissions),    getDiff: wrap("getDiff", api.getDiff),    ...(api.getDiffFileContents === undefined      ? {}      : { getDiffFileContents: wrap("getDiffFileContents", api.getDiffFileContents) }),    runAction: interactive("runAction", api.runAction),    ...(api.updateChangeRequest === undefined      ? {}      : {          updateChangeRequest: interactive("updateChangeRequest", api.updateChangeRequest),        }),    comment: interactive("comment", api.comment),    ...(api.updateComment === undefined      ? {}      : { updateComment: interactive("updateComment", api.updateComment) }),    submitReview: interactive("submitReview", api.submitReview),    listReviewerCandidates: interactive("listReviewerCandidates", api.listReviewerCandidates),    setReviewerRequest: interactive("setReviewerRequest", api.setReviewerRequest),

Lines 1–500 of 2551.