1393 lines · 51.1 KB · TypeScript
RawDownload
import * as Context from "effect/Context";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import * as Result from "effect/Result";import * as Schema from "effect/Schema";import type {  PullRequestAction,  PullRequestComment,  PullRequestCommit,  PullRequestInvolvement,  PullRequestListState,  PullRequestMergeCapabilities,  PullRequestMergeMethod,  PullRequestReaction,  PullRequestReactionContent,  PullRequestReviewCommentDraft,  PullRequestReviewPosition,  PullRequestReviewThread,  PullRequestReviewVerdict,  PullRequestReviewerCandidateList,} from "@t3tools/contracts"; import * as GitLabCli from "../sourceControl/GitLabCli.ts";import {  AWARD_EMOJI_GRAPHQL_QUERY,  decodeAwardEmojiJson,  decodeCommitDiffRefsJson,  decodeCommitsJson,  decodeDiffRefsJson,  decodeDiscussionsJson,  decodeMergeRequestDetailJson,  decodeMergeRequestDiffsJson,  decodeMergeRequestListJson,  decodeNotesJson,  decodeOwnAwardIdJson,  decodeProjectMergeCapabilitiesJson,  decodeProjectUsersJson,  decodeViewerJson,  gitLabAwardName,  type GitLabDiffRefs,  type GitLabMergeRequestDetail,  type GitLabMergeRequestListItem,  type GitLabProjectUsers,} from "./gitLabMergeRequestJson.ts";import type { ProviderListCursor } from "./PullRequestProvider.ts"; /** * Names the read that produced unusable output, so a failure reports the call it came from * rather than borrowing another operation's message. */export class GitLabMergeRequestReadError extends Schema.TaggedErrorClass<GitLabMergeRequestReadError>()(  "GitLabMergeRequestReadError",  {    command: Schema.Literal("glab"),    cwd: Schema.String,    operation: Schema.String,    cause: Schema.Defect(),  },) {  get detail(): string {    return `GitLab CLI returned an unreadable ${this.operation} response.`;  }   override get message(): string {    return `GitLab CLI failed in ${this.operation}: ${this.detail}`;  }} /** Not a decode failure: glab answered, the account it answered for just has no username. */export class GitLabViewerUnavailableError extends Schema.TaggedErrorClass<GitLabViewerUnavailableError>()(  "GitLabViewerUnavailableError",  {    command: Schema.Literal("glab"),    cwd: Schema.String,  },) {  get detail(): string {    return "GitLab CLI returned no username for the authenticated account.";  }   override get message(): string {    return `GitLab CLI failed in getViewerUsername: ${this.detail}`;  }} /** Not a decode failure: GitLab answered, the merge request just has no revisions to place a *  comment against. */export class GitLabDiffRefsUnavailableError extends Schema.TaggedErrorClass<GitLabDiffRefsUnavailableError>()(  "GitLabDiffRefsUnavailableError",  {    command: Schema.Literal("glab"),    cwd: Schema.String,    number: Schema.Int,  },) {  get detail(): string {    return "The merge request reported no diff revisions.";  }   override get message(): string {    return `GitLab CLI failed in getDiffRefs: ${this.detail}`;  }} /** Not a decode failure: the reader asked to carry on from a cursor this walk never handed out. */export class GitLabDiffCursorError extends Schema.TaggedErrorClass<GitLabDiffCursorError>()(  "GitLabDiffCursorError",  {    command: Schema.Literal("glab"),    cwd: Schema.String,  },) {  get detail(): string {    return "The diff cursor was not one this merge request handed out.";  }   override get message(): string {    return `GitLab CLI failed in getMergeRequestDiff: ${this.detail}`;  }} /** Not a decode failure: the reader named a commit that is not a sha this project could hold. */export class GitLabDiffCommitError extends Schema.TaggedErrorClass<GitLabDiffCommitError>()(  "GitLabDiffCommitError",  {    command: Schema.Literal("glab"),    cwd: Schema.String,  },) {  get detail(): string {    return "The named commit was not a commit sha.";  }   override get message(): string {    return `GitLab CLI failed in getMergeRequestDiff: ${this.detail}`;  }} /** The commit exists and decoded, but it has no parent to use as the old revision. */export class GitLabDiffCommitParentUnavailableError extends Schema.TaggedErrorClass<GitLabDiffCommitParentUnavailableError>()(  "GitLabDiffCommitParentUnavailableError",  {    command: Schema.Literal("glab"),    cwd: Schema.String,    commit: Schema.String,  },) {  get detail(): string {    return `Commit ${this.commit} reported no parent revision.`;  }   override get message(): string {    return `GitLab CLI failed in getMergeRequestDiffFileContents: ${this.detail}`;  }} /** A blob exists, but expanding it would be unsafe or would not produce text. */export class GitLabDiffFileContentsUnavailableError extends Schema.TaggedErrorClass<GitLabDiffFileContentsUnavailableError>()(  "GitLabDiffFileContentsUnavailableError",  {    command: Schema.Literal("glab"),    cwd: Schema.String,    path: Schema.String,    reason: Schema.Literals(["oversized", "binary"]),  },) {  get detail(): string {    return this.reason === "oversized"      ? `The diff file '${this.path}' exceeds the 1 MB expansion limit.`      : `The diff file '${this.path}' is binary.`;  }   override get message(): string {    return `GitLab CLI failed in getMergeRequestDiffFileContents: ${this.detail}`;  }} export type GitLabPullRequestCliError =  | GitLabCli.GitLabCliError  | GitLabMergeRequestReadError  | GitLabDiffCursorError  | GitLabDiffCommitError  | GitLabDiffCommitParentUnavailableError  | GitLabDiffFileContentsUnavailableError  | GitLabDiffRefsUnavailableError  | GitLabViewerUnavailableError; /** GitLab's own ceiling on `per_page`, so a larger page has to be walked. */const MAX_PAGE_SIZE = 100;/** Commit history is read one page deep; the rest of a long history stays on GitLab. */const COMMIT_PAGE_SIZE = 100;/** * Pages of the conversation to follow before it is reported as truncated. GitLab caps a page at * a hundred, so this is a thousand notes and a thousand discussions — more than any merge * request a person is reading holds, and a walk that ends whatever the host has. */const CONVERSATION_PAGES = 10;const DIFF_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;const DIFF_TIMEOUT_MS = 60_000;const DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024; export interface GitLabMergeRequestListBatch {  readonly items: ReadonlyArray<GitLabMergeRequestListItem>;  readonly truncated: boolean;  /** Raw GitLab rows consumed to produce this page, including malformed rows. */  readonly cursorAdvance: number;} export interface GitLabMergeRequestDiffSlice {  readonly patch: string;  /** Files in this slice had their hunks withheld, as opposed to there being more slices. */  readonly truncated: boolean;  /** Where the next slice starts, or null once the patch is whole. */  readonly nextCursor: string | null;} export class GitLabPullRequestCli extends Context.Service<  GitLabPullRequestCli,  {    readonly getViewerUsername: (input: {      readonly cwd: string;    }) => Effect.Effect<string, GitLabPullRequestCliError>;     readonly listMergeRequests: (input: {      readonly cwd: string;      readonly repository: string;      readonly state: PullRequestListState;      readonly involvement: PullRequestInvolvement;      readonly viewer: string;      readonly limit: number;      /** Free text for GitLab's own `search`, which matches title and description. */      readonly query?: string | undefined;      /** Where to carry on from in GitLab's stable update-ordered row set. */      readonly cursor?: ProviderListCursor | undefined;    }) => Effect.Effect<GitLabMergeRequestListBatch, GitLabPullRequestCliError>;     readonly getMergeRequestDetail: (input: {      readonly cwd: string;      readonly repository: string;      readonly number: number;    }) => Effect.Effect<GitLabMergeRequestDetail, GitLabPullRequestCliError>;     readonly listNotes: (input: {      readonly cwd: string;      readonly repository: string;      readonly number: number;    }) => Effect.Effect<      { readonly comments: ReadonlyArray<PullRequestComment>; readonly truncated: boolean },      GitLabPullRequestCliError    >;     readonly listCommits: (input: {      readonly cwd: string;      readonly repository: string;      readonly number: number;    }) => Effect.Effect<ReadonlyArray<PullRequestCommit>, GitLabPullRequestCliError>;     readonly getMergeRequestDiff: (input: {      readonly cwd: string;      readonly repository: string;      readonly number: number;      /** Absent asks for the first slice; anything else is a cursor a slice handed back. */      readonly cursor?: string | undefined;      /** One commit's own changes, rather than everything the merge request carries. */      readonly commit?: string | undefined;    }) => Effect.Effect<GitLabMergeRequestDiffSlice, GitLabPullRequestCliError>;     readonly getMergeRequestDiffFileContents: (input: {      readonly cwd: string;      readonly repository: string;      readonly number: number;      readonly commit?: string | undefined;      readonly changeType: "change" | "rename-pure" | "rename-changed" | "new" | "deleted";      readonly oldPath: string;      readonly newPath: string;    }) => Effect.Effect<      { readonly oldContents: string; readonly newContents: string },      GitLabPullRequestCliError    >;     readonly getProjectMergeCapabilities: (input: {      readonly cwd: string;      readonly repository: string;    }) => Effect.Effect<PullRequestMergeCapabilities, GitLabPullRequestCliError>;     /**     * Who this merge request may be sent to, and who it has already been sent to. Two reads at     * once, because GitLab keeps the people with access on the project and the reviewers on the     * merge request, and neither answers for the other.     */    readonly listReviewerCandidates: (input: {      readonly cwd: string;      readonly repository: string;      readonly number: number;    }) => Effect.Effect<PullRequestReviewerCandidateList, GitLabPullRequestCliError>;     readonly setReviewerRequest: (input: {      readonly cwd: string;      readonly repository: string;      readonly number: number;      readonly reviewers: ReadonlyArray<{ readonly id: string }>;      readonly requested: boolean;    }) => Effect.Effect<void, GitLabPullRequestCliError>;     readonly runMergeRequestAction: (input: {      readonly cwd: string;      readonly repository: string;      readonly number: number;      readonly action: PullRequestAction;      readonly mergeMethod?: PullRequestMergeMethod;    }) => Effect.Effect<void, GitLabPullRequestCliError>;     /** Whichever of the two is given is sent. GitLab calls a merge request's body its description. */    readonly updateMergeRequest: (input: {      readonly cwd: string;      readonly repository: string;      readonly number: number;      readonly title?: string | undefined;      readonly description?: string | undefined;    }) => Effect.Effect<void, GitLabPullRequestCliError>;     readonly commentOnMergeRequest: (input: {      readonly cwd: string;      readonly repository: string;      readonly number: number;      readonly body: string;    }) => Effect.Effect<void, GitLabPullRequestCliError>;     readonly updateNote: (input: {      readonly cwd: string;      readonly repository: string;      readonly number: number;      readonly noteId: string;      readonly body: string;    }) => Effect.Effect<void, GitLabPullRequestCliError>;     readonly listDiscussions: (input: {      readonly cwd: string;      readonly repository: string;      readonly number: number;    }) => Effect.Effect<      { readonly threads: ReadonlyArray<PullRequestReviewThread>; readonly truncated: boolean },      GitLabPullRequestCliError    >;     readonly submitReview: (input: {      readonly cwd: string;      readonly repository: string;      readonly number: number;      readonly verdict: PullRequestReviewVerdict;      readonly body: string;      readonly comments: ReadonlyArray<PullRequestReviewCommentDraft>;    }) => Effect.Effect<void, GitLabPullRequestCliError>;     readonly replyToDiscussion: (input: {      readonly cwd: string;      readonly repository: string;      readonly number: number;      readonly discussionId: string;      readonly body: string;    }) => Effect.Effect<void, GitLabPullRequestCliError>;     readonly setDiscussionResolution: (input: {      readonly cwd: string;      readonly repository: string;      readonly number: number;      readonly discussionId: string;      readonly resolved: boolean;    }) => Effect.Effect<void, GitLabPullRequestCliError>;     /** The awards on the merge request and on every note of it, keyed by the note's REST id. */    readonly listReactions: (input: {      readonly cwd: string;      readonly repository: string;      readonly number: number;    }) => Effect.Effect<      {        readonly reactions: ReadonlyArray<PullRequestReaction>;        readonly reactionsByNoteId: ReadonlyMap<string, ReadonlyArray<PullRequestReaction>>;      },      GitLabPullRequestCliError    >;     /**     * Awards an emoji, or takes the award back. `noteId` is a note of the merge request; absent     * awards the merge request itself, which is where its description's reactions live.     */    readonly setReaction: (input: {      readonly cwd: string;      readonly repository: string;      readonly number: number;      readonly noteId?: string | undefined;      readonly content: PullRequestReactionContent;      readonly reacted: boolean;    }) => Effect.Effect<void, GitLabPullRequestCliError>;  }>()("t3/pullRequest/GitLabPullRequestCli") {} /** The REST API addresses a project by its URL-encoded full path. */function projectPath(repository: string): string {  return encodeURIComponent(repository.trim());} function gitLabReviewPositionLines(  position: PullRequestReviewPosition,):  | { readonly new_line: number }  | { readonly old_line: number }  | { readonly old_line: number; readonly new_line: number } {  switch (position.kind) {    case "added":      return { new_line: position.newLine };    case "deleted":      return { old_line: position.oldLine };    case "context":      return { old_line: position.oldLine, new_line: position.newLine };  }} function stateParam(state: PullRequestListState): string {  // GitLab's `closed` already excludes merged merge requests, so no extra filter is needed,  // and it spans every state under `all`.  return state === "open" ? "opened" : state;} function involvementParams(input: {  readonly involvement: PullRequestInvolvement;  readonly viewer: string;}): ReadonlyArray<readonly [string, string]> {  switch (input.involvement) {    case "authored":      return [["author_username", input.viewer]];    case "reviewing":      return [["reviewer_username", input.viewer]];    case "all":      return [];  }} /** * The page a diff cursor names, or null for anything this walk cannot have issued. The cursor * arrives from the reader as a string and goes straight into a query, so it is parsed rather * than trusted; the length bound keeps a page number out of exponential notation. */function diffCursorPage(cursor: string): number | null {  return /^[1-9][0-9]{0,6}$/.test(cursor) ? Number(cursor) : null;} /** * A commit sha arrives from the reader and goes straight into a request path, so it is checked * rather than trusted: hexadecimal only, from the shortest abbreviation a host prints up to a * whole sha. */function isCommitSha(value: string): boolean {  return /^[0-9a-f]{7,64}$/i.test(value);} function searchParams(search: string | undefined): ReadonlyArray<readonly [string, string]> {  const trimmed = search?.trim() ?? "";  return trimmed.length === 0 ? [] : [["search", trimmed]];} function query(params: ReadonlyArray<readonly [string, string]>): string {  return params.map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join("&");} function actionArgs(  action: PullRequestAction,  mergeMethod: PullRequestMergeMethod | undefined,): ReadonlyArray<string> {  switch (action) {    case "merge":      return [        "merge",        // glab turns on auto-merge whenever a pipeline is running. The button means merge now.        "--auto-merge=false",        "--yes",        ...(mergeMethod === "squash" ? ["--squash"] : []),        ...(mergeMethod === "rebase" ? ["--rebase"] : []),      ];    // The same command with the flag the other way up: here the wait is the whole point, so    // glab is told to arm the merge rather than talked out of it.    case "enable-auto-merge":      return [        "merge",        "--auto-merge=true",        "--yes",        ...(mergeMethod === "squash" ? ["--squash"] : []),        ...(mergeMethod === "rebase" ? ["--rebase"] : []),      ];    // Never reached: taking the arming back has no `glab mr` command, so it goes to the API.    case "disable-auto-merge":      return [];    case "ready":      return ["update", "--ready"];    case "draft":      return ["update", "--draft"];    case "close":      return ["close"];    // A rebase, because GitLab has no other way to move a branch onto its target: there is no

Lines 1–500 of 1393.