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 noLines 1–500 of 1389. Load more
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