import * as Effect from "effect/Effect";
import * as Schema from "effect/Schema";
import type {
PullRequestStackMembership,
PullRequestAction,
PullRequestStackHead,
PullRequestActor,
PullRequestBaseComparison,
PullRequestCapabilities,
PullRequestChecksState,
PullRequestCheck,
PullRequestComment,
PullRequestFileViewed,
PullRequestCommit,
PullRequestInvolvement,
PullRequestLabel,
PullRequestListFilters,
PullRequestListState,
PullRequestMergeCapabilities,
PullRequestMergeMethod,
PullRequestMergeability,
PullRequestOmittedFileStat,
PullRequestReaction,
PullRequestReactionContent,
PullRequestReviewCommentDraft,
PullRequestReviewDecision,
PullRequestReviewThread,
PullRequestThreadCommentsResult,
PullRequestReviewVerdict,
PullRequestReviewerCandidateList,
PullRequestReviewerKind,
PullRequestLabelCandidateList,
PullRequestState,
PullRequestPreview,
PullRequestUpdateMethod,
PullRequestViewerPermissions,
SourceControlProviderKind,
} from "@t3tools/contracts";
import { SourceControlProviderKind as SourceControlProviderKindSchema } from "@t3tools/contracts";
/**
* The one failure shape every provider reports, so the service can decide what a failure means
* without knowing which CLI or API produced it.
*
* `reason` is the part the service acts on: a missing or unauthenticated tool disables the
* provider for the whole workspace, a rate limit pauses its host, and anything else is specific
* to the request.
*/
export class PullRequestProviderError extends Schema.TaggedError<PullRequestProviderError>()(
"PullRequestProviderError",
{
provider: SourceControlProviderKindSchema,
operation: Schema.String,
reason: Schema.Literals(["missing-tool", "unauthenticated", "rate-limited", "failed"]),
detail: Schema.String,
retryAt: Schema.optional(Schema.Number),
cause: Schema.optional(Schema.Defect()),
},
) {
override get message(): string {
return `${this.provider} failed in ${this.operation}: ${this.detail}`;
}
}
export interface PullRequestProviderFailure {
readonly reason: PullRequestProviderError["reason"];
readonly retryAt?: number | undefined;
}
/** A change request as the provider sees it, before the service attaches project context. */
export interface ProviderChangeRequest {
readonly stack?: PullRequestStackMembership;
readonly number: number;
readonly title: string;
readonly url: string;
readonly author: PullRequestActor | null;
readonly headBranch: string;
readonly headRepositoryNameWithOwner?: string | null;
readonly baseBranch: string;
readonly state: PullRequestState;
readonly isDraft: boolean;
readonly mergeability: PullRequestMergeability;
readonly additions: number;
readonly deletions: number;
readonly createdAt: string;
readonly closedAt?: string | null;
readonly mergedAt?: string | null;
readonly updatedAt: string;
/** Accounts with a review requested. Team-level requests are excluded by each provider. */
readonly reviewRequestLogins: ReadonlyArray<string>;
readonly labels: ReadonlyArray<PullRequestLabel>;
/** Absent from a host that does not summarise its reviews, which is every host but GitHub. */
readonly reviewDecision?: PullRequestReviewDecision | null | undefined;
/** Absent from a host that reports no check rollup on its listings. */
readonly checksState?: PullRequestChecksState | null | undefined;
}
/** The fields needed to keep a linked thread's pull request status live. */
export interface ProviderChangeRequestSummary {
readonly number: number;
readonly title: string;
readonly url: string;
readonly headBranch: string;
readonly baseBranch: string;
readonly state: PullRequestState;
/** Present when the host says an open pull request is still a draft. */
readonly isDraft?: boolean;
readonly closedAt?: string | null;
readonly mergedAt?: string | null;
readonly updatedAt: string;
/** Overview fields, present where the host's single read returns them at no extra cost. */
readonly author?: PullRequestActor | null | undefined;
readonly additions?: number | undefined;
readonly deletions?: number | undefined;
readonly changedFiles?: number | undefined;
readonly reviewDecision?: PullRequestReviewDecision | null | undefined;
readonly checksState?: PullRequestChecksState | null | undefined;
readonly mergeability?: PullRequestMergeability | undefined;
}
/** One layer of a host-native stack, bottom to top order is the array's. */
export interface ProviderChangeRequestStackLayer {
readonly title?: string;
readonly isDraft?: boolean;
readonly headSha?: string;
readonly number: number;
readonly headBranch: string;
readonly state: PullRequestState;
}
/**
* A host-native stack: an ordered set of change requests the host itself merges and retargets as
* a unit. Only GitHub offers one today; the neutral shape lets the sync reactor and the UI stay
* ignorant of which host said so.
*/
export interface ProviderChangeRequestStack {
readonly id: string;
readonly number: number;
readonly url: string;
readonly base: string;
readonly layers: ReadonlyArray<ProviderChangeRequestStackLayer>;
}
export interface ProviderChangeRequestPage {
readonly items: ReadonlyArray<ProviderChangeRequest>;
/** True when the host has more rows than the page size asked for. */
readonly truncated: boolean;
/**
* Optional count-based cursor advance. Most hosts advance by the rows delivered after local
* de-duplication; an offset-paged host may need to count malformed raw rows it consumed too.
*/
readonly cursorAdvance?: number;
/**
* This page can be carried on from, so the service may hand the caller a cursor for it. False
* where the host answered in an order a cursor means nothing in, which leaves a larger `limit`
* as the only way to the rest — what every listing did before there were cursors.
*/
readonly continues: boolean;
}
/**
* Where a repository's next slice starts, as the provider that has to ask for it needs it. Built
* by the service out of the slice it just handed over, so the boundary that decides whether a row
* arrives twice or not at all is decided in one place rather than in four.
*/
export interface ProviderListCursor {
/**
* The instant of the oldest row already handed over, checked against a timestamp's shape before
* it gets here because it goes into a host's own filter. Asked for inclusively: several rows
* share one instant often enough — a bot that touches eight change requests writes one timestamp
* on all eight — and asking for strictly older would lose whichever of them the slice ended
* before. The service drops the ones it has already sent.
*/
readonly updatedBefore: string;
/**
* How many provider rows this repository has consumed so far, for a host that carries on by
* counting rather than by date. Usually this is the number handed over; malformed raw rows may
* count too when the provider reports a `cursorAdvance`.
*/
readonly delivered: number;
}
/** One repository's row inside an answer that spans several of them. */
export interface ProviderBatchedChangeRequest extends ProviderChangeRequest {
/** Provider-native identity, exactly as it was asked for, so the caller can file the row. */
readonly repository: string;
}
/**
* One slice of a host read across several repositories at once, newest update first across all
* of them. There is no per-repository page here because the host was asked one question: the
* caller splits the rows by `repository` and works out where each of them carries on from the
* oldest row in the slice, which every repository the slice covers is now read up to.
*/
export interface ProviderBatchedChangeRequestPage {
readonly items: ReadonlyArray<ProviderBatchedChangeRequest>;
/** True when the host has more rows than the slice asked for, for any of the repositories. */
readonly truncated: boolean;
}
/** The line counts for one change request, which a listing may leave for a second read. */
export interface ProviderChangeRequestStat {
readonly repository: string;
readonly number: number;
readonly additions: number;
readonly deletions: number;
}
export interface ProviderChangeRequestDetail extends ProviderChangeRequest {
readonly body: string;
readonly changedFiles: number;
readonly mergedAt: string | null;
readonly closedAt: string | null;
readonly reviewers: ReadonlyArray<PullRequestActor>;
readonly checks: ReadonlyArray<PullRequestCheck>;
readonly mergeCapabilities: PullRequestMergeCapabilities;
readonly viewerPermissions: PullRequestViewerPermissions;
/** Absent from a host that cannot compare the branch with its base, which is most of them. */
readonly baseComparison?: PullRequestBaseComparison;
readonly behindBy?: number;
/** Absent from a host that does not report whether it is armed to merge this on its own. */
readonly autoMergeEnabled?: boolean;
/** The strategy stored with an armed auto-merge, where the host reports it. */
readonly autoMergeMethod?: PullRequestMergeMethod;
/** Workflow runs on this head commit that still need a maintainer's approval. */
readonly workflowApprovalsRequired?: number;
}
/** The conversation-shaped half of a detail, loaded after the core can already render. */
export interface ProviderChangeRequestActivity {
/** An optional richer actor, e.g. after GitHub's GraphQL read supplies an avatar. */
readonly author?: PullRequestActor | null;
/** Optional because most hosts already report their reviewer list in the core detail. */
readonly reviewers?: ReadonlyArray<PullRequestActor>;
readonly comments: ReadonlyArray<PullRequestComment>;
/**
* The host's own count of the conversation, which a bounded read can fall short of. A host
* that reports no count of its own answers with what it handed over, which is the same number
* once the read went to the end.
*/
readonly commentCount: number;
readonly commentsTruncated: boolean;
readonly reviewThreads: ReadonlyArray<PullRequestReviewThread>;
readonly commits: ReadonlyArray<PullRequestCommit>;
/** The change request's own reactions, from a host that has them. */
readonly reactions?: ReadonlyArray<PullRequestReaction>;
}
export interface ProviderDiffSlice {
readonly patch: string;
/** Something in this slice could not be shown, as opposed to there being more slices. */
readonly truncated: boolean;
readonly nextCursor: string | null;
/** The host's own counts for the files whose hunks it withheld from this slice. */
readonly omittedFileStats?: ReadonlyArray<PullRequestOmittedFileStat>;
}
export interface ProviderDiffFileContents {
readonly oldContents: string;
readonly newContents: string;
}
export interface ProviderFilesViewed {
readonly files: ReadonlyArray<PullRequestFileViewed>;
/** The host has more files than were read, so the ones missing here are not "unviewed". */
readonly truncated: boolean;
}
/**
* What version each of the asked-for files is at, on the change request's head. Opaque strings,
* compared only against one another, where the empty string is the answer for a file the change
* request deletes rather than a gap. A path is absent only where the read could not say, so a
* provider converts its host's own absences on the way here.
*
* The whole path a version travels, and the three senses of null along it, are in
* `docs/internals/pull-request-file-revisions.md`.
*/
export interface ProviderFileRevisions {
readonly revisions: ReadonlyMap<string, string>;
/**
* Whether these are every file the change request carries rather than only the paths asked
* about. A host with no per-file version reads the whole change to answer for one file, and
* saying so is what keeps the next tick, naming a path nothing asked about before, from making
* it read the whole change again. False for a read cut short, which cannot speak past the cut.
*/
readonly complete?: boolean;
}
export interface ProviderRepositoryRef {
readonly cwd: string;
/** Provider-native repository identity, e.g. `owner/repo` or `group/subgroup/project`. */
readonly repository: string;
/**
* The host it lives on, which `repository` deliberately leaves out — the same `owner/repo`
* exists on github.com and on a GitHub Enterprise install, and only the caller knows which
* one a project's remote points at.
*/
readonly host: string;
}
/**
* One host's change requests. Implementations own their own tool and JSON shapes and hand back
* the neutral types above; anything a host cannot do is declared in `capabilities` rather than
* failing at call time.
*/
export interface PullRequestProviderApi {
readonly withVerifiedCredential?: <A, E, R>(
input: { readonly cwd: string; readonly host: string },
use: (identity: {
readonly accountId: string;
readonly viewer: string;
readonly credentialFingerprint: string;
}) => Effect.Effect<A, E, R>,
) => Effect.Effect<A, E | PullRequestProviderError, R>;
readonly getRoutingIdentity?: (input: {
readonly cwd: string;
readonly host: string;
}) => Effect.Effect<
{ readonly accountId: string; readonly viewer: string },
PullRequestProviderError
>;
readonly kind: SourceControlProviderKind;
readonly capabilities: PullRequestCapabilities;
/** The signed-in account, which is what involvement filtering compares against. */
readonly getViewer: (input: {
readonly cwd: string;
readonly host?: string;
}) => Effect.Effect<string, PullRequestProviderError>;
readonly listChangeRequests: (
input: ProviderRepositoryRef & {
readonly state: PullRequestListState;
readonly involvement: PullRequestInvolvement;
readonly viewer: string;
readonly limit: number;
/**
* Free text to narrow the listing by, as the host understands it. A host with no text
* filter of its own ignores it and answers with the page it would have answered with
* anyway — the caller narrows what it gets, so an unfiltered page is a wider answer
* rather than a wrong one.
*/
readonly query?: string | undefined;
/**
* Where to carry on from, rather than reading this repository from its newest row. Absent
* asks for the first slice, which is every listing that has not been continued.
*/
readonly cursor?: ProviderListCursor | undefined;
/**
* Further narrowings, which a host applies as far as it can and ignores the rest of —
* an unnarrowed page is a wider answer rather than a wrong one, and the caller narrows
* what it gets for the fields a row carries.
*/
readonly filters?: PullRequestListFilters | undefined;
},
) => Effect.Effect<ProviderChangeRequestPage, PullRequestProviderError>;
/**
* The same listing for a whole host in one request, for a host that has a search across
* repositories. Optional: three of the four hosts here have no such API, and breaking the port
* for them to spare GitHub a fan-out would be paying for the fix with everyone else's clarity.
* The caller falls back to `listChangeRequests` per repository where this is absent, and where
* it fails.
*
* `limit` is the whole slice rather than a size per repository, because that is the shape of
* the answer: the newest `limit` rows across every repository named, which is exactly the rows
* a page ordered by update shows.
*
* `cursor` is one boundary for all of them, so a caller with repositories standing at different
* boundaries asks in groups rather than in one call.
*/
readonly listChangeRequestsAcross?: (input: {
/** Any checkout on the host, which is what the tool is run in. */
readonly cwd: string;
readonly host: string;
readonly repositories: ReadonlyArray<string>;
readonly state: PullRequestListState;
readonly involvement: PullRequestInvolvement;
readonly viewer: string;
readonly limit: number;
readonly query?: string | undefined;
readonly cursor?: ProviderListCursor | undefined;
readonly filters?: PullRequestListFilters | undefined;
}) => Effect.Effect<ProviderBatchedChangeRequestPage, PullRequestProviderError>;
/**
* The line counts for rows a listing has already handed over. Only implemented by a provider
* whose listing leaves them out — for everyone else the numbers arrived with the row, and the
* caller has nothing to ask for.
*/
readonly listChangeRequestStats?: (input: {
readonly cwd: string;
readonly host: string;
readonly changeRequests: ReadonlyArray<{
readonly repository: string;
readonly number: number;
}>;
}) => Effect.Effect<ReadonlyArray<ProviderChangeRequestStat>, PullRequestProviderError>;
readonly getChangeRequest: (
input: ProviderRepositoryRef & { readonly number: number },
) => Effect.Effect<ProviderChangeRequestDetail, PullRequestProviderError>;
/** Hosts without a narrow read use their existing detail response for hover cards. */
readonly getChangeRequestPreview?: (
input: ProviderRepositoryRef & { readonly number: number },
) => Effect.Effect<
Omit<PullRequestPreview, "projectId" | "repository">,
PullRequestProviderError
>;
/**
* The cheap live fields used by linked threads. Optional because a provider without a narrow
* endpoint can fall back to its full detail read at the service boundary.
*/
readonly getChangeRequestSummary?: (
input: ProviderRepositoryRef & { readonly number: number },
) => Effect.Effect<ProviderChangeRequestSummary, PullRequestProviderError>;
/**
* The host-native stack a change request belongs to, or null when it is not stacked. Optional
* because most hosts have no such object; the service derives chains from base branches there.
*/
readonly getChangeRequestStack?: (
input: ProviderRepositoryRef & { readonly includeDetails?: boolean; readonly number: number },
) => Effect.Effect<ProviderChangeRequestStack | null, PullRequestProviderError>;
/** Comments, line threads, and commits, kept off the critical path for the core detail. */
readonly getChangeRequestActivity: (
input: ProviderRepositoryRef & { readonly number: number },
) => Effect.Effect<ProviderChangeRequestActivity, PullRequestProviderError>;
/** One explicit page after a reader asks to continue an unfinished review thread. */
readonly getReviewThreadComments?: (
input: ProviderRepositoryRef & {
readonly number: number;
readonly threadId: string;
readonly cursor: string;
},
) => Effect.Effect<PullRequestThreadCommentsResult, PullRequestProviderError>;
/**
* The same answer `getChangeRequest` carries, on its own. Asked before anything is written, so
* a request that reached the server without going past the page is refused by what the host
* says rather than by what the client claimed — and asked freshly, because access granted or
* taken away since the page loaded is exactly the case this guards.
*
* Implementations read the cheapest thing that answers it, which for a host with nothing to say
* is no request at all.
*/
readonly getViewerPermissions: (
input: ProviderRepositoryRef & {
readonly number: number;
/** Skip branch comparison when checking permission for an unrelated operation. */
readonly includeUpdateBranch?: boolean;
},
) => Effect.Effect<PullRequestViewerPermissions, PullRequestProviderError>;
/**
* One slice of the patch. Only called when `capabilities.diff` is true. A provider that can
* serve the whole diff at once answers with `nextCursor: null` and is done; one that pages
* hands back whatever it needs to find the next slice.
*/
readonly getDiff: (
input: ProviderRepositoryRef & {
readonly number: number;
readonly cursor?: string | undefined;
/** One commit's own changes, rather than everything the change request carries. */
readonly commit?: string | undefined;
},
) => Effect.Effect<ProviderDiffSlice, PullRequestProviderError>;
/**
* Full files at the exact revisions the host used for its patch. Optional where the provider
* exposes no diff at all; the service refuses expansion there just as it refuses the patch.
*/
readonly getDiffFileContents?: (
input: ProviderRepositoryRef & {
readonly number: number;
readonly commit?: string | undefined;
readonly changeType: "change" | "rename-pure" | "rename-changed" | "new" | "deleted";
readonly oldPath: string;
readonly newPath: string;
},
) => Effect.Effect<ProviderDiffFileContents, PullRequestProviderError>;
/**
* Which files the reader has already cleared. Only called when `capabilities.viewedFiles` is
* `"host"`, and read apart from the patch: this moves with every press rather than every push.
*/
readonly getFilesViewed?: (
input: ProviderRepositoryRef & { readonly number: number },
) => Effect.Effect<ProviderFilesViewed, PullRequestProviderError>;
/**
* Clears files, or puts them back. Only called when `capabilities.viewedFiles` is `"host"`.
* A host with no bulk form is still owed one round trip for the batch rather than one per file,
* since the point of gathering presses is that the host is asked once.
*/
readonly setFilesViewed?: (
input: ProviderRepositoryRef & {
readonly number: number;
readonly files: ReadonlyArray<{ readonly path: string; readonly viewed: boolean }>;
},
) => Effect.Effect<void, PullRequestProviderError>;
/**
* What version the head has of each of these files. Required of a host whose
* `capabilities.viewedFiles` is `"environment"`, and unused by one that keeps the marks itself:
* the marks live here, but only the host can say whether what a reader cleared last week is
* still what is in front of them. Asked for the marked paths alone, so the cost follows how
* much of the change request has been read rather than how large it is.
*/
readonly getFileRevisions?: (
input: ProviderRepositoryRef & {
readonly number: number;
readonly paths: ReadonlyArray<string>;
},
) => Effect.Effect<ProviderFileRevisions, PullRequestProviderError>;
readonly runAction: (
input: ProviderRepositoryRef & {
readonly number: number;
readonly action: PullRequestAction;
readonly stackNumber?: number;
readonly expectedStackHeads?: ReadonlyArray<PullRequestStackHead>;
/** Meaningful for `merge` and `enable-auto-merge`; absent takes the host's own default. */
readonly mergeMethod?: PullRequestMergeMethod;
/** Only meaningful for `update-branch`; absent takes the host's own default. */
readonly updateMethod?: PullRequestUpdateMethod;
},
) => Effect.Effect<void, PullRequestProviderError>;
/**
* Rewrites the change request's own words. Only called when `capabilities.edit.changeRequest`
* is true, and never with both fields absent — the caller refuses that before it gets here,
* because a host asked to change nothing answers differently on each of them.
*/
readonly updateChangeRequest?: (
input: ProviderRepositoryRef & {
readonly number: number;
readonly title?: string | undefined;
readonly body?: string | undefined;
},
) => Effect.Effect<void, PullRequestProviderError>;
readonly comment: (
input: ProviderRepositoryRef & { readonly number: number; readonly body: string },
) => Effect.Effect<void, PullRequestProviderError>;
/**
* Rewrites a remark somebody already posted. Only called when `capabilities.edit.comment` is
* true, with an id exactly as the conversation carried it.
*
* Whether this remark is the reader's to rewrite is the host's own answer: no read here can
* settle it, since access can be taken away between the conversation being read and the
* rewrite being sent, and a host refuses a stranger's remark with a sentence saying so.
*/
readonly updateComment?: (
input: ProviderRepositoryRef & {
readonly number: number;
readonly commentId: string;
readonly kind: "issue-comment" | "review-comment";
readonly body: string;
},
) => Effect.Effect<void, PullRequestProviderError>;
/**
* Sends a whole review at once. Only called for a verdict the host declared in
* `capabilities.review.verdicts`, and with line comments only where it declared
* `inlineComment`.
*/
readonly submitReview: (
input: ProviderRepositoryRef & {
readonly number: number;
readonly verdict: PullRequestReviewVerdict;
readonly body: string;
readonly comments: ReadonlyArray<PullRequestReviewCommentDraft>;
},
) => Effect.Effect<void, PullRequestProviderError>;
/**
* The people this viewer may ask for a review, with whoever has already been asked marked as
* such. Only called when `capabilities.reviewers.listCandidates` is true.
*
* The author is left out by each provider rather than by the caller, because only the provider
* knows how the host spells the same person in a candidate list and on a pull request.
*/
readonly listReviewerCandidates: (
input: ProviderRepositoryRef & { readonly number: number },
) => Effect.Effect<PullRequestReviewerCandidateList, PullRequestProviderError>;
/**
* Asks for a review, or takes the request back. Only called when
* `capabilities.reviewers.request` is true.
*
* One call for both directions, because that is what every host does with them: GitHub posts and
* deletes the same collection, and GitLab and Bitbucket write the whole reviewer set either way.
* Asking again somebody who has already reviewed is a request like any other — which is how a
* re-request is made.
*/
readonly setReviewerRequest: (
input: ProviderRepositoryRef & {
readonly number: number;
readonly reviewers: ReadonlyArray<{
readonly id: string;
readonly kind: PullRequestReviewerKind;
}>;
readonly requested: boolean;
},
) => Effect.Effect<void, PullRequestProviderError>;
/**
* The repository's labels, with the ones already on the change request marked. Present with
* `setLabels` only where `capabilities.labels` is true; the service refuses both without it.
*/
readonly listLabelCandidates?: (
input: ProviderRepositoryRef & { readonly number: number },
) => Effect.Effect<PullRequestLabelCandidateList, PullRequestProviderError>;
/** Puts labels on the change request, or takes them off. One call for both directions. */
readonly setLabels?: (
input: ProviderRepositoryRef & {
readonly number: number;
readonly labels: ReadonlyArray<string>;
readonly applied: boolean;
},
) => Effect.Effect<void, PullRequestProviderError>;
/** Only called when `capabilities.review.reply` is true. */
readonly replyToThread: (
input: ProviderRepositoryRef & {
readonly number: number;
readonly threadId: string;
readonly body: string;
},
) => Effect.Effect<void, PullRequestProviderError>;
/**
* Adds a reaction, or takes it back. Only called when `capabilities.reactions` is true.
*
* `subjectId` is a remark's id as the conversation carried it; absent means the change request
* itself, whose reactions sit on its description. Whatever a host needs to address either of
* them is worked out here, because the id a conversation travels with is the one the reader has.
*/
readonly setReaction: (
input: ProviderRepositoryRef & {
readonly number: number;
readonly subjectId?: string | undefined;
readonly content: PullRequestReactionContent;
readonly reacted: boolean;
},
) => Effect.Effect<void, PullRequestProviderError>;
/** Only called when `capabilities.review.resolve` is true. */
readonly setThreadResolution: (
input: ProviderRepositoryRef & {
readonly number: number;
readonly threadId: string;
readonly resolved: boolean;
},
) => Effect.Effect<void, PullRequestProviderError>;
}