diff --git a/apps/server/package.json b/apps/server/package.json index 232a99960f96ebd4dc971f5bfc63743a9f66a624..a246302618a3ace14fb157ebfb4d232a64758321 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -48,6 +48,7 @@ "@t3tools/source-control-azure-devops": "workspace:*", "@t3tools/source-control-core": "workspace:*", "@t3tools/source-control-forgejo": "workspace:*", + "@t3tools/source-control-gitlab": "workspace:*", "effect": "catalog:", "jose": "catalog:", "node-pty": "^1.2.0-beta.15", @@ -64,6 +65,7 @@ "@t3tools/contracts": "workspace:*", "@t3tools/provider-testing": "workspace:*", "@t3tools/shared": "workspace:*", + "@t3tools/source-control-testing": "workspace:*", "@t3tools/ssh": "workspace:*", "@t3tools/tailscale": "workspace:*", "@t3tools/web": "workspace:*", diff --git a/apps/server/scripts/evaluate-thread-titles.ts b/apps/server/scripts/evaluate-thread-titles.ts index 92313a0869c7019f0d33393481a3fc21e6191cc2..0b3fb1393239b5b72b09be9e6866ae2399331cb0 100644 --- a/apps/server/scripts/evaluate-thread-titles.ts +++ b/apps/server/scripts/evaluate-thread-titles.ts @@ -24,7 +24,6 @@ import { import * as ThreadTitleLinks from "../src/textGeneration/ThreadTitleLinks.ts"; import * as SourceControlProviderRegistry from "../src/sourceControl/SourceControlProviderRegistry.ts"; import * as GitHubApi from "../src/sourceControl/GitHubApi.ts"; -import * as GitLabCli from "../src/sourceControl/GitLabCli.ts"; import * as SourceControlBuiltInDrivers from "../src/sourceControl/builtInDrivers.ts"; import * as BitbucketApi from "../src/sourceControl/BitbucketApi.ts"; import * as ServerSettings from "../src/serverSettings.ts"; @@ -152,7 +151,6 @@ await Effect.runPromise( Layer.provide( Layer.mergeAll( GitHubApi.layerWithDependencies, - GitLabCli.layer, SourceControlBuiltInDrivers.layer, // No saved credentials here; Bitbucket falls back to T3CODE_BITBUCKET_* variables. BitbucketApi.layer, diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 9df266d56c8f58675cd310b246b4e38057c86879..92b7fcd607fc1046deb875c7b99f6c3779f71837 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -44,7 +44,9 @@ import { } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import { decodeGitHubPullRequestListJson } from "../sourceControl/gitHubPullRequests.ts"; -import * as GitLabCli from "../sourceControl/GitLabCli.ts"; +import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; +import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; +import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; @@ -53,7 +55,7 @@ import * as VcsProjectConfig from "../vcs/VcsProjectConfig.ts"; import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as GitWorkflowService from "./GitWorkflowService.ts"; -import * as GitLabSourceControlProvider from "../sourceControl/GitLabSourceControlProvider.ts"; +import * as GitLabSourceControlProvider from "@t3tools/source-control-gitlab/server/GitLabSourceControlProvider"; import { ForgejoPullRequestSchema, toForgejoChangeRequest, @@ -2108,17 +2110,19 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { source_project: { path_with_namespace: "Group/Subgroup/Fork" }, }, ]); - const calls: VcsProcess.VcsProcessInput[] = []; + const calls: SourceControlHost.SourceControlProcessInput[] = []; const provider = yield* GitLabSourceControlProvider.make.pipe( Effect.provide( GitLabCli.layer.pipe( Layer.provide( - Layer.mock(VcsProcess.VcsProcess)({ - run: (input) => - Effect.sync(() => { - calls.push(input); - return fakeGhOutput(output); - }), + TestSourceControlHost.layer({ + process: { + run: (input) => + Effect.sync(() => { + calls.push(input); + return fakeGhOutput(output); + }), + }, }), ), ), diff --git a/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts b/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts deleted file mode 100644 index 8fb1334de364462999a163d35e73b97f41adc3ab..0000000000000000000000000000000000000000 --- a/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts +++ /dev/null @@ -1,1536 +0,0 @@ -import { afterEach, assert, expect, it, vi } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import { ChildProcessSpawner } from "effect/process"; - -import * as GitLabCli from "../sourceControl/GitLabCli.ts"; -import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts"; - -const mockedExecute = vi.fn(); - -const layer = it.layer( - GitLabPullRequestCli.layer.pipe( - Layer.provide( - Layer.mock(GitLabCli.GitLabCli)({ - execute: mockedExecute, - }), - ), - ), -); - -function output(stdout: string, stdoutTruncated = false, stdoutInvalidUtf8 = false) { - return { - exitCode: ChildProcessSpawner.ExitCode(0), - stdout, - stderr: "", - stdoutTruncated, - stderrTruncated: false, - stdoutInvalidUtf8, - }; -} - -function mergeRequests(count: number, firstNumber: number): string { - return JSON.stringify( - Array.from({ length: count }, (_, index) => ({ - iid: firstNumber + index, - title: `Merge request ${firstNumber + index}`, - web_url: `https://gitlab.com/acme/web/-/merge_requests/${firstNumber + index}`, - source_branch: "feat/page", - target_branch: "main", - created_at: "2026-07-01T00:00:00Z", - updated_at: "2026-07-02T00:00:00Z", - })), - ); -} - -/** A page of `/diffs` as GitLab serves it, a full one unless the count says otherwise. */ -function diffPage(firstIndex: number, count = 100): string { - return JSON.stringify( - Array.from({ length: count }, (_, index) => ({ - old_path: `src/${firstIndex + index}.ts`, - new_path: `src/${firstIndex + index}.ts`, - diff: "@@ -1 +1 @@\n-a\n+b\n", - })), - ); -} - -/** A page of merge request notes, which is what the flat conversation is read from. */ -function notes(count: number, firstId: number): string { - return JSON.stringify( - Array.from({ length: count }, (_, index) => ({ - id: firstId + index, - body: `note ${firstId + index}`, - author: { username: "bilal" }, - created_at: "2026-07-01T00:00:00Z", - })), - ); -} - -/** Who opened the merge request, and somebody already reviewing it. */ -const author = { id: 1, username: "bilal" }; -const reviewer = { id: 5, username: "octocat" }; - -/** One merge request as `/merge_requests/:iid` answers with it. */ -function mergeRequestJson(overrides: Record): string { - return JSON.stringify({ - iid: 7, - title: "Merge request 7", - web_url: "https://gitlab.com/acme/web/-/merge_requests/7", - source_branch: "feat/page", - target_branch: "main", - created_at: "2026-07-01T00:00:00Z", - updated_at: "2026-07-02T00:00:00Z", - author, - ...overrides, - }); -} - -/** The endpoint or subcommand of the nth glab invocation. */ -function argsOfCall(index: number): ReadonlyArray { - return callAt(index).args; -} - -/** The whole nth invocation, so a request body can be asserted alongside its path. */ -function callAt(index: number) { - const call = mockedExecute.mock.calls[index]; - assert.isDefined(call); - return call[0]; -} - -afterEach(() => { - mockedExecute.mockReset(); -}); - -layer("GitLabPullRequestCli.layer", (it) => { - it.effect("asks GitLab for one row more than the page, to probe for a next page", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output(mergeRequests(3, 1)))); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - const batch = yield* cli.listMergeRequests({ - cwd: "/w", - repository: "acme/web", - state: "open", - involvement: "all", - viewer: "bilal", - limit: 10, - }); - - assert.strictEqual(batch.items.length, 3); - assert.isFalse(batch.truncated); - assert.strictEqual(batch.cursorAdvance, 3); - const path = argsOfCall(0)[1] ?? ""; - expect(path).toContain("projects/acme%2Fweb/merge_requests"); - expect(path).toContain("per_page=11"); - expect(path).toContain("state=opened"); - }), - ); - - it.effect("walks pages at a fixed size, because GitLab pages by offset", () => - Effect.gen(function* () { - mockedExecute - .mockReturnValueOnce(Effect.succeed(output(mergeRequests(100, 1)))) - .mockReturnValueOnce(Effect.succeed(output(mergeRequests(100, 101)))); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - const batch = yield* cli.listMergeRequests({ - cwd: "/w", - repository: "acme/web", - state: "open", - involvement: "all", - viewer: "bilal", - limit: 150, - }); - - assert.strictEqual(batch.items.length, 150); - assert.isTrue(batch.truncated); - for (const index of [0, 1]) { - expect(argsOfCall(index)[1]).toContain("per_page=100"); - } - expect(argsOfCall(0)[1]).toContain("page=1"); - expect(argsOfCall(1)[1]).toContain("page=2"); - }), - ); - - it.effect("hands a search to GitLab's own search parameter", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - yield* cli.listMergeRequests({ - cwd: "/w", - repository: "acme/web", - state: "open", - involvement: "all", - viewer: "bilal", - limit: 10, - query: "page", - }); - - // GitLab matches `search` against title and description, which is more than the row shows. - expect(argsOfCall(0)[1]).toContain("search=page"); - }), - ); - - it.effect("carries on from the number of rows already delivered", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output(mergeRequests(3, 1)))); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - yield* cli.listMergeRequests({ - cwd: "/w", - repository: "acme/web", - state: "open", - involvement: "all", - viewer: "bilal", - limit: 10, - cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 10 }, - }); - - // GitLab's timestamp filter has no tie-breaker, so an offset is what advances through a - // boundary shared by more rows than one page can hold. - const path = argsOfCall(0)[1] ?? ""; - expect(path).not.toContain("updated_before="); - expect(path).toContain("order_by=updated_at"); - expect(path).toContain("per_page=11"); - expect(path).toContain("page=1"); - }), - ); - - it.effect("advances beyond several pages sharing the cursor timestamp", () => - Effect.gen(function* () { - mockedExecute - .mockReturnValueOnce(Effect.succeed(output(mergeRequests(11, 144)))) - .mockReturnValueOnce(Effect.succeed(output(mergeRequests(11, 155)))); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - const batch = yield* cli.listMergeRequests({ - cwd: "/w", - repository: "acme/web", - state: "open", - involvement: "all", - viewer: "bilal", - limit: 10, - cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 150 }, - }); - - expect(argsOfCall(0)[1]).toContain("per_page=11"); - expect(argsOfCall(0)[1]).toContain("page=14"); - expect(argsOfCall(1)[1]).toContain("page=15"); - expect(batch.items.map((item) => item.number)).toEqual([ - 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, - ]); - assert.isTrue(batch.truncated); - }), - ); - - it.effect("advances the cursor through malformed raw rows", () => - Effect.gen(function* () { - const rows = JSON.parse(mergeRequests(2, 1)) as ReadonlyArray; - mockedExecute.mockReturnValueOnce( - Effect.succeed(output(JSON.stringify([{ iid: "malformed" }, ...rows]))), - ); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - const batch = yield* cli.listMergeRequests({ - cwd: "/w", - repository: "acme/web", - state: "open", - involvement: "all", - viewer: "bilal", - limit: 2, - }); - - expect(batch.items.map((item) => item.number)).toEqual([1, 2]); - assert.strictEqual(batch.cursorAdvance, 3); - assert.isTrue(batch.truncated); - }), - ); - - it.effect("URL-encodes a search, so it cannot add a parameter of its own", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - yield* cli.listMergeRequests({ - cwd: "/w", - repository: "acme/web", - state: "open", - involvement: "all", - viewer: "bilal", - limit: 10, - query: '-a&per_page=1 "b"', - }); - - const path = argsOfCall(0)[1] ?? ""; - expect(path).toContain("search=-a%26per_page%3D1%20%22b%22"); - // The page size the walk fixed is still the only one in the query. - assert.strictEqual(path.match(/per_page=/g)?.length, 1); - }), - ); - - it.effect("asks for no search at all when the reader typed only spaces", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - yield* cli.listMergeRequests({ - cwd: "/w", - repository: "acme/web", - state: "open", - involvement: "all", - viewer: "bilal", - limit: 10, - query: " ", - }); - - expect(argsOfCall(0)[1]).not.toContain("search="); - }), - ); - - it.effect("stops walking on a short page", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output(mergeRequests(40, 1)))); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - const batch = yield* cli.listMergeRequests({ - cwd: "/w", - repository: "acme/web", - state: "open", - involvement: "all", - viewer: "bilal", - limit: 150, - }); - - assert.strictEqual(batch.items.length, 40); - assert.isFalse(batch.truncated); - assert.strictEqual(mockedExecute.mock.calls.length, 1); - }), - ); - - it.effect("stops walking when every row on a page fails to decode", () => - Effect.gen(function* () { - // Full pages of unusable rows: nothing is collected, so the collected-count bound never - // trips and only the page bound can end the walk. - const unusable = JSON.stringify(Array.from({ length: 100 }, () => ({ iid: "nope" }))); - mockedExecute.mockReturnValue(Effect.succeed(output(unusable))); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - const batch = yield* cli.listMergeRequests({ - cwd: "/w", - repository: "acme/web", - state: "open", - involvement: "all", - viewer: "bilal", - limit: 150, - }); - - assert.strictEqual(batch.items.length, 0); - // ceil((150 + 1) / 100) pages, not one request per page forever. - assert.strictEqual(mockedExecute.mock.calls.length, 2); - }), - ); - - it.effect("asks GitLab for every state on the All tab", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - yield* cli.listMergeRequests({ - cwd: "/w", - repository: "acme/web", - state: "all", - involvement: "all", - viewer: "bilal", - limit: 10, - }); - - expect(argsOfCall(0)[1]).toContain("state=all"); - }), - ); - - it.effect("filters by the reviewer when the viewer is reviewing", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - yield* cli.listMergeRequests({ - cwd: "/w", - repository: "acme/web", - state: "open", - involvement: "reviewing", - viewer: "bilal", - limit: 10, - }); - - expect(argsOfCall(0)[1]).toContain("reviewer_username=bilal"); - }), - ); - - it.effect("addresses a nested group project by its encoded full path", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - yield* cli.listMergeRequests({ - cwd: "/w", - repository: "acme/platform/web", - state: "open", - involvement: "all", - viewer: "bilal", - limit: 10, - }); - - expect(argsOfCall(0)[1]).toContain("projects/acme%2Fplatform%2Fweb/merge_requests"); - }), - ); - - it.effect("merges immediately rather than leaving auto-merge armed", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output(""))); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - yield* cli.runMergeRequestAction({ - cwd: "/w", - repository: "acme/web", - number: 7, - action: "merge", - mergeMethod: "squash", - }); - - expect(argsOfCall(0)).toEqual([ - "mr", - "merge", - "7", - "--repo", - "acme/web", - "--auto-merge=false", - "--yes", - "--squash", - ]); - }), - ); - - it.effect("arms auto-merge with the same strategy a merge would have used", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output(""))); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - yield* cli.runMergeRequestAction({ - cwd: "/w", - repository: "acme/web", - number: 7, - action: "enable-auto-merge", - mergeMethod: "squash", - }); - - expect(argsOfCall(0)).toEqual([ - "mr", - "merge", - "7", - "--repo", - "acme/web", - "--auto-merge=true", - "--yes", - "--squash", - ]); - }), - ); - - it.effect("cancels an armed auto-merge through the API glab has no flag for", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output("{}"))); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - yield* cli.runMergeRequestAction({ - cwd: "/w", - repository: "acme/platform/web", - number: 7, - action: "disable-auto-merge", - }); - - expect(argsOfCall(0)).toEqual([ - "api", - "projects/acme%2Fplatform%2Fweb/merge_requests/7/cancel_merge_when_pipeline_succeeds", - "--method", - "POST", - ]); - }), - ); - - it.effect("brings a stale branch up to date by rebasing it, the only way GitLab has", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output(""))); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - yield* cli.runMergeRequestAction({ - cwd: "/w", - repository: "acme/web", - number: 7, - action: "update-branch", - }); - - expect(argsOfCall(0)).toEqual(["mr", "rebase", "7", "--repo", "acme/web"]); - }), - ); - - it.effect("moves a merge request back to draft through glab", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output(""))); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - yield* cli.runMergeRequestAction({ - cwd: "/w", - repository: "acme/web", - number: 7, - action: "draft", - }); - - expect(argsOfCall(0)).toEqual(["mr", "update", "7", "--repo", "acme/web", "--draft"]); - }), - ); - - it.effect("sends a comment body over stdin, never in argv", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output(""))); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - yield* cli.commentOnMergeRequest({ - cwd: "/w", - repository: "acme/web", - number: 7, - body: "true", - }); - - const call = mockedExecute.mock.calls[0]; - assert.isDefined(call); - expect(call[0].args).toEqual([ - "api", - "projects/acme%2Fweb/merge_requests/7/notes", - "--method", - "POST", - "--input", - "-", - "--header", - "Content-Type: application/json", - ]); - // A JSON body, so a comment reading as a literal `true` stays text. - expect(call[0].stdin).toBe('{"body":"true"}'); - }), - ); - - it.effect("reads one diff page and hands back the cursor for the next", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output(diffPage(0)))); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - const diff = yield* cli.getMergeRequestDiff({ - cwd: "/w", - repository: "acme/web", - number: 7, - }); - - // One page per call: the reader asks for the rest, the walk does not run on by itself. - assert.strictEqual(mockedExecute.mock.calls.length, 1); - assert.isNotNull(diff.nextCursor); - // A full page means more files, not a slice with something missing from it. - assert.isFalse(diff.truncated); - expect(argsOfCall(0)[1]).toContain("merge_requests/7/diffs?per_page=100&page=1"); - }), - ); - - it.effect("carries on from a cursor at the page it names", () => - Effect.gen(function* () { - mockedExecute - .mockReturnValueOnce(Effect.succeed(output(diffPage(0)))) - .mockReturnValueOnce(Effect.succeed(output(diffPage(100, 3)))); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - const target = { cwd: "/w", repository: "acme/web", number: 7 }; - - const first = yield* cli.getMergeRequestDiff(target); - assert.isNotNull(first.nextCursor); - const second = yield* cli.getMergeRequestDiff({ ...target, cursor: first.nextCursor }); - - expect(argsOfCall(1)[1]).toContain("page=2"); - // A short page is the end of the change set, so there is nothing to carry on from. - assert.isNull(second.nextCursor); - expect(second.patch).toContain("diff --git a/src/100.ts b/src/100.ts"); - }), - ); - - it.effect("refuses a cursor it never handed out rather than reading it into a query", () => - Effect.gen(function* () { - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - const error = yield* Effect.flip( - cli.getMergeRequestDiff({ - cwd: "/w", - repository: "acme/web", - number: 7, - cursor: "1&per_page=1", - }), - ); - - assert.strictEqual(error._tag, "GitLabDiffCursorError"); - assert.strictEqual(mockedExecute.mock.calls.length, 0); - }), - ); - - it.effect("reads a named commit from its own diff, and pages inside it", () => - Effect.gen(function* () { - mockedExecute - .mockReturnValueOnce(Effect.succeed(output(diffPage(0)))) - .mockReturnValueOnce(Effect.succeed(output(diffPage(100, 3)))); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - const target = { - cwd: "/w", - repository: "acme/web", - number: 7, - commit: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", - }; - - const first = yield* cli.getMergeRequestDiff(target); - assert.isNotNull(first.nextCursor); - const second = yield* cli.getMergeRequestDiff({ ...target, cursor: first.nextCursor }); - - const commitPath = - "projects/acme%2Fweb/repository/commits/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0/diff"; - expect(argsOfCall(0)[1]).toBe(`${commitPath}?per_page=100&page=1`); - // The whole path, not just the page: a cursor branch that dropped the commit would still - // ask for page 2, of the merge request's own diff. - expect(argsOfCall(1)[1]).toBe(`${commitPath}?per_page=100&page=2`); - assert.isNull(second.nextCursor); - }), - ); - - it.effect("refuses a commit that is not a sha rather than reading it into a path", () => - Effect.gen(function* () { - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - const error = yield* Effect.flip( - cli.getMergeRequestDiff({ - cwd: "/w", - repository: "acme/web", - number: 7, - commit: "../../merge_requests/8/diffs", - }), - ); - - assert.strictEqual(error._tag, "GitLabDiffCommitError"); - assert.strictEqual(mockedExecute.mock.calls.length, 0); - }), - ); - - it.effect("reports a commit with no parent as a structured error", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce( - Effect.succeed(output(JSON.stringify({ id: "a1b2c3d", parent_ids: [] }))), - ); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - const error = yield* Effect.flip( - cli.getMergeRequestDiffFileContents({ - cwd: "/w", - repository: "acme/web", - number: 7, - commit: "a1b2c3d", - changeType: "change", - oldPath: "src/a.ts", - newPath: "src/a.ts", - }), - ); - - assert.strictEqual(error._tag, "GitLabDiffCommitParentUnavailableError"); - if (error._tag === "GitLabDiffCommitParentUnavailableError") { - assert.strictEqual(error.commit, "a1b2c3d"); - } - }), - ); - - it.effect("expands a new file from a root commit without requiring a parent", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce( - Effect.succeed(output(JSON.stringify({ id: "a1b2c3d", parent_ids: [] }))), - ); - mockedExecute.mockReturnValueOnce(Effect.succeed(output("first contents\n"))); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - const contents = yield* cli.getMergeRequestDiffFileContents({ - cwd: "/w", - repository: "acme/web", - number: 7, - commit: "a1b2c3d", - changeType: "new", - oldPath: "src/first.ts", - newPath: "src/first.ts", - }); - - expect(contents).toEqual({ oldContents: "", newContents: "first contents\n" }); - expect(argsOfCall(1)[1]).toContain("raw?ref=a1b2c3d"); - }), - ); - - it.effect("reports an oversized diff file with its path and reason", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce( - Effect.succeed( - output( - JSON.stringify({ - diff_refs: { - base_sha: "a1b2c3d", - head_sha: "b1c2d3e", - start_sha: "a1b2c3d", - }, - }), - ), - ), - ); - mockedExecute.mockReturnValueOnce(Effect.succeed(output("partial", true))); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - const error = yield* Effect.flip( - cli.getMergeRequestDiffFileContents({ - cwd: "/w", - repository: "acme/web", - number: 7, - changeType: "deleted", - oldPath: "src/large.ts", - newPath: "src/large.ts", - }), - ); - - assert.strictEqual(error._tag, "GitLabDiffFileContentsUnavailableError"); - if (error._tag === "GitLabDiffFileContentsUnavailableError") { - assert.strictEqual(error.path, "src/large.ts"); - assert.strictEqual(error.reason, "oversized"); - } - }), - ); - - it.effect("reports undecodable diff file contents as binary", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce( - Effect.succeed( - output( - JSON.stringify({ - diff_refs: { - base_sha: "a1b2c3d", - head_sha: "b1c2d3e", - start_sha: "a1b2c3d", - }, - }), - ), - ), - ); - mockedExecute.mockReturnValueOnce( - Effect.succeed(output("binary\uFFFDcontents", false, true)), - ); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - const error = yield* Effect.flip( - cli.getMergeRequestDiffFileContents({ - cwd: "/w", - repository: "acme/web", - number: 7, - changeType: "deleted", - oldPath: "assets/logo.png", - newPath: "assets/logo.png", - }), - ); - - assert.strictEqual(error._tag, "GitLabDiffFileContentsUnavailableError"); - if (error._tag === "GitLabDiffFileContentsUnavailableError") { - assert.strictEqual(error.path, "assets/logo.png"); - assert.strictEqual(error.reason, "binary"); - } - }), - ); - - it.effect("returns valid text containing a literal replacement character", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce( - Effect.succeed( - output( - JSON.stringify({ - diff_refs: { - base_sha: "a1b2c3d", - head_sha: "b1c2d3e", - start_sha: "a1b2c3d", - }, - }), - ), - ), - ); - mockedExecute.mockReturnValueOnce(Effect.succeed(output("before\uFFFDafter"))); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - const contents = yield* cli.getMergeRequestDiffFileContents({ - cwd: "/w", - repository: "acme/web", - number: 7, - changeType: "deleted", - oldPath: "docs/encoding.md", - newPath: "docs/encoding.md", - }); - - assert.strictEqual(contents.oldContents, "before\uFFFDafter"); - }), - ); - - it.effect("ends the diff on a page with no files rather than asking for it again", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - const diff = yield* cli.getMergeRequestDiff({ - cwd: "/w", - repository: "acme/web", - number: 7, - cursor: "4", - }); - - assert.strictEqual(diff.patch, ""); - assert.isNull(diff.nextCursor); - }), - ); - - it.effect("fails a diff page cut off mid-JSON rather than calling the diff whole", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce( - // A byte-truncated prefix: valid JSON never survives the cut. - Effect.succeed({ ...output('[{"old_path":"src/x.ts","new_p'), stdoutTruncated: true }), - ); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - const error = yield* Effect.flip( - cli.getMergeRequestDiff({ cwd: "/w", repository: "acme/web", number: 7 }), - ); - - // An empty slice with no cursor would report every file from this page on as already - // read, which is the one answer that loses a change without saying so. - assert.strictEqual(error._tag, "GitLabMergeRequestReadError"); - }), - ); - - it.effect("offers no squash when the project does not say it allows one", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce( - Effect.succeed(output(JSON.stringify({ merge_method: "merge" }))), - ); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - const capabilities = yield* cli.getProjectMergeCapabilities({ - cwd: "/w", - repository: "acme/web", - }); - - assert.deepStrictEqual(capabilities, { merge: true, squash: false, rebase: false }); - }), - ); - - it.effect("reads the project's merge settings as its merge capabilities", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce( - Effect.succeed(output(JSON.stringify({ merge_method: "ff", squash_option: "never" }))), - ); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - const capabilities = yield* cli.getProjectMergeCapabilities({ - cwd: "/w", - repository: "acme/web", - }); - - assert.deepStrictEqual(capabilities, { merge: false, squash: false, rebase: true }); - }), - ); - - it.effect("asks the detail read for the divergence GitLab withholds by default", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"message":"404 Not Found"}'))); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - yield* Effect.ignore( - cli.getMergeRequestDetail({ cwd: "/w", repository: "acme/web", number: 7 }), - ); - - expect(argsOfCall(0)[1]).toBe( - "projects/acme%2Fweb/merge_requests/7?include_diverged_commits_count=true", - ); - }), - ); - - it.effect("fails the read when GitLab returns something unreadable", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"message":"404 Not Found"}'))); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - const error = yield* Effect.flip( - cli.getMergeRequestDetail({ cwd: "/w", repository: "acme/web", number: 7 }), - ); - - assert.strictEqual(error._tag, "GitLabMergeRequestReadError"); - }), - ); - - it.effect("fails when the authenticated account has no username", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output(JSON.stringify({ username: "" })))); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - const error = yield* Effect.flip(cli.getViewerUsername({ cwd: "/w" })); - - assert.strictEqual(error._tag, "GitLabViewerUnavailableError"); - }), - ); - - it.effect("walks the notes until GitLab answers with a short page", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output(notes(100, 1)))); - mockedExecute.mockReturnValueOnce(Effect.succeed(output(notes(2, 101)))); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - const { comments, truncated } = yield* cli.listNotes({ - cwd: "/w", - repository: "acme/web", - number: 7, - }); - - expect(argsOfCall(0).join(" ")).toContain("page=1"); - expect(argsOfCall(1).join(" ")).toContain("page=2"); - assert.strictEqual(comments.length, 102); - assert.isFalse(truncated); - }), - ); - - it.effect("stops the note walk at its bound and says the conversation was cut short", () => - Effect.gen(function* () { - // GitLab that never answers short: the walk has to end itself. - mockedExecute.mockReturnValue(Effect.succeed(output(notes(100, 1)))); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - const { truncated } = yield* cli.listNotes({ - cwd: "/w", - repository: "acme/web", - number: 7, - }); - - assert.strictEqual(mockedExecute.mock.calls.length, 10); - assert.isTrue(truncated); - }), - ); - - it.effect("reads a positioned discussion as a thread anchored to its line", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce( - Effect.succeed( - output( - JSON.stringify([ - { - id: "abc123", - notes: [ - { - id: 1, - body: "rename this", - author: { username: "bilal", avatar_url: "https://avatars/b.png" }, - created_at: "2026-07-01T00:00:00Z", - resolvable: true, - resolved: true, - position: { - position_type: "text", - new_path: "src/a.ts", - old_path: "src/a.ts", - new_line: 12, - old_line: null, - }, - }, - { - id: 2, - body: "done", - author: { username: "julius" }, - created_at: "2026-07-01T01:00:00Z", - }, - ], - }, - // A plain note is the timeline's business, not the diff's. - { id: "def456", notes: [{ id: 3, body: "ship it", created_at: "2026-07-01Z" }] }, - ]), - ), - ), - ); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - const { threads } = yield* cli.listDiscussions({ - cwd: "/w", - repository: "acme/web", - number: 7, - }); - - assert.strictEqual(threads.length, 1); - expect(threads[0]).toMatchObject({ - id: "abc123", - path: "src/a.ts", - line: 12, - side: "right", - isResolved: true, - }); - assert.strictEqual(threads[0]?.comments.length, 2); - }), - ); - - it.effect("sends a review as its comments, then its summary, then the verdict", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce( - Effect.succeed( - output( - JSON.stringify({ - iid: 7, - title: "t", - web_url: "https://gitlab.com/acme/web/-/merge_requests/7", - source_branch: "feat", - target_branch: "main", - created_at: "2026-07-01T00:00:00Z", - updated_at: "2026-07-01T00:00:00Z", - diff_refs: { base_sha: "base", head_sha: "head", start_sha: "start" }, - }), - ), - ), - ); - mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - yield* cli.submitReview({ - cwd: "/w", - repository: "acme/web", - number: 7, - verdict: "approve", - body: "Looks right.", - comments: [ - { - path: "src/b.ts", - oldPath: "src/a.ts", - position: { kind: "deleted", oldLine: 4 }, - body: "why remove?", - }, - ], - }); - - // The diff revisions first, because a positioned comment cannot be placed without them. - expect(argsOfCall(0)[1]).toContain("merge_requests/7"); - expect(argsOfCall(1)[1]).toContain("/discussions"); - expect(JSON.parse(callAt(1).stdin ?? "")).toEqual({ - body: "why remove?", - position: { - base_sha: "base", - head_sha: "head", - start_sha: "start", - position_type: "text", - // A renamed file is the only case the two differ, and GitLab cannot place a - // position that names the same path on both sides of the rename. - old_path: "src/a.ts", - new_path: "src/b.ts", - old_line: 4, - }, - }); - expect(argsOfCall(2)[1]).toContain("/notes"); - // The verdict goes last, so a review that failed part-way is never an approval. - expect(argsOfCall(3)[1]).toContain("/approve"); - }), - ); - - it.effect("does not ask for diff revisions when a review carries no line comments", () => - Effect.gen(function* () { - mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - yield* cli.submitReview({ - cwd: "/w", - repository: "acme/web", - number: 7, - verdict: "comment", - body: "One thought.", - comments: [], - }); - - assert.strictEqual(mockedExecute.mock.calls.length, 1); - expect(argsOfCall(0)[1]).toContain("/notes"); - }), - ); - - it.effect("resolves a discussion in place rather than posting to it", () => - Effect.gen(function* () { - mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - yield* cli.setDiscussionResolution({ - cwd: "/w", - repository: "acme/web", - number: 7, - discussionId: "abc123", - resolved: true, - }); - - expect(argsOfCall(0)).toContain("--method"); - expect(argsOfCall(0)).toContain("PUT"); - expect(argsOfCall(0)[1]).toContain("/discussions/abc123"); - expect(JSON.parse(callAt(0).stdin ?? "")).toEqual({ resolved: true }); - }), - ); - - it.effect("awards an emoji through a POST naming it, not a body", () => - Effect.gen(function* () { - mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - yield* cli.setReaction({ - cwd: "/w", - repository: "acme/web", - number: 7, - content: "thumbs-up", - reacted: true, - }); - - assert.strictEqual(mockedExecute.mock.calls.length, 1); - expect(argsOfCall(0)).toEqual([ - "api", - "projects/acme%2Fweb/merge_requests/7/award_emoji?name=thumbsup", - "--method", - "POST", - ]); - }), - ); - - it.effect("removes an award by listing them and deleting the reader's own id", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce( - Effect.succeed(output(JSON.stringify({ username: "bilal" }))), - ); - mockedExecute.mockReturnValueOnce( - Effect.succeed( - output( - JSON.stringify([ - { id: 5, name: "thumbsup", user: { username: "bilal" } }, - { id: 6, name: "thumbsup", user: { username: "julius" } }, - ]), - ), - ), - ); - mockedExecute.mockReturnValueOnce(Effect.succeed(output("{}"))); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - yield* cli.setReaction({ - cwd: "/w", - repository: "acme/web", - number: 7, - content: "thumbs-up", - reacted: false, - }); - - assert.strictEqual(mockedExecute.mock.calls.length, 3); - expect(argsOfCall(2)).toEqual([ - "api", - "projects/acme%2Fweb/merge_requests/7/award_emoji/5", - "--method", - "DELETE", - ]); - }), - ); - - it.effect("does nothing when the reader has no award of that name to take back", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce( - Effect.succeed(output(JSON.stringify({ username: "bilal" }))), - ); - mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - yield* cli.setReaction({ - cwd: "/w", - repository: "acme/web", - number: 7, - content: "thumbs-up", - reacted: false, - }); - - // Nothing to delete: the reaction the caller asked to take back is already gone. - assert.strictEqual(mockedExecute.mock.calls.length, 2); - }), - ); - - it.effect("names a merge request with no diff revisions rather than calling it unreadable", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce( - Effect.succeed( - output( - JSON.stringify({ - iid: 7, - title: "t", - web_url: "https://gitlab.com/acme/web/-/merge_requests/7", - source_branch: "feat", - target_branch: "main", - created_at: "2026-07-01T00:00:00Z", - updated_at: "2026-07-01T00:00:00Z", - diff_refs: null, - }), - ), - ), - ); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - const error = yield* Effect.flip( - cli.submitReview({ - cwd: "/w", - repository: "acme/web", - number: 7, - verdict: "comment", - body: "", - comments: [{ path: "src/a.ts", position: { kind: "added", newLine: 4 }, body: "nit" }], - }), - ); - - // Nothing failed to decode: GitLab answered, and the answer has nowhere to put a - // positioned comment. - assert.strictEqual(error._tag, "GitLabDiffRefsUnavailableError"); - }), - ); - - it.effect("reads who has access to the project and who is already on the merge request", () => - Effect.gen(function* () { - mockedExecute - .mockReturnValueOnce(Effect.succeed(output(mergeRequestJson({ reviewers: [reviewer] })))) - .mockReturnValueOnce( - Effect.succeed(output(JSON.stringify([author, reviewer, { id: 9, username: "hubot" }]))), - ); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - const list = yield* cli.listReviewerCandidates({ - cwd: "/w", - repository: "acme/web", - number: 7, - }); - - expect(argsOfCall(1)[1]).toBe("projects/acme%2Fweb/users?per_page=100"); - // The author is left out, and whoever GitLab already has as a reviewer is marked. - expect(list.candidates.map((candidate) => [candidate.id, candidate.isRequested])).toEqual([ - ["5", true], - ["9", false], - ]); - assert.isFalse(list.truncated); - }), - ); - - it.effect("writes the reviewer set back with the one being asked added to it", () => - Effect.gen(function* () { - mockedExecute - .mockReturnValueOnce(Effect.succeed(output(mergeRequestJson({ reviewers: [reviewer] })))) - .mockReturnValueOnce(Effect.succeed(output("{}"))); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - yield* cli.setReviewerRequest({ - cwd: "/w", - repository: "acme/web", - number: 7, - reviewers: [{ id: "9" }], - requested: true, - }); - - // GitLab replaces the whole set, so the reviewer already on the merge request has to be - // sent back with the new one or the request would take them off it. - expect(argsOfCall(1)).toContain("PUT"); - expect(JSON.parse(callAt(1).stdin ?? "")).toEqual({ reviewer_ids: [5, 9] }); - }), - ); - - it.effect("takes a reviewer out of the set rather than clearing it", () => - Effect.gen(function* () { - mockedExecute - .mockReturnValueOnce( - Effect.succeed( - output(mergeRequestJson({ reviewers: [reviewer, { id: 9, username: "hubot" }] })), - ), - ) - .mockReturnValueOnce(Effect.succeed(output("{}"))); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - yield* cli.setReviewerRequest({ - cwd: "/w", - repository: "acme/web", - number: 7, - reviewers: [{ id: "9" }], - requested: false, - }); - - expect(JSON.parse(callAt(1).stdin ?? "")).toEqual({ reviewer_ids: [5] }); - }), - ); - - it.effect("ignores an id GitLab could not have handed out, which names nobody", () => - Effect.gen(function* () { - mockedExecute - .mockReturnValueOnce(Effect.succeed(output(mergeRequestJson({ reviewers: [reviewer] })))) - .mockReturnValueOnce(Effect.succeed(output("{}"))); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - yield* cli.setReviewerRequest({ - cwd: "/w", - repository: "acme/web", - number: 7, - reviewers: [{ id: "octocat" }], - requested: true, - }); - - // Sending it as a number would rewrite the reviewer set around something nobody chose. - expect(JSON.parse(callAt(1).stdin ?? "")).toEqual({ reviewer_ids: [5] }); - }), - ); - - it.effect("rewrites a title without touching the description", () => - Effect.gen(function* () { - mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - yield* cli.updateMergeRequest({ - cwd: "/w", - repository: "acme/web", - number: 7, - title: "A better title", - }); - - expect(argsOfCall(0)).toEqual([ - "api", - "projects/acme%2Fweb/merge_requests/7", - "--method", - "PUT", - "--input", - "-", - "--header", - "Content-Type: application/json", - ]); - expect(JSON.parse(callAt(0).stdin ?? "")).toEqual({ title: "A better title" }); - }), - ); - - it.effect("sends a rewritten body as GitLab's description, and nothing else", () => - Effect.gen(function* () { - mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - yield* cli.updateMergeRequest({ - cwd: "/w", - repository: "acme/web", - number: 7, - description: "What this changes.", - }); - - // A title sent as an empty string would wipe the one the merge request already has. - expect(JSON.parse(callAt(0).stdin ?? "")).toEqual({ description: "What this changes." }); - }), - ); - - it.effect("rewrites title and description together in one request", () => - Effect.gen(function* () { - mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - yield* cli.updateMergeRequest({ - cwd: "/w", - repository: "acme/web", - number: 7, - title: "A better title", - description: "What this changes.", - }); - - assert.strictEqual(mockedExecute.mock.calls.length, 1); - expect(JSON.parse(callAt(0).stdin ?? "")).toEqual({ - title: "A better title", - description: "What this changes.", - }); - }), - ); - - it.effect("rewrites a note in place through the note it names", () => - Effect.gen(function* () { - mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - yield* cli.updateNote({ - cwd: "/w", - repository: "acme/web", - number: 7, - noteId: "42", - body: "true", - }); - - expect(argsOfCall(0)).toEqual([ - "api", - "projects/acme%2Fweb/merge_requests/7/notes/42", - "--method", - "PUT", - "--input", - "-", - "--header", - "Content-Type: application/json", - ]); - // A JSON body, so a note rewritten to a literal `true` stays text. - expect(callAt(0).stdin).toBe('{"body":"true"}'); - }), - ); - it.effect("reads blob ids for the marked paths at the merge request's head", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce( - Effect.succeed( - output( - JSON.stringify({ - iid: 7, - title: "t", - web_url: "https://gitlab.com/acme/web/-/merge_requests/7", - source_branch: "feat", - target_branch: "main", - created_at: "2026-07-01T00:00:00Z", - updated_at: "2026-07-01T00:00:00Z", - diff_refs: { base_sha: "base", head_sha: "head", start_sha: "start" }, - }), - ), - ), - ); - mockedExecute.mockReturnValueOnce( - Effect.succeed( - output( - JSON.stringify({ - data: { - project: { repository: { blobs: { nodes: [{ path: "src/a.ts", oid: "aaa" }] } } }, - }, - }), - ), - ), - ); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - const revisions = yield* cli.getFileRevisions({ - cwd: "/w", - repository: "acme/web", - number: 7, - paths: ["src/a.ts", "src/gone.ts"], - }); - - // Every path was looked for at the head, so one the head does not have is one the merge - // request removed: said as the empty version, which a mark on it was stamped with too. - expect([...revisions]).toEqual([ - ["src/a.ts", "aaa"], - ["src/gone.ts", ""], - ]); - // The head the reader is looking at, not whatever the source branch has moved on to. - const body: unknown = JSON.parse(callAt(1).stdin ?? "{}"); - expect(body).toMatchObject({ - variables: { fullPath: "acme/web", ref: "head", paths: ["src/a.ts", "src/gone.ts"] }, - }); - }), - ); - - it.effect("leaves the paths out when GitLab did not answer the blobs query", () => - Effect.gen(function* () { - mockedExecute.mockReturnValueOnce( - Effect.succeed( - output( - JSON.stringify({ - iid: 7, - title: "t", - web_url: "https://gitlab.com/acme/web/-/merge_requests/7", - source_branch: "feat", - target_branch: "main", - created_at: "2026-07-01T00:00:00Z", - updated_at: "2026-07-01T00:00:00Z", - diff_refs: { base_sha: "base", head_sha: "head", start_sha: "start" }, - }), - ), - ), - ); - // What GitLab says of a project the token cannot see. It is not the head having none of - // these files, and reading it that way would report every file the reader has cleared as - // changed on nothing worse than a permission. - mockedExecute.mockReturnValueOnce( - Effect.succeed(output(JSON.stringify({ data: { project: null } }))), - ); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - const revisions = yield* cli.getFileRevisions({ - cwd: "/w", - repository: "acme/web", - number: 7, - paths: ["src/a.ts", "src/b.ts"], - }); - - expect([...revisions]).toEqual([]); - }), - ); - - it.effect("asks GitLab nothing when no file is marked", () => - Effect.gen(function* () { - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - const revisions = yield* cli.getFileRevisions({ - cwd: "/w", - repository: "acme/web", - number: 7, - paths: [], - }); - - expect([...revisions]).toEqual([]); - expect(mockedExecute).not.toHaveBeenCalled(); - }), - ); - - it.effect("splits the paths across requests, because GitLab charges the query by how many", () => - Effect.gen(function* () { - const paths = Array.from({ length: 150 }, (_, index) => `src/${index}.ts`); - mockedExecute.mockReturnValueOnce( - Effect.succeed( - output( - JSON.stringify({ - iid: 7, - title: "t", - web_url: "https://gitlab.com/acme/web/-/merge_requests/7", - source_branch: "feat", - target_branch: "main", - created_at: "2026-07-01T00:00:00Z", - updated_at: "2026-07-01T00:00:00Z", - diff_refs: { base_sha: "base", head_sha: "head", start_sha: "start" }, - }), - ), - ), - ); - mockedExecute.mockImplementation((request) => { - const body = JSON.parse(request.stdin ?? "{}") as { - readonly variables: { readonly paths: ReadonlyArray }; - }; - return Effect.succeed( - output( - JSON.stringify({ - data: { - project: { - repository: { - blobs: { - nodes: body.variables.paths.map((path) => ({ path, oid: `oid-${path}` })), - }, - }, - }, - }, - }), - ), - ); - }); - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - const revisions = yield* cli.getFileRevisions({ - cwd: "/w", - repository: "acme/web", - number: 7, - paths, - }); - - assert.strictEqual(revisions.size, 150); - assert.strictEqual(revisions.get("src/149.ts"), "oid-src/149.ts"); - // The diff refs, then two batches: a hundred paths and the fifty left over. - assert.strictEqual(mockedExecute.mock.calls.length, 3); - }), - ); -}); diff --git a/apps/server/src/pullRequest/GitLabPullRequestCli.ts b/apps/server/src/pullRequest/GitLabPullRequestCli.ts deleted file mode 100644 index 933e6874218737dae99bb852194175a524be8326..0000000000000000000000000000000000000000 --- a/apps/server/src/pullRequest/GitLabPullRequestCli.ts +++ /dev/null @@ -1,1495 +0,0 @@ -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, - decodeRepositoryBlobsJson, - decodeViewerJson, - gitLabAwardName, - REPOSITORY_BLOBS_GRAPHQL_QUERY, - type GitLabDiffRefs, - type GitLabMergeRequestDetail, - type GitLabMergeRequestListItem, - type GitLabProjectUsers, -} from "./gitLabMergeRequestJson.ts"; -import type { ProviderListCursor } from "@t3tools/source-control-core/server/PullRequestProvider"; - -/** - * 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.TaggedError()( - "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.TaggedError()( - "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.TaggedError()( - "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.TaggedError()( - "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.TaggedError()( - "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.TaggedError()( - "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.TaggedError()( - "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; - 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; - - 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; - - readonly getMergeRequestDetail: (input: { - readonly cwd: string; - readonly repository: string; - readonly number: number; - }) => Effect.Effect; - - readonly listNotes: (input: { - readonly cwd: string; - readonly repository: string; - readonly number: number; - }) => Effect.Effect< - { readonly comments: ReadonlyArray; readonly truncated: boolean }, - GitLabPullRequestCliError - >; - - readonly listCommits: (input: { - readonly cwd: string; - readonly repository: string; - readonly number: number; - }) => Effect.Effect, 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; - - 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; - - /** - * What the merge request's head has of each of these paths, as blob ids. - * - * The head sha comes from the merge request's own diff refs, so the answer is the version a - * reader is looking at rather than whatever the source branch has moved on to. A path the - * head does not have is answered as the empty revision, since the batch it was asked in was - * looked at; a batch GitLab did not answer for is left out instead. - */ - readonly getFileRevisions: (input: { - readonly cwd: string; - readonly repository: string; - readonly number: number; - readonly paths: ReadonlyArray; - }) => Effect.Effect, 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; - - readonly setReviewerRequest: (input: { - readonly cwd: string; - readonly repository: string; - readonly number: number; - readonly reviewers: ReadonlyArray<{ readonly id: string }>; - readonly requested: boolean; - }) => Effect.Effect; - - readonly runMergeRequestAction: (input: { - readonly cwd: string; - readonly repository: string; - readonly number: number; - readonly action: PullRequestAction; - readonly mergeMethod?: PullRequestMergeMethod; - }) => Effect.Effect; - - /** 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; - - readonly commentOnMergeRequest: (input: { - readonly cwd: string; - readonly repository: string; - readonly number: number; - readonly body: string; - }) => Effect.Effect; - - readonly updateNote: (input: { - readonly cwd: string; - readonly repository: string; - readonly number: number; - readonly noteId: string; - readonly body: string; - }) => Effect.Effect; - - readonly listDiscussions: (input: { - readonly cwd: string; - readonly repository: string; - readonly number: number; - }) => Effect.Effect< - { readonly threads: ReadonlyArray; 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; - }) => Effect.Effect; - - readonly replyToDiscussion: (input: { - readonly cwd: string; - readonly repository: string; - readonly number: number; - readonly discussionId: string; - readonly body: string; - }) => Effect.Effect; - - readonly setDiscussionResolution: (input: { - readonly cwd: string; - readonly repository: string; - readonly number: number; - readonly discussionId: string; - readonly resolved: boolean; - }) => Effect.Effect; - - /** 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; - readonly reactionsByNoteId: ReadonlyMap>; - }, - 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; - } ->()("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 { - 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 { - const trimmed = search?.trim() ?? ""; - return trimmed.length === 0 ? [] : [["search", trimmed]]; -} - -function query(params: ReadonlyArray): string { - return params.map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join("&"); -} - -function actionArgs( - action: PullRequestAction, - mergeMethod: PullRequestMergeMethod | undefined, -): ReadonlyArray { - 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 - // merge-the-target-in equivalent of GitHub's update button, which is why this host declares - // `rebase` alone and never has to read the method it was handed. - case "update-branch": - return ["rebase"]; - case "reopen": - return ["reopen"]; - // Never reached: this host does not declare the action, so the service refuses it first. - case "revert": - case "approve-workflows": - throw new Error(`GitLab merge request action ${action} is unsupported`); - } -} - -/** @public Service construction is part of the canonical Effect module API. */ -export const make = Effect.gen(function* () { - const gitlab = yield* GitLabCli.GitLabCli; - - const api = (input: { - readonly cwd: string; - readonly path: string; - readonly method?: string; - readonly stdin?: string; - readonly maxOutputBytes?: number; - readonly timeoutMs?: number; - }) => - gitlab.execute({ - cwd: input.cwd, - args: [ - "api", - input.path, - ...(input.method === undefined ? [] : ["--method", input.method]), - // A raw body from stdin: argv is visible in process listings and is echoed back - // inside process-runner failure messages. Unlike `gh`, `glab api --input` sends no - // Content-Type at all, and GitLab answers a bodyless content type with HTTP 415. - ...(input.stdin === undefined - ? [] - : ["--input", "-", "--header", "Content-Type: application/json"]), - ], - ...(input.stdin === undefined ? {} : { stdin: input.stdin }), - ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), - ...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }), - }); - - /** - * `per_page` stops at 100, so a larger page is walked one request at a time. The walk is - * bounded twice over: it stops on a short page or once the extra row that reveals a next - * page has been read, and it never asks for more pages than the caller's page needs. The - * second bound is what makes it terminate when every row on a page fails to decode, which - * leaves nothing collected but does not mean GitLab has run out of rows. - */ - const listPage = (input: { - readonly cwd: string; - readonly repository: string; - readonly state: PullRequestListState; - readonly involvement: PullRequestInvolvement; - readonly viewer: string; - readonly limit: number; - readonly query?: string | undefined; - readonly cursor?: ProviderListCursor | undefined; - readonly page: number; - readonly collected: ReadonlyArray; - readonly cursorAdvance: number; - }): Effect.Effect => { - // A continuation uses GitLab's offset pagination. Its timestamp filter is inclusive and has - // no tie-breaker, so a page where many rows share the boundary would otherwise return the - // same prefix forever. `delivered` is the stable offset the service has already handed over. - const delivered = input.cursor?.delivered ?? 0; - const perPage = Math.min(input.limit + 1, MAX_PAGE_SIZE); - const firstPage = Math.floor(delivered / perPage) + 1; - const skipOnFirstPage = input.page === firstPage ? delivered % perPage : 0; - // A page made entirely of malformed rows has no item from which the service can build a - // continuation. Bound the walk to the raw span this request asked for rather than recursing - // forever on a host that keeps returning full unusable pages. - const lastPage = Math.floor((delivered + input.limit) / perPage) + 1; - return api({ - cwd: input.cwd, - path: `projects/${projectPath(input.repository)}/merge_requests?${query([ - ["state", stateParam(input.state)], - ...involvementParams(input), - // The listing is read through `glab api` rather than `glab mr list`, so the search is - // the REST API's own `search` parameter — the one `mr list --search` passes on. It - // matches title and description, and travels URL-encoded like every other value here, - // so no text in it can become a parameter of its own. - ...searchParams(input.query), - ["order_by", "updated_at"], - ["sort", "desc"], - ["per_page", String(perPage)], - ["page", String(input.page)], - ])}`, - }).pipe( - Effect.flatMap((result) => { - const raw = result.stdout.trim(); - if (raw.length === 0) { - return Effect.succeed({ - items: input.collected, - truncated: false, - cursorAdvance: input.cursorAdvance, - }); - } - const decoded = decodeMergeRequestListJson(raw); - if (!Result.isSuccess(decoded)) { - return Effect.fail( - new GitLabMergeRequestReadError({ - command: "glab", - cwd: input.cwd, - operation: "listMergeRequests", - cause: decoded.failure, - }), - ); - } - const pageItems: GitLabMergeRequestListItem[] = []; - const pageRawIndexes: number[] = []; - for (const [index, item] of decoded.success.items.entries()) { - const rawIndex = decoded.success.rawIndexes[index]!; - if (rawIndex < skipOnFirstPage) continue; - pageItems.push(item); - pageRawIndexes.push(rawIndex); - } - const remaining = input.limit - input.collected.length; - const lastItemRawIndex = pageRawIndexes[remaining - 1]; - if (lastItemRawIndex !== undefined) { - const consumed = lastItemRawIndex + 1 - skipOnFirstPage; - return Effect.succeed({ - items: [...input.collected, ...pageItems.slice(0, remaining)], - truncated: - lastItemRawIndex + 1 < decoded.success.rawCount || - decoded.success.rawCount === perPage, - cursorAdvance: input.cursorAdvance + consumed, - }); - } - const collected = [...input.collected, ...pageItems]; - const consumed = Math.max(0, decoded.success.rawCount - skipOnFirstPage); - // Counted before decoding, so a skipped malformed row cannot end paging early. - const exhausted = decoded.success.rawCount < perPage; - if (exhausted) { - return Effect.succeed({ - items: collected, - truncated: false, - cursorAdvance: input.cursorAdvance + consumed, - }); - } - if (input.page >= lastPage) { - return Effect.succeed({ - items: collected, - truncated: true, - cursorAdvance: input.cursorAdvance + consumed, - }); - } - return listPage({ - ...input, - page: input.page + 1, - collected, - cursorAdvance: input.cursorAdvance + consumed, - }); - }), - ); - }; - - /** - * One page of a merge request's files, as a patch that stands on its own. GitLab pages - * `/diffs` by offset and has no cursor of its own, so the page number is the cursor; the - * caller carries on from it for as long as GitLab keeps handing full pages back. - * - * A named commit is read from the commit's own diff, which answers in the same shape and pages - * the same way. - */ - const diffPage = (input: { - readonly cwd: string; - readonly repository: string; - readonly number: number; - readonly page: number; - readonly commit?: string | undefined; - }): Effect.Effect => - api({ - cwd: input.cwd, - path: `projects/${projectPath(input.repository)}/${ - input.commit === undefined - ? `merge_requests/${input.number}/diffs` - : `repository/commits/${input.commit}/diff` - }?${query([ - ["per_page", String(MAX_PAGE_SIZE)], - ["page", String(input.page)], - ])}`, - maxOutputBytes: DIFF_MAX_OUTPUT_BYTES, - timeoutMs: DIFF_TIMEOUT_MS, - }).pipe( - Effect.flatMap((result) => { - // A byte-truncated response is a JSON prefix, so this page cannot be read at all. - // Answering with no cursor would call the diff whole while silently dropping this page - // and every one after it, so the read fails and says which page could not be had. - if (result.stdoutTruncated) { - return Effect.fail( - new GitLabMergeRequestReadError({ - command: "glab", - cwd: input.cwd, - operation: "getMergeRequestDiff", - cause: new Error( - `Page ${input.page} of the merge request diff was too large to read.`, - ), - }), - ); - } - const decoded = decodeMergeRequestDiffsJson(result.stdout.trim()); - if (!Result.isSuccess(decoded)) { - return Effect.fail( - new GitLabMergeRequestReadError({ - command: "glab", - cwd: input.cwd, - operation: "getMergeRequestDiff", - cause: decoded.failure, - }), - ); - } - const patch = decoded.success.patch; - // Counted before decoding, so a page whose files all failed to decode still moves on - // rather than pointing the reader back at the page it just read. - const morePages = decoded.success.rawCount >= MAX_PAGE_SIZE; - return Effect.succeed({ - // The slice ends on a newline, so a file GitLab gave a header and no hunks for does - // not run into the first line of the next slice. - patch: patch.length === 0 ? patch : patch.replace(/\n?$/, "\n"), - truncated: decoded.success.truncated, - nextCursor: morePages ? String(input.page + 1) : null, - }); - }), - ); - - /** - * The conversation, a page at a time. GitLab pages by offset and reports no total, so a short - * page is the only thing that says it is done — and the raw count decides, not the kept one: - * the notes GitLab wrote itself are dropped, and a whole page of them still means there is - * more to read. - */ - const notesPage = (input: { - readonly cwd: string; - readonly repository: string; - readonly number: number; - readonly page: number; - readonly collected: ReadonlyArray; - }): Effect.Effect< - { readonly comments: ReadonlyArray; readonly truncated: boolean }, - GitLabPullRequestCliError - > => - api({ - cwd: input.cwd, - path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/notes?${query( - [ - ["per_page", String(MAX_PAGE_SIZE)], - ["page", String(input.page)], - ["order_by", "created_at"], - ["sort", "asc"], - ], - )}`, - }).pipe( - Effect.flatMap((result) => { - const decoded = decodeNotesJson(result.stdout.trim()); - if (!Result.isSuccess(decoded)) { - return Effect.fail( - new GitLabMergeRequestReadError({ - command: "glab", - cwd: input.cwd, - operation: "listNotes", - cause: decoded.failure, - }), - ); - } - const collected = [...input.collected, ...decoded.success.comments]; - if (decoded.success.rawCount < MAX_PAGE_SIZE) { - return Effect.succeed({ comments: collected, truncated: false }); - } - return input.page >= CONVERSATION_PAGES - ? Effect.succeed({ comments: collected, truncated: true }) - : notesPage({ ...input, page: input.page + 1, collected }); - }), - ); - - /** The positioned discussions, walked the same way and stopped by the same bound. */ - const discussionsPage = (input: { - readonly cwd: string; - readonly repository: string; - readonly number: number; - readonly page: number; - readonly collected: ReadonlyArray; - }): Effect.Effect< - { readonly threads: ReadonlyArray; readonly truncated: boolean }, - GitLabPullRequestCliError - > => - api({ - cwd: input.cwd, - path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/discussions?${query( - [ - ["per_page", String(MAX_PAGE_SIZE)], - ["page", String(input.page)], - ], - )}`, - }).pipe( - Effect.flatMap((result) => { - const decoded = decodeDiscussionsJson(result.stdout.trim()); - if (!Result.isSuccess(decoded)) { - return Effect.fail( - new GitLabMergeRequestReadError({ - command: "glab", - cwd: input.cwd, - operation: "listDiscussions", - cause: decoded.failure, - }), - ); - } - const collected = [...input.collected, ...decoded.success.threads]; - // The raw count again: this endpoint returns the plain notes too, so a full page of - // those is not the end of the positioned ones. - if (decoded.success.rawCount < MAX_PAGE_SIZE) { - return Effect.succeed({ threads: collected, truncated: false }); - } - return input.page >= CONVERSATION_PAGES - ? Effect.succeed({ threads: collected, truncated: true }) - : discussionsPage({ ...input, page: input.page + 1, collected }); - }), - ); - - /** - * The revisions a positioned comment is written against. GitLab resolves a comment's line - * against these three shas, so a review with line comments cannot be sent without them. - */ - const getDiffRefs = (input: { - readonly cwd: string; - readonly repository: string; - readonly number: number; - }): Effect.Effect => - api({ - cwd: input.cwd, - path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}`, - }).pipe( - Effect.flatMap((result): Effect.Effect => { - const decoded = decodeDiffRefsJson(result.stdout.trim()); - if (!Result.isSuccess(decoded)) { - return Effect.fail( - new GitLabMergeRequestReadError({ - command: "glab", - cwd: input.cwd, - operation: "getDiffRefs", - cause: decoded.failure, - }), - ); - } - // A merge request with no diff refs is a well-formed answer that cannot carry a - // positioned comment — a dead end, but not something that failed to be read. - return decoded.success === null - ? Effect.fail( - new GitLabDiffRefsUnavailableError({ - command: "glab", - cwd: input.cwd, - number: input.number, - }), - ) - : Effect.succeed(decoded.success); - }), - ); - - const getCommitDiffRefs = (input: { - readonly cwd: string; - readonly repository: string; - readonly commit: string; - readonly allowRoot: boolean; - }): Effect.Effect => - api({ - cwd: input.cwd, - path: `projects/${projectPath(input.repository)}/repository/commits/${input.commit}`, - }).pipe( - Effect.flatMap((result): Effect.Effect => { - const decoded = decodeCommitDiffRefsJson(result.stdout.trim()); - if (!Result.isSuccess(decoded)) { - return Effect.fail( - new GitLabMergeRequestReadError({ - command: "glab", - cwd: input.cwd, - operation: "getMergeRequestDiffFileContents", - cause: decoded.failure, - }), - ); - } - return decoded.success === null - ? input.allowRoot - ? Effect.succeed({ - baseSha: "", - headSha: input.commit, - startSha: "", - }) - : Effect.fail( - new GitLabDiffCommitParentUnavailableError({ - command: "glab", - cwd: input.cwd, - commit: input.commit, - }), - ) - : Effect.succeed(decoded.success); - }), - ); - - /** - * The merge request itself, which several calls need for different parts of it: the detail for - * everything, and the reviewer paths for the ids GitLab writes a reviewer set with. - */ - const mergeRequestDetail = (input: { - readonly cwd: string; - readonly repository: string; - readonly number: number; - }): Effect.Effect => - api({ - cwd: input.cwd, - // How far behind the target branch this one is comes only when asked for by name, and it - // is asked for here rather than on a second read because it is the same merge request. - path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}?${query([ - ["include_diverged_commits_count", "true"], - ])}`, - }).pipe( - Effect.flatMap((result) => { - const decoded = decodeMergeRequestDetailJson(result.stdout.trim()); - return Result.isSuccess(decoded) - ? Effect.succeed(decoded.success) - : Effect.fail( - new GitLabMergeRequestReadError({ - command: "glab", - cwd: input.cwd, - operation: "getMergeRequestDetail", - cause: decoded.failure, - }), - ); - }), - ); - - /** The people with access to the project, one page deep. */ - const projectUsers = (input: { - readonly cwd: string; - readonly repository: string; - }): Effect.Effect => - api({ - cwd: input.cwd, - path: `projects/${projectPath(input.repository)}/users?${query([ - ["per_page", String(MAX_PAGE_SIZE)], - ])}`, - }).pipe( - Effect.flatMap((result) => { - const decoded = decodeProjectUsersJson(result.stdout.trim()); - return Result.isSuccess(decoded) - ? Effect.succeed(decoded.success) - : Effect.fail( - new GitLabMergeRequestReadError({ - command: "glab", - cwd: input.cwd, - operation: "listReviewerCandidates", - cause: decoded.failure, - }), - ); - }), - ); - - /** Where an award is written: a note of the merge request, or the merge request itself. */ - const awardSubjectPath = (input: { - readonly repository: string; - readonly number: number; - readonly noteId?: string | undefined; - }) => { - const mergeRequest = `projects/${projectPath(input.repository)}/merge_requests/${input.number}`; - return input.noteId === undefined - ? `${mergeRequest}/award_emoji` - : `${mergeRequest}/notes/${encodeURIComponent(input.noteId)}/award_emoji`; - }; - - /** - * The awards on the merge request and its notes, a page of notes at a time. Bounded by the same - * count as the conversation itself: awards past the notes that were read belong to notes the - * page is not showing. - */ - const awardsPage = (input: { - readonly cwd: string; - readonly repository: string; - readonly number: number; - readonly cursor: string | null; - readonly page: number; - readonly collected: { - readonly reactions: ReadonlyArray; - readonly reactionsByNoteId: Map>; - } | null; - }): Effect.Effect< - { - readonly reactions: ReadonlyArray; - readonly reactionsByNoteId: ReadonlyMap>; - }, - GitLabPullRequestCliError - > => - api({ - cwd: input.cwd, - path: "graphql", - method: "POST", - stdin: JSON.stringify({ - query: AWARD_EMOJI_GRAPHQL_QUERY, - variables: { - fullPath: input.repository, - iid: String(input.number), - cursor: input.cursor, - }, - }), - }).pipe( - Effect.flatMap((result) => { - const decoded = decodeAwardEmojiJson(result.stdout.trim()); - if (!Result.isSuccess(decoded)) { - return Effect.fail( - new GitLabMergeRequestReadError({ - command: "glab", - cwd: input.cwd, - operation: "listReactions", - cause: decoded.failure, - }), - ); - } - const collected = input.collected ?? { - reactions: decoded.success.reactions, - reactionsByNoteId: new Map>(), - }; - for (const [id, reactions] of decoded.success.reactionsByNoteId) - collected.reactionsByNoteId.set(id, reactions); - return decoded.success.nextCursor === null || input.page >= CONVERSATION_PAGES - ? Effect.succeed(collected) - : awardsPage({ - ...input, - cursor: decoded.success.nextCursor, - page: input.page + 1, - collected, - }); - }), - ); - - /** - * GitLab charges the blobs query by how many paths it is handed, and its connection hands back - * one page. A hundred at a time keeps each request inside both. - */ - const BLOB_PATHS_PER_REQUEST = 100; - - const blobsAt = (input: { - readonly cwd: string; - readonly repository: string; - readonly ref: string; - readonly paths: ReadonlyArray; - }): Effect.Effect | null, GitLabPullRequestCliError> => - api({ - cwd: input.cwd, - path: "graphql", - method: "POST", - stdin: JSON.stringify({ - query: REPOSITORY_BLOBS_GRAPHQL_QUERY, - variables: { fullPath: input.repository, ref: input.ref, paths: input.paths }, - }), - }).pipe( - Effect.flatMap( - (result): Effect.Effect | null, GitLabPullRequestCliError> => { - const decoded = decodeRepositoryBlobsJson(result.stdout.trim()); - return Result.isSuccess(decoded) - ? Effect.succeed(decoded.success) - : Effect.fail( - new GitLabMergeRequestReadError({ - command: "glab", - cwd: input.cwd, - operation: "getFileRevisions", - cause: decoded.failure, - }), - ); - }, - ), - ); - - const fileRevisions = (input: { - readonly cwd: string; - readonly repository: string; - readonly number: number; - readonly paths: ReadonlyArray; - }): Effect.Effect, GitLabPullRequestCliError> => - input.paths.length === 0 - ? Effect.succeed(new Map()) - : getDiffRefs(input).pipe( - Effect.flatMap((refs) => { - const batches: Array> = []; - for (let at = 0; at < input.paths.length; at += BLOB_PATHS_PER_REQUEST) { - batches.push(input.paths.slice(at, at + BLOB_PATHS_PER_REQUEST)); - } - return Effect.forEach( - batches, - (paths) => - blobsAt({ ...input, ref: refs.headSha, paths }).pipe( - Effect.map((page) => ({ paths, page })), - ), - { concurrency: 2 }, - ).pipe( - Effect.map((pages) => { - const revisions = new Map(); - for (const { paths, page } of pages) { - // A batch GitLab did not answer says nothing about its paths, so they are left - // out and the caller reads them as versions it could not learn, which leaves - // the marks on them alone. Filling them in as removed would report every file - // a reader has cleared as changed over a project the token cannot see. - if (page === null) continue; - for (const [path, oid] of page) revisions.set(path, oid); - // Within a batch that was answered, every path was looked for at the head, so - // one that is not there is one the merge request removed. Said as the empty - // revision, which is an answer the caller can compare against and keep. - for (const path of paths) { - if (!revisions.has(path)) revisions.set(path, ""); - } - } - return revisions as ReadonlyMap; - }), - ); - }), - ); - - const viewerUsername = (input: { readonly cwd: string }) => - api({ cwd: input.cwd, path: "user" }).pipe( - Effect.flatMap((result): Effect.Effect => { - const decoded = decodeViewerJson(result.stdout.trim()); - if (!Result.isSuccess(decoded)) { - return Effect.fail( - new GitLabMergeRequestReadError({ - command: "glab", - cwd: input.cwd, - operation: "getViewerUsername", - cause: decoded.failure, - }), - ); - } - return decoded.success === null - ? Effect.fail(new GitLabViewerUnavailableError({ command: "glab", cwd: input.cwd })) - : Effect.succeed(decoded.success); - }), - ); - - return GitLabPullRequestCli.of({ - getViewerUsername: viewerUsername, - - listMergeRequests: (input) => { - const perPage = Math.min(input.limit + 1, MAX_PAGE_SIZE); - const page = Math.floor((input.cursor?.delivered ?? 0) / perPage) + 1; - return listPage({ ...input, page, collected: [], cursorAdvance: 0 }); - }, - - getMergeRequestDetail: mergeRequestDetail, - - listNotes: (input) => notesPage({ ...input, page: 1, collected: [] }), - - listReactions: (input) => awardsPage({ ...input, cursor: null, page: 1, collected: null }), - - getFileRevisions: fileRevisions, - - setReaction: (input) => - Effect.gen(function* () { - const subject = awardSubjectPath(input); - if (input.reacted) { - yield* api({ - cwd: input.cwd, - path: `${subject}?${query([["name", gitLabAwardName(input.content)]])}`, - method: "POST", - }); - return; - } - // GitLab deletes an award by its id and takes no emoji name there, so the reader's own - // award of that name is looked up first. Nothing to delete is success: the reaction the - // caller asked to take back is already gone. - const viewer = yield* viewerUsername({ cwd: input.cwd }); - const listed = yield* api({ cwd: input.cwd, path: subject }); - const own = decodeOwnAwardIdJson(listed.stdout.trim(), { - content: input.content, - viewer, - }); - if (!Result.isSuccess(own)) { - return yield* new GitLabMergeRequestReadError({ - command: "glab", - cwd: input.cwd, - operation: "setReaction", - cause: own.failure, - }); - } - if (own.success === null) return; - yield* api({ - cwd: input.cwd, - path: `${subject}/${own.success}`, - method: "DELETE", - }); - }), - - listCommits: (input) => - api({ - cwd: input.cwd, - path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/commits?${query( - [ - ["per_page", String(COMMIT_PAGE_SIZE)], - ["with_stats", "true"], - ], - )}`, - }).pipe( - Effect.flatMap((result) => { - const decoded = decodeCommitsJson(result.stdout.trim()); - return Result.isSuccess(decoded) - ? Effect.succeed(decoded.success) - : Effect.fail( - new GitLabMergeRequestReadError({ - command: "glab", - cwd: input.cwd, - operation: "listCommits", - cause: decoded.failure, - }), - ); - }), - ), - - getMergeRequestDiff: (input) => { - if (input.commit !== undefined && !isCommitSha(input.commit)) { - return Effect.fail(new GitLabDiffCommitError({ command: "glab", cwd: input.cwd })); - } - const target = { - cwd: input.cwd, - repository: input.repository, - number: input.number, - ...(input.commit === undefined ? {} : { commit: input.commit }), - }; - if (input.cursor === undefined) { - return diffPage({ ...target, page: 1 }); - } - const page = diffCursorPage(input.cursor); - return page === null - ? Effect.fail(new GitLabDiffCursorError({ command: "glab", cwd: input.cwd })) - : diffPage({ ...target, page }); - }, - - getMergeRequestDiffFileContents: (input) => - Effect.gen(function* () { - if (input.commit !== undefined && !isCommitSha(input.commit)) { - return yield* new GitLabDiffCommitError({ command: "glab", cwd: input.cwd }); - } - const refs = yield* input.commit === undefined - ? getDiffRefs(input) - : getCommitDiffRefs({ - cwd: input.cwd, - repository: input.repository, - commit: input.commit, - allowRoot: input.changeType === "new", - }); - - const readFile = (revision: string, filePath: string) => - api({ - cwd: input.cwd, - path: `projects/${projectPath(input.repository)}/repository/files/${encodeURIComponent( - filePath, - )}/raw?ref=${encodeURIComponent(revision)}`, - maxOutputBytes: DIFF_FILE_MAX_OUTPUT_BYTES, - timeoutMs: DIFF_TIMEOUT_MS, - }).pipe( - Effect.flatMap((result) => - result.stdoutTruncated || - result.stdout.includes("\0") || - result.stdoutInvalidUtf8 === true - ? Effect.fail( - new GitLabDiffFileContentsUnavailableError({ - command: "glab", - cwd: input.cwd, - path: filePath, - reason: result.stdoutTruncated ? "oversized" : "binary", - }), - ) - : Effect.succeed(result.stdout), - ), - ); - - const [oldContents, newContents] = yield* Effect.all( - [ - input.changeType === "new" ? Effect.succeed("") : readFile(refs.baseSha, input.oldPath), - input.changeType === "deleted" - ? Effect.succeed("") - : readFile(refs.headSha, input.newPath), - ], - { concurrency: 2 }, - ); - return { oldContents, newContents }; - }), - - getProjectMergeCapabilities: (input) => - api({ - cwd: input.cwd, - path: `projects/${projectPath(input.repository)}?license=false`, - }).pipe( - Effect.flatMap((result) => { - const decoded = decodeProjectMergeCapabilitiesJson(result.stdout.trim()); - return Result.isSuccess(decoded) - ? Effect.succeed(decoded.success) - : Effect.fail( - new GitLabMergeRequestReadError({ - command: "glab", - cwd: input.cwd, - operation: "getProjectMergeCapabilities", - cause: decoded.failure, - }), - ); - }), - ), - - listReviewerCandidates: (input) => - Effect.all([mergeRequestDetail(input), projectUsers(input)], { concurrency: 2 }).pipe( - Effect.map(([mergeRequest, users]) => { - const author = mergeRequest.author?.login; - const requested = new Set(mergeRequest.reviewRequestLogins); - return { - // The author is dropped rather than shown unusable: GitLab refuses to make the person - // who opened a merge request its reviewer. - candidates: users.candidates.flatMap((candidate) => - candidate.login === author - ? [] - : [{ ...candidate, isRequested: requested.has(candidate.login) }], - ), - truncated: users.rawCount >= MAX_PAGE_SIZE, - }; - }), - ), - - setReviewerRequest: (input) => - mergeRequestDetail(input).pipe( - Effect.flatMap((mergeRequest) => { - // GitLab has no endpoint that adds or removes one reviewer: `reviewer_ids` replaces the - // whole set, so the set that is already there is read first and the change applied to - // it. Asking again for somebody already on it writes the same set back, which is how - // GitLab re-requests a review. - const ids = new Set(mergeRequest.reviewerIds); - for (const reviewer of input.reviewers) { - const id = Number(reviewer.id); - // A candidate GitLab did not name is not an id it would accept, and sending it would - // rewrite the reviewer set around a number nobody chose. - if (!Number.isSafeInteger(id) || id <= 0) continue; - if (input.requested) ids.add(id); - else ids.delete(id); - } - return api({ - cwd: input.cwd, - path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}`, - method: "PUT", - stdin: JSON.stringify({ reviewer_ids: [...ids] }), - }); - }), - Effect.asVoid, - ), - - runMergeRequestAction: (input) => { - // `glab mr merge` arms auto-merge and never disarms it, so the one direction the CLI has - // no flag for is asked of GitLab directly through the same `api` passthrough the rest of - // this module writes with. - if (input.action === "disable-auto-merge") { - return api({ - cwd: input.cwd, - path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/cancel_merge_when_pipeline_succeeds`, - method: "POST", - }).pipe(Effect.asVoid); - } - const [subcommand, ...flags] = actionArgs(input.action, input.mergeMethod); - return gitlab - .execute({ - cwd: input.cwd, - args: ["mr", subcommand!, String(input.number), "--repo", input.repository, ...flags], - }) - .pipe(Effect.asVoid); - }, - - updateMergeRequest: (input) => - api({ - cwd: input.cwd, - path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}`, - method: "PUT", - // Only the fields the caller asked to change: GitLab leaves out what it is not sent, and - // clears what it is sent empty — so a title corrected on its own must carry no - // description at all. - stdin: JSON.stringify({ - ...(input.title === undefined ? {} : { title: input.title }), - ...(input.description === undefined ? {} : { description: input.description }), - }), - }).pipe(Effect.asVoid), - - commentOnMergeRequest: (input) => - api({ - cwd: input.cwd, - path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/notes`, - method: "POST", - // A JSON body rather than a `--raw-field`: glab coerces a field that reads as a - // literal `true` or a number, and a comment body is text either way. - stdin: JSON.stringify({ body: input.body }), - }).pipe(Effect.asVoid), - - updateNote: (input) => - api({ - cwd: input.cwd, - path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/notes/${encodeURIComponent( - input.noteId, - )}`, - method: "PUT", - stdin: JSON.stringify({ body: input.body }), - }).pipe(Effect.asVoid), - - listDiscussions: (input) => discussionsPage({ ...input, page: 1, collected: [] }), - - submitReview: (input) => - Effect.gen(function* () { - const project = projectPath(input.repository); - const mergeRequest = `projects/${project}/merge_requests/${input.number}`; - // GitLab has no pending review to attach comments to, so a review is replayed as the - // requests it is made of: the line comments, then the summary, then the verdict. A - // failure part-way therefore leaves what was already posted in place, which is why - // the verdict goes last — a half-sent review is never an approval. - if (input.comments.length > 0) { - const refs = yield* getDiffRefs(input); - yield* Effect.forEach( - input.comments, - (comment) => - api({ - cwd: input.cwd, - path: `${mergeRequest}/discussions`, - method: "POST", - stdin: JSON.stringify({ - body: comment.body, - position: { - base_sha: refs.baseSha, - head_sha: refs.headSha, - start_sha: refs.startSha, - position_type: "text", - // Both paths are sent because GitLab resolves a position against both - // sides of the diff. They differ only for a renamed file, which is why the - // draft carries the name the file had before the change. - old_path: comment.oldPath ?? comment.path, - new_path: comment.path, - ...gitLabReviewPositionLines(comment.position), - }, - }), - }), - { discard: true }, - ); - } - if (input.body.trim().length > 0) { - yield* api({ - cwd: input.cwd, - path: `${mergeRequest}/notes`, - method: "POST", - // A JSON body rather than a `--raw-field`, for the reason the plain comment gives: - // glab coerces a field that reads as a literal `true` or a number. - // @effect-diagnostics-next-line preferSchemaOverJson:off - stdin: JSON.stringify({ body: input.body }), - }); - } - if (input.verdict === "approve") { - yield* api({ cwd: input.cwd, path: `${mergeRequest}/approve`, method: "POST" }); - } - }), - - replyToDiscussion: (input) => - api({ - cwd: input.cwd, - path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/discussions/${encodeURIComponent( - input.discussionId, - )}/notes`, - method: "POST", - stdin: JSON.stringify({ body: input.body }), - }).pipe(Effect.asVoid), - - setDiscussionResolution: (input) => - api({ - cwd: input.cwd, - path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/discussions/${encodeURIComponent( - input.discussionId, - )}`, - method: "PUT", - stdin: JSON.stringify({ resolved: input.resolved }), - }).pipe(Effect.asVoid), - }); -}); - -export const layer = Layer.effect(GitLabPullRequestCli, make); diff --git a/apps/server/src/pullRequest/GitLabPullRequestProvider.test.ts b/apps/server/src/pullRequest/GitLabPullRequestProvider.test.ts deleted file mode 100644 index d506aace948929752948cb39339cd8d861eb314f..0000000000000000000000000000000000000000 --- a/apps/server/src/pullRequest/GitLabPullRequestProvider.test.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { assert, describe, expect, it, vi } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; - -import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts"; -import { gitLabViewerPermissions, make } from "./GitLabPullRequestProvider.ts"; - -describe("gitLabViewerPermissions", () => { - it("offers everything to a viewer GitLab says can merge", () => { - expect(gitLabViewerPermissions({ viewerCanMerge: true })).toEqual({ - // Arming a merge for later and taking the arming back answer to the same `can_merge`. - actions: [ - "merge", - "ready", - "draft", - "close", - "reopen", - "update-branch", - "enable-auto-merge", - "disable-auto-merge", - ], - comment: true, - resolve: true, - verdicts: ["comment", "approve"], - // GitLab says nothing about who may set a reviewer, and an unreported permission is granted. - requestReviewers: true, - // Rebase and nothing else: GitLab cannot merge a target branch into a source branch, so - // offering the choice would be offering something no request could carry out. - updateMethods: ["rebase"], - }); - }); - - it("keeps merge, now and later, from a viewer GitLab says cannot", () => { - // `user.can_merge` already accounts for the role, the approval rules and a protected target - // branch, so it is the one answer here that does not have to be inferred. - expect(gitLabViewerPermissions({ viewerCanMerge: false })).toEqual({ - actions: ["ready", "draft", "close", "reopen"], - comment: true, - resolve: true, - verdicts: ["comment", "approve"], - requestReviewers: true, - }); - }); - - it("names no way of updating a branch it will not let this viewer update", () => { - // The action and the strategy behind it go together: a button offered with nothing to press - // it with, or a strategy left standing next to a withheld button, is a half-refusal. - expect(gitLabViewerPermissions({ viewerCanMerge: false }).updateMethods).toBeUndefined(); - }); - - it("treats an author with read access as any other reader, which is all GitLab says", () => { - // Its REST API names no relationship between the viewer and the merge request beyond - // `can_merge`, so the four an author keeps stay offered to everyone rather than being taken - // from the one person entitled to them. - expect(gitLabViewerPermissions({ viewerCanMerge: false }).actions).toEqual([ - "ready", - "draft", - "close", - "reopen", - ]); - }); -}); - -describe("getChangeRequest base freshness", () => { - const detail = { - number: 7, - title: "Merge request 7", - url: "https://gitlab.com/acme/web/-/merge_requests/7", - author: null, - headBranch: "feat/page", - baseBranch: "main", - state: "open" as const, - isDraft: false, - mergeability: "mergeable" as const, - additions: 0, - deletions: 0, - createdAt: "2026-07-01T00:00:00Z", - updatedAt: "2026-07-02T00:00:00Z", - reviewRequestLogins: [], - labels: [], - body: "", - changedFiles: 1, - mergedAt: null, - closedAt: null, - reviewers: [], - checks: [], - viewerCanMerge: true, - reviewerIds: [], - }; - - it.effect("reads checks without fetching project settings", () => - Effect.gen(function* () { - const provider = yield* make.pipe( - Effect.provide( - Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({ - getMergeRequestDetail: () => Effect.succeed(detail), - }), - ), - ); - const read = provider.getChangeRequestChecks; - if (read === undefined) return yield* Effect.die("checks read missing"); - expect( - yield* read({ cwd: "/w", repository: "group/subgroup/web", host: "gitlab.com", number: 7 }), - ).toEqual({ state: "open", checks: [] }); - }), - ); - - const readWith = (divergence: { readonly divergedCommits?: number }) => - Effect.gen(function* () { - const provider = yield* make; - return yield* provider.getChangeRequest({ - cwd: "/w", - repository: "acme/web", - host: "gitlab.com", - number: 7, - }); - }).pipe( - Effect.provide( - Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({ - getMergeRequestDetail: () => Effect.succeed({ ...detail, ...divergence }), - getProjectMergeCapabilities: () => - Effect.succeed({ merge: true, squash: true, rebase: true }), - }), - ), - ); - - it.effect("reads a counted divergence as a branch that has fallen behind", () => - Effect.gen(function* () { - const changeRequest = yield* readWith({ divergedCommits: 3 }); - - expect(changeRequest.baseComparison).toBe("behind"); - expect(changeRequest.behindBy).toBe(3); - }), - ); - - it.effect("reads a divergence of none as a branch that is current", () => - Effect.gen(function* () { - const changeRequest = yield* readWith({ divergedCommits: 0 }); - - expect(changeRequest.baseComparison).toBe("up-to-date"); - expect(changeRequest.behindBy).toBe(0); - }), - ); - - it.effect("says nothing at all where GitLab counted nothing", () => - Effect.gen(function* () { - // An install too old to answer has to leave the page silent rather than let it claim the - // branch is current, which is the one wrong thing this banner could say. - const changeRequest = yield* readWith({}); - - expect(changeRequest.baseComparison).toBe("unknown"); - expect(changeRequest.behindBy).toBeUndefined(); - }), - ); -}); - -describe("rewriting what has already been said", () => { - const updateMergeRequest = vi.fn(() => Effect.void); - const updateNote = vi.fn(() => Effect.void); - - const providerWith = make.pipe( - Effect.provide( - Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({ updateMergeRequest, updateNote }), - ), - ); - - it.effect("sends only the half of the merge request the reader rewrote", () => - Effect.gen(function* () { - const provider = yield* providerWith; - assert.isDefined(provider.updateChangeRequest); - - yield* provider.updateChangeRequest({ - cwd: "/w", - repository: "acme/web", - host: "gitlab.com", - number: 7, - body: "What this changes.", - }); - - // GitLab calls it the description, and the title stays out of the request entirely. - expect(updateMergeRequest).toHaveBeenCalledWith({ - cwd: "/w", - repository: "acme/web", - number: 7, - description: "What this changes.", - }); - }), - ); - - it.effect("rewrites a positioned comment through the same note as any other", () => - Effect.gen(function* () { - const provider = yield* providerWith; - assert.isDefined(provider.updateComment); - - yield* provider.updateComment({ - cwd: "/w", - repository: "acme/web", - host: "gitlab.com", - number: 7, - commentId: "42", - kind: "review-comment", - body: "Reworded.", - }); - - expect(updateNote).toHaveBeenCalledWith({ - cwd: "/w", - repository: "acme/web", - number: 7, - noteId: "42", - body: "Reworded.", - }); - }), - ); -}); diff --git a/apps/server/src/pullRequest/GitLabPullRequestProvider.ts b/apps/server/src/pullRequest/GitLabPullRequestProvider.ts deleted file mode 100644 index 1b5b987cd5acb3c25dc6fd8f080b703c6a07d6c9..0000000000000000000000000000000000000000 --- a/apps/server/src/pullRequest/GitLabPullRequestProvider.ts +++ /dev/null @@ -1,339 +0,0 @@ -import * as Effect from "effect/Effect"; -import type { - PullRequestCapabilities, - PullRequestReaction, - PullRequestViewerPermissions, -} from "@t3tools/contracts"; - -import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts"; -import { - PullRequestProviderError, - type PullRequestProviderFailure, - type ProviderChangeRequestActivity, - type ProviderChangeRequestDetail, - type PullRequestProviderApi, -} from "@t3tools/source-control-core/server/PullRequestProvider"; - -const CAPABILITIES: PullRequestCapabilities = { - diff: true, - comment: true, - actions: [ - "merge", - "ready", - "draft", - "close", - "reopen", - "update-branch", - "enable-auto-merge", - "disable-auto-merge", - ], - // GitLab offers all three, though a project settles on one; `mergeCapabilities` narrows it. - mergeMethods: ["merge", "squash", "rebase"], - // Rebase alone: GitLab moves a stale branch onto its target by replaying it, and has nothing - // that merges the target back in the way GitHub's update button can. Declaring only what it - // does is what lets a request to merge the target in be refused instead of quietly rebasing. - updateMethods: ["rebase"], - search: true, - reactions: true, - // GitLab keeps a reader's viewed files in one browser's local storage, where nothing outside - // that browser can read or write them. So the marks made here are this environment's own: they - // follow the reader between the clients connected to it, but they are not the ones gitlab.com - // shows, and the surface says so rather than implying a review can be carried on from there. - viewedFiles: "environment", - review: { - inlineComment: true, - reply: true, - resolve: true, - // No "changes requested": GitLab has approval and unresolved discussions, and nothing that - // says a merge request has been reviewed and rejected. - verdicts: ["comment", "approve"], - }, - reviewers: { request: true, listCandidates: true }, - edit: { changeRequest: true, comment: true }, -}; - -/** - * The actions `user.can_merge` answers for. Rebasing writes to the source branch rather than to - * the target, so it is not literally the same permission — but GitLab reports nothing narrower, - * and someone it will not let land this change has no business rewriting its branch either. - */ -const MERGE_ACTIONS: ReadonlySet = new Set([ - "merge", - "update-branch", - "enable-auto-merge", - "disable-auto-merge", -]); - -/** - * What the signed-in account may do here. GitLab answers exactly one of these questions per - * viewer, on the merge request itself: `user.can_merge`, which is why merging is the only thing - * narrowed. - * - * The rest stay granted. GitLab's REST API reports the viewer's role on the project but never - * whether they opened this merge request — and its author may close it, reopen it and move it in - * and out of draft whatever their role, just as the author of a note may resolve the discussion - * it started. Withholding those controls from the one person entitled to them is the worse of the - * two mistakes, so they are offered and GitLab explains any refusal itself. - * - * Asking for a review is granted for the same reason: GitLab takes a reviewer set from the author - * and from anyone with the Developer role, and states neither of those two facts here. - */ -export function gitLabViewerPermissions(input: { - readonly viewerCanMerge: boolean; -}): PullRequestViewerPermissions { - return { - // Arming the merge and taking the arming back are the merge, deferred, so they answer to - // the same `can_merge` the merge itself does. - actions: CAPABILITIES.actions.filter( - (action) => !MERGE_ACTIONS.has(action) || input.viewerCanMerge, - ), - comment: true, - resolve: true, - verdicts: CAPABILITIES.review.verdicts, - requestReviewers: true, - ...(input.viewerCanMerge ? { updateMethods: CAPABILITIES.updateMethods } : {}), - }; -} - -/** The CLI tags that mean the tool itself is unusable, rather than one request failing. */ -export function gitLabProviderFailure( - error: GitLabPullRequestCli.GitLabPullRequestCliError, -): PullRequestProviderFailure { - if (error._tag === "GitLabCliUnavailableError") return { reason: "missing-tool" }; - if (error._tag === "GitLabCliAuthenticationError") return { reason: "unauthenticated" }; - if (error._tag === "GitLabCliRateLimitError") return { reason: "rate-limited" }; - if (error._tag === "GitLabMergeRequestNotFoundError") return { reason: "not-found" }; - return { reason: "failed" }; -} - -export const make = Effect.gen(function* () { - const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; - - const fail = (operation: string) => (error: GitLabPullRequestCli.GitLabPullRequestCliError) => - new PullRequestProviderError({ - provider: "gitlab", - operation, - ...gitLabProviderFailure(error), - detail: error.detail, - cause: error, - }); - - const provider: PullRequestProviderApi = { - kind: "gitlab", - capabilities: CAPABILITIES, - - getViewer: (input) => - cli.getViewerUsername({ cwd: input.cwd }).pipe(Effect.mapError(fail("getViewer"))), - - listChangeRequests: (input) => - cli - .listMergeRequests({ - cwd: input.cwd, - repository: input.repository, - state: input.state, - involvement: input.involvement, - viewer: input.viewer, - limit: input.limit, - query: input.query, - cursor: input.cursor, - }) - .pipe( - Effect.mapError(fail("listChangeRequests")), - // GitLab is asked for its merge requests by update, newest first, whether or not it is - // being carried on from — so every page it answers is one a cursor can continue. - Effect.map((batch) => ({ ...batch, continues: true })), - ), - - getChangeRequestChecks: (input) => - cli.getMergeRequestDetail(input).pipe( - Effect.map(({ state, checks }) => ({ state, checks })), - Effect.mapError(fail("getChangeRequestChecks")), - ), - - getChangeRequest: (input) => - Effect.all( - [ - cli.getMergeRequestDetail(input), - cli.getProjectMergeCapabilities({ cwd: input.cwd, repository: input.repository }), - ], - { concurrency: 2 }, - ).pipe( - Effect.mapError(fail("getChangeRequest")), - Effect.map(([mergeRequest, mergeCapabilities]): ProviderChangeRequestDetail => ({ - ...mergeRequest, - mergeCapabilities, - viewerPermissions: gitLabViewerPermissions(mergeRequest), - // A GitLab too old to count the divergence says nothing here rather than "up to - // date": the banner is worth missing, and a wrong all-clear is not worth showing. - baseComparison: - mergeRequest.divergedCommits === undefined - ? "unknown" - : mergeRequest.divergedCommits > 0 - ? "behind" - : "up-to-date", - ...(mergeRequest.divergedCommits === undefined - ? {} - : { behindBy: mergeRequest.divergedCommits }), - })), - ), - - getChangeRequestActivity: (input) => - Effect.all( - [ - cli - .listNotes(input) - .pipe(Effect.orElseSucceed(() => ({ comments: [], truncated: true }))), - cli.listCommits(input).pipe(Effect.orElseSucceed(() => [])), - cli - .listDiscussions(input) - .pipe(Effect.orElseSucceed(() => ({ threads: [], truncated: true }))), - // The notes endpoint carries no award of any kind, so they are read alongside it. A - // failed read costs the conversation its reactions rather than its words. - cli.listReactions(input).pipe( - Effect.orElseSucceed(() => ({ - reactions: [] as ReadonlyArray, - reactionsByNoteId: new Map>(), - })), - ), - ], - { concurrency: 4 }, - ).pipe( - Effect.mapError(fail("getChangeRequestActivity")), - Effect.map(([notes, commits, discussions, awards]): ProviderChangeRequestActivity => ({ - reactions: awards.reactions, - comments: notes.comments.map((comment) => ({ - ...comment, - reactions: awards.reactionsByNoteId.get(comment.id) ?? [], - })), - // GitLab reports no count of its own, so the walk's own total is the host's: the - // notes endpoint carries every comment on the merge request, including the ones - // written under a discussion, and it is read until GitLab runs out. - commentCount: notes.comments.length, - commentsTruncated: notes.truncated || discussions.truncated, - reviewThreads: discussions.threads.map((thread) => ({ - ...thread, - comments: thread.comments.map((comment) => ({ - ...comment, - reactions: awards.reactionsByNoteId.get(comment.id) ?? [], - })), - })), - commits, - })), - ), - - // The same read the detail takes it from, on its own: `user.can_merge` lives on the merge - // request, so there is no cheaper thing to ask GitLab. - getViewerPermissions: (input) => - cli - .getMergeRequestDetail(input) - .pipe(Effect.mapError(fail("getViewerPermissions")), Effect.map(gitLabViewerPermissions)), - - getDiff: (input) => cli.getMergeRequestDiff(input).pipe(Effect.mapError(fail("getDiff"))), - - // What each marked file is at the head, which is what tells a mark that still stands from one - // the branch has moved past. GitLab's own local-storage marks are keyed on the blob id too, - // so this stales at the same moment its web UI would. - getFileRevisions: (input) => - cli.getFileRevisions(input).pipe( - Effect.mapError(fail("getFileRevisions")), - Effect.map((revisions) => ({ revisions })), - ), - - // Users only: GitLab requests a review of a person, and the groups that can stand in for one - // appear in approval rules rather than in a merge request's reviewers. - listReviewerCandidates: (input) => - cli - .listReviewerCandidates({ - cwd: input.cwd, - repository: input.repository, - number: input.number, - }) - .pipe(Effect.mapError(fail("listReviewerCandidates"))), - - setReviewerRequest: (input) => - cli - .setReviewerRequest({ - cwd: input.cwd, - repository: input.repository, - number: input.number, - reviewers: input.reviewers, - requested: input.requested, - }) - .pipe(Effect.mapError(fail("setReviewerRequest"))), - - runAction: (input) => - cli - .runMergeRequestAction({ - cwd: input.cwd, - repository: input.repository, - number: input.number, - action: input.action, - ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), - }) - .pipe(Effect.mapError(fail("runAction"))), - - updateChangeRequest: (input) => - cli - .updateMergeRequest({ - cwd: input.cwd, - repository: input.repository, - number: input.number, - ...(input.title === undefined ? {} : { title: input.title }), - ...(input.body === undefined ? {} : { description: input.body }), - }) - .pipe(Effect.mapError(fail("updateChangeRequest"))), - - comment: (input) => cli.commentOnMergeRequest(input).pipe(Effect.mapError(fail("comment"))), - - // The kind is not read: every comment this provider hands out, positioned or not, carries a - // plain REST note id, and one endpoint rewrites both. - updateComment: (input) => - cli - .updateNote({ - cwd: input.cwd, - repository: input.repository, - number: input.number, - noteId: input.commentId, - body: input.body, - }) - .pipe(Effect.mapError(fail("updateComment"))), - - submitReview: (input) => cli.submitReview(input).pipe(Effect.mapError(fail("submitReview"))), - - replyToThread: (input) => - cli - .replyToDiscussion({ - cwd: input.cwd, - repository: input.repository, - number: input.number, - discussionId: input.threadId, - body: input.body, - }) - .pipe(Effect.mapError(fail("replyToThread"))), - - setReaction: (input) => - cli - .setReaction({ - cwd: input.cwd, - repository: input.repository, - number: input.number, - ...(input.subjectId === undefined ? {} : { noteId: input.subjectId }), - content: input.content, - reacted: input.reacted, - }) - .pipe(Effect.mapError(fail("setReaction"))), - - setThreadResolution: (input) => - cli - .setDiscussionResolution({ - cwd: input.cwd, - repository: input.repository, - number: input.number, - discussionId: input.threadId, - resolved: input.resolved, - }) - .pipe(Effect.mapError(fail("setThreadResolution"))), - }; - - return provider; -}); diff --git a/apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts b/apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts index 38f58735398001c68af44c7abecceb9e5c1e6a56..b26bbe8ae9bad5f5bdd5be773baf27689df1728f 100644 --- a/apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts +++ b/apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts @@ -4,11 +4,11 @@ import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/Azu import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; -import * as GitLabCli from "../sourceControl/GitLabCli.ts"; +import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; import { azureDevOpsProviderFailure } from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestProvider"; import { bitbucketProviderFailure } from "./BitbucketPullRequestProvider.ts"; import { gitHubProviderFailure } from "./GitHubPullRequestProvider.ts"; -import { gitLabProviderFailure } from "./GitLabPullRequestProvider.ts"; +import { gitLabProviderFailure } from "@t3tools/source-control-gitlab/server/GitLabPullRequestProvider"; const cause = new Error("redacted provider failure"); diff --git a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts index 009d3d3f8b4f1c99c459f135b03f46efbc0de980..74d7dc47174848c1568e2fd95e37182bb77d8c80 100644 --- a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts +++ b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts @@ -5,13 +5,10 @@ import type { SourceControlProviderKind } from "@t3tools/contracts"; import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; -import * as GitLabCli from "../sourceControl/GitLabCli.ts"; import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts"; import * as BitbucketPullRequestProvider from "./BitbucketPullRequestProvider.ts"; import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts"; -import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts"; -import * as GitLabPullRequestProvider from "./GitLabPullRequestProvider.ts"; import * as BuiltInDrivers from "../sourceControl/builtInDrivers.ts"; import type { PullRequestProviderApi } from "@t3tools/source-control-core/server/PullRequestProvider"; @@ -44,7 +41,6 @@ export function fromProviders( export const make = Effect.gen(function* () { const providers = yield* Effect.all([ GitHubPullRequestProvider.make, - GitLabPullRequestProvider.make, BitbucketPullRequestProvider.make, ]); const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) => @@ -65,7 +61,6 @@ export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe( ), ), ), - Layer.provide(GitLabPullRequestCli.layer.pipe(Layer.provide(GitLabCli.layer))), Layer.provide(BuiltInDrivers.layer), Layer.provide(BitbucketPullRequestApi.layer.pipe(Layer.provide(BitbucketApi.layer))), ); diff --git a/apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts b/apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts deleted file mode 100644 index c9bc12743756c2593c55348a13c4b3c9edd8c918..0000000000000000000000000000000000000000 --- a/apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts +++ /dev/null @@ -1,803 +0,0 @@ -import * as Result from "effect/Result"; -import { describe, expect, it } from "vite-plus/test"; - -import { - decodeAwardEmojiJson, - decodeRepositoryBlobsJson, - decodeCommitsJson, - decodeMergeRequestDetailJson, - decodeMergeRequestDiffsJson, - decodeMergeRequestListJson, - decodeNotesJson, - decodeOwnAwardIdJson, - decodeViewerJson, - gitLabAwardName, -} from "./gitLabMergeRequestJson.ts"; - -function listJson(entries: ReadonlyArray>): string { - return JSON.stringify( - entries.map((entry) => ({ - iid: 1, - title: "Add the merge requests page", - web_url: "https://gitlab.com/acme/web/-/merge_requests/1", - source_branch: "feat/page", - target_branch: "main", - created_at: "2026-07-01T00:00:00Z", - updated_at: "2026-07-02T00:00:00Z", - ...entry, - })), - ); -} - -function detailJson(entry: Record): string { - return JSON.stringify({ - iid: 1, - title: "Add the merge requests page", - web_url: "https://gitlab.com/acme/web/-/merge_requests/1", - source_branch: "feat/page", - target_branch: "main", - created_at: "2026-07-01T00:00:00Z", - updated_at: "2026-07-02T00:00:00Z", - ...entry, - }); -} - -function expectSuccess(result: Result.Result): A { - expect(Result.isSuccess(result)).toBe(true); - if (!Result.isSuccess(result)) throw new Error("expected a successful decode"); - return result.success; -} - -describe("decodeMergeRequestListJson", () => { - it("reads a merge request as a change request", () => { - const batch = expectSuccess( - decodeMergeRequestListJson( - listJson([ - { - iid: 42, - author: { username: "bilal", name: "Bilal" }, - state: "opened", - merge_status: "can_be_merged", - draft: false, - reviewers: [{ username: "julius" }], - labels: ["backend", " "], - }, - ]), - ), - ); - - expect(batch.items).toHaveLength(1); - expect(batch.items[0]).toMatchObject({ - number: 42, - author: { login: "bilal", name: "Bilal" }, - headBranch: "feat/page", - baseBranch: "main", - state: "open", - isDraft: false, - mergeability: "mergeable", - reviewRequestLogins: ["julius"], - labels: [{ name: "backend", color: null }], - }); - }); - - it("reports no line counts, which GitLab does not expose", () => { - const batch = expectSuccess(decodeMergeRequestListJson(listJson([{}]))); - - expect(batch.items[0]).toMatchObject({ additions: 0, deletions: 0 }); - }); - - it("treats a merged timestamp as merged whatever the state says", () => { - const batch = expectSuccess( - decodeMergeRequestListJson( - listJson([{ state: "opened", merged_at: "2026-07-03T00:00:00Z" }]), - ), - ); - - expect(batch.items[0]?.state).toBe("merged"); - }); - - it("keeps a locked merge request open", () => { - const batch = expectSuccess(decodeMergeRequestListJson(listJson([{ state: "locked" }]))); - - expect(batch.items[0]?.state).toBe("open"); - }); - - it("reads the legacy draft flag", () => { - const batch = expectSuccess(decodeMergeRequestListJson(listJson([{ work_in_progress: true }]))); - - expect(batch.items[0]?.isDraft).toBe(true); - }); - - it("calls a conflicted merge request conflicting even while the merge check is pending", () => { - const batch = expectSuccess( - decodeMergeRequestListJson(listJson([{ merge_status: "checking", has_conflicts: true }])), - ); - - expect(batch.items[0]?.mergeability).toBe("conflicting"); - }); - - it("leaves an unfinished merge check unknown", () => { - const batch = expectSuccess( - decodeMergeRequestListJson(listJson([{ merge_status: "checking" }])), - ); - - expect(batch.items[0]?.mergeability).toBe("unknown"); - }); - - it("skips a malformed row but still counts it, so paging does not stop early", () => { - const batch = expectSuccess( - decodeMergeRequestListJson( - JSON.stringify([{ iid: "not a number" }, ...JSON.parse(listJson([{}]))]), - ), - ); - - expect(batch.items).toHaveLength(1); - expect(batch.rawIndexes).toEqual([1]); - expect(batch.rawCount).toBe(2); - }); -}); - -describe("decodeMergeRequestDetailJson", () => { - it("reads the description, file count and pipeline", () => { - const detail = expectSuccess( - decodeMergeRequestDetailJson( - detailJson({ - description: "Ships the page.", - changes_count: "3", - reviewers: [{ username: "julius", name: "Julius" }], - head_pipeline: { - status: "success", - web_url: "https://gitlab.com/acme/web/-/pipelines/9", - source: "merge_request_event", - }, - }), - ), - ); - - expect(detail.body).toBe("Ships the page."); - expect(detail.changedFiles).toBe(3); - expect(detail.reviewers).toEqual([{ login: "julius", name: "Julius", avatarUrl: null }]); - expect(detail.checks).toEqual([ - { - name: "Pipeline", - status: "success", - description: "merge_request_event", - url: "https://gitlab.com/acme/web/-/pipelines/9", - }, - ]); - }); - - it("reads an uncounted change set as its floor", () => { - const detail = expectSuccess( - decodeMergeRequestDetailJson(detailJson({ changes_count: "1000+" })), - ); - - expect(detail.changedFiles).toBe(1000); - }); - - it("falls back to no file count when GitLab omits one", () => { - const detail = expectSuccess(decodeMergeRequestDetailJson(detailJson({}))); - - expect(detail.changedFiles).toBe(0); - }); - - it("reads either auto-merge field, and says nothing where GitLab named neither", () => { - const armed = (entry: Record) => - expectSuccess(decodeMergeRequestDetailJson(detailJson(entry))).autoMergeEnabled; - - expect(armed({ merge_when_pipeline_succeeds: true })).toBe(true); - // The newer name for the same fact, which older GitLab installs do not send. - expect(armed({ auto_merge_enabled: true })).toBe(true); - expect(armed({ merge_when_pipeline_succeeds: false })).toBe(false); - // Absent is GitLab not saying, which the page must not read as "not armed". - expect(armed({})).toBeUndefined(); - }); - - it("keeps the squash choice stored with auto-merge", () => { - expect( - expectSuccess( - decodeMergeRequestDetailJson( - detailJson({ auto_merge_enabled: true, squash_on_merge: true }), - ), - ), - ).toMatchObject({ autoMergeEnabled: true, autoMergeMethod: "squash" }); - - expect( - expectSuccess( - decodeMergeRequestDetailJson( - detailJson({ - auto_merge_enabled: true, - squash: true, - squash_on_merge: false, - }), - ), - ).autoMergeMethod, - ).toBeUndefined(); - }); - - it("keeps a divergence GitLab did not count apart from a divergence of none", () => { - const behind = (entry: Record) => - expectSuccess(decodeMergeRequestDetailJson(detailJson(entry))).divergedCommits; - - expect(behind({ diverged_commits_count: 3 })).toBe(3); - // Counted and found level, which is the one answer that entitles the page to say so. - expect(behind({ diverged_commits_count: 0 })).toBe(0); - // An install that does not answer, and a null where the answer would have gone, are both - // silence: reading either as zero would tell a stale branch it is current. - expect(behind({})).toBeUndefined(); - expect(behind({ diverged_commits_count: null })).toBeUndefined(); - }); - - it("maps a pipeline waiting on a person to neutral, not failure", () => { - const detail = expectSuccess( - decodeMergeRequestDetailJson(detailJson({ head_pipeline: { status: "manual" } })), - ); - - expect(detail.checks[0]?.status).toBe("neutral"); - }); -}); - -describe("decodeViewerJson", () => { - it("reads the signed-in username", () => { - expect(expectSuccess(decodeViewerJson(JSON.stringify({ username: "bilal" })))).toBe("bilal"); - }); - - it("returns nothing when the account has no username", () => { - expect(expectSuccess(decodeViewerJson(JSON.stringify({ username: " " })))).toBeNull(); - }); -}); - -describe("decodeNotesJson", () => { - it("keeps comments and drops GitLab's own activity notes", () => { - const notes = expectSuccess( - decodeNotesJson( - JSON.stringify([ - { - id: 1, - body: "assigned to @bilal", - system: true, - created_at: "2026-07-01T00:00:00Z", - }, - { - id: 2, - body: "Looks good.", - author: { username: "julius" }, - created_at: "2026-07-02T00:00:00Z", - }, - { id: 3, body: " ", created_at: "2026-07-03T00:00:00Z" }, - ]), - ), - ); - - expect(notes.comments).toHaveLength(1); - expect(notes.comments[0]).toMatchObject({ - id: "2", - kind: "issue-comment", - body: "Looks good.", - }); - // The raw count keeps the dropped notes visible to the caller, which needs them to page. - expect(notes.rawCount).toBe(3); - }); - - it("reads a line note as a review comment on its file", () => { - const notes = expectSuccess( - decodeNotesJson( - JSON.stringify([ - { - id: 7, - type: "DiffNote", - body: "Rename this.", - created_at: "2026-07-02T00:00:00Z", - position: { new_path: "src/app.ts", old_path: "src/old.ts" }, - }, - ]), - ), - ); - - expect(notes.comments[0]).toMatchObject({ kind: "review-comment", path: "src/app.ts" }); - }); - - it("falls back to the old path for a note on a deleted line", () => { - const notes = expectSuccess( - decodeNotesJson( - JSON.stringify([ - { - id: 8, - type: "DiffNote", - body: "Gone.", - created_at: "2026-07-02T00:00:00Z", - position: { new_path: null, old_path: "src/old.ts" }, - }, - ]), - ), - ); - - expect(notes.comments[0]?.path).toBe("src/old.ts"); - }); -}); - -describe("decodeCommitsJson", () => { - it("returns commits oldest first", () => { - const commits = expectSuccess( - decodeCommitsJson( - JSON.stringify([ - { id: "bbb", title: "second", committed_date: "2026-07-02T00:00:00Z" }, - { id: "aaa", title: "first", committed_date: "2026-07-01T00:00:00Z" }, - ]), - ), - ); - - expect(commits.map((commit) => commit.oid)).toEqual(["aaa", "bbb"]); - }); - - it("skips commits whose id is empty", () => { - const commits = expectSuccess( - decodeCommitsJson( - JSON.stringify([ - { id: " ", title: "invalid", committed_date: "2026-07-02T00:00:00Z" }, - { id: "aaa", committed_date: "2026-07-01T00:00:00Z" }, - ]), - ), - ); - - expect(commits.map((commit) => commit.oid)).toEqual(["aaa"]); - }); - - it("falls back to the creation timestamp when there is no commit date", () => { - const commits = expectSuccess( - decodeCommitsJson( - JSON.stringify([ - { - id: "aaa", - created_at: "2026-07-01T00:00:00+08:00", - author_name: "Ada Lovelace", - author_email: "ada@example.com", - }, - ]), - ), - ); - - expect(commits[0]).toMatchObject({ - oid: "aaa", - committedDate: "2026-07-01T00:00:00+08:00", - authors: [{ login: "Ada Lovelace", name: "Ada Lovelace", avatarUrl: null }], - }); - }); - - it("carries commit additions and deletions when GitLab returns stats", () => { - const commits = expectSuccess( - decodeCommitsJson( - JSON.stringify([ - { - id: "aaa", - committed_date: "2026-07-01T00:00:00Z", - stats: { additions: 21, deletions: 8, total: 29 }, - }, - ]), - ), - ); - - expect(commits[0]).toMatchObject({ additions: 21, deletions: 8 }); - }); -}); - -describe("decodeMergeRequestDiffsJson", () => { - it("quotes literal backslashes without interpreting them as escapes", () => { - const result = expectSuccess( - decodeMergeRequestDiffsJson( - JSON.stringify([ - { - old_path: String.raw`src\notes.ts`, - new_path: String.raw`src\notes.ts`, - diff: "@@ -1 +1 @@\n-old\n+new", - }, - ]), - ), - ); - - expect(result.patch).toBe( - [ - String.raw`diff --git "a/src\\notes.ts" "b/src\\notes.ts"`, - String.raw`--- "a/src\\notes.ts"`, - String.raw`+++ "b/src\\notes.ts"`, - "@@ -1 +1 @@", - "-old", - "+new", - "", - ].join("\n"), - ); - }); - - it("preserves spaces and literal backslashes in both rename paths", () => { - const result = expectSuccess( - decodeMergeRequestDiffsJson( - JSON.stringify([ - { - old_path: String.raw` old\name.ts `, - new_path: String.raw` new\name.ts `, - renamed_file: true, - diff: "", - }, - ]), - ), - ); - - expect(result.patch).toBe( - [ - String.raw`diff --git "a/ old\\name.ts " "b/ new\\name.ts "`, - String.raw`rename from " old\\name.ts "`, - String.raw`rename to " new\\name.ts "`, - String.raw`--- "a/ old\\name.ts "`, - String.raw`+++ "b/ new\\name.ts "`, - ].join("\n"), - ); - }); - - it("assembles a unified patch GitLab does not return", () => { - const result = expectSuccess( - decodeMergeRequestDiffsJson( - JSON.stringify([ - { - old_path: "src/app.ts", - new_path: "src/app.ts", - diff: "@@ -1 +1 @@\n-old\n+new\n", - }, - ]), - ), - ); - - expect(result.patch).toBe( - [ - "diff --git a/src/app.ts b/src/app.ts", - "--- a/src/app.ts", - "+++ b/src/app.ts", - "@@ -1 +1 @@", - "-old", - "+new", - "", - ].join("\n"), - ); - expect(result.truncated).toBe(false); - }); - - it("points a new file at /dev/null on the left and a deleted file on the right", () => { - const result = expectSuccess( - decodeMergeRequestDiffsJson( - JSON.stringify([ - { - old_path: "src/new.ts", - new_path: "src/new.ts", - new_file: true, - b_mode: "100755", - diff: "@@ -0,0 +1 @@\n+hello\n", - }, - { - old_path: "src/gone.ts", - new_path: "src/gone.ts", - deleted_file: true, - diff: "@@ -1 +0,0 @@\n-bye\n", - }, - ]), - ), - ); - - expect(result.patch).toContain("new file mode 100755"); - expect(result.patch).toContain("--- /dev/null"); - expect(result.patch).toContain("deleted file mode 100644"); - expect(result.patch).toContain("+++ /dev/null"); - }); - - it("records a rename so the patch names both paths", () => { - const result = expectSuccess( - decodeMergeRequestDiffsJson( - JSON.stringify([ - { old_path: "src/old.ts", new_path: "src/new.ts", renamed_file: true, diff: "" }, - ]), - ), - ); - - expect(result.patch).toContain("rename from src/old.ts"); - expect(result.patch).toContain("rename to src/new.ts"); - }); - - it("reports truncation for a file GitLab refused to inline", () => { - const result = expectSuccess( - decodeMergeRequestDiffsJson( - JSON.stringify([{ old_path: "big.bin", new_path: "big.bin", diff: "", too_large: true }]), - ), - ); - - expect(result.truncated).toBe(true); - expect(result.patch).toContain("diff --git a/big.bin b/big.bin"); - }); - - it("reports how many files GitLab returned, so the caller can page", () => { - const result = expectSuccess( - decodeMergeRequestDiffsJson( - JSON.stringify( - Array.from({ length: 3 }, (_, index) => ({ - old_path: `src/${index}.ts`, - new_path: `src/${index}.ts`, - diff: "@@ -1 +1 @@\n-a\n+b\n", - })), - ), - ), - ); - - expect(result.rawCount).toBe(3); - expect(result.truncated).toBe(false); - expect(result.patch).toContain("src/2.ts"); - }); - - it("fails when GitLab did not return a list", () => { - expect(Result.isFailure(decodeMergeRequestDiffsJson('{"message":"404"}'))).toBe(true); - }); -}); - -describe("merge request viewer fields", () => { - it("carries GitLab's own answer for whether this viewer can merge", () => { - expect( - expectSuccess(decodeMergeRequestDetailJson(detailJson({ user: { can_merge: false } }))) - .viewerCanMerge, - ).toBe(false); - expect( - expectSuccess(decodeMergeRequestDetailJson(detailJson({ user: { can_merge: true } }))) - .viewerCanMerge, - ).toBe(true); - }); - - it("leaves merging permitted where GitLab answered without the field", () => { - // Only the single-merge-request endpoint carries `user`, and an install that answers without - // it has said nothing about the viewer rather than said no. - expect(expectSuccess(decodeMergeRequestDetailJson(detailJson({}))).viewerCanMerge).toBe(true); - expect( - expectSuccess(decodeMergeRequestDetailJson(detailJson({ user: null }))).viewerCanMerge, - ).toBe(true); - }); -}); - -describe("decodeAwardEmojiJson", () => { - it("reads MR-level awards, keys per-note awards by the REST id inside their gid, and ignores an award outside the eight", () => { - const result = expectSuccess( - decodeAwardEmojiJson( - JSON.stringify({ - data: { - currentUser: { username: "bilal" }, - project: { - mergeRequest: { - awardEmoji: { - nodes: [ - { name: "thumbsup", user: { username: "bilal" } }, - { name: "thumbsup", user: { username: "julius" } }, - ], - }, - notes: { - pageInfo: { hasNextPage: false, endCursor: null }, - nodes: [ - { - id: "gid://gitlab/DiffNote/42", - awardEmoji: { nodes: [{ name: "heart", user: { username: "julius" } }] }, - }, - { - id: "gid://gitlab/Note/7", - // Not one of the eight the contract carries. - awardEmoji: { - nodes: [{ name: "partyparrot", user: { username: "bilal" } }], - }, - }, - ], - }, - }, - }, - }, - }), - ), - ); - - // `bilal` is `currentUser`, so the group they are in reads back as reacted, but their own - // username is left out of `actors` — the page names them "You" instead — while `count` still - // counts them; `julius` alone does not turn a group's own `viewerHasReacted` on. - expect(result.reactions).toEqual([ - { content: "thumbs-up", count: 2, actors: ["julius"], viewerHasReacted: true }, - ]); - // Note 7's only award named nobody the eight recognise, so it carries no reactions and is - // left out of the map rather than kept empty. - expect([...result.reactionsByNoteId]).toEqual([ - ["42", [{ content: "heart", count: 1, actors: ["julius"], viewerHasReacted: false }]], - ]); - expect(result.nextCursor).toBeNull(); - }); - - it("hands back a cursor when GitLab has more notes to page", () => { - const result = expectSuccess( - decodeAwardEmojiJson( - JSON.stringify({ - data: { - currentUser: null, - project: { - mergeRequest: { - awardEmoji: { nodes: [] }, - notes: { pageInfo: { hasNextPage: true, endCursor: "Y3Vyc29yOjE" }, nodes: [] }, - }, - }, - }, - }), - ), - ); - - expect(result.nextCursor).toBe("Y3Vyc29yOjE"); - }); - - it("matches the viewer's username case-insensitively, since GitLab is not consistent about case", () => { - const result = expectSuccess( - decodeAwardEmojiJson( - JSON.stringify({ - data: { - currentUser: { username: "Bilal" }, - project: { - mergeRequest: { - awardEmoji: { - nodes: [ - { name: "heart", user: { username: "bilal" } }, - { name: "heart", user: { username: "julius" } }, - ], - }, - notes: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, - }, - }, - }, - }), - ), - ); - - expect(result.reactions).toEqual([ - { content: "heart", count: 2, actors: ["julius"], viewerHasReacted: true }, - ]); - }); -}); - -describe("decodeOwnAwardIdJson", () => { - const awards = JSON.stringify([ - { id: 101, name: "thumbsup", user: { username: "julius" } }, - { id: 102, name: "thumbsup", user: { username: "bilal" } }, - ]); - - it("finds the reader's own award of that name, which is the one a removal deletes", () => { - expect( - expectSuccess(decodeOwnAwardIdJson(awards, { content: "thumbs-up", viewer: "bilal" })), - ).toBe(102); - }); - - it("returns nothing where the reader has no award of that name", () => { - expect( - expectSuccess(decodeOwnAwardIdJson(awards, { content: "heart", viewer: "bilal" })), - ).toBeNull(); - }); -}); - -describe("gitLabAwardName", () => { - it("spells the contents whose GitLab award name is not their own kebab-case", () => { - expect(gitLabAwardName("thumbs-up")).toBe("thumbsup"); - expect(gitLabAwardName("laugh")).toBe("laughing"); - expect(gitLabAwardName("hooray")).toBe("tada"); - }); -}); - -describe("decodeRepositoryBlobsJson", () => { - /** Null is the query going unanswered, which these cases are not about. */ - function expectBlobs(result: Result.Result | null, unknown>) { - const blobs = expectSuccess(result); - expect(blobs).not.toBe(null); - if (blobs === null) throw new Error("expected an answered blobs query"); - return blobs; - } - - it("reads a blob id per path", () => { - const blobs = expectBlobs( - decodeRepositoryBlobsJson( - JSON.stringify({ - data: { - project: { - repository: { - blobs: { - nodes: [ - { path: "src/a.ts", oid: "aaa111" }, - { path: "src/b.ts", oid: "bbb222" }, - ], - }, - }, - }, - }, - }), - ), - ); - - expect([...blobs]).toEqual([ - ["src/a.ts", "aaa111"], - ["src/b.ts", "bbb222"], - ]); - }); - - it("leaves out a node missing either half, which names no version", () => { - const blobs = expectBlobs( - decodeRepositoryBlobsJson( - JSON.stringify({ - data: { - project: { - repository: { - blobs: { - nodes: [ - { path: "src/a.ts", oid: null }, - { path: null, oid: "bbb222" }, - null, - { path: "src/c.ts", oid: "ccc333" }, - ], - }, - }, - }, - }, - }), - ), - ); - - expect([...blobs]).toEqual([["src/c.ts", "ccc333"]]); - }); - - it("keys a blob by the path the host spelled, spaces and all", () => { - // A leading or trailing space is a legal part of a file's name. Trimmed here, the id lands - // under a key the asked-for path is not spelled with, and the caller fills that path in as - // the empty revision: a mark on the file then never compares against the real head blob. - const blobs = expectBlobs( - decodeRepositoryBlobsJson( - JSON.stringify({ - data: { - project: { - repository: { - blobs: { - nodes: [ - { path: " leading.ts", oid: "aaa111" }, - { path: "trailing.ts ", oid: "bbb222" }, - { path: " ", oid: "ccc333" }, - { path: "", oid: "ddd444" }, - ], - }, - }, - }, - }, - }), - ), - ); - - expect([...blobs]).toEqual([ - [" leading.ts", "aaa111"], - ["trailing.ts ", "bbb222"], - // A name that is only spaces is one Git carries too, so it is a path like any other. - [" ", "ccc333"], - ]); - }); - - it("tells a project the reader cannot see from a revision with none of the files", () => { - // Null is the query going unanswered. Read as an empty answer it would say the head has none - // of the asked-for files, which reports every file a reader has cleared as changed. - expect( - expectSuccess(decodeRepositoryBlobsJson(JSON.stringify({ data: { project: null } }))), - ).toBe(null); - expect( - expectSuccess( - decodeRepositoryBlobsJson(JSON.stringify({ data: { project: { repository: null } } })), - ), - ).toBe(null); - expect( - expectSuccess( - decodeRepositoryBlobsJson( - JSON.stringify({ data: { project: { repository: { blobs: { nodes: [] } } } } }), - ), - ), - ).toEqual(new Map()); - }); - - it("fails on output that is not the query's shape", () => { - expect(Result.isSuccess(decodeRepositoryBlobsJson("not json"))).toBe(false); - expect(Result.isSuccess(decodeRepositoryBlobsJson(JSON.stringify({ errors: [] })))).toBe(false); - }); -}); diff --git a/apps/server/src/pullRequest/gitLabMergeRequestJson.ts b/apps/server/src/pullRequest/gitLabMergeRequestJson.ts deleted file mode 100644 index b9991920d0d3a880bcf1edb12b0bb777f87d012a..0000000000000000000000000000000000000000 --- a/apps/server/src/pullRequest/gitLabMergeRequestJson.ts +++ /dev/null @@ -1,1032 +0,0 @@ -import * as Cause from "effect/Cause"; -import * as Exit from "effect/Exit"; -import * as Result from "effect/Result"; -import * as Schema from "effect/Schema"; -import type { - PullRequestActor, - PullRequestCheck, - PullRequestCheckStatus, - PullRequestComment, - PullRequestCommit, - PullRequestLabel, - PullRequestMergeability, - PullRequestMergeCapabilities, - PullRequestMergeMethod, - PullRequestReaction, - PullRequestReactionContent, - PullRequestReviewThread, - PullRequestReviewerCandidate, - PullRequestState, -} from "@t3tools/contracts"; -import { TrimmedNonEmptyString } from "@t3tools/contracts"; -import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; -import { decodeJsonResult } from "@t3tools/shared/schemaJson"; - -/** - * GitLab's REST enums are decoded as plain strings and normalized here: a GitLab release that - * adds a pipeline status or a merge status must not fail the whole payload. - */ -const RawUserSchema = Schema.Struct({ - /** - * GitLab writes a merge request's reviewers as numeric ids and takes no usernames there, so the - * id is carried alongside the handle rather than looked up again when a review is asked for. - */ - id: Schema.optional(Schema.Int), - username: Schema.String, - name: Schema.optional(Schema.NullOr(Schema.String)), - avatar_url: Schema.optional(Schema.NullOr(Schema.String)), -}); - -const RawPipelineSchema = Schema.Struct({ - status: Schema.optional(Schema.NullOr(Schema.String)), - web_url: Schema.optional(Schema.NullOr(Schema.String)), - source: Schema.optional(Schema.NullOr(Schema.String)), -}); - -const RawMergeRequestSchema = Schema.Struct({ - iid: Schema.Int, - title: Schema.String, - web_url: Schema.String, - description: Schema.optional(Schema.NullOr(Schema.String)), - author: Schema.optional(Schema.NullOr(RawUserSchema)), - source_branch: Schema.String, - target_branch: Schema.String, - state: Schema.optional(Schema.NullOr(Schema.String)), - draft: Schema.optional(Schema.Boolean), - work_in_progress: Schema.optional(Schema.Boolean), - merge_status: Schema.optional(Schema.NullOr(Schema.String)), - has_conflicts: Schema.optional(Schema.NullOr(Schema.Boolean)), - created_at: Schema.String, - updated_at: Schema.String, - merged_at: Schema.optional(Schema.NullOr(Schema.String)), - closed_at: Schema.optional(Schema.NullOr(Schema.String)), - reviewers: Schema.optional(Schema.NullOr(Schema.Array(RawUserSchema))), - labels: Schema.optional(Schema.NullOr(Schema.Array(Schema.String))), - // A string, and "1000+" past GitLab's counting limit, so it is parsed rather than decoded. - changes_count: Schema.optional(Schema.NullOr(Schema.String)), - head_pipeline: Schema.optional(Schema.NullOr(RawPipelineSchema)), - /** - * What the requesting account may do, which only the single-merge-request endpoint carries. - * GitLab answers `can_merge` for this viewer against this merge request, so it already accounts - * for the role, the approval rules and a protected target branch — none of which a project's - * access level on its own would tell apart. - */ - user: Schema.optional( - Schema.NullOr(Schema.Struct({ can_merge: Schema.optional(Schema.Boolean) })), - ), - /** - * Whether GitLab is holding this merge request to merge it once its pipeline goes green. - * `merge_when_pipeline_succeeds` is the field every version answers with; newer ones also - * carry `auto_merge_enabled`, which is the same fact under the name GitLab settled on, so - * either one saying yes is a yes. - */ - merge_when_pipeline_succeeds: Schema.optional(Schema.NullOr(Schema.Boolean)), - auto_merge_enabled: Schema.optional(Schema.NullOr(Schema.Boolean)), - /** The merge request's stored squash choice, including project-policy overrides. */ - squash_on_merge: Schema.optional(Schema.NullOr(Schema.Boolean)), - squash: Schema.optional(Schema.NullOr(Schema.Boolean)), - /** - * How far the target branch has moved on since this one left it, which is the same number - * GitLab's own "out of date" wording counts. It costs a walk of the two branches, so GitLab - * withholds it unless `include_diverged_commits_count` asks for it, and answers it only for a - * single merge request — a list never carries it, however it is asked for. - */ - diverged_commits_count: Schema.optional(Schema.NullOr(Schema.Int)), -}); - -const RawNoteSchema = Schema.Struct({ - id: Schema.Int, - body: Schema.optional(Schema.NullOr(Schema.String)), - author: Schema.optional(Schema.NullOr(RawUserSchema)), - created_at: Schema.String, - /** True for notes GitLab writes itself ("assigned to…"), which are events, not comments. */ - system: Schema.optional(Schema.Boolean), - type: Schema.optional(Schema.NullOr(Schema.String)), - position: Schema.optional( - Schema.NullOr( - Schema.Struct({ - new_path: Schema.optional(Schema.NullOr(Schema.String)), - old_path: Schema.optional(Schema.NullOr(Schema.String)), - }), - ), - ), -}); - -/** - * A discussion note carrying its place in the diff, which is the shape the whole thread view - * is built from. `resolved` lives on the note rather than on the discussion: GitLab calls a - * discussion resolved once every resolvable note in it is. - */ -const RawDiscussionNoteSchema = Schema.Struct({ - id: Schema.Int, - body: Schema.optional(Schema.NullOr(Schema.String)), - author: Schema.optional(Schema.NullOr(RawUserSchema)), - created_at: Schema.String, - system: Schema.optional(Schema.Boolean), - resolvable: Schema.optional(Schema.Boolean), - resolved: Schema.optional(Schema.NullOr(Schema.Boolean)), - position: Schema.optional( - Schema.NullOr( - Schema.Struct({ - position_type: Schema.optional(Schema.NullOr(Schema.String)), - new_path: Schema.optional(Schema.NullOr(Schema.String)), - old_path: Schema.optional(Schema.NullOr(Schema.String)), - new_line: Schema.optional(Schema.NullOr(Schema.Int)), - old_line: Schema.optional(Schema.NullOr(Schema.Int)), - }), - ), - ), -}); - -const RawDiscussionSchema = Schema.Struct({ - id: Schema.String, - notes: Schema.optional(Schema.NullOr(Schema.Array(RawDiscussionNoteSchema))), -}); - -const RawDiffRefsSchema = Schema.Struct({ - diff_refs: Schema.optional( - Schema.NullOr( - Schema.Struct({ - base_sha: Schema.String, - head_sha: Schema.String, - start_sha: Schema.String, - }), - ), - ), -}); - -const RawCommitSchema = Schema.Struct({ - id: TrimmedNonEmptyString, - title: Schema.optional(Schema.NullOr(Schema.String)), - committed_date: Schema.optional(Schema.NullOr(Schema.String)), - created_at: Schema.optional(Schema.NullOr(Schema.String)), - parent_ids: Schema.optional(Schema.Array(Schema.String)), - author_name: Schema.optional(Schema.NullOr(Schema.String)), - author_email: Schema.optional(Schema.NullOr(Schema.String)), - stats: Schema.optional( - Schema.NullOr( - Schema.Struct({ - additions: Schema.optional(Schema.Int), - deletions: Schema.optional(Schema.Int), - }), - ), - ), -}); - -const RawDiffSchema = Schema.Struct({ - old_path: Schema.String, - new_path: Schema.String, - a_mode: Schema.optional(Schema.NullOr(Schema.String)), - b_mode: Schema.optional(Schema.NullOr(Schema.String)), - new_file: Schema.optional(Schema.Boolean), - renamed_file: Schema.optional(Schema.Boolean), - deleted_file: Schema.optional(Schema.Boolean), - diff: Schema.optional(Schema.NullOr(Schema.String)), - /** GitLab omits the hunks for a file it considers too large to inline. */ - too_large: Schema.optional(Schema.NullOr(Schema.Boolean)), - /** And for one it collapsed, which withholds them the same way. */ - collapsed: Schema.optional(Schema.NullOr(Schema.Boolean)), -}); - -const RawViewerSchema = Schema.Struct({ - username: Schema.optional(Schema.NullOr(Schema.String)), -}); - -/** A GitLab project settles on one merge strategy plus an optional squash. */ -const RawProjectMergeSettingsSchema = Schema.Struct({ - merge_method: Schema.optional(Schema.NullOr(Schema.String)), - squash_option: Schema.optional(Schema.NullOr(Schema.String)), -}); - -export interface GitLabMergeRequestListItem { - readonly number: number; - readonly title: string; - readonly url: string; - readonly author: PullRequestActor | null; - readonly headBranch: string; - readonly baseBranch: string; - readonly state: PullRequestState; - readonly isDraft: boolean; - readonly mergeability: PullRequestMergeability; - /** - * GitLab reports neither added nor removed lines on a merge request, so both stay zero and - * the surface omits the stat. The Code tab counts them from the patch it already fetched. - */ - readonly additions: number; - readonly deletions: number; - readonly createdAt: string; - readonly updatedAt: string; - readonly reviewRequestLogins: ReadonlyArray; - readonly labels: ReadonlyArray; -} - -export interface GitLabMergeRequestDetail extends GitLabMergeRequestListItem { - readonly body: string; - readonly changedFiles: number; - readonly mergedAt: string | null; - readonly closedAt: string | null; - readonly reviewers: ReadonlyArray; - readonly checks: ReadonlyArray; - /** False only where GitLab said so; an answer without the field leaves merging permitted. */ - readonly viewerCanMerge: boolean; - /** The reviewers as GitLab addresses them, which is what writing the set back takes. */ - readonly reviewerIds: ReadonlyArray; - /** Absent where GitLab named neither auto-merge field, which is not the same as off. */ - readonly autoMergeEnabled?: boolean; - /** GitLab only exposes the stored strategy separately when that strategy is squash. */ - readonly autoMergeMethod?: PullRequestMergeMethod; - /** - * Absent where GitLab did not count, which is not the same as a branch that has nothing behind - * it: an install too old to answer must not be read as saying the branch is current. - */ - readonly divergedCommits?: number; -} - -function trimmed(value: string | null | undefined): string | null { - const text = value?.trim() ?? ""; - return text.length > 0 ? text : null; -} - -function toActor(raw: Schema.Schema.Type | null | undefined) { - const login = trimmed(raw?.username); - return login === null - ? null - : { login, name: trimmed(raw?.name), avatarUrl: trimmed(raw?.avatar_url) }; -} - -function toState(raw: Schema.Schema.Type): PullRequestState { - if (trimmed(raw.merged_at) !== null) return "merged"; - switch (raw.state?.trim().toLowerCase()) { - case "merged": - return "merged"; - case "closed": - return "closed"; - default: - // `locked` is an open merge request whose discussion is locked. - return "open"; - } -} - -function toMergeability( - raw: Schema.Schema.Type, -): PullRequestMergeability { - if (raw.has_conflicts === true) return "conflicting"; - switch (raw.merge_status?.trim().toLowerCase()) { - case "can_be_merged": - return "mergeable"; - case "cannot_be_merged": - return "conflicting"; - default: - // `unchecked` and `checking` mean GitLab has not finished the merge check yet. - return "unknown"; - } -} - -function toLabels(raw: ReadonlyArray | null | undefined): ReadonlyArray { - // GitLab returns label names only, so there is no colour to carry. - return (raw ?? []).flatMap((label) => { - const name = trimmed(label); - return name === null ? [] : [{ name, color: null }]; - }); -} - -/** - * "3" for a counted change set, "1000+" once GitLab gives up counting. The leading number is - * the floor either way, which reads better than dropping an uncounted change set to nothing. - */ -function toChangedFiles(value: string | null | undefined): number { - const parsed = Number.parseInt(value?.trim() ?? "", 10); - return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; -} - -function toPipelineStatus(value: string | null | undefined): PullRequestCheckStatus { - switch (value?.trim().toLowerCase()) { - case "success": - return "success"; - case "failed": - return "failure"; - case "canceled": - case "cancelling": - return "cancelled"; - case "skipped": - return "skipped"; - // A pipeline waiting on a person is not progress, and it is not a failure either. - case "manual": - case "scheduled": - return "neutral"; - default: - return "pending"; - } -} - -/** - * GitLab has no per-job check list on a merge request, so its pipeline is reported as the one - * check. The jobs behind it stay one click away through the pipeline URL. - */ -function toChecks( - raw: Schema.Schema.Type, -): ReadonlyArray { - const pipeline = raw.head_pipeline; - if (!pipeline) return []; - return [ - { - name: "Pipeline", - status: toPipelineStatus(pipeline.status), - description: trimmed(pipeline.source), - url: trimmed(pipeline.web_url), - }, - ]; -} - -function toListItem( - raw: Schema.Schema.Type, -): GitLabMergeRequestListItem { - return { - number: raw.iid, - title: raw.title, - url: raw.web_url, - author: toActor(raw.author), - headBranch: raw.source_branch, - baseBranch: raw.target_branch, - state: toState(raw), - isDraft: raw.draft ?? raw.work_in_progress ?? false, - mergeability: toMergeability(raw), - additions: 0, - deletions: 0, - createdAt: raw.created_at, - updatedAt: raw.updated_at, - reviewRequestLogins: (raw.reviewers ?? []).flatMap((reviewer) => { - const login = trimmed(reviewer.username); - return login === null ? [] : [login]; - }), - labels: toLabels(raw.labels), - }; -} - -function toDetail(raw: Schema.Schema.Type): GitLabMergeRequestDetail { - const listItem = toListItem(raw); - const autoMerge = - raw.merge_when_pipeline_succeeds == null && raw.auto_merge_enabled == null - ? undefined - : raw.merge_when_pipeline_succeeds === true || raw.auto_merge_enabled === true; - return { - ...listItem, - body: raw.description ?? "", - changedFiles: toChangedFiles(raw.changes_count), - mergedAt: trimmed(raw.merged_at), - closedAt: trimmed(raw.closed_at), - // Built from the reviewers themselves rather than from their logins, so the avatars survive. - reviewers: (raw.reviewers ?? []).flatMap((reviewer) => { - const actor = toActor(reviewer); - return actor === null ? [] : [actor]; - }), - checks: toChecks(raw), - viewerCanMerge: raw.user?.can_merge !== false, - reviewerIds: (raw.reviewers ?? []).flatMap((reviewer) => - reviewer.id === undefined ? [] : [reviewer.id], - ), - ...(autoMerge === undefined ? {} : { autoMergeEnabled: autoMerge }), - ...(autoMerge === true && raw.squash_on_merge === true - ? { autoMergeMethod: "squash" as const } - : {}), - ...(raw.diverged_commits_count == null ? {} : { divergedCommits: raw.diverged_commits_count }), - }; -} - -const decodeUnknownList = decodeJsonResult(Schema.Array(Schema.Unknown)); -const decodeMergeRequestEntry = Schema.decodeUnknownExit(RawMergeRequestSchema); -const decodeMergeRequest = decodeJsonResult(RawMergeRequestSchema); -const decodeNoteEntry = Schema.decodeUnknownExit(RawNoteSchema); -const decodeUserEntry = Schema.decodeUnknownExit(RawUserSchema); -const decodeCommitEntry = Schema.decodeUnknownExit(RawCommitSchema); -const decodeCommit = decodeJsonResult(RawCommitSchema); -const decodeDiffEntry = Schema.decodeUnknownExit(RawDiffSchema); -const decodeDiscussionEntry = Schema.decodeUnknownExit(RawDiscussionSchema); -const decodeDiffRefs = decodeJsonResult(RawDiffRefsSchema); -const decodeViewer = decodeJsonResult(RawViewerSchema); -const decodeProjectMergeSettings = decodeJsonResult(RawProjectMergeSettingsSchema); - -type DecodeFailure = Cause.Cause; - -export interface GitLabProjectUsers { - readonly candidates: ReadonlyArray; - /** Rows GitLab returned, counted before decoding, so a skipped row cannot hide a next page. */ - readonly rawCount: number; -} - -export interface GitLabMergeRequestListBatch { - readonly items: ReadonlyArray; - /** Zero-based positions of the decoded items in GitLab's raw page. */ - readonly rawIndexes: ReadonlyArray; - /** Rows GitLab returned, counted before decoding, so a skipped row cannot hide a next page. */ - readonly rawCount: number; -} - -/** Malformed entries are skipped rather than failing the batch: one unexpected merge request - * must not blank the whole list. */ -export function decodeMergeRequestListJson( - raw: string, -): Result.Result { - const decoded = decodeUnknownList(raw); - if (!Result.isSuccess(decoded)) { - return Result.fail(decoded.failure); - } - const items: GitLabMergeRequestListItem[] = []; - const rawIndexes: number[] = []; - for (const [rawIndex, entry] of decoded.success.entries()) { - const item = decodeMergeRequestEntry(entry); - if (Exit.isSuccess(item)) { - items.push(toListItem(item.value)); - rawIndexes.push(rawIndex); - } - } - return Result.succeed({ items, rawIndexes, rawCount: decoded.success.length }); -} - -export function decodeMergeRequestDetailJson( - raw: string, -): Result.Result { - const decoded = decodeMergeRequest(raw); - return Result.isSuccess(decoded) - ? Result.succeed(toDetail(decoded.success)) - : Result.fail(decoded.failure); -} - -export function decodeViewerJson(raw: string): Result.Result { - const decoded = decodeViewer(raw); - return Result.isSuccess(decoded) - ? Result.succeed(trimmed(decoded.success.username)) - : Result.fail(decoded.failure); -} - -/** - * The people with access to the project, which `GET /projects/:id/users` answers with — the same - * list GitLab's own reviewer field is filled from, including the members a group above the project - * lends it. A malformed row is skipped rather than failing the menu it belongs to. - * - * Nobody is marked requested here: who has been asked lives on the merge request, and only the - * caller holds both. - */ -export function decodeProjectUsersJson( - raw: string, -): Result.Result { - const decoded = decodeUnknownList(raw); - if (!Result.isSuccess(decoded)) { - return Result.fail(decoded.failure); - } - const candidates: PullRequestReviewerCandidate[] = []; - for (const entry of decoded.success) { - const user = decodeUserEntry(entry); - if (Exit.isFailure(user) || user.value.id === undefined) continue; - const actor = toActor(user.value); - if (actor === null) continue; - candidates.push({ - ...actor, - id: String(user.value.id), - kind: "user", - isRequested: false, - }); - } - return Result.succeed({ candidates, rawCount: decoded.success.length }); -} - -/** - * GitLab settles the strategy per project rather than offering all three per merge request: - * `merge_method` picks one of merge commit, semi-linear or fast-forward, and squashing is a - * separate switch. An unrecognized setting offers nothing rather than offering a strategy the - * project forbids. - */ -export function decodeProjectMergeCapabilitiesJson( - raw: string, -): Result.Result { - const decoded = decodeProjectMergeSettings(raw); - if (!Result.isSuccess(decoded)) { - return Result.fail(decoded.failure); - } - const mergeMethod = decoded.success.merge_method?.trim().toLowerCase(); - const squashOption = decoded.success.squash_option?.trim().toLowerCase(); - return Result.succeed({ - merge: mergeMethod === "merge", - // Both semi-linear and fast-forward histories are reached by rebasing onto the target. - rebase: mergeMethod === "rebase_merge" || mergeMethod === "ff", - // Only GitLab's own enabling values. An absent or unrecognized setting offers nothing, - // rather than offering a squash the project may forbid. - squash: - squashOption === "always" || squashOption === "default_on" || squashOption === "default_off", - }); -} - -/** - * Comments only. System notes are GitLab's own activity feed entries, and a `DiffNote` is the - * root of a line-level discussion, which is what the review-comment kind means. - * - * The raw note count comes back alongside, because dropping notes hides whether the page was - * full: a caller cannot tell "no more notes" from "a page of activity entries" without it. - */ -/** The three revisions a positioned comment is written against. */ -export interface GitLabDiffRefs { - readonly baseSha: string; - readonly headSha: string; - readonly startSha: string; -} - -export interface GitLabDiscussions { - readonly threads: ReadonlyArray; - /** Discussions GitLab returned, counted before decoding, so a skipped one still counts. */ - readonly rawCount: number; -} - -/** - * Positioned discussions only. GitLab returns the merge request's whole conversation here, - * including the plain notes the timeline already shows, and only a positioned one belongs - * against a line of the diff. - */ -export function decodeDiscussionsJson( - raw: string, -): Result.Result { - const decoded = decodeUnknownList(raw); - if (!Result.isSuccess(decoded)) { - return Result.fail(decoded.failure); - } - const threads: PullRequestReviewThread[] = []; - for (const entry of decoded.success) { - const discussion = decodeDiscussionEntry(entry); - if (!Exit.isSuccess(discussion)) continue; - const notes = (discussion.value.notes ?? []).filter((note) => note.system !== true); - const root = notes[0]; - const position = root?.position; - if (root === undefined || !position || position.position_type !== "text") continue; - // A comment on an added or context line carries `new_line`; one on a removed line carries - // only `old_line`, and belongs against the file as it was. - const side = position.new_line === null || position.new_line === undefined ? "left" : "right"; - const path = trimmed(side === "left" ? position.old_path : position.new_path); - const line = side === "left" ? position.old_line : position.new_line; - if (path === null) continue; - threads.push({ - id: discussion.value.id, - path, - line: typeof line === "number" && line > 0 ? line : null, - side, - isResolved: root.resolved === true, - // GitLab reports no equivalent of "written against a line that has since moved", so a - // thread the diff cannot place is worked out from the diff itself rather than claimed - // here. - isOutdated: false, - comments: notes.map((note) => ({ - id: String(note.id), - author: toActor(note.author), - body: note.body ?? "", - createdAt: note.created_at, - url: null, - })), - }); - } - return Result.succeed({ threads, rawCount: decoded.success.length }); -} - -export function decodeDiffRefsJson( - raw: string, -): Result.Result { - const decoded = decodeDiffRefs(raw); - if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); - const refs = decoded.success.diff_refs; - return Result.succeed( - refs ? { baseSha: refs.base_sha, headSha: refs.head_sha, startSha: refs.start_sha } : null, - ); -} - -export function decodeNotesJson( - raw: string, -): Result.Result< - { readonly comments: ReadonlyArray; readonly rawCount: number }, - DecodeFailure -> { - const decoded = decodeUnknownList(raw); - if (!Result.isSuccess(decoded)) { - return Result.fail(decoded.failure); - } - const comments: PullRequestComment[] = []; - for (const entry of decoded.success) { - const note = decodeNoteEntry(entry); - if (Exit.isFailure(note)) continue; - const value = note.value; - if (value.system === true) continue; - const body = value.body ?? ""; - if (body.trim().length === 0) continue; - const isDiffNote = value.type?.trim() === "DiffNote"; - comments.push({ - id: String(value.id), - kind: isDiffNote ? "review-comment" : "issue-comment", - author: toActor(value.author), - body, - createdAt: value.created_at, - url: null, - path: trimmed(value.position?.new_path) ?? trimmed(value.position?.old_path), - reviewState: null, - }); - } - return Result.succeed({ comments, rawCount: decoded.success.length }); -} - -export function decodeCommitsJson( - raw: string, -): Result.Result, DecodeFailure> { - const decoded = decodeUnknownList(raw); - if (!Result.isSuccess(decoded)) { - return Result.fail(decoded.failure); - } - const commits: PullRequestCommit[] = []; - for (const entry of decoded.success) { - const commit = decodeCommitEntry(entry); - if (Exit.isFailure(commit)) continue; - const committedDate = trimmed(commit.value.committed_date) ?? trimmed(commit.value.created_at); - if (committedDate === null) continue; - commits.push({ - oid: commit.value.id, - messageHeadline: commit.value.title ?? "", - committedDate, - ...(commit.value.stats === null || commit.value.stats === undefined - ? {} - : { - additions: Math.max(0, commit.value.stats.additions ?? 0), - deletions: Math.max(0, commit.value.stats.deletions ?? 0), - }), - authors: (() => { - const login = trimmed(commit.value.author_name) ?? trimmed(commit.value.author_email); - return login === null - ? [] - : [{ login, name: trimmed(commit.value.author_name), avatarUrl: null }]; - })(), - }); - } - // GitLab lists a merge request's commits newest first; the timeline reads oldest first. - return Result.succeed(commits.toReversed()); -} - -/** The exact comparison GitLab uses for a commit-scoped diff. */ -export function decodeCommitDiffRefsJson( - raw: string, -): Result.Result { - const decoded = decodeCommit(raw); - if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); - const baseSha = trimmed(decoded.success.parent_ids?.[0]); - const headSha = trimmed(decoded.success.id); - return Result.succeed( - baseSha === null || headSha === null ? null : { baseSha, headSha, startSha: baseSha }, - ); -} - -function diffHeaderPaths(raw: Schema.Schema.Type): { - readonly from: string; - readonly to: string; -} { - return { - from: raw.new_file === true ? "/dev/null" : quoteGitPatchPath(`a/${raw.old_path}`), - to: raw.deleted_file === true ? "/dev/null" : quoteGitPatchPath(`b/${raw.new_path}`), - }; -} - -export interface GitLabMergeRequestPatch { - readonly patch: string; - /** At least one file's hunks were withheld by GitLab as too large to inline. */ - readonly truncated: boolean; - /** Files GitLab returned, counted before decoding, so the caller can page. */ - readonly rawCount: number; -} - -/** - * GitLab returns hunks per file with no `diff --git` header, so the unified patch every diff - * viewer expects is assembled here. This decodes one page; walking pages is the caller's job, - * which is why the raw file count comes back with the patch. - */ -export function decodeMergeRequestDiffsJson( - raw: string, -): Result.Result { - const decoded = decodeUnknownList(raw); - if (!Result.isSuccess(decoded)) { - return Result.fail(decoded.failure); - } - const sections: string[] = []; - let truncated = false; - for (const entry of decoded.success) { - const file = decodeDiffEntry(entry); - if (Exit.isFailure(file)) continue; - const value = file.value; - const hunks = value.diff ?? ""; - if (hunks.length === 0) { - // A file GitLab declined to inline still belongs in the file list, header only. - truncated = truncated || value.too_large === true || value.collapsed === true; - } - const { from, to } = diffHeaderPaths(value); - const header = [ - `diff --git ${quoteGitPatchPath(`a/${value.old_path}`)} ${quoteGitPatchPath(`b/${value.new_path}`)}`, - ...(value.new_file === true ? [`new file mode ${value.b_mode ?? "100644"}`] : []), - ...(value.deleted_file === true ? [`deleted file mode ${value.a_mode ?? "100644"}`] : []), - ...(value.renamed_file === true - ? [ - `rename from ${quoteGitPatchPath(value.old_path)}`, - `rename to ${quoteGitPatchPath(value.new_path)}`, - ] - : []), - `--- ${from}`, - `+++ ${to}`, - ].join("\n"); - sections.push(hunks.length === 0 ? header : `${header}\n${hunks.replace(/\n?$/, "\n")}`); - } - return Result.succeed({ - patch: sections.join("\n"), - truncated, - rawCount: decoded.success.length, - }); -} - -/** GitLab's award names for the eight reactions the contract carries. */ -const GITLAB_AWARD_BY_CONTENT: Readonly> = { - "thumbs-up": "thumbsup", - "thumbs-down": "thumbsdown", - laugh: "laughing", - hooray: "tada", - confused: "confused", - heart: "heart", - rocket: "rocket", - eyes: "eyes", -}; - -const CONTENT_BY_GITLAB_AWARD: Readonly> = - Object.fromEntries( - Object.entries(GITLAB_AWARD_BY_CONTENT).map(([content, name]) => [name, content]), - ) as Readonly>; - -export function gitLabAwardName(content: PullRequestReactionContent): string { - return GITLAB_AWARD_BY_CONTENT[content]; -} - -/** - * Awards on the merge request and on every note of it, in one read. The REST notes endpoint the - * conversation comes from carries no award at all, and asking per note would be a request each. - * - * `currentUser` rides along because GitLab names who awarded but never says whether that is the - * reader — so the comparison is made here rather than paid for with a request of its own. - */ -export const AWARD_EMOJI_GRAPHQL_QUERY = `query($fullPath: ID!, $iid: String!, $cursor: String) { - currentUser { username } - project(fullPath: $fullPath) { - mergeRequest(iid: $iid) { - awardEmoji { nodes { name user { username } } } - notes(first: 100, after: $cursor) { - pageInfo { hasNextPage endCursor } - nodes { id awardEmoji { nodes { name user { username } } } } - } - } - } -}`; - -const RawAwardEmojiNodesSchema = Schema.optional( - Schema.NullOr( - Schema.Struct({ - nodes: Schema.optional( - Schema.NullOr( - Schema.Array( - Schema.NullOr( - Schema.Struct({ - name: Schema.optional(Schema.NullOr(Schema.String)), - user: Schema.optional( - Schema.NullOr( - Schema.Struct({ username: Schema.optional(Schema.NullOr(Schema.String)) }), - ), - ), - }), - ), - ), - ), - ), - }), - ), -); - -const RawAwardEmojiPageSchema = Schema.Struct({ - data: Schema.Struct({ - currentUser: Schema.optional( - Schema.NullOr(Schema.Struct({ username: Schema.optional(Schema.NullOr(Schema.String)) })), - ), - project: Schema.NullOr( - Schema.Struct({ - mergeRequest: Schema.NullOr( - Schema.Struct({ - awardEmoji: RawAwardEmojiNodesSchema, - notes: Schema.optional( - Schema.NullOr( - Schema.Struct({ - pageInfo: Schema.optional( - Schema.Struct({ - hasNextPage: Schema.optional(Schema.Boolean), - endCursor: Schema.optional(Schema.NullOr(Schema.String)), - }), - ), - nodes: Schema.Array( - Schema.NullOr( - Schema.Struct({ - id: Schema.optional(Schema.NullOr(Schema.String)), - awardEmoji: RawAwardEmojiNodesSchema, - }), - ), - ), - }), - ), - ), - }), - ), - }), - ), - }), -}); - -const decodeAwardEmojiPage = decodeJsonResult(RawAwardEmojiPageSchema); - -/** - * The awards on one subject, grouped the way a reaction pill is drawn. The viewer's own username - * is left out of `actors` — the page names them "You" instead, and leaving it in would name them - * twice — but `count` still counts them along with everyone else. - */ -function toReactions( - nodes: Schema.Schema.Type, - viewer: string | null, -): ReadonlyArray { - const normalizedViewer = viewer?.toLowerCase() ?? null; - const groups = new Map< - PullRequestReactionContent, - { count: number; actors: string[]; viewer: boolean } - >(); - for (const node of nodes?.nodes ?? []) { - // An award outside the eight is left out rather than shown under a name the picker has no - // way to take back: GitLab accepts any emoji, and the other hosts accept none of them. - const content = CONTENT_BY_GITLAB_AWARD[trimmed(node?.name)?.toLowerCase() ?? ""]; - if (content === undefined) continue; - const username = trimmed(node?.user?.username); - if (username === null) continue; - const group = groups.get(content) ?? { count: 0, actors: [], viewer: false }; - group.count++; - if (normalizedViewer !== null && username.toLowerCase() === normalizedViewer) { - group.viewer = true; - } else { - group.actors.push(username); - } - groups.set(content, group); - } - return [...groups].flatMap(([content, group]) => - group.count === 0 - ? [] - : [{ content, count: group.count, actors: group.actors, viewerHasReacted: group.viewer }], - ); -} - -/** `gid://gitlab/DiffNote/42` is note 42, which is the id the REST conversation carries. */ -function noteIdOf(gid: string | null | undefined): string | null { - const id = trimmed(gid)?.split("/").at(-1); - return id !== undefined && /^\d+$/.test(id) ? id : null; -} - -export interface GitLabAwardEmojiPage { - /** The merge request's own awards, which are the ones on its description. */ - readonly reactions: ReadonlyArray; - readonly reactionsByNoteId: ReadonlyMap>; - readonly nextCursor: string | null; -} - -export function decodeAwardEmojiJson( - raw: string, -): Result.Result { - const decoded = decodeAwardEmojiPage(raw); - if (!Result.isSuccess(decoded)) { - return Result.fail(decoded.failure); - } - const data = decoded.success.data; - const viewer = trimmed(data.currentUser?.username); - const mergeRequest = data.project?.mergeRequest; - const reactionsByNoteId = new Map>(); - for (const node of mergeRequest?.notes?.nodes ?? []) { - const id = noteIdOf(node?.id); - if (id === null) continue; - const reactions = toReactions(node?.awardEmoji, viewer); - if (reactions.length > 0) reactionsByNoteId.set(id, reactions); - } - const pageInfo = mergeRequest?.notes?.pageInfo; - return Result.succeed({ - reactions: toReactions(mergeRequest?.awardEmoji, viewer), - reactionsByNoteId, - nextCursor: pageInfo?.hasNextPage === true ? (trimmed(pageInfo.endCursor) ?? null) : null, - }); -} - -const RawAwardSchema = Schema.Struct({ - id: Schema.Int, - name: Schema.optional(Schema.NullOr(Schema.String)), - user: Schema.optional( - Schema.NullOr(Schema.Struct({ username: Schema.optional(Schema.NullOr(Schema.String)) })), - ), -}); - -const decodeAward = Schema.decodeUnknownExit(RawAwardSchema); - -/** - * The reader's own award of one name on a subject, which is what taking a reaction back is - * addressed by: GitLab deletes an award by its id and has no way to name one by its emoji. - */ -export function decodeOwnAwardIdJson( - raw: string, - input: { readonly content: PullRequestReactionContent; readonly viewer: string }, -): Result.Result { - const decoded = decodeUnknownList(raw); - if (!Result.isSuccess(decoded)) { - return Result.fail(decoded.failure); - } - const name = gitLabAwardName(input.content); - for (const entry of decoded.success) { - const award = decodeAward(entry); - if (Exit.isFailure(award)) continue; - const value = award.value; - if (trimmed(value.name)?.toLowerCase() !== name) continue; - if (trimmed(value.user?.username) !== input.viewer) continue; - return Result.succeed(value.id); - } - return Result.succeed(null); -} - -/** - * What the given paths are at one revision, as blob ids. Asked for by path rather than by walking - * the tree, since GitLab charges this query by how many paths it is given. A path the revision - * does not have comes back missing rather than as an error, which is the answer for a deleted file. - */ -export const REPOSITORY_BLOBS_GRAPHQL_QUERY = `query($fullPath: ID!, $ref: String!, $paths: [String!]!) { - project(fullPath: $fullPath) { - repository { - blobs(ref: $ref, paths: $paths) { - nodes { path oid } - } - } - } -}`; - -const RawRepositoryBlobsSchema = Schema.Struct({ - data: Schema.Struct({ - project: Schema.NullOr( - Schema.Struct({ - repository: Schema.optional( - Schema.NullOr( - Schema.Struct({ - blobs: Schema.optional( - Schema.NullOr( - Schema.Struct({ - nodes: Schema.optional( - Schema.NullOr( - Schema.Array( - Schema.NullOr( - Schema.Struct({ - path: Schema.optional(Schema.NullOr(Schema.String)), - oid: Schema.optional(Schema.NullOr(Schema.String)), - }), - ), - ), - ), - ), - }), - ), - ), - }), - ), - ), - }), - ), - }), -}); - -const decodeRepositoryBlobs = decodeJsonResult(RawRepositoryBlobsSchema); - -/** - * Blob ids by path, or null where GitLab did not answer the query at all (a project the token - * cannot see, or a repository with no blobs connection). That case must be told apart from an - * empty answer: read as "the revision has none of these files", it would report every cleared - * file as changed again. A node missing either half is left out, since the caller treats an - * absent path as one the revision does not carry. - */ -export function decodeRepositoryBlobsJson( - raw: string, -): Result.Result | null, DecodeFailure> { - const decoded = decodeRepositoryBlobs(raw); - if (!Result.isSuccess(decoded)) { - return Result.fail(decoded.failure); - } - const nodes = decoded.success.data.project?.repository?.blobs?.nodes; - if (nodes === undefined || nodes === null) return Result.succeed(null); - const blobs = new Map(); - for (const node of nodes) { - // Not trimmed, unlike everything else read out of this payload: a leading or trailing space - // is a legal part of a file's name, and trimming it would key this map under a name the - // caller's asked-for path never matches. - const path = node?.path; - const oid = trimmed(node?.oid); - if (path === undefined || path === null || path.length === 0 || oid === null) continue; - blobs.set(path, oid); - } - return Result.succeed(blobs); -} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 82c5de077ef75e1411f17d5ba5927db26ea6c02f..8ea3b5cda1981b9e190c440df1927dd1bc05d869 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -54,7 +54,6 @@ import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; import * as GitHubApi from "./sourceControl/GitHubApi.ts"; -import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; @@ -302,7 +301,6 @@ const layerSourceControlProviderRegistry = SourceControlProviderRegistry.layer.p Layer.mergeAll( BitbucketApi.layer, GitHubApi.layerWithDependencies, - GitLabCli.layer, SourceControlBuiltInDrivers.layer, ), ), diff --git a/apps/server/src/sourceControl/GitLabCli.test.ts b/apps/server/src/sourceControl/GitLabCli.test.ts deleted file mode 100644 index 5a10f13b8f8f4d879734e4bef41000b0747458d2..0000000000000000000000000000000000000000 --- a/apps/server/src/sourceControl/GitLabCli.test.ts +++ /dev/null @@ -1,384 +0,0 @@ -import { assert, it, afterEach, expect, vi } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import { ChildProcessSpawner } from "effect/process"; - -import { VcsProcessExitError } from "@t3tools/contracts"; - -import * as VcsProcess from "../vcs/VcsProcess.ts"; -import * as GitLabCli from "./GitLabCli.ts"; - -const mockedRun = vi.fn(); -const layer = it.layer( - GitLabCli.layer.pipe( - Layer.provide( - Layer.mock(VcsProcess.VcsProcess)({ - run: mockedRun, - }), - ), - ), -); - -function processOutput(stdout: string): VcsProcess.VcsProcessOutput { - return { - exitCode: ChildProcessSpawner.ExitCode(0), - stdout, - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - }; -} - -afterEach(() => { - mockedRun.mockReset(); -}); - -layer("GitLabCli.layer", (it) => { - it.effect("parses merge request view output", () => - Effect.gen(function* () { - mockedRun.mockReturnValueOnce( - Effect.succeed( - processOutput( - JSON.stringify({ - iid: 42, - title: "Add MR thread creation", - web_url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", - target_branch: "main", - source_branch: "feature/mr-threads", - state: "closed", - closed_at: "2026-08-23T10:00:00Z", - source_project_id: 101, - target_project_id: 100, - source_project: { - path_with_namespace: "octocat/t3code", - }, - }), - ), - ), - ); - - const result = yield* Effect.gen(function* () { - const glab = yield* GitLabCli.GitLabCli; - return yield* glab.getMergeRequest({ - cwd: "/repo", - reference: "42", - }); - }); - - assert.deepStrictEqual(result, { - number: 42, - title: "Add MR thread creation", - url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", - baseRefName: "main", - headRefName: "feature/mr-threads", - state: "closed", - closedAt: "2026-08-23T10:00:00Z", - mergedAt: null, - isCrossRepository: true, - headRepositoryNameWithOwner: "octocat/t3code", - headRepositoryOwnerLogin: "octocat", - }); - expect(mockedRun).toHaveBeenCalledWith( - expect.objectContaining({ - command: "glab", - cwd: "/repo", - args: ["mr", "view", "42", "--output", "json"], - }), - ); - }), - ); - - it.effect("skips invalid entries when parsing MR lists", () => - Effect.gen(function* () { - mockedRun.mockReturnValueOnce( - Effect.succeed( - processOutput( - JSON.stringify([ - { - iid: 0, - title: "invalid", - web_url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/0", - target_branch: "main", - source_branch: "feature/invalid", - }, - { - iid: 43, - title: " Valid MR ", - web_url: " https://gitlab.com/pingdotgg/t3code/-/merge_requests/43 ", - target_branch: " main ", - source_branch: " feature/mr-list ", - state: "merged", - merged_at: "2026-08-23T11:00:00Z", - }, - ]), - ), - ), - ); - - const result = yield* Effect.gen(function* () { - const glab = yield* GitLabCli.GitLabCli; - return yield* glab.listMergeRequests({ - cwd: "/repo", - headSelector: "feature/mr-list", - state: "all", - }); - }); - - assert.deepStrictEqual(result, [ - { - number: 43, - title: "Valid MR", - url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/43", - baseRefName: "main", - headRefName: "feature/mr-list", - state: "merged", - closedAt: null, - mergedAt: "2026-08-23T11:00:00Z", - }, - ]); - expect(mockedRun).toHaveBeenCalledWith( - expect.objectContaining({ - command: "glab", - cwd: "/repo", - args: [ - "mr", - "list", - "--source-branch", - "feature/mr-list", - "--all", - "--per-page", - "20", - "--output", - "json", - ], - }), - ); - }), - ); - - it.effect("reads repository clone URLs", () => - Effect.gen(function* () { - mockedRun.mockReturnValueOnce( - Effect.succeed( - processOutput( - JSON.stringify({ - path_with_namespace: "octocat/t3code", - web_url: "https://gitlab.com/octocat/t3code", - http_url_to_repo: "https://gitlab.com/octocat/t3code.git", - ssh_url_to_repo: "git@gitlab.com:octocat/t3code.git", - }), - ), - ), - ); - - const result = yield* Effect.gen(function* () { - const glab = yield* GitLabCli.GitLabCli; - return yield* glab.getRepositoryCloneUrls({ - cwd: "/repo", - repository: "octocat/t3code", - }); - }); - - assert.deepStrictEqual(result, { - nameWithOwner: "octocat/t3code", - url: "https://gitlab.com/octocat/t3code", - sshUrl: "git@gitlab.com:octocat/t3code.git", - }); - }), - ); - - it.effect("creates merge requests through the GitLab API without placing the body in argv", () => - Effect.gen(function* () { - mockedRun.mockReturnValueOnce(Effect.succeed(processOutput("{}"))); - - const glab = yield* GitLabCli.GitLabCli; - yield* glab.createMergeRequest({ - cwd: "/repo", - baseBranch: "main", - headSelector: "owner:feature/provider", - title: "Provider MR", - bodyFile: "/tmp/t3-mr-body.md", - }); - - expect(mockedRun).toHaveBeenCalledWith( - expect.objectContaining({ - command: "glab", - cwd: "/repo", - args: [ - "api", - "--method", - "POST", - "projects/:fullpath/merge_requests", - "--raw-field", - "source_branch=feature/provider", - "--raw-field", - "target_branch=main", - "--raw-field", - "title=Provider MR", - "--field", - "description=@/tmp/t3-mr-body.md", - ], - }), - ); - }), - ); - - it.effect("creates repositories under an explicit namespace", () => - Effect.gen(function* () { - mockedRun - - .mockReturnValueOnce(Effect.succeed(processOutput(JSON.stringify({ id: 1234 })))) - .mockReturnValueOnce( - Effect.succeed( - processOutput( - JSON.stringify({ - path_with_namespace: "octocat/t3code", - web_url: "https://gitlab.com/octocat/t3code", - http_url_to_repo: "https://gitlab.com/octocat/t3code.git", - ssh_url_to_repo: "git@gitlab.com:octocat/t3code.git", - }), - ), - ), - ); - - const glab = yield* GitLabCli.GitLabCli; - const result = yield* glab.createRepository({ - cwd: "/repo", - repository: "octocat/t3code", - visibility: "public", - }); - - assert.deepStrictEqual(result, { - nameWithOwner: "octocat/t3code", - url: "https://gitlab.com/octocat/t3code", - sshUrl: "git@gitlab.com:octocat/t3code.git", - }); - expect(mockedRun).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - command: "glab", - cwd: "/repo", - args: ["api", "namespaces/octocat"], - }), - ); - expect(mockedRun).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - command: "glab", - cwd: "/repo", - args: [ - "api", - "--method", - "POST", - "projects", - "--raw-field", - "path=t3code", - "--raw-field", - "name=t3code", - "--raw-field", - "visibility=public", - "--raw-field", - "namespace_id=1234", - ], - }), - ); - }), - ); - - it.effect("does not pass unsupported force flags when checking out merge requests", () => - Effect.gen(function* () { - mockedRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); - - const glab = yield* GitLabCli.GitLabCli; - yield* glab.checkoutMergeRequest({ - cwd: "/repo", - reference: "42", - force: true, - }); - - expect(mockedRun).toHaveBeenCalledWith( - expect.objectContaining({ - command: "glab", - cwd: "/repo", - args: ["mr", "checkout", "42"], - }), - ); - }), - ); - - it.effect("surfaces a friendly error when the merge request is not found", () => - Effect.gen(function* () { - const cause = new VcsProcessExitError({ - operation: "GitLabCli.execute", - command: "glab", - cwd: "/repo", - exitCode: 1, - detail: "GET 404 merge request not found", - failureKind: "not-found", - }); - mockedRun.mockReturnValueOnce(Effect.fail(cause)); - - const error = yield* Effect.gen(function* () { - const glab = yield* GitLabCli.GitLabCli; - return yield* glab.getMergeRequest({ - cwd: "/repo", - reference: "4888", - }); - }).pipe(Effect.flip); - - assert.equal(error.message.includes("Merge request 4888 was not found"), true); - assert.strictEqual(error._tag, "GitLabMergeRequestNotFoundError"); - assert.strictEqual(error.command, "glab"); - assert.strictEqual(error.cwd, "/repo"); - assert.strictEqual(error.cause, cause); - assert.equal(error.message.includes(cause.detail), false); - }), - ); - - it.effect("keeps non-merge-request not-found failures generic", () => - Effect.gen(function* () { - const cause = new VcsProcessExitError({ - operation: "GitLabCli.execute", - command: "glab", - cwd: "/repo", - exitCode: 1, - detail: "GET 404 project not found", - failureKind: "not-found", - }); - mockedRun.mockReturnValueOnce(Effect.fail(cause)); - - const error = yield* Effect.gen(function* () { - const glab = yield* GitLabCli.GitLabCli; - return yield* glab.getRepositoryCloneUrls({ - cwd: "/repo", - repository: "missing/project", - }); - }).pipe(Effect.flip); - - assert.strictEqual(error._tag, "GitLabCliCommandError"); - assert.strictEqual(error.cause, cause); - }), - ); - - it.effect("preserves rate-limit failures as a distinct error", () => - Effect.gen(function* () { - const cause = new VcsProcessExitError({ - operation: "GitLabCli.execute", - command: "glab", - cwd: "/repo", - exitCode: 1, - detail: "API rate limit exceeded.", - failureKind: "rate-limited", - }); - mockedRun.mockReturnValueOnce(Effect.fail(cause)); - - const glab = yield* GitLabCli.GitLabCli; - const error = yield* glab - .execute({ cwd: "/repo", args: ["api", "projects"] }) - .pipe(Effect.flip); - - assert.strictEqual(error._tag, "GitLabCliRateLimitError"); - assert.strictEqual(error.cause, cause); - }), - ); -}); diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts deleted file mode 100644 index 2ec69a527fedffa9f1f78dd0cfa74a8a947992d1..0000000000000000000000000000000000000000 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ /dev/null @@ -1,660 +0,0 @@ -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Match from "effect/Match"; -import * as Option from "effect/Option"; -import * as Result from "effect/Result"; -import * as Schema from "effect/Schema"; -import type * as DateTime from "effect/DateTime"; - -import { - TrimmedNonEmptyString, - type SourceControlRepositoryVisibility, - type VcsError, -} from "@t3tools/contracts"; - -import * as VcsProcess from "../vcs/VcsProcess.ts"; -import { - decodeGitLabMergeRequestJson, - decodeGitLabMergeRequestListJson, -} from "./gitLabMergeRequests.ts"; -import type * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; - -const DEFAULT_TIMEOUT_MS = 30_000; - -const gitLabCliExecutionErrorContext = { - operation: Schema.Literal("execute"), - command: Schema.Literal("glab"), - cwd: Schema.String, - cause: Schema.Defect(), -}; - -const gitLabCliDecodeErrorContext = { - command: Schema.Literal("glab"), - cwd: Schema.String, - cause: Schema.Defect(), -}; - -export class GitLabCliUnavailableError extends Schema.TaggedError()( - "GitLabCliUnavailableError", - gitLabCliExecutionErrorContext, -) { - get detail(): string { - return "GitLab CLI (`glab`) is required but not available on PATH."; - } - - override get message(): string { - return `GitLab CLI failed in ${this.operation}: ${this.detail}`; - } -} - -export class GitLabCliAuthenticationError extends Schema.TaggedError()( - "GitLabCliAuthenticationError", - gitLabCliExecutionErrorContext, -) { - get detail(): string { - return "GitLab CLI is not authenticated. Run `glab auth login` and retry."; - } - - override get message(): string { - return `GitLab CLI failed in ${this.operation}: ${this.detail}`; - } -} - -export class GitLabCliRateLimitError extends Schema.TaggedError()( - "GitLabCliRateLimitError", - gitLabCliExecutionErrorContext, -) { - get detail(): string { - return "GitLab API rate limit exceeded."; - } - - override get message(): string { - return `GitLab CLI failed in ${this.operation}: ${this.detail}`; - } -} - -export class GitLabMergeRequestNotFoundError extends Schema.TaggedError()( - "GitLabMergeRequestNotFoundError", - { - ...gitLabCliExecutionErrorContext, - reference: Schema.String, - }, -) { - get detail(): string { - return `Merge request ${this.reference} was not found. Check the MR number or URL and try again.`; - } - - override get message(): string { - return `GitLab CLI failed in ${this.operation}: ${this.detail}`; - } - - static fromVcsError( - context: { - readonly operation: "execute"; - readonly command: "glab"; - readonly cwd: string; - readonly reference: string; - }, - error: VcsError, - ): GitLabCliError { - if (error._tag === "VcsProcessExitError" && error.failureKind === "not-found") { - return new GitLabMergeRequestNotFoundError({ ...context, cause: error }); - } - - return GitLabCliCommandError.fromVcsError( - { - operation: context.operation, - command: context.command, - cwd: context.cwd, - }, - error, - ); - } -} - -export class GitLabCliCommandError extends Schema.TaggedError()( - "GitLabCliCommandError", - gitLabCliExecutionErrorContext, -) { - get detail(): string { - return "GitLab CLI command failed."; - } - - override get message(): string { - return `GitLab CLI failed in ${this.operation}: ${this.detail}`; - } - - static fromVcsError( - context: { - readonly operation: "execute"; - readonly command: "glab"; - readonly cwd: string; - }, - error: VcsError, - ): GitLabCliError { - return Match.valueTags(error, { - VcsProcessSpawnError: (cause) => new GitLabCliUnavailableError({ ...context, cause }), - VcsProcessExitError: (cause) => { - switch (cause.failureKind) { - case "authentication": - return new GitLabCliAuthenticationError({ ...context, cause }); - case "rate-limited": - return new GitLabCliRateLimitError({ ...context, cause }); - case "not-found": - case "command-failed": - case undefined: - return new GitLabCliCommandError({ ...context, cause }); - } - }, - VcsProcessTimeoutError: (cause) => new GitLabCliCommandError({ ...context, cause }), - VcsProcessStdinWriteError: (cause) => new GitLabCliCommandError({ ...context, cause }), - VcsProcessOutputReadError: (cause) => new GitLabCliCommandError({ ...context, cause }), - VcsProcessOutputLimitError: (cause) => new GitLabCliCommandError({ ...context, cause }), - VcsProcessMissingExitCodeError: (cause) => new GitLabCliCommandError({ ...context, cause }), - VcsRepositoryDetectionError: (cause) => new GitLabCliCommandError({ ...context, cause }), - VcsUnsupportedOperationError: (cause) => new GitLabCliCommandError({ ...context, cause }), - }); - } -} - -export class GitLabMergeRequestListDecodeError extends Schema.TaggedError()( - "GitLabMergeRequestListDecodeError", - { - ...gitLabCliDecodeErrorContext, - operation: Schema.Literal("listMergeRequests"), - }, -) { - get detail(): string { - return "GitLab CLI returned invalid MR list JSON."; - } - - override get message(): string { - return `GitLab CLI failed in ${this.operation}: ${this.detail}`; - } -} - -export class GitLabMergeRequestDecodeError extends Schema.TaggedError()( - "GitLabMergeRequestDecodeError", - { - ...gitLabCliDecodeErrorContext, - operation: Schema.Literal("getMergeRequest"), - reference: Schema.String, - }, -) { - get detail(): string { - return "GitLab CLI returned invalid merge request JSON."; - } - - override get message(): string { - return `GitLab CLI failed in ${this.operation}: ${this.detail}`; - } -} - -export class GitLabRepositoryDecodeError extends Schema.TaggedError()( - "GitLabRepositoryDecodeError", - { - ...gitLabCliDecodeErrorContext, - operation: Schema.Literals(["getRepositoryCloneUrls", "createRepository", "getDefaultBranch"]), - repository: Schema.optional(Schema.String), - }, -) { - get detail(): string { - return "GitLab CLI returned invalid repository JSON."; - } - - override get message(): string { - return `GitLab CLI failed in ${this.operation}: ${this.detail}`; - } -} - -export class GitLabNamespaceDecodeError extends Schema.TaggedError()( - "GitLabNamespaceDecodeError", - { - ...gitLabCliDecodeErrorContext, - operation: Schema.Literal("createRepository"), - namespacePath: Schema.String, - }, -) { - get detail(): string { - return "GitLab CLI returned invalid namespace JSON."; - } - - override get message(): string { - return `GitLab CLI failed in ${this.operation}: ${this.detail}`; - } -} - -export const GitLabCliError = Schema.Union([ - GitLabCliUnavailableError, - GitLabCliAuthenticationError, - GitLabCliRateLimitError, - GitLabMergeRequestNotFoundError, - GitLabCliCommandError, - GitLabMergeRequestListDecodeError, - GitLabMergeRequestDecodeError, - GitLabRepositoryDecodeError, - GitLabNamespaceDecodeError, -]); -export type GitLabCliError = typeof GitLabCliError.Type; - -export interface GitLabMergeRequestSummary { - readonly number: number; - readonly title: string; - readonly url: string; - readonly baseRefName: string; - readonly headRefName: string; - readonly state?: "open" | "closed" | "merged"; - readonly isDraft?: boolean; - readonly closedAt?: string | null; - readonly mergedAt?: string | null; - readonly updatedAt?: Option.Option; - readonly isCrossRepository?: boolean; - readonly headRepositoryNameWithOwner?: string | null; - readonly headRepositoryOwnerLogin?: string | null; -} - -export interface GitLabRepositoryCloneUrls { - readonly nameWithOwner: string; - readonly url: string; - readonly sshUrl: string; -} - -export class GitLabCli extends Context.Service< - GitLabCli, - { - readonly execute: (input: { - readonly cwd: string; - readonly args: ReadonlyArray; - readonly timeoutMs?: number; - /** Piped to the child's stdin, for payloads that must never appear in argv. */ - readonly stdin?: string; - readonly maxOutputBytes?: number; - }) => Effect.Effect; - - readonly listMergeRequests: (input: { - readonly cwd: string; - readonly headSelector: string; - readonly source?: SourceControlProvider.SourceControlRefSelector; - readonly state: "open" | "closed" | "merged" | "all"; - readonly limit?: number; - }) => Effect.Effect, GitLabCliError>; - - readonly getMergeRequest: (input: { - readonly cwd: string; - readonly reference: string; - }) => Effect.Effect; - - readonly getRepositoryCloneUrls: (input: { - readonly cwd: string; - readonly repository: string; - }) => Effect.Effect; - - readonly createRepository: (input: { - readonly cwd: string; - readonly repository: string; - readonly visibility: SourceControlRepositoryVisibility; - }) => Effect.Effect; - - readonly createMergeRequest: (input: { - readonly cwd: string; - readonly baseBranch: string; - readonly headSelector: string; - readonly source?: SourceControlProvider.SourceControlRefSelector; - readonly target?: SourceControlProvider.SourceControlRefSelector; - readonly title: string; - readonly bodyFile: string; - }) => Effect.Effect; - - readonly getDefaultBranch: (input: { - readonly cwd: string; - }) => Effect.Effect; - - readonly checkoutMergeRequest: (input: { - readonly cwd: string; - readonly reference: string; - readonly force?: boolean; - }) => Effect.Effect; - } ->()("t3/sourceControl/GitLabCli") {} - -const RawGitLabRepositoryCloneUrlsSchema = Schema.Struct({ - path_with_namespace: TrimmedNonEmptyString, - web_url: TrimmedNonEmptyString, - http_url_to_repo: TrimmedNonEmptyString, - ssh_url_to_repo: TrimmedNonEmptyString, -}); - -const RawGitLabDefaultBranchSchema = Schema.Struct({ - default_branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), -}); - -const RawGitLabNamespaceSchema = Schema.Struct({ - id: Schema.Number, -}); - -const decodeGitLabRepositoryCloneUrls = Schema.decodeEffect( - Schema.fromJsonString(RawGitLabRepositoryCloneUrlsSchema), -); -const decodeGitLabDefaultBranch = Schema.decodeEffect( - Schema.fromJsonString(RawGitLabDefaultBranchSchema), -); -const decodeGitLabNamespace = Schema.decodeEffect(Schema.fromJsonString(RawGitLabNamespaceSchema)); - -function normalizeRepositoryCloneUrls( - raw: Schema.Schema.Type, -): GitLabRepositoryCloneUrls { - return { - nameWithOwner: raw.path_with_namespace, - url: raw.web_url, - sshUrl: raw.ssh_url_to_repo, - }; -} - -function stateArgs(state: "open" | "closed" | "merged" | "all"): ReadonlyArray { - switch (state) { - case "open": - return []; - case "closed": - return ["--closed"]; - case "merged": - return ["--merged"]; - case "all": - return ["--all"]; - } -} - -function normalizeHeadSelector(headSelector: string): string { - const trimmed = headSelector.trim(); - const ownerBranch = /^[^:]+:(.+)$/.exec(trimmed); - return ownerBranch?.[1]?.trim() || trimmed; -} - -function sourceRefName(input: { - readonly headSelector: string; - readonly source?: SourceControlProvider.SourceControlRefSelector; -}): string { - return input.source?.refName ?? normalizeHeadSelector(input.headSelector); -} - -function sourceProjectIdentifier( - source: SourceControlProvider.SourceControlRefSelector | undefined, -): string | null { - return source?.repository ?? source?.owner ?? null; -} - -function toSummaryWithOptionalUpdatedAt( - record: GitLabMergeRequestSummary & { - readonly updatedAt: Option.Option; - }, -): GitLabMergeRequestSummary { - const { updatedAt, ...summary } = record; - return Option.isSome(updatedAt) ? { ...summary, updatedAt } : summary; -} - -function parseRepositoryPath(repository: string): { - readonly namespacePath: string | null; - readonly projectPath: string; -} { - const parts: Array = []; - for (const part of repository.split("/")) { - const trimmed = part.trim(); - if (trimmed.length > 0) { - parts.push(trimmed); - } - } - const projectPath = parts.at(-1) ?? repository.trim(); - const namespacePath = parts.length > 1 ? parts.slice(0, -1).join("/") : null; - return { namespacePath, projectPath }; -} - -/** @public Service construction is part of the canonical Effect module API. */ -export const make = Effect.gen(function* () { - const process = yield* VcsProcess.VcsProcess; - - const run = ( - input: Parameters[0], - mapError: (error: VcsError) => GitLabCliError, - ) => - process - .run({ - operation: "GitLabCli.execute", - command: "glab", - args: input.args, - cwd: input.cwd, - timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, - ...(input.stdin === undefined ? {} : { stdin: input.stdin }), - ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), - }) - .pipe(Effect.mapError(mapError)); - - const execute: GitLabCli["Service"]["execute"] = (input) => - run(input, (error) => - GitLabCliCommandError.fromVcsError( - { operation: "execute", command: "glab", cwd: input.cwd }, - error, - ), - ); - - const executeMergeRequest = (input: { - readonly cwd: string; - readonly reference: string; - readonly args: ReadonlyArray; - }) => - run(input, (error) => - GitLabMergeRequestNotFoundError.fromVcsError( - { - operation: "execute", - command: "glab", - cwd: input.cwd, - reference: input.reference, - }, - error, - ), - ); - - return GitLabCli.of({ - execute, - listMergeRequests: (input) => - execute({ - cwd: input.cwd, - args: [ - "mr", - "list", - "--source-branch", - sourceRefName(input), - ...stateArgs(input.state), - "--per-page", - String(input.limit ?? 20), - "--output", - "json", - ], - }).pipe( - Effect.map((result) => result.stdout.trim()), - Effect.flatMap((raw) => - raw.length === 0 - ? Effect.succeed([]) - : Effect.sync(() => decodeGitLabMergeRequestListJson(raw)).pipe( - Effect.flatMap((decoded) => { - if (!Result.isSuccess(decoded)) { - return Effect.fail( - new GitLabMergeRequestListDecodeError({ - operation: "listMergeRequests", - command: "glab", - cwd: input.cwd, - cause: decoded.failure, - }), - ); - } - - return Effect.succeed(decoded.success.map(toSummaryWithOptionalUpdatedAt)); - }), - ), - ), - ), - getMergeRequest: (input) => - executeMergeRequest({ - cwd: input.cwd, - reference: input.reference, - args: ["mr", "view", input.reference, "--output", "json"], - }).pipe( - Effect.map((result) => result.stdout.trim()), - Effect.flatMap((raw) => - Effect.sync(() => decodeGitLabMergeRequestJson(raw)).pipe( - Effect.flatMap((decoded) => { - if (!Result.isSuccess(decoded)) { - return Effect.fail( - new GitLabMergeRequestDecodeError({ - operation: "getMergeRequest", - command: "glab", - cwd: input.cwd, - reference: input.reference, - cause: decoded.failure, - }), - ); - } - - return Effect.succeed(toSummaryWithOptionalUpdatedAt(decoded.success)); - }), - ), - ), - ), - getRepositoryCloneUrls: (input) => - execute({ - cwd: input.cwd, - args: ["api", `projects/${encodeURIComponent(input.repository)}`], - }).pipe( - Effect.map((result) => result.stdout.trim()), - Effect.flatMap((raw) => - decodeGitLabRepositoryCloneUrls(raw).pipe( - Effect.mapError( - (cause) => - new GitLabRepositoryDecodeError({ - operation: "getRepositoryCloneUrls", - command: "glab", - cwd: input.cwd, - repository: input.repository, - cause, - }), - ), - ), - ), - Effect.map(normalizeRepositoryCloneUrls), - ), - createRepository: (input) => { - const { namespacePath, projectPath } = parseRepositoryPath(input.repository); - const namespaceId: Effect.Effect = namespacePath - ? execute({ - cwd: input.cwd, - args: ["api", `namespaces/${encodeURIComponent(namespacePath)}`], - }).pipe( - Effect.map((result) => result.stdout.trim()), - Effect.flatMap((raw) => - decodeGitLabNamespace(raw).pipe( - Effect.mapError( - (cause) => - new GitLabNamespaceDecodeError({ - operation: "createRepository", - command: "glab", - cwd: input.cwd, - namespacePath, - cause, - }), - ), - ), - ), - Effect.map((namespace) => namespace.id), - ) - : Effect.succeed(null); - - return namespaceId.pipe( - Effect.flatMap((resolvedNamespaceId) => - execute({ - cwd: input.cwd, - args: [ - "api", - "--method", - "POST", - "projects", - "--raw-field", - `path=${projectPath}`, - "--raw-field", - `name=${projectPath}`, - "--raw-field", - `visibility=${input.visibility}`, - ...(resolvedNamespaceId === null - ? [] - : ["--raw-field", `namespace_id=${resolvedNamespaceId}`]), - ], - }), - ), - Effect.map((result) => result.stdout.trim()), - Effect.flatMap((raw) => - decodeGitLabRepositoryCloneUrls(raw).pipe( - Effect.mapError( - (cause) => - new GitLabRepositoryDecodeError({ - operation: "createRepository", - command: "glab", - cwd: input.cwd, - repository: input.repository, - cause, - }), - ), - ), - ), - Effect.map(normalizeRepositoryCloneUrls), - ); - }, - createMergeRequest: (input) => { - const sourceProject = sourceProjectIdentifier(input.source); - return execute({ - cwd: input.cwd, - args: [ - "api", - "--method", - "POST", - "projects/:fullpath/merge_requests", - "--raw-field", - `source_branch=${sourceRefName(input)}`, - "--raw-field", - `target_branch=${input.target?.refName ?? input.baseBranch}`, - ...(sourceProject ? ["--raw-field", `source_project_id=${sourceProject}`] : []), - "--raw-field", - `title=${input.title}`, - "--field", - `description=@${input.bodyFile}`, - ], - }).pipe(Effect.asVoid); - }, - getDefaultBranch: (input) => - execute({ - cwd: input.cwd, - args: ["api", "projects/:fullpath"], - }).pipe( - Effect.map((result) => result.stdout.trim()), - Effect.flatMap((raw) => - decodeGitLabDefaultBranch(raw).pipe( - Effect.mapError( - (cause) => - new GitLabRepositoryDecodeError({ - operation: "getDefaultBranch", - command: "glab", - cwd: input.cwd, - cause, - }), - ), - ), - ), - Effect.map((value) => value.default_branch ?? null), - ), - checkoutMergeRequest: (input) => - executeMergeRequest({ - cwd: input.cwd, - reference: input.reference, - args: ["mr", "checkout", input.reference], - }).pipe(Effect.asVoid), - }); -}); - -export const layer = Layer.effect(GitLabCli, make); diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts deleted file mode 100644 index 513c2e2fc3b5070f14ed7b0ebefff489441f5517..0000000000000000000000000000000000000000 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts +++ /dev/null @@ -1,309 +0,0 @@ -import { assert, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import { ChildProcessSpawner } from "effect/process"; - -import * as GitLabCli from "./GitLabCli.ts"; -import { parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts"; -import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; - -function makeProvider(gitlab: Partial) { - return GitLabSourceControlProvider.make.pipe( - Effect.provide(Layer.mock(GitLabCli.GitLabCli)(gitlab)), - ); -} - -it.effect("maps GitLab MR summaries into provider-neutral change requests", () => - Effect.gen(function* () { - const provider = yield* makeProvider({ - getMergeRequest: () => - Effect.succeed({ - number: 42, - title: "Add GitLab provider", - url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", - baseRefName: "main", - headRefName: "feature/source-control", - state: "closed", - closedAt: "2026-08-23T10:00:00Z", - isCrossRepository: true, - headRepositoryNameWithOwner: "fork/t3code", - headRepositoryOwnerLogin: "fork", - }), - }); - - const changeRequest = yield* provider.getChangeRequest({ - cwd: "/repo", - reference: "42", - }); - - assert.deepStrictEqual(changeRequest, { - provider: "gitlab", - number: 42, - title: "Add GitLab provider", - url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", - baseRefName: "main", - headRefName: "feature/source-control", - state: "closed", - closedAt: "2026-08-23T10:00:00Z", - mergedAt: null, - updatedAt: Option.none(), - isCrossRepository: true, - headRepositoryNameWithOwner: "fork/t3code", - headRepositoryOwnerLogin: "fork", - }); - }), -); - -it.effect("adds repository context while retaining GitLab CLI causes", () => - Effect.gen(function* () { - const cause = new GitLabCli.GitLabCliCommandError({ - operation: "execute", - command: "glab", - cwd: "/repo", - cause: new Error("raw upstream detail that should remain in the cause"), - }); - const provider = yield* makeProvider({ - createRepository: () => Effect.fail(cause), - }); - - const error = yield* provider - .createRepository({ - cwd: "/repo", - repository: "owner/repo", - visibility: "private", - }) - .pipe(Effect.flip); - - assert.deepStrictEqual( - { - provider: error.provider, - operation: error.operation, - command: error.command, - cwd: error.cwd, - repository: error.repository, - detail: error.detail, - }, - { - provider: "gitlab", - operation: "createRepository", - command: "glab", - cwd: "/repo", - repository: "owner/repo", - detail: "GitLab CLI command failed.", - }, - ); - assert.strictEqual(error.cause, cause); - assert.equal(error.message.includes("raw upstream detail"), false); - }), -); - -it.effect("lists GitLab MRs through provider-neutral input names", () => - Effect.gen(function* () { - let listInput: Parameters[0] | null = null; - const provider = yield* makeProvider({ - listMergeRequests: (input) => { - listInput = input; - return Effect.succeed([]); - }, - }); - - yield* provider.listChangeRequests({ - cwd: "/repo", - headSelector: "feature/provider", - state: "all", - limit: 10, - }); - - assert.deepStrictEqual(listInput, { - cwd: "/repo", - headSelector: "feature/provider", - state: "all", - limit: 10, - }); - }), -); - -it.effect("creates GitLab MRs through provider-neutral input names", () => - Effect.gen(function* () { - let createInput: Parameters[0] | null = - null; - const provider = yield* makeProvider({ - createMergeRequest: (input) => { - createInput = input; - return Effect.void; - }, - }); - - yield* provider.createChangeRequest({ - cwd: "/repo", - baseRefName: "main", - headSelector: "owner:feature/provider", - title: "Provider MR", - bodyFile: "/tmp/body.md", - }); - - assert.deepStrictEqual(createInput, { - cwd: "/repo", - baseBranch: "main", - headSelector: "owner:feature/provider", - source: { - owner: "owner", - refName: "feature/provider", - }, - title: "Provider MR", - bodyFile: "/tmp/body.md", - }); - }), -); - -it("accepts authenticated GitLab hosts when another configured host fails", () => { - const auth = GitLabSourceControlProvider.discovery.parseAuth({ - exitCode: ChildProcessSpawner.ExitCode(1), - stdout: `gitlab.com - x gitlab.com: API call failed: 401 Unauthorized - ! No token found -self-hosted.example.test - ✓ Logged in to self-hosted.example.test as gitlab-user - ✓ Token found: ****** -`, - stderr: "", - }); - - assert.deepStrictEqual( - { - status: auth.status, - account: auth.account, - host: auth.host, - }, - { - status: "authenticated", - account: Option.some("gitlab-user"), - host: Option.some("self-hosted.example.test"), - }, - ); -}); - -it("refines unknown GitLab remotes with mixed-case provider hosts", () => { - const provider = GitLabSourceControlProvider.discovery.refineUnknownRemote?.({ - cwd: "/repo", - context: { - provider: { - kind: "unknown", - name: "Self-Hosted.Example.Test", - baseUrl: "https://Self-Hosted.Example.Test", - }, - remoteName: "origin", - remoteUrl: "https://Self-Hosted.Example.Test/group/project.git", - }, - auth: { - exitCode: ChildProcessSpawner.ExitCode(0), - stdout: `self-hosted.example.test - ✓ Logged in to self-hosted.example.test as gitlab-user - ✓ Token found: ****** -`, - stderr: "", - }, - }); - - assert.deepStrictEqual(provider, { - kind: "gitlab", - name: "GitLab Self-Hosted", - baseUrl: "https://Self-Hosted.Example.Test", - }); -}); - -it("parses authenticated GitLab auth status hosts with ports and single-label names", () => { - assert.deepStrictEqual( - parseGitLabAuthStatusHosts(`localhost:8080 - ✓ Logged in to localhost:8080 as local-user -selfhosted - ✓ Logged in to selfhosted as single-label-user -`), - [ - { host: "localhost:8080", account: "local-user" }, - { host: "selfhosted", account: "single-label-user" }, - ], - ); -}); - -it.effect.each(["merge_requests", "issues"])( - "resolves %s subjects on the linked host without using the checkout", - (kind) => - Effect.gen(function* () { - const provider = yield* makeProvider({ - execute: (input) => { - assert.deepStrictEqual(input.args, [ - "api", - "--hostname", - "gitlab.com", - `projects/group%2Fsubgroup%2Fproject/${kind}/42`, - ]); - assert.strictEqual(input.maxOutputBytes, 32_000); - assert.strictEqual(input.timeoutMs, 3_000); - return Effect.succeed({ - exitCode: ChildProcessSpawner.ExitCode(0), - stdout: JSON.stringify({ - title: "Pairing expiry", - description: "Preserve remote access", - }), - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - }); - }, - }); - const lookup = provider.resolveLink?.({ - cwd: "/unrelated", - url: new URL(`https://gitlab.com/group/subgroup/project/-/${kind}/42`), - }); - assert.ok(lookup); - assert.deepStrictEqual(yield* lookup, { - title: "Pairing expiry", - body: "Preserve remote access", - }); - assert.strictEqual( - provider.resolveLink?.({ - cwd: "/unrelated", - url: new URL("https://gitlab.com/owner/repo"), - }), - undefined, - ); - }), -); - -it.effect.each(["read", "decode"] as const)( - "retains the %s failure without exposing its raw contents", - (stage) => - Effect.gen(function* () { - const cause = new GitLabCli.GitLabCliCommandError({ - command: "glab", - cwd: "/repo", - operation: "execute", - cause: new Error("private response text"), - }); - const provider = yield* makeProvider({ - execute: () => - stage === "read" - ? Effect.fail(cause) - : Effect.succeed({ - exitCode: ChildProcessSpawner.ExitCode(0), - stdout: "private response text", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - }), - }); - const lookup = provider.resolveLink?.({ - cwd: "/repo", - url: new URL("https://gitlab.com/owner/repo/-/issues/42"), - }); - assert.ok(lookup); - const error = yield* Effect.flip(lookup); - assert.strictEqual(error.operation, stage === "read" ? "resolveLink" : "resolveLink.decode"); - assert.strictEqual(error.detail, "The linked subject could not be read."); - assert.notInclude(error.message, "private response text"); - if (stage === "read") assert.strictEqual(error.cause, cause); - else assert.propertyVal(error.cause, "_tag", "SchemaError"); - }), -); diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts deleted file mode 100644 index ef63f11cfdf24553ef43de44df9d89d29c0eb77b..0000000000000000000000000000000000000000 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts +++ /dev/null @@ -1,308 +0,0 @@ -import * as Schema from "effect/Schema"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; - -import * as GitLabCli from "./GitLabCli.ts"; -import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; -import { - combinedAuthOutput, - firstSafeAuthLine, - matchFirst, - parseCliHost, - providerAuth, - type SourceControlAuthProbeInput, - type SourceControlCliDiscoverySpec, - type SourceControlUnknownRemoteRefinementInput, -} from "@t3tools/source-control-core/server/discovery"; -import { findAuthenticatedGitLabHost, parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts"; - -const decodeLinkSubject = Schema.decodeUnknownEffect( - Schema.fromJsonString( - Schema.Struct({ title: Schema.String, description: Schema.NullOr(Schema.String) }), - ), -); - -function toChangeRequest(summary: GitLabCli.GitLabMergeRequestSummary): ChangeRequest { - return { - provider: "gitlab", - number: summary.number, - title: summary.title, - url: summary.url, - baseRefName: summary.baseRefName, - headRefName: summary.headRefName, - state: summary.state ?? "open", - ...(summary.isDraft === true ? { isDraft: true } : {}), - closedAt: summary.closedAt ?? null, - mergedAt: summary.mergedAt ?? null, - updatedAt: summary.updatedAt ?? Option.none(), - ...(summary.isCrossRepository !== undefined - ? { isCrossRepository: summary.isCrossRepository } - : {}), - ...(summary.headRepositoryNameWithOwner !== undefined - ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner } - : {}), - ...(summary.headRepositoryOwnerLogin !== undefined - ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin } - : {}), - }; -} - -function parseGitLabAuth(input: SourceControlAuthProbeInput) { - const output = combinedAuthOutput(input); - const authenticatedHost = findAuthenticatedGitLabHost(parseGitLabAuthStatusHosts(output)); - const account = - authenticatedHost?.account ?? - matchFirst(output, [ - /Logged in to .* as\s+([^\s(]+)/iu, - /Logged in to .* account\s+([^\s(]+)/iu, - /account:\s*([^\s(]+)/iu, - ]); - const host = authenticatedHost?.host ?? parseCliHost(output); - - if (account) { - return providerAuth({ status: "authenticated", account, host }); - } - - if (input.exitCode !== 0) { - return providerAuth({ - status: "unauthenticated", - host, - detail: firstSafeAuthLine(output) ?? "Run `glab auth login` to authenticate GitLab CLI.", - }); - } - - return providerAuth({ - status: "unknown", - host, - detail: firstSafeAuthLine(output) ?? "GitLab CLI auth status could not be parsed.", - }); -} - -function refineUnknownGitLabRemote(input: SourceControlUnknownRemoteRefinementInput) { - const host = input.context.provider.name.toLowerCase(); - const authenticated = parseGitLabAuthStatusHosts(combinedAuthOutput(input.auth)).some( - (entry) => entry.account !== null && entry.host === host, - ); - - if (!authenticated) { - return null; - } - - return { - kind: "gitlab", - name: "GitLab Self-Hosted", - baseUrl: input.context.provider.baseUrl, - } as const; -} - -export const discovery = { - type: "cli", - kind: "gitlab", - label: "GitLab", - executable: "glab", - versionArgs: ["--version"], - authArgs: ["auth", "status"], - parseAuth: parseGitLabAuth, - refineUnknownRemote: refineUnknownGitLabRemote, - installHint: - "Install the GitLab command-line tool (`glab`) from https://gitlab.com/gitlab-org/cli or your package manager (for example `brew install glab`).", -} satisfies SourceControlCliDiscoverySpec; - -export const make = Effect.gen(function* () { - const gitlab = yield* GitLabCli.GitLabCli; - - const readLinkSubject = Effect.fn("GitLabSourceControlProvider.readLinkSubject")(function* ( - input: { readonly cwd: string; readonly url: URL }, - endpoint: string, - ) { - const result = yield* gitlab - .execute({ - cwd: input.cwd, - args: ["api", "--hostname", input.url.host, endpoint], - timeoutMs: 3_000, - maxOutputBytes: 32_000, - }) - .pipe( - Effect.mapError( - (cause) => - new SourceControlProviderError({ - provider: "gitlab", - operation: "resolveLink", - cwd: input.cwd, - detail: "The linked subject could not be read.", - cause, - }), - ), - ); - const subject = yield* decodeLinkSubject(result.stdout).pipe( - Effect.mapError( - (cause) => - new SourceControlProviderError({ - provider: "gitlab", - operation: "resolveLink.decode", - cwd: input.cwd, - detail: "The linked subject could not be read.", - cause, - }), - ), - ); - return { title: subject.title, body: subject.description }; - }); - - return SourceControlProvider.SourceControlProvider.of({ - kind: "gitlab", - resolveLink: (input) => { - // Automatic enrichment must not send ambient CLI credentials to a host from message text. - if (input.url.host !== "gitlab.com") return undefined; - const match = /^\/(.+)\/-\/(merge_requests|issues)\/([1-9]\d*)(?:\/.*)?$/.exec( - input.url.pathname, - ); - if (!match) return undefined; - return readLinkSubject( - input, - `projects/${encodeURIComponent(match[1]!)}/${match[2]}/${match[3]}`, - ); - }, - listChangeRequests: (input) => { - const source = SourceControlProvider.sourceControlRefFromInput(input); - return gitlab - .listMergeRequests({ - cwd: input.cwd, - headSelector: input.headSelector, - ...(source ? { source } : {}), - state: input.state, - ...(input.limit !== undefined ? { limit: input.limit } : {}), - }) - .pipe( - Effect.map((items) => items.map(toChangeRequest)), - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "gitlab", - operation: "listChangeRequests", - command: error.command, - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.headSelector, - ), - detail: error.detail, - cause: error, - }), - ), - ); - }, - getChangeRequest: (input) => - gitlab.getMergeRequest(input).pipe( - Effect.map(toChangeRequest), - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "gitlab", - operation: "getChangeRequest", - command: error.command, - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.reference, - ), - detail: error.detail, - cause: error, - }), - ), - ), - createChangeRequest: (input) => { - const source = SourceControlProvider.sourceControlRefFromInput(input); - return gitlab - .createMergeRequest({ - cwd: input.cwd, - baseBranch: input.baseRefName, - headSelector: input.headSelector, - ...(source ? { source } : {}), - ...(input.target ? { target: input.target } : {}), - title: input.title, - bodyFile: input.bodyFile, - }) - .pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "gitlab", - operation: "createChangeRequest", - command: error.command, - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.headSelector, - ), - detail: error.detail, - cause: error, - }), - ), - ); - }, - getRepositoryCloneUrls: (input) => - gitlab.getRepositoryCloneUrls(input).pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "gitlab", - operation: "getRepositoryCloneUrls", - command: error.command, - cwd: input.cwd, - repository: SourceControlProvider.transportSafeSourceControlErrorValue( - input.repository, - ), - detail: error.detail, - cause: error, - }), - ), - ), - createRepository: (input) => - gitlab.createRepository(input).pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "gitlab", - operation: "createRepository", - command: error.command, - cwd: input.cwd, - repository: SourceControlProvider.transportSafeSourceControlErrorValue( - input.repository, - ), - detail: error.detail, - cause: error, - }), - ), - ), - getDefaultBranch: (input) => - gitlab.getDefaultBranch(input).pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "gitlab", - operation: "getDefaultBranch", - command: error.command, - cwd: input.cwd, - detail: error.detail, - cause: error, - }), - ), - ), - checkoutChangeRequest: (input) => - gitlab.checkoutMergeRequest(input).pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "gitlab", - operation: "checkoutChangeRequest", - command: error.command, - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.reference, - ), - detail: error.detail, - cause: error, - }), - ), - ), - }); -}); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index e818a92746359495c7b54a31c000d1dcfa01f266..c87d731953da9812cd4360ae850deadc347df3c8 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -20,7 +20,8 @@ import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/Azu import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli"; import * as BitbucketApi from "./BitbucketApi.ts"; import * as GitHubApi from "./GitHubApi.ts"; -import * as GitLabCli from "./GitLabCli.ts"; +import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; +import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; import * as ForgejoSourceControlProvider from "@t3tools/source-control-forgejo/server/ForgejoSourceControlProvider"; import * as ForgejoPullRequestProvider from "@t3tools/source-control-forgejo/server/ForgejoPullRequestProvider"; @@ -46,6 +47,7 @@ const layerSourceControlProviderRegistryTest = (input: { Layer.mock(GitHubApi.GitHubApi)({}), Layer.mock(GitVcsDriver.GitVcsDriver)({}), Layer.mock(GitLabCli.GitLabCli)({}), + Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({}), Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}), Layer.mock(VcsProcess.VcsProcess)(input.process), diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index 5f48bae18588eea281644217c774312612ce715c..582848b38a4b65a3a52bcba421ccc4f2a7e30bf5 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -19,7 +19,8 @@ import * as BitbucketApi from "./BitbucketApi.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as GitHubApi from "./GitHubApi.ts"; import * as GitHubCredentials from "./GitHubCredentials.ts"; -import * as GitLabCli from "./GitLabCli.ts"; +import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; +import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; @@ -114,6 +115,7 @@ function makeRegistry(input: { }), Layer.mock(GitVcsDriver.GitVcsDriver)({}), Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}), + Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({}), Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-registry-test-", diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index 948d583296faf8974a1994d34543e8d84c3893db..9eeae3d935f7a80ac606a0005a594b12f593290e 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -13,7 +13,6 @@ import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/source import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; -import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; import * as BuiltInDrivers from "./builtInDrivers.ts"; import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; import { @@ -306,7 +305,6 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit export const make = Effect.gen(function* () { const github = yield* GitHubSourceControlProvider.make; const githubDiscovery = yield* GitHubSourceControlProvider.makeDiscovery; - const gitlab = yield* GitLabSourceControlProvider.make; const bitbucket = yield* BitbucketSourceControlProvider.make; const bitbucketDiscovery = yield* BitbucketSourceControlProvider.makeDiscovery; const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) => @@ -326,11 +324,6 @@ export const make = Effect.gen(function* () { provider: github, discovery: githubDiscovery, }, - { - kind: "gitlab", - provider: gitlab, - discovery: GitLabSourceControlProvider.discovery, - }, { kind: "bitbucket", provider: bitbucket, diff --git a/apps/server/src/sourceControl/builtInDrivers.ts b/apps/server/src/sourceControl/builtInDrivers.ts index 584bf139c0fb64205d60428c66d987e18e1ea0a1..83ee1d35e8f5f91a9a897e04a827f1ada4dca455 100644 --- a/apps/server/src/sourceControl/builtInDrivers.ts +++ b/apps/server/src/sourceControl/builtInDrivers.ts @@ -12,12 +12,15 @@ import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops import * as AzureDevOpsDriver from "@t3tools/source-control-azure-devops/server/driver"; import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; import * as ForgejoDriver from "@t3tools/source-control-forgejo/server/driver"; +import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; +import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; +import * as GitLabDriver from "@t3tools/source-control-gitlab/server/driver"; import type { SourceControlDriver } from "@t3tools/source-control-core/server/driver"; import * as Layer from "effect/Layer"; import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; -const drivers = [AzureDevOpsDriver.driver, ForgejoDriver.driver]; +const drivers = [GitLabDriver.driver, AzureDevOpsDriver.driver, ForgejoDriver.driver]; /** Every service a built-in driver's `make` needs; the server's layers must provide them all. */ export type BuiltInSourceControlDriversEnv = @@ -31,4 +34,5 @@ export const BUILT_IN_SOURCE_CONTROL_DRIVERS: ReadonlyArray< export const layer = Layer.mergeAll( AzureDevOpsPullRequestCli.layer.pipe(Layer.provideMerge(AzureDevOpsCli.layer)), ForgejoCli.layer, + GitLabPullRequestCli.layer.pipe(Layer.provideMerge(GitLabCli.layer)), ).pipe(Layer.provideMerge(ServerSourceControlHost.layer)); diff --git a/apps/server/src/sourceControl/gitLabAuthStatus.ts b/apps/server/src/sourceControl/gitLabAuthStatus.ts deleted file mode 100644 index f54e300353ad41d6c47de75b38719195eb43bae6..0000000000000000000000000000000000000000 --- a/apps/server/src/sourceControl/gitLabAuthStatus.ts +++ /dev/null @@ -1,48 +0,0 @@ -const HOST_LINE_PATTERN = /^(?:[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?|\[[a-f0-9:.]+\])(?::\d+)?$/iu; -const LOGGED_IN_PATTERN = /Logged in to .+? as\s+([^\s(]+)/iu; - -export interface GitLabAuthStatusHost { - readonly host: string; - readonly account: string | null; -} - -export function parseGitLabAuthStatusHosts(text: string): ReadonlyArray { - const hosts: GitLabAuthStatusHost[] = []; - let currentHost: string | null = null; - let currentLines: string[] = []; - - const flush = () => { - if (currentHost === null) return; - - const account = LOGGED_IN_PATTERN.exec(currentLines.join("\n"))?.[1]?.trim() || null; - hosts.push({ host: currentHost, account }); - currentHost = null; - currentLines = []; - }; - - for (const rawLine of text.split(/\r?\n/)) { - const line = rawLine.trim(); - if (line.length === 0) continue; - - const isHostLine = - rawLine.length === rawLine.trimStart().length && HOST_LINE_PATTERN.test(line); - if (isHostLine) { - flush(); - currentHost = line.toLowerCase(); - continue; - } - - if (currentHost !== null) { - currentLines.push(line); - } - } - - flush(); - return hosts; -} - -export function findAuthenticatedGitLabHost( - hosts: ReadonlyArray, -): GitLabAuthStatusHost | undefined { - return hosts.find((host) => host.account !== null); -} diff --git a/apps/server/src/sourceControl/gitLabMergeRequests.ts b/apps/server/src/sourceControl/gitLabMergeRequests.ts deleted file mode 100644 index d03d43411224af8ec3d24b9bda88454377e57524..0000000000000000000000000000000000000000 --- a/apps/server/src/sourceControl/gitLabMergeRequests.ts +++ /dev/null @@ -1,161 +0,0 @@ -import * as Cause from "effect/Cause"; -import * as DateTime from "effect/DateTime"; -import * as Exit from "effect/Exit"; -import * as Option from "effect/Option"; -import * as Result from "effect/Result"; -import * as Schema from "effect/Schema"; -import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; -import { decodeJsonResult } from "@t3tools/shared/schemaJson"; - -export interface NormalizedGitLabMergeRequestRecord { - readonly number: number; - readonly title: string; - readonly url: string; - readonly baseRefName: string; - readonly headRefName: string; - readonly state: "open" | "closed" | "merged"; - readonly isDraft?: boolean; - readonly closedAt?: string | null; - readonly mergedAt?: string | null; - readonly updatedAt: Option.Option; - readonly isCrossRepository?: boolean; - readonly headRepositoryNameWithOwner?: string | null; - readonly headRepositoryOwnerLogin?: string | null; -} - -const GitLabProjectReferenceSchema = Schema.Struct({ - path_with_namespace: Schema.optional(Schema.String), - pathWithNamespace: Schema.optional(Schema.String), - namespace: Schema.optional( - Schema.NullOr( - Schema.Struct({ - path: Schema.optional(Schema.String), - full_path: Schema.optional(Schema.String), - fullPath: Schema.optional(Schema.String), - }), - ), - ), -}); - -const GitLabMergeRequestSchema = Schema.Struct({ - iid: PositiveInt, - title: TrimmedNonEmptyString, - web_url: TrimmedNonEmptyString, - source_branch: TrimmedNonEmptyString, - target_branch: TrimmedNonEmptyString, - state: Schema.optional(Schema.NullOr(Schema.String)), - draft: Schema.optional(Schema.Boolean), - work_in_progress: Schema.optional(Schema.Boolean), - closed_at: Schema.optional(Schema.NullOr(Schema.String)), - merged_at: Schema.optional(Schema.NullOr(Schema.String)), - updated_at: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), - source_project_id: Schema.optional(Schema.NullOr(Schema.Number)), - target_project_id: Schema.optional(Schema.NullOr(Schema.Number)), - source_project: Schema.optional(Schema.NullOr(GitLabProjectReferenceSchema)), - target_project: Schema.optional(Schema.NullOr(GitLabProjectReferenceSchema)), -}); - -function trimOptionalString(value: string | null | undefined): string | null { - const trimmed = value?.trim() ?? ""; - return trimmed.length > 0 ? trimmed : null; -} - -function normalizeGitLabMergeRequestState( - state: string | null | undefined, -): "open" | "closed" | "merged" { - const normalized = state?.trim().toLowerCase(); - if (normalized === "merged") { - return "merged"; - } - if (normalized === "closed") { - return "closed"; - } - return "open"; -} - -function projectPathWithNamespace( - project: Schema.Schema.Type | null | undefined, -): string | null { - const explicit = - trimOptionalString(project?.path_with_namespace) ?? - trimOptionalString(project?.pathWithNamespace); - if (explicit) { - return explicit; - } - - const namespacePath = - trimOptionalString(project?.namespace?.full_path) ?? - trimOptionalString(project?.namespace?.fullPath) ?? - trimOptionalString(project?.namespace?.path); - return namespacePath; -} - -function ownerLoginFromPathWithNamespace(pathWithNamespace: string | null): string | null { - const [owner] = pathWithNamespace?.split("/") ?? []; - return trimOptionalString(owner); -} - -function normalizeGitLabMergeRequestRecord( - raw: Schema.Schema.Type, -): NormalizedGitLabMergeRequestRecord { - const sourceProjectPath = projectPathWithNamespace(raw.source_project); - const targetProjectPath = projectPathWithNamespace(raw.target_project); - const isCrossRepository = - typeof raw.source_project_id === "number" && typeof raw.target_project_id === "number" - ? raw.source_project_id !== raw.target_project_id - : sourceProjectPath !== null && targetProjectPath !== null - ? sourceProjectPath.toLowerCase() !== targetProjectPath.toLowerCase() - : undefined; - const headRepositoryOwnerLogin = ownerLoginFromPathWithNamespace(sourceProjectPath); - - return { - number: raw.iid, - title: raw.title, - url: raw.web_url, - baseRefName: raw.target_branch, - headRefName: raw.source_branch, - state: normalizeGitLabMergeRequestState(raw.state), - ...(raw.draft === true || raw.work_in_progress === true ? { isDraft: true } : {}), - closedAt: raw.closed_at ?? null, - mergedAt: raw.merged_at ?? null, - updatedAt: raw.updated_at ?? Option.none(), - ...(typeof isCrossRepository === "boolean" ? { isCrossRepository } : {}), - ...(sourceProjectPath ? { headRepositoryNameWithOwner: sourceProjectPath } : {}), - ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}), - }; -} - -const decodeGitLabMergeRequestList = decodeJsonResult(Schema.Array(Schema.Unknown)); -const decodeGitLabMergeRequest = decodeJsonResult(GitLabMergeRequestSchema); -const decodeGitLabMergeRequestEntry = Schema.decodeUnknownExit(GitLabMergeRequestSchema); - -export function decodeGitLabMergeRequestListJson( - raw: string, -): Result.Result< - ReadonlyArray, - Cause.Cause -> { - const result = decodeGitLabMergeRequestList(raw); - if (Result.isSuccess(result)) { - const mergeRequests: NormalizedGitLabMergeRequestRecord[] = []; - for (const entry of result.success) { - const decodedEntry = decodeGitLabMergeRequestEntry(entry); - if (Exit.isFailure(decodedEntry)) { - continue; - } - mergeRequests.push(normalizeGitLabMergeRequestRecord(decodedEntry.value)); - } - return Result.succeed(mergeRequests); - } - return Result.fail(result.failure); -} - -export function decodeGitLabMergeRequestJson( - raw: string, -): Result.Result> { - const result = decodeGitLabMergeRequest(raw); - if (Result.isSuccess(result)) { - return Result.succeed(normalizeGitLabMergeRequestRecord(result.success)); - } - return Result.fail(result.failure); -} diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 899f021eb937838dd213e273d8a58592fb3ec699..19ed2600ba5e739daf3375cf1234363b77484c93 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -231,7 +231,6 @@ import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery. import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; import * as GitHubApi from "./sourceControl/GitHubApi.ts"; -import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts"; import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; @@ -3205,7 +3204,6 @@ export const layer = Layer.unwrap( Layer.mergeAll( BitbucketApi.layer, GitHubApi.layerWithDependencies, - GitLabCli.layer, SourceControlBuiltInDrivers.layer, ), ), diff --git a/docs/internals/pull-request-file-revisions.md b/docs/internals/pull-request-file-revisions.md index ceb80de8efe03f5d6e519b0645502e320cfd6d1a..8fd0dfdb69db12746bbcab551fee15af12369540 100644 --- a/docs/internals/pull-request-file-revisions.md +++ b/docs/internals/pull-request-file-revisions.md @@ -7,7 +7,7 @@ one. No type distinguishes those meanings and no single file states the whole pa this page is for. The failure is on record rather than hypothetical. The interface doc above -[GitLab's `getFileRevisions`](../../apps/server/src/pullRequest/GitLabPullRequestCli.ts) once said +[GitLab's `getFileRevisions`](../../packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts) once said the opposite of the code beneath it, because it described a missing path the way the layer below means it. Anything written against that reading would treat a version the host declined to give as a file the change request deleted, which reports every file a reader has cleared as changed across @@ -30,7 +30,7 @@ What "never got to look at" is belongs to the host, and the contract cannot know - [Bitbucket](../../apps/server/src/pullRequest/BitbucketPullRequestApi.ts) reads them off the pull request's own patch, the only place it states a file's version, so a patch cut short at the byte ceiling leaves the paths past the cut out. -- [GitLab](../../apps/server/src/pullRequest/GitLabPullRequestCli.ts) asks in batches, so a batch +- [GitLab](../../packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts) asks in batches, so a batch it could not read leaves that batch's paths out. Those three facts stay with their providers. The contract states only that absence means the read diff --git a/package.json b/package.json index bb1d183efb891058eab579b338e39679d10eeecc..4e0ba35ecc7e09b2990be8fe7e7ea12d0de6a349 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "tc": "vp run -r --concurrency-limit 2 typecheck", "lint": "vp lint --report-unused-disable-directives", "knip": "knip --preprocessor ./scripts/knip-schemas.ts", - "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --workspace packages/source-control-core --workspace packages/source-control-azure-devops --workspace packages/source-control-forgejo --workspace packages/source-control-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", + "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --workspace packages/source-control-core --workspace packages/source-control-azure-devops --workspace packages/source-control-forgejo --workspace packages/source-control-gitlab --workspace packages/source-control-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", "lint:mobile": "node scripts/mobile-native-static-check.ts", "test": "vp run -r test", diff --git a/packages/source-control-gitlab/package.json b/packages/source-control-gitlab/package.json new file mode 100644 index 0000000000000000000000000000000000000000..6b99c878ccb83317db3948096ccb117a308765df --- /dev/null +++ b/packages/source-control-gitlab/package.json @@ -0,0 +1,55 @@ +{ + "name": "@t3tools/source-control-gitlab", + "private": true, + "type": "module", + "exports": { + "./server/driver": { + "types": "./src/server/driver.ts", + "import": "./src/server/driver.ts" + }, + "./server/gitLabAuthStatus": { + "types": "./src/server/gitLabAuthStatus.ts", + "import": "./src/server/gitLabAuthStatus.ts" + }, + "./server/GitLabCli": { + "types": "./src/server/GitLabCli.ts", + "import": "./src/server/GitLabCli.ts" + }, + "./server/gitLabMergeRequestJson": { + "types": "./src/server/gitLabMergeRequestJson.ts", + "import": "./src/server/gitLabMergeRequestJson.ts" + }, + "./server/gitLabMergeRequests": { + "types": "./src/server/gitLabMergeRequests.ts", + "import": "./src/server/gitLabMergeRequests.ts" + }, + "./server/GitLabPullRequestCli": { + "types": "./src/server/GitLabPullRequestCli.ts", + "import": "./src/server/GitLabPullRequestCli.ts" + }, + "./server/GitLabPullRequestProvider": { + "types": "./src/server/GitLabPullRequestProvider.ts", + "import": "./src/server/GitLabPullRequestProvider.ts" + }, + "./server/GitLabSourceControlProvider": { + "types": "./src/server/GitLabSourceControlProvider.ts", + "import": "./src/server/GitLabSourceControlProvider.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vp test run --config ../../vite.config.ts --dir ." + }, + "dependencies": { + "@t3tools/contracts": "workspace:*", + "@t3tools/shared": "workspace:*", + "@t3tools/source-control-core": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@t3tools/source-control-testing": "workspace:*", + "@types/node": "catalog:", + "vite-plus": "catalog:" + } +} diff --git a/packages/source-control-gitlab/src/server/GitLabCli.test.ts b/packages/source-control-gitlab/src/server/GitLabCli.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..2756460f78f5923cdfac8c5c442fc1d379df9a64 --- /dev/null +++ b/packages/source-control-gitlab/src/server/GitLabCli.test.ts @@ -0,0 +1,379 @@ +import { assert, it, afterEach, expect, vi } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { ChildProcessSpawner } from "effect/process"; + +import { VcsProcessExitError } from "@t3tools/contracts"; + +import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; +import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; +import * as GitLabCli from "./GitLabCli.ts"; + +const mockedRun = vi.fn(); +const layer = it.layer( + GitLabCli.layer.pipe(Layer.provide(TestSourceControlHost.layer({ process: { run: mockedRun } }))), +); + +function processOutput(stdout: string): SourceControlHost.SourceControlProcessOutput { + return { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }; +} + +afterEach(() => { + mockedRun.mockReset(); +}); + +layer("GitLabCli.layer", (it) => { + it.effect("parses merge request view output", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + JSON.stringify({ + iid: 42, + title: "Add MR thread creation", + web_url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", + target_branch: "main", + source_branch: "feature/mr-threads", + state: "closed", + closed_at: "2026-08-23T10:00:00Z", + source_project_id: 101, + target_project_id: 100, + source_project: { + path_with_namespace: "octocat/t3code", + }, + }), + ), + ), + ); + + const result = yield* Effect.gen(function* () { + const glab = yield* GitLabCli.GitLabCli; + return yield* glab.getMergeRequest({ + cwd: "/repo", + reference: "42", + }); + }); + + assert.deepStrictEqual(result, { + number: 42, + title: "Add MR thread creation", + url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", + baseRefName: "main", + headRefName: "feature/mr-threads", + state: "closed", + closedAt: "2026-08-23T10:00:00Z", + mergedAt: null, + isCrossRepository: true, + headRepositoryNameWithOwner: "octocat/t3code", + headRepositoryOwnerLogin: "octocat", + }); + expect(mockedRun).toHaveBeenCalledWith( + expect.objectContaining({ + command: "glab", + cwd: "/repo", + args: ["mr", "view", "42", "--output", "json"], + }), + ); + }), + ); + + it.effect("skips invalid entries when parsing MR lists", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + JSON.stringify([ + { + iid: 0, + title: "invalid", + web_url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/0", + target_branch: "main", + source_branch: "feature/invalid", + }, + { + iid: 43, + title: " Valid MR ", + web_url: " https://gitlab.com/pingdotgg/t3code/-/merge_requests/43 ", + target_branch: " main ", + source_branch: " feature/mr-list ", + state: "merged", + merged_at: "2026-08-23T11:00:00Z", + }, + ]), + ), + ), + ); + + const result = yield* Effect.gen(function* () { + const glab = yield* GitLabCli.GitLabCli; + return yield* glab.listMergeRequests({ + cwd: "/repo", + headSelector: "feature/mr-list", + state: "all", + }); + }); + + assert.deepStrictEqual(result, [ + { + number: 43, + title: "Valid MR", + url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/43", + baseRefName: "main", + headRefName: "feature/mr-list", + state: "merged", + closedAt: null, + mergedAt: "2026-08-23T11:00:00Z", + }, + ]); + expect(mockedRun).toHaveBeenCalledWith( + expect.objectContaining({ + command: "glab", + cwd: "/repo", + args: [ + "mr", + "list", + "--source-branch", + "feature/mr-list", + "--all", + "--per-page", + "20", + "--output", + "json", + ], + }), + ); + }), + ); + + it.effect("reads repository clone URLs", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + JSON.stringify({ + path_with_namespace: "octocat/t3code", + web_url: "https://gitlab.com/octocat/t3code", + http_url_to_repo: "https://gitlab.com/octocat/t3code.git", + ssh_url_to_repo: "git@gitlab.com:octocat/t3code.git", + }), + ), + ), + ); + + const result = yield* Effect.gen(function* () { + const glab = yield* GitLabCli.GitLabCli; + return yield* glab.getRepositoryCloneUrls({ + cwd: "/repo", + repository: "octocat/t3code", + }); + }); + + assert.deepStrictEqual(result, { + nameWithOwner: "octocat/t3code", + url: "https://gitlab.com/octocat/t3code", + sshUrl: "git@gitlab.com:octocat/t3code.git", + }); + }), + ); + + it.effect("creates merge requests through the GitLab API without placing the body in argv", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce(Effect.succeed(processOutput("{}"))); + + const glab = yield* GitLabCli.GitLabCli; + yield* glab.createMergeRequest({ + cwd: "/repo", + baseBranch: "main", + headSelector: "owner:feature/provider", + title: "Provider MR", + bodyFile: "/tmp/t3-mr-body.md", + }); + + expect(mockedRun).toHaveBeenCalledWith( + expect.objectContaining({ + command: "glab", + cwd: "/repo", + args: [ + "api", + "--method", + "POST", + "projects/:fullpath/merge_requests", + "--raw-field", + "source_branch=feature/provider", + "--raw-field", + "target_branch=main", + "--raw-field", + "title=Provider MR", + "--field", + "description=@/tmp/t3-mr-body.md", + ], + }), + ); + }), + ); + + it.effect("creates repositories under an explicit namespace", () => + Effect.gen(function* () { + mockedRun + + .mockReturnValueOnce(Effect.succeed(processOutput(JSON.stringify({ id: 1234 })))) + .mockReturnValueOnce( + Effect.succeed( + processOutput( + JSON.stringify({ + path_with_namespace: "octocat/t3code", + web_url: "https://gitlab.com/octocat/t3code", + http_url_to_repo: "https://gitlab.com/octocat/t3code.git", + ssh_url_to_repo: "git@gitlab.com:octocat/t3code.git", + }), + ), + ), + ); + + const glab = yield* GitLabCli.GitLabCli; + const result = yield* glab.createRepository({ + cwd: "/repo", + repository: "octocat/t3code", + visibility: "public", + }); + + assert.deepStrictEqual(result, { + nameWithOwner: "octocat/t3code", + url: "https://gitlab.com/octocat/t3code", + sshUrl: "git@gitlab.com:octocat/t3code.git", + }); + expect(mockedRun).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + command: "glab", + cwd: "/repo", + args: ["api", "namespaces/octocat"], + }), + ); + expect(mockedRun).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + command: "glab", + cwd: "/repo", + args: [ + "api", + "--method", + "POST", + "projects", + "--raw-field", + "path=t3code", + "--raw-field", + "name=t3code", + "--raw-field", + "visibility=public", + "--raw-field", + "namespace_id=1234", + ], + }), + ); + }), + ); + + it.effect("does not pass unsupported force flags when checking out merge requests", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); + + const glab = yield* GitLabCli.GitLabCli; + yield* glab.checkoutMergeRequest({ + cwd: "/repo", + reference: "42", + force: true, + }); + + expect(mockedRun).toHaveBeenCalledWith( + expect.objectContaining({ + command: "glab", + cwd: "/repo", + args: ["mr", "checkout", "42"], + }), + ); + }), + ); + + it.effect("surfaces a friendly error when the merge request is not found", () => + Effect.gen(function* () { + const cause = new VcsProcessExitError({ + operation: "GitLabCli.execute", + command: "glab", + cwd: "/repo", + exitCode: 1, + detail: "GET 404 merge request not found", + failureKind: "not-found", + }); + mockedRun.mockReturnValueOnce(Effect.fail(cause)); + + const error = yield* Effect.gen(function* () { + const glab = yield* GitLabCli.GitLabCli; + return yield* glab.getMergeRequest({ + cwd: "/repo", + reference: "4888", + }); + }).pipe(Effect.flip); + + assert.equal(error.message.includes("Merge request 4888 was not found"), true); + assert.strictEqual(error._tag, "GitLabMergeRequestNotFoundError"); + assert.strictEqual(error.command, "glab"); + assert.strictEqual(error.cwd, "/repo"); + assert.strictEqual(error.cause, cause); + assert.equal(error.message.includes(cause.detail), false); + }), + ); + + it.effect("keeps non-merge-request not-found failures generic", () => + Effect.gen(function* () { + const cause = new VcsProcessExitError({ + operation: "GitLabCli.execute", + command: "glab", + cwd: "/repo", + exitCode: 1, + detail: "GET 404 project not found", + failureKind: "not-found", + }); + mockedRun.mockReturnValueOnce(Effect.fail(cause)); + + const error = yield* Effect.gen(function* () { + const glab = yield* GitLabCli.GitLabCli; + return yield* glab.getRepositoryCloneUrls({ + cwd: "/repo", + repository: "missing/project", + }); + }).pipe(Effect.flip); + + assert.strictEqual(error._tag, "GitLabCliCommandError"); + assert.strictEqual(error.cause, cause); + }), + ); + + it.effect("preserves rate-limit failures as a distinct error", () => + Effect.gen(function* () { + const cause = new VcsProcessExitError({ + operation: "GitLabCli.execute", + command: "glab", + cwd: "/repo", + exitCode: 1, + detail: "API rate limit exceeded.", + failureKind: "rate-limited", + }); + mockedRun.mockReturnValueOnce(Effect.fail(cause)); + + const glab = yield* GitLabCli.GitLabCli; + const error = yield* glab + .execute({ cwd: "/repo", args: ["api", "projects"] }) + .pipe(Effect.flip); + + assert.strictEqual(error._tag, "GitLabCliRateLimitError"); + assert.strictEqual(error.cause, cause); + }), + ); +}); diff --git a/packages/source-control-gitlab/src/server/GitLabCli.ts b/packages/source-control-gitlab/src/server/GitLabCli.ts new file mode 100644 index 0000000000000000000000000000000000000000..6845ee7710013017e8b1edd580a085df55484f8b --- /dev/null +++ b/packages/source-control-gitlab/src/server/GitLabCli.ts @@ -0,0 +1,660 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Match from "effect/Match"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import type * as DateTime from "effect/DateTime"; + +import { + TrimmedNonEmptyString, + type SourceControlRepositoryVisibility, + type VcsError, +} from "@t3tools/contracts"; + +import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; +import { + decodeGitLabMergeRequestJson, + decodeGitLabMergeRequestListJson, +} from "./gitLabMergeRequests.ts"; +import type * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; + +const DEFAULT_TIMEOUT_MS = 30_000; + +const gitLabCliExecutionErrorContext = { + operation: Schema.Literal("execute"), + command: Schema.Literal("glab"), + cwd: Schema.String, + cause: Schema.Defect(), +}; + +const gitLabCliDecodeErrorContext = { + command: Schema.Literal("glab"), + cwd: Schema.String, + cause: Schema.Defect(), +}; + +export class GitLabCliUnavailableError extends Schema.TaggedError()( + "GitLabCliUnavailableError", + gitLabCliExecutionErrorContext, +) { + get detail(): string { + return "GitLab CLI (`glab`) is required but not available on PATH."; + } + + override get message(): string { + return `GitLab CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export class GitLabCliAuthenticationError extends Schema.TaggedError()( + "GitLabCliAuthenticationError", + gitLabCliExecutionErrorContext, +) { + get detail(): string { + return "GitLab CLI is not authenticated. Run `glab auth login` and retry."; + } + + override get message(): string { + return `GitLab CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export class GitLabCliRateLimitError extends Schema.TaggedError()( + "GitLabCliRateLimitError", + gitLabCliExecutionErrorContext, +) { + get detail(): string { + return "GitLab API rate limit exceeded."; + } + + override get message(): string { + return `GitLab CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export class GitLabMergeRequestNotFoundError extends Schema.TaggedError()( + "GitLabMergeRequestNotFoundError", + { + ...gitLabCliExecutionErrorContext, + reference: Schema.String, + }, +) { + get detail(): string { + return `Merge request ${this.reference} was not found. Check the MR number or URL and try again.`; + } + + override get message(): string { + return `GitLab CLI failed in ${this.operation}: ${this.detail}`; + } + + static fromVcsError( + context: { + readonly operation: "execute"; + readonly command: "glab"; + readonly cwd: string; + readonly reference: string; + }, + error: VcsError, + ): GitLabCliError { + if (error._tag === "VcsProcessExitError" && error.failureKind === "not-found") { + return new GitLabMergeRequestNotFoundError({ ...context, cause: error }); + } + + return GitLabCliCommandError.fromVcsError( + { + operation: context.operation, + command: context.command, + cwd: context.cwd, + }, + error, + ); + } +} + +export class GitLabCliCommandError extends Schema.TaggedError()( + "GitLabCliCommandError", + gitLabCliExecutionErrorContext, +) { + get detail(): string { + return "GitLab CLI command failed."; + } + + override get message(): string { + return `GitLab CLI failed in ${this.operation}: ${this.detail}`; + } + + static fromVcsError( + context: { + readonly operation: "execute"; + readonly command: "glab"; + readonly cwd: string; + }, + error: VcsError, + ): GitLabCliError { + return Match.valueTags(error, { + VcsProcessSpawnError: (cause) => new GitLabCliUnavailableError({ ...context, cause }), + VcsProcessExitError: (cause) => { + switch (cause.failureKind) { + case "authentication": + return new GitLabCliAuthenticationError({ ...context, cause }); + case "rate-limited": + return new GitLabCliRateLimitError({ ...context, cause }); + case "not-found": + case "command-failed": + case undefined: + return new GitLabCliCommandError({ ...context, cause }); + } + }, + VcsProcessTimeoutError: (cause) => new GitLabCliCommandError({ ...context, cause }), + VcsProcessStdinWriteError: (cause) => new GitLabCliCommandError({ ...context, cause }), + VcsProcessOutputReadError: (cause) => new GitLabCliCommandError({ ...context, cause }), + VcsProcessOutputLimitError: (cause) => new GitLabCliCommandError({ ...context, cause }), + VcsProcessMissingExitCodeError: (cause) => new GitLabCliCommandError({ ...context, cause }), + VcsRepositoryDetectionError: (cause) => new GitLabCliCommandError({ ...context, cause }), + VcsUnsupportedOperationError: (cause) => new GitLabCliCommandError({ ...context, cause }), + }); + } +} + +export class GitLabMergeRequestListDecodeError extends Schema.TaggedError()( + "GitLabMergeRequestListDecodeError", + { + ...gitLabCliDecodeErrorContext, + operation: Schema.Literal("listMergeRequests"), + }, +) { + get detail(): string { + return "GitLab CLI returned invalid MR list JSON."; + } + + override get message(): string { + return `GitLab CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export class GitLabMergeRequestDecodeError extends Schema.TaggedError()( + "GitLabMergeRequestDecodeError", + { + ...gitLabCliDecodeErrorContext, + operation: Schema.Literal("getMergeRequest"), + reference: Schema.String, + }, +) { + get detail(): string { + return "GitLab CLI returned invalid merge request JSON."; + } + + override get message(): string { + return `GitLab CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export class GitLabRepositoryDecodeError extends Schema.TaggedError()( + "GitLabRepositoryDecodeError", + { + ...gitLabCliDecodeErrorContext, + operation: Schema.Literals(["getRepositoryCloneUrls", "createRepository", "getDefaultBranch"]), + repository: Schema.optional(Schema.String), + }, +) { + get detail(): string { + return "GitLab CLI returned invalid repository JSON."; + } + + override get message(): string { + return `GitLab CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export class GitLabNamespaceDecodeError extends Schema.TaggedError()( + "GitLabNamespaceDecodeError", + { + ...gitLabCliDecodeErrorContext, + operation: Schema.Literal("createRepository"), + namespacePath: Schema.String, + }, +) { + get detail(): string { + return "GitLab CLI returned invalid namespace JSON."; + } + + override get message(): string { + return `GitLab CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export const GitLabCliError = Schema.Union([ + GitLabCliUnavailableError, + GitLabCliAuthenticationError, + GitLabCliRateLimitError, + GitLabMergeRequestNotFoundError, + GitLabCliCommandError, + GitLabMergeRequestListDecodeError, + GitLabMergeRequestDecodeError, + GitLabRepositoryDecodeError, + GitLabNamespaceDecodeError, +]); +export type GitLabCliError = typeof GitLabCliError.Type; + +export interface GitLabMergeRequestSummary { + readonly number: number; + readonly title: string; + readonly url: string; + readonly baseRefName: string; + readonly headRefName: string; + readonly state?: "open" | "closed" | "merged"; + readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; + readonly updatedAt?: Option.Option; + readonly isCrossRepository?: boolean; + readonly headRepositoryNameWithOwner?: string | null; + readonly headRepositoryOwnerLogin?: string | null; +} + +export interface GitLabRepositoryCloneUrls { + readonly nameWithOwner: string; + readonly url: string; + readonly sshUrl: string; +} + +export class GitLabCli extends Context.Service< + GitLabCli, + { + readonly execute: (input: { + readonly cwd: string; + readonly args: ReadonlyArray; + readonly timeoutMs?: number; + /** Piped to the child's stdin, for payloads that must never appear in argv. */ + readonly stdin?: string; + readonly maxOutputBytes?: number; + }) => Effect.Effect; + + readonly listMergeRequests: (input: { + readonly cwd: string; + readonly headSelector: string; + readonly source?: SourceControlProvider.SourceControlRefSelector; + readonly state: "open" | "closed" | "merged" | "all"; + readonly limit?: number; + }) => Effect.Effect, GitLabCliError>; + + readonly getMergeRequest: (input: { + readonly cwd: string; + readonly reference: string; + }) => Effect.Effect; + + readonly getRepositoryCloneUrls: (input: { + readonly cwd: string; + readonly repository: string; + }) => Effect.Effect; + + readonly createRepository: (input: { + readonly cwd: string; + readonly repository: string; + readonly visibility: SourceControlRepositoryVisibility; + }) => Effect.Effect; + + readonly createMergeRequest: (input: { + readonly cwd: string; + readonly baseBranch: string; + readonly headSelector: string; + readonly source?: SourceControlProvider.SourceControlRefSelector; + readonly target?: SourceControlProvider.SourceControlRefSelector; + readonly title: string; + readonly bodyFile: string; + }) => Effect.Effect; + + readonly getDefaultBranch: (input: { + readonly cwd: string; + }) => Effect.Effect; + + readonly checkoutMergeRequest: (input: { + readonly cwd: string; + readonly reference: string; + readonly force?: boolean; + }) => Effect.Effect; + } +>()("@t3tools/source-control-gitlab/server/GitLabCli") {} + +const RawGitLabRepositoryCloneUrlsSchema = Schema.Struct({ + path_with_namespace: TrimmedNonEmptyString, + web_url: TrimmedNonEmptyString, + http_url_to_repo: TrimmedNonEmptyString, + ssh_url_to_repo: TrimmedNonEmptyString, +}); + +const RawGitLabDefaultBranchSchema = Schema.Struct({ + default_branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), +}); + +const RawGitLabNamespaceSchema = Schema.Struct({ + id: Schema.Number, +}); + +const decodeGitLabRepositoryCloneUrls = Schema.decodeEffect( + Schema.fromJsonString(RawGitLabRepositoryCloneUrlsSchema), +); +const decodeGitLabDefaultBranch = Schema.decodeEffect( + Schema.fromJsonString(RawGitLabDefaultBranchSchema), +); +const decodeGitLabNamespace = Schema.decodeEffect(Schema.fromJsonString(RawGitLabNamespaceSchema)); + +function normalizeRepositoryCloneUrls( + raw: Schema.Schema.Type, +): GitLabRepositoryCloneUrls { + return { + nameWithOwner: raw.path_with_namespace, + url: raw.web_url, + sshUrl: raw.ssh_url_to_repo, + }; +} + +function stateArgs(state: "open" | "closed" | "merged" | "all"): ReadonlyArray { + switch (state) { + case "open": + return []; + case "closed": + return ["--closed"]; + case "merged": + return ["--merged"]; + case "all": + return ["--all"]; + } +} + +function normalizeHeadSelector(headSelector: string): string { + const trimmed = headSelector.trim(); + const ownerBranch = /^[^:]+:(.+)$/.exec(trimmed); + return ownerBranch?.[1]?.trim() || trimmed; +} + +function sourceRefName(input: { + readonly headSelector: string; + readonly source?: SourceControlProvider.SourceControlRefSelector; +}): string { + return input.source?.refName ?? normalizeHeadSelector(input.headSelector); +} + +function sourceProjectIdentifier( + source: SourceControlProvider.SourceControlRefSelector | undefined, +): string | null { + return source?.repository ?? source?.owner ?? null; +} + +function toSummaryWithOptionalUpdatedAt( + record: GitLabMergeRequestSummary & { + readonly updatedAt: Option.Option; + }, +): GitLabMergeRequestSummary { + const { updatedAt, ...summary } = record; + return Option.isSome(updatedAt) ? { ...summary, updatedAt } : summary; +} + +function parseRepositoryPath(repository: string): { + readonly namespacePath: string | null; + readonly projectPath: string; +} { + const parts: Array = []; + for (const part of repository.split("/")) { + const trimmed = part.trim(); + if (trimmed.length > 0) { + parts.push(trimmed); + } + } + const projectPath = parts.at(-1) ?? repository.trim(); + const namespacePath = parts.length > 1 ? parts.slice(0, -1).join("/") : null; + return { namespacePath, projectPath }; +} + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const { process } = yield* SourceControlHost.SourceControlHost; + + const run = ( + input: Parameters[0], + mapError: (error: VcsError) => GitLabCliError, + ) => + process + .run({ + operation: "GitLabCli.execute", + command: "glab", + args: input.args, + cwd: input.cwd, + timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, + ...(input.stdin === undefined ? {} : { stdin: input.stdin }), + ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), + }) + .pipe(Effect.mapError(mapError)); + + const execute: GitLabCli["Service"]["execute"] = (input) => + run(input, (error) => + GitLabCliCommandError.fromVcsError( + { operation: "execute", command: "glab", cwd: input.cwd }, + error, + ), + ); + + const executeMergeRequest = (input: { + readonly cwd: string; + readonly reference: string; + readonly args: ReadonlyArray; + }) => + run(input, (error) => + GitLabMergeRequestNotFoundError.fromVcsError( + { + operation: "execute", + command: "glab", + cwd: input.cwd, + reference: input.reference, + }, + error, + ), + ); + + return GitLabCli.of({ + execute, + listMergeRequests: (input) => + execute({ + cwd: input.cwd, + args: [ + "mr", + "list", + "--source-branch", + sourceRefName(input), + ...stateArgs(input.state), + "--per-page", + String(input.limit ?? 20), + "--output", + "json", + ], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + raw.length === 0 + ? Effect.succeed([]) + : Effect.sync(() => decodeGitLabMergeRequestListJson(raw)).pipe( + Effect.flatMap((decoded) => { + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitLabMergeRequestListDecodeError({ + operation: "listMergeRequests", + command: "glab", + cwd: input.cwd, + cause: decoded.failure, + }), + ); + } + + return Effect.succeed(decoded.success.map(toSummaryWithOptionalUpdatedAt)); + }), + ), + ), + ), + getMergeRequest: (input) => + executeMergeRequest({ + cwd: input.cwd, + reference: input.reference, + args: ["mr", "view", input.reference, "--output", "json"], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + Effect.sync(() => decodeGitLabMergeRequestJson(raw)).pipe( + Effect.flatMap((decoded) => { + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitLabMergeRequestDecodeError({ + operation: "getMergeRequest", + command: "glab", + cwd: input.cwd, + reference: input.reference, + cause: decoded.failure, + }), + ); + } + + return Effect.succeed(toSummaryWithOptionalUpdatedAt(decoded.success)); + }), + ), + ), + ), + getRepositoryCloneUrls: (input) => + execute({ + cwd: input.cwd, + args: ["api", `projects/${encodeURIComponent(input.repository)}`], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + decodeGitLabRepositoryCloneUrls(raw).pipe( + Effect.mapError( + (cause) => + new GitLabRepositoryDecodeError({ + operation: "getRepositoryCloneUrls", + command: "glab", + cwd: input.cwd, + repository: input.repository, + cause, + }), + ), + ), + ), + Effect.map(normalizeRepositoryCloneUrls), + ), + createRepository: (input) => { + const { namespacePath, projectPath } = parseRepositoryPath(input.repository); + const namespaceId: Effect.Effect = namespacePath + ? execute({ + cwd: input.cwd, + args: ["api", `namespaces/${encodeURIComponent(namespacePath)}`], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + decodeGitLabNamespace(raw).pipe( + Effect.mapError( + (cause) => + new GitLabNamespaceDecodeError({ + operation: "createRepository", + command: "glab", + cwd: input.cwd, + namespacePath, + cause, + }), + ), + ), + ), + Effect.map((namespace) => namespace.id), + ) + : Effect.succeed(null); + + return namespaceId.pipe( + Effect.flatMap((resolvedNamespaceId) => + execute({ + cwd: input.cwd, + args: [ + "api", + "--method", + "POST", + "projects", + "--raw-field", + `path=${projectPath}`, + "--raw-field", + `name=${projectPath}`, + "--raw-field", + `visibility=${input.visibility}`, + ...(resolvedNamespaceId === null + ? [] + : ["--raw-field", `namespace_id=${resolvedNamespaceId}`]), + ], + }), + ), + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + decodeGitLabRepositoryCloneUrls(raw).pipe( + Effect.mapError( + (cause) => + new GitLabRepositoryDecodeError({ + operation: "createRepository", + command: "glab", + cwd: input.cwd, + repository: input.repository, + cause, + }), + ), + ), + ), + Effect.map(normalizeRepositoryCloneUrls), + ); + }, + createMergeRequest: (input) => { + const sourceProject = sourceProjectIdentifier(input.source); + return execute({ + cwd: input.cwd, + args: [ + "api", + "--method", + "POST", + "projects/:fullpath/merge_requests", + "--raw-field", + `source_branch=${sourceRefName(input)}`, + "--raw-field", + `target_branch=${input.target?.refName ?? input.baseBranch}`, + ...(sourceProject ? ["--raw-field", `source_project_id=${sourceProject}`] : []), + "--raw-field", + `title=${input.title}`, + "--field", + `description=@${input.bodyFile}`, + ], + }).pipe(Effect.asVoid); + }, + getDefaultBranch: (input) => + execute({ + cwd: input.cwd, + args: ["api", "projects/:fullpath"], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + decodeGitLabDefaultBranch(raw).pipe( + Effect.mapError( + (cause) => + new GitLabRepositoryDecodeError({ + operation: "getDefaultBranch", + command: "glab", + cwd: input.cwd, + cause, + }), + ), + ), + ), + Effect.map((value) => value.default_branch ?? null), + ), + checkoutMergeRequest: (input) => + executeMergeRequest({ + cwd: input.cwd, + reference: input.reference, + args: ["mr", "checkout", input.reference], + }).pipe(Effect.asVoid), + }); +}); + +export const layer = Layer.effect(GitLabCli, make); diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestCli.test.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestCli.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..b4546162f30ca27968c453987de0df820762c4b9 --- /dev/null +++ b/packages/source-control-gitlab/src/server/GitLabPullRequestCli.test.ts @@ -0,0 +1,1536 @@ +import { afterEach, assert, expect, it, vi } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { ChildProcessSpawner } from "effect/process"; + +import * as GitLabCli from "./GitLabCli.ts"; +import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts"; + +const mockedExecute = vi.fn(); + +const layer = it.layer( + GitLabPullRequestCli.layer.pipe( + Layer.provide( + Layer.mock(GitLabCli.GitLabCli)({ + execute: mockedExecute, + }), + ), + ), +); + +function output(stdout: string, stdoutTruncated = false, stdoutInvalidUtf8 = false) { + return { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated, + stderrTruncated: false, + stdoutInvalidUtf8, + }; +} + +function mergeRequests(count: number, firstNumber: number): string { + return JSON.stringify( + Array.from({ length: count }, (_, index) => ({ + iid: firstNumber + index, + title: `Merge request ${firstNumber + index}`, + web_url: `https://gitlab.com/acme/web/-/merge_requests/${firstNumber + index}`, + source_branch: "feat/page", + target_branch: "main", + created_at: "2026-07-01T00:00:00Z", + updated_at: "2026-07-02T00:00:00Z", + })), + ); +} + +/** A page of `/diffs` as GitLab serves it, a full one unless the count says otherwise. */ +function diffPage(firstIndex: number, count = 100): string { + return JSON.stringify( + Array.from({ length: count }, (_, index) => ({ + old_path: `src/${firstIndex + index}.ts`, + new_path: `src/${firstIndex + index}.ts`, + diff: "@@ -1 +1 @@\n-a\n+b\n", + })), + ); +} + +/** A page of merge request notes, which is what the flat conversation is read from. */ +function notes(count: number, firstId: number): string { + return JSON.stringify( + Array.from({ length: count }, (_, index) => ({ + id: firstId + index, + body: `note ${firstId + index}`, + author: { username: "bilal" }, + created_at: "2026-07-01T00:00:00Z", + })), + ); +} + +/** Who opened the merge request, and somebody already reviewing it. */ +const author = { id: 1, username: "bilal" }; +const reviewer = { id: 5, username: "octocat" }; + +/** One merge request as `/merge_requests/:iid` answers with it. */ +function mergeRequestJson(overrides: Record): string { + return JSON.stringify({ + iid: 7, + title: "Merge request 7", + web_url: "https://gitlab.com/acme/web/-/merge_requests/7", + source_branch: "feat/page", + target_branch: "main", + created_at: "2026-07-01T00:00:00Z", + updated_at: "2026-07-02T00:00:00Z", + author, + ...overrides, + }); +} + +/** The endpoint or subcommand of the nth glab invocation. */ +function argsOfCall(index: number): ReadonlyArray { + return callAt(index).args; +} + +/** The whole nth invocation, so a request body can be asserted alongside its path. */ +function callAt(index: number) { + const call = mockedExecute.mock.calls[index]; + assert.isDefined(call); + return call[0]; +} + +afterEach(() => { + mockedExecute.mockReset(); +}); + +layer("GitLabPullRequestCli.layer", (it) => { + it.effect("asks GitLab for one row more than the page, to probe for a next page", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(mergeRequests(3, 1)))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const batch = yield* cli.listMergeRequests({ + cwd: "/w", + repository: "acme/web", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + }); + + assert.strictEqual(batch.items.length, 3); + assert.isFalse(batch.truncated); + assert.strictEqual(batch.cursorAdvance, 3); + const path = argsOfCall(0)[1] ?? ""; + expect(path).toContain("projects/acme%2Fweb/merge_requests"); + expect(path).toContain("per_page=11"); + expect(path).toContain("state=opened"); + }), + ); + + it.effect("walks pages at a fixed size, because GitLab pages by offset", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce(Effect.succeed(output(mergeRequests(100, 1)))) + .mockReturnValueOnce(Effect.succeed(output(mergeRequests(100, 101)))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const batch = yield* cli.listMergeRequests({ + cwd: "/w", + repository: "acme/web", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 150, + }); + + assert.strictEqual(batch.items.length, 150); + assert.isTrue(batch.truncated); + for (const index of [0, 1]) { + expect(argsOfCall(index)[1]).toContain("per_page=100"); + } + expect(argsOfCall(0)[1]).toContain("page=1"); + expect(argsOfCall(1)[1]).toContain("page=2"); + }), + ); + + it.effect("hands a search to GitLab's own search parameter", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.listMergeRequests({ + cwd: "/w", + repository: "acme/web", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + query: "page", + }); + + // GitLab matches `search` against title and description, which is more than the row shows. + expect(argsOfCall(0)[1]).toContain("search=page"); + }), + ); + + it.effect("carries on from the number of rows already delivered", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(mergeRequests(3, 1)))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.listMergeRequests({ + cwd: "/w", + repository: "acme/web", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 10 }, + }); + + // GitLab's timestamp filter has no tie-breaker, so an offset is what advances through a + // boundary shared by more rows than one page can hold. + const path = argsOfCall(0)[1] ?? ""; + expect(path).not.toContain("updated_before="); + expect(path).toContain("order_by=updated_at"); + expect(path).toContain("per_page=11"); + expect(path).toContain("page=1"); + }), + ); + + it.effect("advances beyond several pages sharing the cursor timestamp", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce(Effect.succeed(output(mergeRequests(11, 144)))) + .mockReturnValueOnce(Effect.succeed(output(mergeRequests(11, 155)))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const batch = yield* cli.listMergeRequests({ + cwd: "/w", + repository: "acme/web", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 150 }, + }); + + expect(argsOfCall(0)[1]).toContain("per_page=11"); + expect(argsOfCall(0)[1]).toContain("page=14"); + expect(argsOfCall(1)[1]).toContain("page=15"); + expect(batch.items.map((item) => item.number)).toEqual([ + 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, + ]); + assert.isTrue(batch.truncated); + }), + ); + + it.effect("advances the cursor through malformed raw rows", () => + Effect.gen(function* () { + const rows = JSON.parse(mergeRequests(2, 1)) as ReadonlyArray; + mockedExecute.mockReturnValueOnce( + Effect.succeed(output(JSON.stringify([{ iid: "malformed" }, ...rows]))), + ); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const batch = yield* cli.listMergeRequests({ + cwd: "/w", + repository: "acme/web", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 2, + }); + + expect(batch.items.map((item) => item.number)).toEqual([1, 2]); + assert.strictEqual(batch.cursorAdvance, 3); + assert.isTrue(batch.truncated); + }), + ); + + it.effect("URL-encodes a search, so it cannot add a parameter of its own", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.listMergeRequests({ + cwd: "/w", + repository: "acme/web", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + query: '-a&per_page=1 "b"', + }); + + const path = argsOfCall(0)[1] ?? ""; + expect(path).toContain("search=-a%26per_page%3D1%20%22b%22"); + // The page size the walk fixed is still the only one in the query. + assert.strictEqual(path.match(/per_page=/g)?.length, 1); + }), + ); + + it.effect("asks for no search at all when the reader typed only spaces", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.listMergeRequests({ + cwd: "/w", + repository: "acme/web", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + query: " ", + }); + + expect(argsOfCall(0)[1]).not.toContain("search="); + }), + ); + + it.effect("stops walking on a short page", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(mergeRequests(40, 1)))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const batch = yield* cli.listMergeRequests({ + cwd: "/w", + repository: "acme/web", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 150, + }); + + assert.strictEqual(batch.items.length, 40); + assert.isFalse(batch.truncated); + assert.strictEqual(mockedExecute.mock.calls.length, 1); + }), + ); + + it.effect("stops walking when every row on a page fails to decode", () => + Effect.gen(function* () { + // Full pages of unusable rows: nothing is collected, so the collected-count bound never + // trips and only the page bound can end the walk. + const unusable = JSON.stringify(Array.from({ length: 100 }, () => ({ iid: "nope" }))); + mockedExecute.mockReturnValue(Effect.succeed(output(unusable))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const batch = yield* cli.listMergeRequests({ + cwd: "/w", + repository: "acme/web", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 150, + }); + + assert.strictEqual(batch.items.length, 0); + // ceil((150 + 1) / 100) pages, not one request per page forever. + assert.strictEqual(mockedExecute.mock.calls.length, 2); + }), + ); + + it.effect("asks GitLab for every state on the All tab", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.listMergeRequests({ + cwd: "/w", + repository: "acme/web", + state: "all", + involvement: "all", + viewer: "bilal", + limit: 10, + }); + + expect(argsOfCall(0)[1]).toContain("state=all"); + }), + ); + + it.effect("filters by the reviewer when the viewer is reviewing", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.listMergeRequests({ + cwd: "/w", + repository: "acme/web", + state: "open", + involvement: "reviewing", + viewer: "bilal", + limit: 10, + }); + + expect(argsOfCall(0)[1]).toContain("reviewer_username=bilal"); + }), + ); + + it.effect("addresses a nested group project by its encoded full path", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.listMergeRequests({ + cwd: "/w", + repository: "acme/platform/web", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + }); + + expect(argsOfCall(0)[1]).toContain("projects/acme%2Fplatform%2Fweb/merge_requests"); + }), + ); + + it.effect("merges immediately rather than leaving auto-merge armed", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(""))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.runMergeRequestAction({ + cwd: "/w", + repository: "acme/web", + number: 7, + action: "merge", + mergeMethod: "squash", + }); + + expect(argsOfCall(0)).toEqual([ + "mr", + "merge", + "7", + "--repo", + "acme/web", + "--auto-merge=false", + "--yes", + "--squash", + ]); + }), + ); + + it.effect("arms auto-merge with the same strategy a merge would have used", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(""))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.runMergeRequestAction({ + cwd: "/w", + repository: "acme/web", + number: 7, + action: "enable-auto-merge", + mergeMethod: "squash", + }); + + expect(argsOfCall(0)).toEqual([ + "mr", + "merge", + "7", + "--repo", + "acme/web", + "--auto-merge=true", + "--yes", + "--squash", + ]); + }), + ); + + it.effect("cancels an armed auto-merge through the API glab has no flag for", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("{}"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.runMergeRequestAction({ + cwd: "/w", + repository: "acme/platform/web", + number: 7, + action: "disable-auto-merge", + }); + + expect(argsOfCall(0)).toEqual([ + "api", + "projects/acme%2Fplatform%2Fweb/merge_requests/7/cancel_merge_when_pipeline_succeeds", + "--method", + "POST", + ]); + }), + ); + + it.effect("brings a stale branch up to date by rebasing it, the only way GitLab has", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(""))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.runMergeRequestAction({ + cwd: "/w", + repository: "acme/web", + number: 7, + action: "update-branch", + }); + + expect(argsOfCall(0)).toEqual(["mr", "rebase", "7", "--repo", "acme/web"]); + }), + ); + + it.effect("moves a merge request back to draft through glab", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(""))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.runMergeRequestAction({ + cwd: "/w", + repository: "acme/web", + number: 7, + action: "draft", + }); + + expect(argsOfCall(0)).toEqual(["mr", "update", "7", "--repo", "acme/web", "--draft"]); + }), + ); + + it.effect("sends a comment body over stdin, never in argv", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(""))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.commentOnMergeRequest({ + cwd: "/w", + repository: "acme/web", + number: 7, + body: "true", + }); + + const call = mockedExecute.mock.calls[0]; + assert.isDefined(call); + expect(call[0].args).toEqual([ + "api", + "projects/acme%2Fweb/merge_requests/7/notes", + "--method", + "POST", + "--input", + "-", + "--header", + "Content-Type: application/json", + ]); + // A JSON body, so a comment reading as a literal `true` stays text. + expect(call[0].stdin).toBe('{"body":"true"}'); + }), + ); + + it.effect("reads one diff page and hands back the cursor for the next", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(diffPage(0)))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const diff = yield* cli.getMergeRequestDiff({ + cwd: "/w", + repository: "acme/web", + number: 7, + }); + + // One page per call: the reader asks for the rest, the walk does not run on by itself. + assert.strictEqual(mockedExecute.mock.calls.length, 1); + assert.isNotNull(diff.nextCursor); + // A full page means more files, not a slice with something missing from it. + assert.isFalse(diff.truncated); + expect(argsOfCall(0)[1]).toContain("merge_requests/7/diffs?per_page=100&page=1"); + }), + ); + + it.effect("carries on from a cursor at the page it names", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce(Effect.succeed(output(diffPage(0)))) + .mockReturnValueOnce(Effect.succeed(output(diffPage(100, 3)))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + const target = { cwd: "/w", repository: "acme/web", number: 7 }; + + const first = yield* cli.getMergeRequestDiff(target); + assert.isNotNull(first.nextCursor); + const second = yield* cli.getMergeRequestDiff({ ...target, cursor: first.nextCursor }); + + expect(argsOfCall(1)[1]).toContain("page=2"); + // A short page is the end of the change set, so there is nothing to carry on from. + assert.isNull(second.nextCursor); + expect(second.patch).toContain("diff --git a/src/100.ts b/src/100.ts"); + }), + ); + + it.effect("refuses a cursor it never handed out rather than reading it into a query", () => + Effect.gen(function* () { + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const error = yield* Effect.flip( + cli.getMergeRequestDiff({ + cwd: "/w", + repository: "acme/web", + number: 7, + cursor: "1&per_page=1", + }), + ); + + assert.strictEqual(error._tag, "GitLabDiffCursorError"); + assert.strictEqual(mockedExecute.mock.calls.length, 0); + }), + ); + + it.effect("reads a named commit from its own diff, and pages inside it", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce(Effect.succeed(output(diffPage(0)))) + .mockReturnValueOnce(Effect.succeed(output(diffPage(100, 3)))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + const target = { + cwd: "/w", + repository: "acme/web", + number: 7, + commit: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", + }; + + const first = yield* cli.getMergeRequestDiff(target); + assert.isNotNull(first.nextCursor); + const second = yield* cli.getMergeRequestDiff({ ...target, cursor: first.nextCursor }); + + const commitPath = + "projects/acme%2Fweb/repository/commits/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0/diff"; + expect(argsOfCall(0)[1]).toBe(`${commitPath}?per_page=100&page=1`); + // The whole path, not just the page: a cursor branch that dropped the commit would still + // ask for page 2, of the merge request's own diff. + expect(argsOfCall(1)[1]).toBe(`${commitPath}?per_page=100&page=2`); + assert.isNull(second.nextCursor); + }), + ); + + it.effect("refuses a commit that is not a sha rather than reading it into a path", () => + Effect.gen(function* () { + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const error = yield* Effect.flip( + cli.getMergeRequestDiff({ + cwd: "/w", + repository: "acme/web", + number: 7, + commit: "../../merge_requests/8/diffs", + }), + ); + + assert.strictEqual(error._tag, "GitLabDiffCommitError"); + assert.strictEqual(mockedExecute.mock.calls.length, 0); + }), + ); + + it.effect("reports a commit with no parent as a structured error", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed(output(JSON.stringify({ id: "a1b2c3d", parent_ids: [] }))), + ); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const error = yield* Effect.flip( + cli.getMergeRequestDiffFileContents({ + cwd: "/w", + repository: "acme/web", + number: 7, + commit: "a1b2c3d", + changeType: "change", + oldPath: "src/a.ts", + newPath: "src/a.ts", + }), + ); + + assert.strictEqual(error._tag, "GitLabDiffCommitParentUnavailableError"); + if (error._tag === "GitLabDiffCommitParentUnavailableError") { + assert.strictEqual(error.commit, "a1b2c3d"); + } + }), + ); + + it.effect("expands a new file from a root commit without requiring a parent", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed(output(JSON.stringify({ id: "a1b2c3d", parent_ids: [] }))), + ); + mockedExecute.mockReturnValueOnce(Effect.succeed(output("first contents\n"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const contents = yield* cli.getMergeRequestDiffFileContents({ + cwd: "/w", + repository: "acme/web", + number: 7, + commit: "a1b2c3d", + changeType: "new", + oldPath: "src/first.ts", + newPath: "src/first.ts", + }); + + expect(contents).toEqual({ oldContents: "", newContents: "first contents\n" }); + expect(argsOfCall(1)[1]).toContain("raw?ref=a1b2c3d"); + }), + ); + + it.effect("reports an oversized diff file with its path and reason", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + JSON.stringify({ + diff_refs: { + base_sha: "a1b2c3d", + head_sha: "b1c2d3e", + start_sha: "a1b2c3d", + }, + }), + ), + ), + ); + mockedExecute.mockReturnValueOnce(Effect.succeed(output("partial", true))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const error = yield* Effect.flip( + cli.getMergeRequestDiffFileContents({ + cwd: "/w", + repository: "acme/web", + number: 7, + changeType: "deleted", + oldPath: "src/large.ts", + newPath: "src/large.ts", + }), + ); + + assert.strictEqual(error._tag, "GitLabDiffFileContentsUnavailableError"); + if (error._tag === "GitLabDiffFileContentsUnavailableError") { + assert.strictEqual(error.path, "src/large.ts"); + assert.strictEqual(error.reason, "oversized"); + } + }), + ); + + it.effect("reports undecodable diff file contents as binary", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + JSON.stringify({ + diff_refs: { + base_sha: "a1b2c3d", + head_sha: "b1c2d3e", + start_sha: "a1b2c3d", + }, + }), + ), + ), + ); + mockedExecute.mockReturnValueOnce( + Effect.succeed(output("binary\uFFFDcontents", false, true)), + ); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const error = yield* Effect.flip( + cli.getMergeRequestDiffFileContents({ + cwd: "/w", + repository: "acme/web", + number: 7, + changeType: "deleted", + oldPath: "assets/logo.png", + newPath: "assets/logo.png", + }), + ); + + assert.strictEqual(error._tag, "GitLabDiffFileContentsUnavailableError"); + if (error._tag === "GitLabDiffFileContentsUnavailableError") { + assert.strictEqual(error.path, "assets/logo.png"); + assert.strictEqual(error.reason, "binary"); + } + }), + ); + + it.effect("returns valid text containing a literal replacement character", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + JSON.stringify({ + diff_refs: { + base_sha: "a1b2c3d", + head_sha: "b1c2d3e", + start_sha: "a1b2c3d", + }, + }), + ), + ), + ); + mockedExecute.mockReturnValueOnce(Effect.succeed(output("before\uFFFDafter"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const contents = yield* cli.getMergeRequestDiffFileContents({ + cwd: "/w", + repository: "acme/web", + number: 7, + changeType: "deleted", + oldPath: "docs/encoding.md", + newPath: "docs/encoding.md", + }); + + assert.strictEqual(contents.oldContents, "before\uFFFDafter"); + }), + ); + + it.effect("ends the diff on a page with no files rather than asking for it again", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const diff = yield* cli.getMergeRequestDiff({ + cwd: "/w", + repository: "acme/web", + number: 7, + cursor: "4", + }); + + assert.strictEqual(diff.patch, ""); + assert.isNull(diff.nextCursor); + }), + ); + + it.effect("fails a diff page cut off mid-JSON rather than calling the diff whole", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + // A byte-truncated prefix: valid JSON never survives the cut. + Effect.succeed({ ...output('[{"old_path":"src/x.ts","new_p'), stdoutTruncated: true }), + ); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const error = yield* Effect.flip( + cli.getMergeRequestDiff({ cwd: "/w", repository: "acme/web", number: 7 }), + ); + + // An empty slice with no cursor would report every file from this page on as already + // read, which is the one answer that loses a change without saying so. + assert.strictEqual(error._tag, "GitLabMergeRequestReadError"); + }), + ); + + it.effect("offers no squash when the project does not say it allows one", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed(output(JSON.stringify({ merge_method: "merge" }))), + ); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const capabilities = yield* cli.getProjectMergeCapabilities({ + cwd: "/w", + repository: "acme/web", + }); + + assert.deepStrictEqual(capabilities, { merge: true, squash: false, rebase: false }); + }), + ); + + it.effect("reads the project's merge settings as its merge capabilities", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed(output(JSON.stringify({ merge_method: "ff", squash_option: "never" }))), + ); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const capabilities = yield* cli.getProjectMergeCapabilities({ + cwd: "/w", + repository: "acme/web", + }); + + assert.deepStrictEqual(capabilities, { merge: false, squash: false, rebase: true }); + }), + ); + + it.effect("asks the detail read for the divergence GitLab withholds by default", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"message":"404 Not Found"}'))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* Effect.ignore( + cli.getMergeRequestDetail({ cwd: "/w", repository: "acme/web", number: 7 }), + ); + + expect(argsOfCall(0)[1]).toBe( + "projects/acme%2Fweb/merge_requests/7?include_diverged_commits_count=true", + ); + }), + ); + + it.effect("fails the read when GitLab returns something unreadable", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"message":"404 Not Found"}'))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const error = yield* Effect.flip( + cli.getMergeRequestDetail({ cwd: "/w", repository: "acme/web", number: 7 }), + ); + + assert.strictEqual(error._tag, "GitLabMergeRequestReadError"); + }), + ); + + it.effect("fails when the authenticated account has no username", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(JSON.stringify({ username: "" })))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const error = yield* Effect.flip(cli.getViewerUsername({ cwd: "/w" })); + + assert.strictEqual(error._tag, "GitLabViewerUnavailableError"); + }), + ); + + it.effect("walks the notes until GitLab answers with a short page", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(notes(100, 1)))); + mockedExecute.mockReturnValueOnce(Effect.succeed(output(notes(2, 101)))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const { comments, truncated } = yield* cli.listNotes({ + cwd: "/w", + repository: "acme/web", + number: 7, + }); + + expect(argsOfCall(0).join(" ")).toContain("page=1"); + expect(argsOfCall(1).join(" ")).toContain("page=2"); + assert.strictEqual(comments.length, 102); + assert.isFalse(truncated); + }), + ); + + it.effect("stops the note walk at its bound and says the conversation was cut short", () => + Effect.gen(function* () { + // GitLab that never answers short: the walk has to end itself. + mockedExecute.mockReturnValue(Effect.succeed(output(notes(100, 1)))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const { truncated } = yield* cli.listNotes({ + cwd: "/w", + repository: "acme/web", + number: 7, + }); + + assert.strictEqual(mockedExecute.mock.calls.length, 10); + assert.isTrue(truncated); + }), + ); + + it.effect("reads a positioned discussion as a thread anchored to its line", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + JSON.stringify([ + { + id: "abc123", + notes: [ + { + id: 1, + body: "rename this", + author: { username: "bilal", avatar_url: "https://avatars/b.png" }, + created_at: "2026-07-01T00:00:00Z", + resolvable: true, + resolved: true, + position: { + position_type: "text", + new_path: "src/a.ts", + old_path: "src/a.ts", + new_line: 12, + old_line: null, + }, + }, + { + id: 2, + body: "done", + author: { username: "julius" }, + created_at: "2026-07-01T01:00:00Z", + }, + ], + }, + // A plain note is the timeline's business, not the diff's. + { id: "def456", notes: [{ id: 3, body: "ship it", created_at: "2026-07-01Z" }] }, + ]), + ), + ), + ); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const { threads } = yield* cli.listDiscussions({ + cwd: "/w", + repository: "acme/web", + number: 7, + }); + + assert.strictEqual(threads.length, 1); + expect(threads[0]).toMatchObject({ + id: "abc123", + path: "src/a.ts", + line: 12, + side: "right", + isResolved: true, + }); + assert.strictEqual(threads[0]?.comments.length, 2); + }), + ); + + it.effect("sends a review as its comments, then its summary, then the verdict", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + JSON.stringify({ + iid: 7, + title: "t", + web_url: "https://gitlab.com/acme/web/-/merge_requests/7", + source_branch: "feat", + target_branch: "main", + created_at: "2026-07-01T00:00:00Z", + updated_at: "2026-07-01T00:00:00Z", + diff_refs: { base_sha: "base", head_sha: "head", start_sha: "start" }, + }), + ), + ), + ); + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.submitReview({ + cwd: "/w", + repository: "acme/web", + number: 7, + verdict: "approve", + body: "Looks right.", + comments: [ + { + path: "src/b.ts", + oldPath: "src/a.ts", + position: { kind: "deleted", oldLine: 4 }, + body: "why remove?", + }, + ], + }); + + // The diff revisions first, because a positioned comment cannot be placed without them. + expect(argsOfCall(0)[1]).toContain("merge_requests/7"); + expect(argsOfCall(1)[1]).toContain("/discussions"); + expect(JSON.parse(callAt(1).stdin ?? "")).toEqual({ + body: "why remove?", + position: { + base_sha: "base", + head_sha: "head", + start_sha: "start", + position_type: "text", + // A renamed file is the only case the two differ, and GitLab cannot place a + // position that names the same path on both sides of the rename. + old_path: "src/a.ts", + new_path: "src/b.ts", + old_line: 4, + }, + }); + expect(argsOfCall(2)[1]).toContain("/notes"); + // The verdict goes last, so a review that failed part-way is never an approval. + expect(argsOfCall(3)[1]).toContain("/approve"); + }), + ); + + it.effect("does not ask for diff revisions when a review carries no line comments", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.submitReview({ + cwd: "/w", + repository: "acme/web", + number: 7, + verdict: "comment", + body: "One thought.", + comments: [], + }); + + assert.strictEqual(mockedExecute.mock.calls.length, 1); + expect(argsOfCall(0)[1]).toContain("/notes"); + }), + ); + + it.effect("resolves a discussion in place rather than posting to it", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.setDiscussionResolution({ + cwd: "/w", + repository: "acme/web", + number: 7, + discussionId: "abc123", + resolved: true, + }); + + expect(argsOfCall(0)).toContain("--method"); + expect(argsOfCall(0)).toContain("PUT"); + expect(argsOfCall(0)[1]).toContain("/discussions/abc123"); + expect(JSON.parse(callAt(0).stdin ?? "")).toEqual({ resolved: true }); + }), + ); + + it.effect("awards an emoji through a POST naming it, not a body", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.setReaction({ + cwd: "/w", + repository: "acme/web", + number: 7, + content: "thumbs-up", + reacted: true, + }); + + assert.strictEqual(mockedExecute.mock.calls.length, 1); + expect(argsOfCall(0)).toEqual([ + "api", + "projects/acme%2Fweb/merge_requests/7/award_emoji?name=thumbsup", + "--method", + "POST", + ]); + }), + ); + + it.effect("removes an award by listing them and deleting the reader's own id", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed(output(JSON.stringify({ username: "bilal" }))), + ); + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + JSON.stringify([ + { id: 5, name: "thumbsup", user: { username: "bilal" } }, + { id: 6, name: "thumbsup", user: { username: "julius" } }, + ]), + ), + ), + ); + mockedExecute.mockReturnValueOnce(Effect.succeed(output("{}"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.setReaction({ + cwd: "/w", + repository: "acme/web", + number: 7, + content: "thumbs-up", + reacted: false, + }); + + assert.strictEqual(mockedExecute.mock.calls.length, 3); + expect(argsOfCall(2)).toEqual([ + "api", + "projects/acme%2Fweb/merge_requests/7/award_emoji/5", + "--method", + "DELETE", + ]); + }), + ); + + it.effect("does nothing when the reader has no award of that name to take back", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed(output(JSON.stringify({ username: "bilal" }))), + ); + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.setReaction({ + cwd: "/w", + repository: "acme/web", + number: 7, + content: "thumbs-up", + reacted: false, + }); + + // Nothing to delete: the reaction the caller asked to take back is already gone. + assert.strictEqual(mockedExecute.mock.calls.length, 2); + }), + ); + + it.effect("names a merge request with no diff revisions rather than calling it unreadable", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + JSON.stringify({ + iid: 7, + title: "t", + web_url: "https://gitlab.com/acme/web/-/merge_requests/7", + source_branch: "feat", + target_branch: "main", + created_at: "2026-07-01T00:00:00Z", + updated_at: "2026-07-01T00:00:00Z", + diff_refs: null, + }), + ), + ), + ); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const error = yield* Effect.flip( + cli.submitReview({ + cwd: "/w", + repository: "acme/web", + number: 7, + verdict: "comment", + body: "", + comments: [{ path: "src/a.ts", position: { kind: "added", newLine: 4 }, body: "nit" }], + }), + ); + + // Nothing failed to decode: GitLab answered, and the answer has nowhere to put a + // positioned comment. + assert.strictEqual(error._tag, "GitLabDiffRefsUnavailableError"); + }), + ); + + it.effect("reads who has access to the project and who is already on the merge request", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce(Effect.succeed(output(mergeRequestJson({ reviewers: [reviewer] })))) + .mockReturnValueOnce( + Effect.succeed(output(JSON.stringify([author, reviewer, { id: 9, username: "hubot" }]))), + ); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const list = yield* cli.listReviewerCandidates({ + cwd: "/w", + repository: "acme/web", + number: 7, + }); + + expect(argsOfCall(1)[1]).toBe("projects/acme%2Fweb/users?per_page=100"); + // The author is left out, and whoever GitLab already has as a reviewer is marked. + expect(list.candidates.map((candidate) => [candidate.id, candidate.isRequested])).toEqual([ + ["5", true], + ["9", false], + ]); + assert.isFalse(list.truncated); + }), + ); + + it.effect("writes the reviewer set back with the one being asked added to it", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce(Effect.succeed(output(mergeRequestJson({ reviewers: [reviewer] })))) + .mockReturnValueOnce(Effect.succeed(output("{}"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.setReviewerRequest({ + cwd: "/w", + repository: "acme/web", + number: 7, + reviewers: [{ id: "9" }], + requested: true, + }); + + // GitLab replaces the whole set, so the reviewer already on the merge request has to be + // sent back with the new one or the request would take them off it. + expect(argsOfCall(1)).toContain("PUT"); + expect(JSON.parse(callAt(1).stdin ?? "")).toEqual({ reviewer_ids: [5, 9] }); + }), + ); + + it.effect("takes a reviewer out of the set rather than clearing it", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce( + Effect.succeed( + output(mergeRequestJson({ reviewers: [reviewer, { id: 9, username: "hubot" }] })), + ), + ) + .mockReturnValueOnce(Effect.succeed(output("{}"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.setReviewerRequest({ + cwd: "/w", + repository: "acme/web", + number: 7, + reviewers: [{ id: "9" }], + requested: false, + }); + + expect(JSON.parse(callAt(1).stdin ?? "")).toEqual({ reviewer_ids: [5] }); + }), + ); + + it.effect("ignores an id GitLab could not have handed out, which names nobody", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce(Effect.succeed(output(mergeRequestJson({ reviewers: [reviewer] })))) + .mockReturnValueOnce(Effect.succeed(output("{}"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.setReviewerRequest({ + cwd: "/w", + repository: "acme/web", + number: 7, + reviewers: [{ id: "octocat" }], + requested: true, + }); + + // Sending it as a number would rewrite the reviewer set around something nobody chose. + expect(JSON.parse(callAt(1).stdin ?? "")).toEqual({ reviewer_ids: [5] }); + }), + ); + + it.effect("rewrites a title without touching the description", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.updateMergeRequest({ + cwd: "/w", + repository: "acme/web", + number: 7, + title: "A better title", + }); + + expect(argsOfCall(0)).toEqual([ + "api", + "projects/acme%2Fweb/merge_requests/7", + "--method", + "PUT", + "--input", + "-", + "--header", + "Content-Type: application/json", + ]); + expect(JSON.parse(callAt(0).stdin ?? "")).toEqual({ title: "A better title" }); + }), + ); + + it.effect("sends a rewritten body as GitLab's description, and nothing else", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.updateMergeRequest({ + cwd: "/w", + repository: "acme/web", + number: 7, + description: "What this changes.", + }); + + // A title sent as an empty string would wipe the one the merge request already has. + expect(JSON.parse(callAt(0).stdin ?? "")).toEqual({ description: "What this changes." }); + }), + ); + + it.effect("rewrites title and description together in one request", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.updateMergeRequest({ + cwd: "/w", + repository: "acme/web", + number: 7, + title: "A better title", + description: "What this changes.", + }); + + assert.strictEqual(mockedExecute.mock.calls.length, 1); + expect(JSON.parse(callAt(0).stdin ?? "")).toEqual({ + title: "A better title", + description: "What this changes.", + }); + }), + ); + + it.effect("rewrites a note in place through the note it names", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.updateNote({ + cwd: "/w", + repository: "acme/web", + number: 7, + noteId: "42", + body: "true", + }); + + expect(argsOfCall(0)).toEqual([ + "api", + "projects/acme%2Fweb/merge_requests/7/notes/42", + "--method", + "PUT", + "--input", + "-", + "--header", + "Content-Type: application/json", + ]); + // A JSON body, so a note rewritten to a literal `true` stays text. + expect(callAt(0).stdin).toBe('{"body":"true"}'); + }), + ); + it.effect("reads blob ids for the marked paths at the merge request's head", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + JSON.stringify({ + iid: 7, + title: "t", + web_url: "https://gitlab.com/acme/web/-/merge_requests/7", + source_branch: "feat", + target_branch: "main", + created_at: "2026-07-01T00:00:00Z", + updated_at: "2026-07-01T00:00:00Z", + diff_refs: { base_sha: "base", head_sha: "head", start_sha: "start" }, + }), + ), + ), + ); + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + JSON.stringify({ + data: { + project: { repository: { blobs: { nodes: [{ path: "src/a.ts", oid: "aaa" }] } } }, + }, + }), + ), + ), + ); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const revisions = yield* cli.getFileRevisions({ + cwd: "/w", + repository: "acme/web", + number: 7, + paths: ["src/a.ts", "src/gone.ts"], + }); + + // Every path was looked for at the head, so one the head does not have is one the merge + // request removed: said as the empty version, which a mark on it was stamped with too. + expect([...revisions]).toEqual([ + ["src/a.ts", "aaa"], + ["src/gone.ts", ""], + ]); + // The head the reader is looking at, not whatever the source branch has moved on to. + const body: unknown = JSON.parse(callAt(1).stdin ?? "{}"); + expect(body).toMatchObject({ + variables: { fullPath: "acme/web", ref: "head", paths: ["src/a.ts", "src/gone.ts"] }, + }); + }), + ); + + it.effect("leaves the paths out when GitLab did not answer the blobs query", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + JSON.stringify({ + iid: 7, + title: "t", + web_url: "https://gitlab.com/acme/web/-/merge_requests/7", + source_branch: "feat", + target_branch: "main", + created_at: "2026-07-01T00:00:00Z", + updated_at: "2026-07-01T00:00:00Z", + diff_refs: { base_sha: "base", head_sha: "head", start_sha: "start" }, + }), + ), + ), + ); + // What GitLab says of a project the token cannot see. It is not the head having none of + // these files, and reading it that way would report every file the reader has cleared as + // changed on nothing worse than a permission. + mockedExecute.mockReturnValueOnce( + Effect.succeed(output(JSON.stringify({ data: { project: null } }))), + ); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const revisions = yield* cli.getFileRevisions({ + cwd: "/w", + repository: "acme/web", + number: 7, + paths: ["src/a.ts", "src/b.ts"], + }); + + expect([...revisions]).toEqual([]); + }), + ); + + it.effect("asks GitLab nothing when no file is marked", () => + Effect.gen(function* () { + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const revisions = yield* cli.getFileRevisions({ + cwd: "/w", + repository: "acme/web", + number: 7, + paths: [], + }); + + expect([...revisions]).toEqual([]); + expect(mockedExecute).not.toHaveBeenCalled(); + }), + ); + + it.effect("splits the paths across requests, because GitLab charges the query by how many", () => + Effect.gen(function* () { + const paths = Array.from({ length: 150 }, (_, index) => `src/${index}.ts`); + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + JSON.stringify({ + iid: 7, + title: "t", + web_url: "https://gitlab.com/acme/web/-/merge_requests/7", + source_branch: "feat", + target_branch: "main", + created_at: "2026-07-01T00:00:00Z", + updated_at: "2026-07-01T00:00:00Z", + diff_refs: { base_sha: "base", head_sha: "head", start_sha: "start" }, + }), + ), + ), + ); + mockedExecute.mockImplementation((request) => { + const body = JSON.parse(request.stdin ?? "{}") as { + readonly variables: { readonly paths: ReadonlyArray }; + }; + return Effect.succeed( + output( + JSON.stringify({ + data: { + project: { + repository: { + blobs: { + nodes: body.variables.paths.map((path) => ({ path, oid: `oid-${path}` })), + }, + }, + }, + }, + }), + ), + ); + }); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const revisions = yield* cli.getFileRevisions({ + cwd: "/w", + repository: "acme/web", + number: 7, + paths, + }); + + assert.strictEqual(revisions.size, 150); + assert.strictEqual(revisions.get("src/149.ts"), "oid-src/149.ts"); + // The diff refs, then two batches: a hundred paths and the fifty left over. + assert.strictEqual(mockedExecute.mock.calls.length, 3); + }), + ); +}); diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts new file mode 100644 index 0000000000000000000000000000000000000000..643d130c85bc8a35410eec4eadbdb98775c95f4c --- /dev/null +++ b/packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts @@ -0,0 +1,1495 @@ +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 "./GitLabCli.ts"; +import { + AWARD_EMOJI_GRAPHQL_QUERY, + decodeAwardEmojiJson, + decodeCommitDiffRefsJson, + decodeCommitsJson, + decodeDiffRefsJson, + decodeDiscussionsJson, + decodeMergeRequestDetailJson, + decodeMergeRequestDiffsJson, + decodeMergeRequestListJson, + decodeNotesJson, + decodeOwnAwardIdJson, + decodeProjectMergeCapabilitiesJson, + decodeProjectUsersJson, + decodeRepositoryBlobsJson, + decodeViewerJson, + gitLabAwardName, + REPOSITORY_BLOBS_GRAPHQL_QUERY, + type GitLabDiffRefs, + type GitLabMergeRequestDetail, + type GitLabMergeRequestListItem, + type GitLabProjectUsers, +} from "./gitLabMergeRequestJson.ts"; +import type { ProviderListCursor } from "@t3tools/source-control-core/server/PullRequestProvider"; + +/** + * 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.TaggedError()( + "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.TaggedError()( + "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.TaggedError()( + "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.TaggedError()( + "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.TaggedError()( + "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.TaggedError()( + "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.TaggedError()( + "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; + 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; + + 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; + + readonly getMergeRequestDetail: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + }) => Effect.Effect; + + readonly listNotes: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + }) => Effect.Effect< + { readonly comments: ReadonlyArray; readonly truncated: boolean }, + GitLabPullRequestCliError + >; + + readonly listCommits: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + }) => Effect.Effect, 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; + + 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; + + /** + * What the merge request's head has of each of these paths, as blob ids. + * + * The head sha comes from the merge request's own diff refs, so the answer is the version a + * reader is looking at rather than whatever the source branch has moved on to. A path the + * head does not have is answered as the empty revision, since the batch it was asked in was + * looked at; a batch GitLab did not answer for is left out instead. + */ + readonly getFileRevisions: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly paths: ReadonlyArray; + }) => Effect.Effect, 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; + + readonly setReviewerRequest: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly reviewers: ReadonlyArray<{ readonly id: string }>; + readonly requested: boolean; + }) => Effect.Effect; + + readonly runMergeRequestAction: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly action: PullRequestAction; + readonly mergeMethod?: PullRequestMergeMethod; + }) => Effect.Effect; + + /** 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; + + readonly commentOnMergeRequest: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly body: string; + }) => Effect.Effect; + + readonly updateNote: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly noteId: string; + readonly body: string; + }) => Effect.Effect; + + readonly listDiscussions: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + }) => Effect.Effect< + { readonly threads: ReadonlyArray; 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; + }) => Effect.Effect; + + readonly replyToDiscussion: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly discussionId: string; + readonly body: string; + }) => Effect.Effect; + + readonly setDiscussionResolution: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly discussionId: string; + readonly resolved: boolean; + }) => Effect.Effect; + + /** 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; + readonly reactionsByNoteId: ReadonlyMap>; + }, + 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; + } +>()("@t3tools/source-control-gitlab/server/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 { + 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 { + const trimmed = search?.trim() ?? ""; + return trimmed.length === 0 ? [] : [["search", trimmed]]; +} + +function query(params: ReadonlyArray): string { + return params.map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join("&"); +} + +function actionArgs( + action: PullRequestAction, + mergeMethod: PullRequestMergeMethod | undefined, +): ReadonlyArray { + 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 + // merge-the-target-in equivalent of GitHub's update button, which is why this host declares + // `rebase` alone and never has to read the method it was handed. + case "update-branch": + return ["rebase"]; + case "reopen": + return ["reopen"]; + // Never reached: this host does not declare the action, so the service refuses it first. + case "revert": + case "approve-workflows": + throw new Error(`GitLab merge request action ${action} is unsupported`); + } +} + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const gitlab = yield* GitLabCli.GitLabCli; + + const api = (input: { + readonly cwd: string; + readonly path: string; + readonly method?: string; + readonly stdin?: string; + readonly maxOutputBytes?: number; + readonly timeoutMs?: number; + }) => + gitlab.execute({ + cwd: input.cwd, + args: [ + "api", + input.path, + ...(input.method === undefined ? [] : ["--method", input.method]), + // A raw body from stdin: argv is visible in process listings and is echoed back + // inside process-runner failure messages. Unlike `gh`, `glab api --input` sends no + // Content-Type at all, and GitLab answers a bodyless content type with HTTP 415. + ...(input.stdin === undefined + ? [] + : ["--input", "-", "--header", "Content-Type: application/json"]), + ], + ...(input.stdin === undefined ? {} : { stdin: input.stdin }), + ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), + ...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }), + }); + + /** + * `per_page` stops at 100, so a larger page is walked one request at a time. The walk is + * bounded twice over: it stops on a short page or once the extra row that reveals a next + * page has been read, and it never asks for more pages than the caller's page needs. The + * second bound is what makes it terminate when every row on a page fails to decode, which + * leaves nothing collected but does not mean GitLab has run out of rows. + */ + const listPage = (input: { + readonly cwd: string; + readonly repository: string; + readonly state: PullRequestListState; + readonly involvement: PullRequestInvolvement; + readonly viewer: string; + readonly limit: number; + readonly query?: string | undefined; + readonly cursor?: ProviderListCursor | undefined; + readonly page: number; + readonly collected: ReadonlyArray; + readonly cursorAdvance: number; + }): Effect.Effect => { + // A continuation uses GitLab's offset pagination. Its timestamp filter is inclusive and has + // no tie-breaker, so a page where many rows share the boundary would otherwise return the + // same prefix forever. `delivered` is the stable offset the service has already handed over. + const delivered = input.cursor?.delivered ?? 0; + const perPage = Math.min(input.limit + 1, MAX_PAGE_SIZE); + const firstPage = Math.floor(delivered / perPage) + 1; + const skipOnFirstPage = input.page === firstPage ? delivered % perPage : 0; + // A page made entirely of malformed rows has no item from which the service can build a + // continuation. Bound the walk to the raw span this request asked for rather than recursing + // forever on a host that keeps returning full unusable pages. + const lastPage = Math.floor((delivered + input.limit) / perPage) + 1; + return api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/merge_requests?${query([ + ["state", stateParam(input.state)], + ...involvementParams(input), + // The listing is read through `glab api` rather than `glab mr list`, so the search is + // the REST API's own `search` parameter — the one `mr list --search` passes on. It + // matches title and description, and travels URL-encoded like every other value here, + // so no text in it can become a parameter of its own. + ...searchParams(input.query), + ["order_by", "updated_at"], + ["sort", "desc"], + ["per_page", String(perPage)], + ["page", String(input.page)], + ])}`, + }).pipe( + Effect.flatMap((result) => { + const raw = result.stdout.trim(); + if (raw.length === 0) { + return Effect.succeed({ + items: input.collected, + truncated: false, + cursorAdvance: input.cursorAdvance, + }); + } + const decoded = decodeMergeRequestListJson(raw); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "listMergeRequests", + cause: decoded.failure, + }), + ); + } + const pageItems: GitLabMergeRequestListItem[] = []; + const pageRawIndexes: number[] = []; + for (const [index, item] of decoded.success.items.entries()) { + const rawIndex = decoded.success.rawIndexes[index]!; + if (rawIndex < skipOnFirstPage) continue; + pageItems.push(item); + pageRawIndexes.push(rawIndex); + } + const remaining = input.limit - input.collected.length; + const lastItemRawIndex = pageRawIndexes[remaining - 1]; + if (lastItemRawIndex !== undefined) { + const consumed = lastItemRawIndex + 1 - skipOnFirstPage; + return Effect.succeed({ + items: [...input.collected, ...pageItems.slice(0, remaining)], + truncated: + lastItemRawIndex + 1 < decoded.success.rawCount || + decoded.success.rawCount === perPage, + cursorAdvance: input.cursorAdvance + consumed, + }); + } + const collected = [...input.collected, ...pageItems]; + const consumed = Math.max(0, decoded.success.rawCount - skipOnFirstPage); + // Counted before decoding, so a skipped malformed row cannot end paging early. + const exhausted = decoded.success.rawCount < perPage; + if (exhausted) { + return Effect.succeed({ + items: collected, + truncated: false, + cursorAdvance: input.cursorAdvance + consumed, + }); + } + if (input.page >= lastPage) { + return Effect.succeed({ + items: collected, + truncated: true, + cursorAdvance: input.cursorAdvance + consumed, + }); + } + return listPage({ + ...input, + page: input.page + 1, + collected, + cursorAdvance: input.cursorAdvance + consumed, + }); + }), + ); + }; + + /** + * One page of a merge request's files, as a patch that stands on its own. GitLab pages + * `/diffs` by offset and has no cursor of its own, so the page number is the cursor; the + * caller carries on from it for as long as GitLab keeps handing full pages back. + * + * A named commit is read from the commit's own diff, which answers in the same shape and pages + * the same way. + */ + const diffPage = (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly page: number; + readonly commit?: string | undefined; + }): Effect.Effect => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/${ + input.commit === undefined + ? `merge_requests/${input.number}/diffs` + : `repository/commits/${input.commit}/diff` + }?${query([ + ["per_page", String(MAX_PAGE_SIZE)], + ["page", String(input.page)], + ])}`, + maxOutputBytes: DIFF_MAX_OUTPUT_BYTES, + timeoutMs: DIFF_TIMEOUT_MS, + }).pipe( + Effect.flatMap((result) => { + // A byte-truncated response is a JSON prefix, so this page cannot be read at all. + // Answering with no cursor would call the diff whole while silently dropping this page + // and every one after it, so the read fails and says which page could not be had. + if (result.stdoutTruncated) { + return Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "getMergeRequestDiff", + cause: new Error( + `Page ${input.page} of the merge request diff was too large to read.`, + ), + }), + ); + } + const decoded = decodeMergeRequestDiffsJson(result.stdout.trim()); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "getMergeRequestDiff", + cause: decoded.failure, + }), + ); + } + const patch = decoded.success.patch; + // Counted before decoding, so a page whose files all failed to decode still moves on + // rather than pointing the reader back at the page it just read. + const morePages = decoded.success.rawCount >= MAX_PAGE_SIZE; + return Effect.succeed({ + // The slice ends on a newline, so a file GitLab gave a header and no hunks for does + // not run into the first line of the next slice. + patch: patch.length === 0 ? patch : patch.replace(/\n?$/, "\n"), + truncated: decoded.success.truncated, + nextCursor: morePages ? String(input.page + 1) : null, + }); + }), + ); + + /** + * The conversation, a page at a time. GitLab pages by offset and reports no total, so a short + * page is the only thing that says it is done — and the raw count decides, not the kept one: + * the notes GitLab wrote itself are dropped, and a whole page of them still means there is + * more to read. + */ + const notesPage = (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly page: number; + readonly collected: ReadonlyArray; + }): Effect.Effect< + { readonly comments: ReadonlyArray; readonly truncated: boolean }, + GitLabPullRequestCliError + > => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/notes?${query( + [ + ["per_page", String(MAX_PAGE_SIZE)], + ["page", String(input.page)], + ["order_by", "created_at"], + ["sort", "asc"], + ], + )}`, + }).pipe( + Effect.flatMap((result) => { + const decoded = decodeNotesJson(result.stdout.trim()); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "listNotes", + cause: decoded.failure, + }), + ); + } + const collected = [...input.collected, ...decoded.success.comments]; + if (decoded.success.rawCount < MAX_PAGE_SIZE) { + return Effect.succeed({ comments: collected, truncated: false }); + } + return input.page >= CONVERSATION_PAGES + ? Effect.succeed({ comments: collected, truncated: true }) + : notesPage({ ...input, page: input.page + 1, collected }); + }), + ); + + /** The positioned discussions, walked the same way and stopped by the same bound. */ + const discussionsPage = (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly page: number; + readonly collected: ReadonlyArray; + }): Effect.Effect< + { readonly threads: ReadonlyArray; readonly truncated: boolean }, + GitLabPullRequestCliError + > => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/discussions?${query( + [ + ["per_page", String(MAX_PAGE_SIZE)], + ["page", String(input.page)], + ], + )}`, + }).pipe( + Effect.flatMap((result) => { + const decoded = decodeDiscussionsJson(result.stdout.trim()); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "listDiscussions", + cause: decoded.failure, + }), + ); + } + const collected = [...input.collected, ...decoded.success.threads]; + // The raw count again: this endpoint returns the plain notes too, so a full page of + // those is not the end of the positioned ones. + if (decoded.success.rawCount < MAX_PAGE_SIZE) { + return Effect.succeed({ threads: collected, truncated: false }); + } + return input.page >= CONVERSATION_PAGES + ? Effect.succeed({ threads: collected, truncated: true }) + : discussionsPage({ ...input, page: input.page + 1, collected }); + }), + ); + + /** + * The revisions a positioned comment is written against. GitLab resolves a comment's line + * against these three shas, so a review with line comments cannot be sent without them. + */ + const getDiffRefs = (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + }): Effect.Effect => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}`, + }).pipe( + Effect.flatMap((result): Effect.Effect => { + const decoded = decodeDiffRefsJson(result.stdout.trim()); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "getDiffRefs", + cause: decoded.failure, + }), + ); + } + // A merge request with no diff refs is a well-formed answer that cannot carry a + // positioned comment — a dead end, but not something that failed to be read. + return decoded.success === null + ? Effect.fail( + new GitLabDiffRefsUnavailableError({ + command: "glab", + cwd: input.cwd, + number: input.number, + }), + ) + : Effect.succeed(decoded.success); + }), + ); + + const getCommitDiffRefs = (input: { + readonly cwd: string; + readonly repository: string; + readonly commit: string; + readonly allowRoot: boolean; + }): Effect.Effect => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/repository/commits/${input.commit}`, + }).pipe( + Effect.flatMap((result): Effect.Effect => { + const decoded = decodeCommitDiffRefsJson(result.stdout.trim()); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "getMergeRequestDiffFileContents", + cause: decoded.failure, + }), + ); + } + return decoded.success === null + ? input.allowRoot + ? Effect.succeed({ + baseSha: "", + headSha: input.commit, + startSha: "", + }) + : Effect.fail( + new GitLabDiffCommitParentUnavailableError({ + command: "glab", + cwd: input.cwd, + commit: input.commit, + }), + ) + : Effect.succeed(decoded.success); + }), + ); + + /** + * The merge request itself, which several calls need for different parts of it: the detail for + * everything, and the reviewer paths for the ids GitLab writes a reviewer set with. + */ + const mergeRequestDetail = (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + }): Effect.Effect => + api({ + cwd: input.cwd, + // How far behind the target branch this one is comes only when asked for by name, and it + // is asked for here rather than on a second read because it is the same merge request. + path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}?${query([ + ["include_diverged_commits_count", "true"], + ])}`, + }).pipe( + Effect.flatMap((result) => { + const decoded = decodeMergeRequestDetailJson(result.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "getMergeRequestDetail", + cause: decoded.failure, + }), + ); + }), + ); + + /** The people with access to the project, one page deep. */ + const projectUsers = (input: { + readonly cwd: string; + readonly repository: string; + }): Effect.Effect => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/users?${query([ + ["per_page", String(MAX_PAGE_SIZE)], + ])}`, + }).pipe( + Effect.flatMap((result) => { + const decoded = decodeProjectUsersJson(result.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "listReviewerCandidates", + cause: decoded.failure, + }), + ); + }), + ); + + /** Where an award is written: a note of the merge request, or the merge request itself. */ + const awardSubjectPath = (input: { + readonly repository: string; + readonly number: number; + readonly noteId?: string | undefined; + }) => { + const mergeRequest = `projects/${projectPath(input.repository)}/merge_requests/${input.number}`; + return input.noteId === undefined + ? `${mergeRequest}/award_emoji` + : `${mergeRequest}/notes/${encodeURIComponent(input.noteId)}/award_emoji`; + }; + + /** + * The awards on the merge request and its notes, a page of notes at a time. Bounded by the same + * count as the conversation itself: awards past the notes that were read belong to notes the + * page is not showing. + */ + const awardsPage = (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly cursor: string | null; + readonly page: number; + readonly collected: { + readonly reactions: ReadonlyArray; + readonly reactionsByNoteId: Map>; + } | null; + }): Effect.Effect< + { + readonly reactions: ReadonlyArray; + readonly reactionsByNoteId: ReadonlyMap>; + }, + GitLabPullRequestCliError + > => + api({ + cwd: input.cwd, + path: "graphql", + method: "POST", + stdin: JSON.stringify({ + query: AWARD_EMOJI_GRAPHQL_QUERY, + variables: { + fullPath: input.repository, + iid: String(input.number), + cursor: input.cursor, + }, + }), + }).pipe( + Effect.flatMap((result) => { + const decoded = decodeAwardEmojiJson(result.stdout.trim()); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "listReactions", + cause: decoded.failure, + }), + ); + } + const collected = input.collected ?? { + reactions: decoded.success.reactions, + reactionsByNoteId: new Map>(), + }; + for (const [id, reactions] of decoded.success.reactionsByNoteId) + collected.reactionsByNoteId.set(id, reactions); + return decoded.success.nextCursor === null || input.page >= CONVERSATION_PAGES + ? Effect.succeed(collected) + : awardsPage({ + ...input, + cursor: decoded.success.nextCursor, + page: input.page + 1, + collected, + }); + }), + ); + + /** + * GitLab charges the blobs query by how many paths it is handed, and its connection hands back + * one page. A hundred at a time keeps each request inside both. + */ + const BLOB_PATHS_PER_REQUEST = 100; + + const blobsAt = (input: { + readonly cwd: string; + readonly repository: string; + readonly ref: string; + readonly paths: ReadonlyArray; + }): Effect.Effect | null, GitLabPullRequestCliError> => + api({ + cwd: input.cwd, + path: "graphql", + method: "POST", + stdin: JSON.stringify({ + query: REPOSITORY_BLOBS_GRAPHQL_QUERY, + variables: { fullPath: input.repository, ref: input.ref, paths: input.paths }, + }), + }).pipe( + Effect.flatMap( + (result): Effect.Effect | null, GitLabPullRequestCliError> => { + const decoded = decodeRepositoryBlobsJson(result.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "getFileRevisions", + cause: decoded.failure, + }), + ); + }, + ), + ); + + const fileRevisions = (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly paths: ReadonlyArray; + }): Effect.Effect, GitLabPullRequestCliError> => + input.paths.length === 0 + ? Effect.succeed(new Map()) + : getDiffRefs(input).pipe( + Effect.flatMap((refs) => { + const batches: Array> = []; + for (let at = 0; at < input.paths.length; at += BLOB_PATHS_PER_REQUEST) { + batches.push(input.paths.slice(at, at + BLOB_PATHS_PER_REQUEST)); + } + return Effect.forEach( + batches, + (paths) => + blobsAt({ ...input, ref: refs.headSha, paths }).pipe( + Effect.map((page) => ({ paths, page })), + ), + { concurrency: 2 }, + ).pipe( + Effect.map((pages) => { + const revisions = new Map(); + for (const { paths, page } of pages) { + // A batch GitLab did not answer says nothing about its paths, so they are left + // out and the caller reads them as versions it could not learn, which leaves + // the marks on them alone. Filling them in as removed would report every file + // a reader has cleared as changed over a project the token cannot see. + if (page === null) continue; + for (const [path, oid] of page) revisions.set(path, oid); + // Within a batch that was answered, every path was looked for at the head, so + // one that is not there is one the merge request removed. Said as the empty + // revision, which is an answer the caller can compare against and keep. + for (const path of paths) { + if (!revisions.has(path)) revisions.set(path, ""); + } + } + return revisions as ReadonlyMap; + }), + ); + }), + ); + + const viewerUsername = (input: { readonly cwd: string }) => + api({ cwd: input.cwd, path: "user" }).pipe( + Effect.flatMap((result): Effect.Effect => { + const decoded = decodeViewerJson(result.stdout.trim()); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "getViewerUsername", + cause: decoded.failure, + }), + ); + } + return decoded.success === null + ? Effect.fail(new GitLabViewerUnavailableError({ command: "glab", cwd: input.cwd })) + : Effect.succeed(decoded.success); + }), + ); + + return GitLabPullRequestCli.of({ + getViewerUsername: viewerUsername, + + listMergeRequests: (input) => { + const perPage = Math.min(input.limit + 1, MAX_PAGE_SIZE); + const page = Math.floor((input.cursor?.delivered ?? 0) / perPage) + 1; + return listPage({ ...input, page, collected: [], cursorAdvance: 0 }); + }, + + getMergeRequestDetail: mergeRequestDetail, + + listNotes: (input) => notesPage({ ...input, page: 1, collected: [] }), + + listReactions: (input) => awardsPage({ ...input, cursor: null, page: 1, collected: null }), + + getFileRevisions: fileRevisions, + + setReaction: (input) => + Effect.gen(function* () { + const subject = awardSubjectPath(input); + if (input.reacted) { + yield* api({ + cwd: input.cwd, + path: `${subject}?${query([["name", gitLabAwardName(input.content)]])}`, + method: "POST", + }); + return; + } + // GitLab deletes an award by its id and takes no emoji name there, so the reader's own + // award of that name is looked up first. Nothing to delete is success: the reaction the + // caller asked to take back is already gone. + const viewer = yield* viewerUsername({ cwd: input.cwd }); + const listed = yield* api({ cwd: input.cwd, path: subject }); + const own = decodeOwnAwardIdJson(listed.stdout.trim(), { + content: input.content, + viewer, + }); + if (!Result.isSuccess(own)) { + return yield* new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "setReaction", + cause: own.failure, + }); + } + if (own.success === null) return; + yield* api({ + cwd: input.cwd, + path: `${subject}/${own.success}`, + method: "DELETE", + }); + }), + + listCommits: (input) => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/commits?${query( + [ + ["per_page", String(COMMIT_PAGE_SIZE)], + ["with_stats", "true"], + ], + )}`, + }).pipe( + Effect.flatMap((result) => { + const decoded = decodeCommitsJson(result.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "listCommits", + cause: decoded.failure, + }), + ); + }), + ), + + getMergeRequestDiff: (input) => { + if (input.commit !== undefined && !isCommitSha(input.commit)) { + return Effect.fail(new GitLabDiffCommitError({ command: "glab", cwd: input.cwd })); + } + const target = { + cwd: input.cwd, + repository: input.repository, + number: input.number, + ...(input.commit === undefined ? {} : { commit: input.commit }), + }; + if (input.cursor === undefined) { + return diffPage({ ...target, page: 1 }); + } + const page = diffCursorPage(input.cursor); + return page === null + ? Effect.fail(new GitLabDiffCursorError({ command: "glab", cwd: input.cwd })) + : diffPage({ ...target, page }); + }, + + getMergeRequestDiffFileContents: (input) => + Effect.gen(function* () { + if (input.commit !== undefined && !isCommitSha(input.commit)) { + return yield* new GitLabDiffCommitError({ command: "glab", cwd: input.cwd }); + } + const refs = yield* input.commit === undefined + ? getDiffRefs(input) + : getCommitDiffRefs({ + cwd: input.cwd, + repository: input.repository, + commit: input.commit, + allowRoot: input.changeType === "new", + }); + + const readFile = (revision: string, filePath: string) => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/repository/files/${encodeURIComponent( + filePath, + )}/raw?ref=${encodeURIComponent(revision)}`, + maxOutputBytes: DIFF_FILE_MAX_OUTPUT_BYTES, + timeoutMs: DIFF_TIMEOUT_MS, + }).pipe( + Effect.flatMap((result) => + result.stdoutTruncated || + result.stdout.includes("\0") || + result.stdoutInvalidUtf8 === true + ? Effect.fail( + new GitLabDiffFileContentsUnavailableError({ + command: "glab", + cwd: input.cwd, + path: filePath, + reason: result.stdoutTruncated ? "oversized" : "binary", + }), + ) + : Effect.succeed(result.stdout), + ), + ); + + const [oldContents, newContents] = yield* Effect.all( + [ + input.changeType === "new" ? Effect.succeed("") : readFile(refs.baseSha, input.oldPath), + input.changeType === "deleted" + ? Effect.succeed("") + : readFile(refs.headSha, input.newPath), + ], + { concurrency: 2 }, + ); + return { oldContents, newContents }; + }), + + getProjectMergeCapabilities: (input) => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}?license=false`, + }).pipe( + Effect.flatMap((result) => { + const decoded = decodeProjectMergeCapabilitiesJson(result.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "getProjectMergeCapabilities", + cause: decoded.failure, + }), + ); + }), + ), + + listReviewerCandidates: (input) => + Effect.all([mergeRequestDetail(input), projectUsers(input)], { concurrency: 2 }).pipe( + Effect.map(([mergeRequest, users]) => { + const author = mergeRequest.author?.login; + const requested = new Set(mergeRequest.reviewRequestLogins); + return { + // The author is dropped rather than shown unusable: GitLab refuses to make the person + // who opened a merge request its reviewer. + candidates: users.candidates.flatMap((candidate) => + candidate.login === author + ? [] + : [{ ...candidate, isRequested: requested.has(candidate.login) }], + ), + truncated: users.rawCount >= MAX_PAGE_SIZE, + }; + }), + ), + + setReviewerRequest: (input) => + mergeRequestDetail(input).pipe( + Effect.flatMap((mergeRequest) => { + // GitLab has no endpoint that adds or removes one reviewer: `reviewer_ids` replaces the + // whole set, so the set that is already there is read first and the change applied to + // it. Asking again for somebody already on it writes the same set back, which is how + // GitLab re-requests a review. + const ids = new Set(mergeRequest.reviewerIds); + for (const reviewer of input.reviewers) { + const id = Number(reviewer.id); + // A candidate GitLab did not name is not an id it would accept, and sending it would + // rewrite the reviewer set around a number nobody chose. + if (!Number.isSafeInteger(id) || id <= 0) continue; + if (input.requested) ids.add(id); + else ids.delete(id); + } + return api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}`, + method: "PUT", + stdin: JSON.stringify({ reviewer_ids: [...ids] }), + }); + }), + Effect.asVoid, + ), + + runMergeRequestAction: (input) => { + // `glab mr merge` arms auto-merge and never disarms it, so the one direction the CLI has + // no flag for is asked of GitLab directly through the same `api` passthrough the rest of + // this module writes with. + if (input.action === "disable-auto-merge") { + return api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/cancel_merge_when_pipeline_succeeds`, + method: "POST", + }).pipe(Effect.asVoid); + } + const [subcommand, ...flags] = actionArgs(input.action, input.mergeMethod); + return gitlab + .execute({ + cwd: input.cwd, + args: ["mr", subcommand!, String(input.number), "--repo", input.repository, ...flags], + }) + .pipe(Effect.asVoid); + }, + + updateMergeRequest: (input) => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}`, + method: "PUT", + // Only the fields the caller asked to change: GitLab leaves out what it is not sent, and + // clears what it is sent empty — so a title corrected on its own must carry no + // description at all. + stdin: JSON.stringify({ + ...(input.title === undefined ? {} : { title: input.title }), + ...(input.description === undefined ? {} : { description: input.description }), + }), + }).pipe(Effect.asVoid), + + commentOnMergeRequest: (input) => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/notes`, + method: "POST", + // A JSON body rather than a `--raw-field`: glab coerces a field that reads as a + // literal `true` or a number, and a comment body is text either way. + stdin: JSON.stringify({ body: input.body }), + }).pipe(Effect.asVoid), + + updateNote: (input) => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/notes/${encodeURIComponent( + input.noteId, + )}`, + method: "PUT", + stdin: JSON.stringify({ body: input.body }), + }).pipe(Effect.asVoid), + + listDiscussions: (input) => discussionsPage({ ...input, page: 1, collected: [] }), + + submitReview: (input) => + Effect.gen(function* () { + const project = projectPath(input.repository); + const mergeRequest = `projects/${project}/merge_requests/${input.number}`; + // GitLab has no pending review to attach comments to, so a review is replayed as the + // requests it is made of: the line comments, then the summary, then the verdict. A + // failure part-way therefore leaves what was already posted in place, which is why + // the verdict goes last — a half-sent review is never an approval. + if (input.comments.length > 0) { + const refs = yield* getDiffRefs(input); + yield* Effect.forEach( + input.comments, + (comment) => + api({ + cwd: input.cwd, + path: `${mergeRequest}/discussions`, + method: "POST", + stdin: JSON.stringify({ + body: comment.body, + position: { + base_sha: refs.baseSha, + head_sha: refs.headSha, + start_sha: refs.startSha, + position_type: "text", + // Both paths are sent because GitLab resolves a position against both + // sides of the diff. They differ only for a renamed file, which is why the + // draft carries the name the file had before the change. + old_path: comment.oldPath ?? comment.path, + new_path: comment.path, + ...gitLabReviewPositionLines(comment.position), + }, + }), + }), + { discard: true }, + ); + } + if (input.body.trim().length > 0) { + yield* api({ + cwd: input.cwd, + path: `${mergeRequest}/notes`, + method: "POST", + // A JSON body rather than a `--raw-field`, for the reason the plain comment gives: + // glab coerces a field that reads as a literal `true` or a number. + // @effect-diagnostics-next-line preferSchemaOverJson:off + stdin: JSON.stringify({ body: input.body }), + }); + } + if (input.verdict === "approve") { + yield* api({ cwd: input.cwd, path: `${mergeRequest}/approve`, method: "POST" }); + } + }), + + replyToDiscussion: (input) => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/discussions/${encodeURIComponent( + input.discussionId, + )}/notes`, + method: "POST", + stdin: JSON.stringify({ body: input.body }), + }).pipe(Effect.asVoid), + + setDiscussionResolution: (input) => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/discussions/${encodeURIComponent( + input.discussionId, + )}`, + method: "PUT", + stdin: JSON.stringify({ resolved: input.resolved }), + }).pipe(Effect.asVoid), + }); +}); + +export const layer = Layer.effect(GitLabPullRequestCli, make); diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.test.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d506aace948929752948cb39339cd8d861eb314f --- /dev/null +++ b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.test.ts @@ -0,0 +1,214 @@ +import { assert, describe, expect, it, vi } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts"; +import { gitLabViewerPermissions, make } from "./GitLabPullRequestProvider.ts"; + +describe("gitLabViewerPermissions", () => { + it("offers everything to a viewer GitLab says can merge", () => { + expect(gitLabViewerPermissions({ viewerCanMerge: true })).toEqual({ + // Arming a merge for later and taking the arming back answer to the same `can_merge`. + actions: [ + "merge", + "ready", + "draft", + "close", + "reopen", + "update-branch", + "enable-auto-merge", + "disable-auto-merge", + ], + comment: true, + resolve: true, + verdicts: ["comment", "approve"], + // GitLab says nothing about who may set a reviewer, and an unreported permission is granted. + requestReviewers: true, + // Rebase and nothing else: GitLab cannot merge a target branch into a source branch, so + // offering the choice would be offering something no request could carry out. + updateMethods: ["rebase"], + }); + }); + + it("keeps merge, now and later, from a viewer GitLab says cannot", () => { + // `user.can_merge` already accounts for the role, the approval rules and a protected target + // branch, so it is the one answer here that does not have to be inferred. + expect(gitLabViewerPermissions({ viewerCanMerge: false })).toEqual({ + actions: ["ready", "draft", "close", "reopen"], + comment: true, + resolve: true, + verdicts: ["comment", "approve"], + requestReviewers: true, + }); + }); + + it("names no way of updating a branch it will not let this viewer update", () => { + // The action and the strategy behind it go together: a button offered with nothing to press + // it with, or a strategy left standing next to a withheld button, is a half-refusal. + expect(gitLabViewerPermissions({ viewerCanMerge: false }).updateMethods).toBeUndefined(); + }); + + it("treats an author with read access as any other reader, which is all GitLab says", () => { + // Its REST API names no relationship between the viewer and the merge request beyond + // `can_merge`, so the four an author keeps stay offered to everyone rather than being taken + // from the one person entitled to them. + expect(gitLabViewerPermissions({ viewerCanMerge: false }).actions).toEqual([ + "ready", + "draft", + "close", + "reopen", + ]); + }); +}); + +describe("getChangeRequest base freshness", () => { + const detail = { + number: 7, + title: "Merge request 7", + url: "https://gitlab.com/acme/web/-/merge_requests/7", + author: null, + headBranch: "feat/page", + baseBranch: "main", + state: "open" as const, + isDraft: false, + mergeability: "mergeable" as const, + additions: 0, + deletions: 0, + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-02T00:00:00Z", + reviewRequestLogins: [], + labels: [], + body: "", + changedFiles: 1, + mergedAt: null, + closedAt: null, + reviewers: [], + checks: [], + viewerCanMerge: true, + reviewerIds: [], + }; + + it.effect("reads checks without fetching project settings", () => + Effect.gen(function* () { + const provider = yield* make.pipe( + Effect.provide( + Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({ + getMergeRequestDetail: () => Effect.succeed(detail), + }), + ), + ); + const read = provider.getChangeRequestChecks; + if (read === undefined) return yield* Effect.die("checks read missing"); + expect( + yield* read({ cwd: "/w", repository: "group/subgroup/web", host: "gitlab.com", number: 7 }), + ).toEqual({ state: "open", checks: [] }); + }), + ); + + const readWith = (divergence: { readonly divergedCommits?: number }) => + Effect.gen(function* () { + const provider = yield* make; + return yield* provider.getChangeRequest({ + cwd: "/w", + repository: "acme/web", + host: "gitlab.com", + number: 7, + }); + }).pipe( + Effect.provide( + Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({ + getMergeRequestDetail: () => Effect.succeed({ ...detail, ...divergence }), + getProjectMergeCapabilities: () => + Effect.succeed({ merge: true, squash: true, rebase: true }), + }), + ), + ); + + it.effect("reads a counted divergence as a branch that has fallen behind", () => + Effect.gen(function* () { + const changeRequest = yield* readWith({ divergedCommits: 3 }); + + expect(changeRequest.baseComparison).toBe("behind"); + expect(changeRequest.behindBy).toBe(3); + }), + ); + + it.effect("reads a divergence of none as a branch that is current", () => + Effect.gen(function* () { + const changeRequest = yield* readWith({ divergedCommits: 0 }); + + expect(changeRequest.baseComparison).toBe("up-to-date"); + expect(changeRequest.behindBy).toBe(0); + }), + ); + + it.effect("says nothing at all where GitLab counted nothing", () => + Effect.gen(function* () { + // An install too old to answer has to leave the page silent rather than let it claim the + // branch is current, which is the one wrong thing this banner could say. + const changeRequest = yield* readWith({}); + + expect(changeRequest.baseComparison).toBe("unknown"); + expect(changeRequest.behindBy).toBeUndefined(); + }), + ); +}); + +describe("rewriting what has already been said", () => { + const updateMergeRequest = vi.fn(() => Effect.void); + const updateNote = vi.fn(() => Effect.void); + + const providerWith = make.pipe( + Effect.provide( + Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({ updateMergeRequest, updateNote }), + ), + ); + + it.effect("sends only the half of the merge request the reader rewrote", () => + Effect.gen(function* () { + const provider = yield* providerWith; + assert.isDefined(provider.updateChangeRequest); + + yield* provider.updateChangeRequest({ + cwd: "/w", + repository: "acme/web", + host: "gitlab.com", + number: 7, + body: "What this changes.", + }); + + // GitLab calls it the description, and the title stays out of the request entirely. + expect(updateMergeRequest).toHaveBeenCalledWith({ + cwd: "/w", + repository: "acme/web", + number: 7, + description: "What this changes.", + }); + }), + ); + + it.effect("rewrites a positioned comment through the same note as any other", () => + Effect.gen(function* () { + const provider = yield* providerWith; + assert.isDefined(provider.updateComment); + + yield* provider.updateComment({ + cwd: "/w", + repository: "acme/web", + host: "gitlab.com", + number: 7, + commentId: "42", + kind: "review-comment", + body: "Reworded.", + }); + + expect(updateNote).toHaveBeenCalledWith({ + cwd: "/w", + repository: "acme/web", + number: 7, + noteId: "42", + body: "Reworded.", + }); + }), + ); +}); diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts new file mode 100644 index 0000000000000000000000000000000000000000..1b5b987cd5acb3c25dc6fd8f080b703c6a07d6c9 --- /dev/null +++ b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts @@ -0,0 +1,339 @@ +import * as Effect from "effect/Effect"; +import type { + PullRequestCapabilities, + PullRequestReaction, + PullRequestViewerPermissions, +} from "@t3tools/contracts"; + +import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts"; +import { + PullRequestProviderError, + type PullRequestProviderFailure, + type ProviderChangeRequestActivity, + type ProviderChangeRequestDetail, + type PullRequestProviderApi, +} from "@t3tools/source-control-core/server/PullRequestProvider"; + +const CAPABILITIES: PullRequestCapabilities = { + diff: true, + comment: true, + actions: [ + "merge", + "ready", + "draft", + "close", + "reopen", + "update-branch", + "enable-auto-merge", + "disable-auto-merge", + ], + // GitLab offers all three, though a project settles on one; `mergeCapabilities` narrows it. + mergeMethods: ["merge", "squash", "rebase"], + // Rebase alone: GitLab moves a stale branch onto its target by replaying it, and has nothing + // that merges the target back in the way GitHub's update button can. Declaring only what it + // does is what lets a request to merge the target in be refused instead of quietly rebasing. + updateMethods: ["rebase"], + search: true, + reactions: true, + // GitLab keeps a reader's viewed files in one browser's local storage, where nothing outside + // that browser can read or write them. So the marks made here are this environment's own: they + // follow the reader between the clients connected to it, but they are not the ones gitlab.com + // shows, and the surface says so rather than implying a review can be carried on from there. + viewedFiles: "environment", + review: { + inlineComment: true, + reply: true, + resolve: true, + // No "changes requested": GitLab has approval and unresolved discussions, and nothing that + // says a merge request has been reviewed and rejected. + verdicts: ["comment", "approve"], + }, + reviewers: { request: true, listCandidates: true }, + edit: { changeRequest: true, comment: true }, +}; + +/** + * The actions `user.can_merge` answers for. Rebasing writes to the source branch rather than to + * the target, so it is not literally the same permission — but GitLab reports nothing narrower, + * and someone it will not let land this change has no business rewriting its branch either. + */ +const MERGE_ACTIONS: ReadonlySet = new Set([ + "merge", + "update-branch", + "enable-auto-merge", + "disable-auto-merge", +]); + +/** + * What the signed-in account may do here. GitLab answers exactly one of these questions per + * viewer, on the merge request itself: `user.can_merge`, which is why merging is the only thing + * narrowed. + * + * The rest stay granted. GitLab's REST API reports the viewer's role on the project but never + * whether they opened this merge request — and its author may close it, reopen it and move it in + * and out of draft whatever their role, just as the author of a note may resolve the discussion + * it started. Withholding those controls from the one person entitled to them is the worse of the + * two mistakes, so they are offered and GitLab explains any refusal itself. + * + * Asking for a review is granted for the same reason: GitLab takes a reviewer set from the author + * and from anyone with the Developer role, and states neither of those two facts here. + */ +export function gitLabViewerPermissions(input: { + readonly viewerCanMerge: boolean; +}): PullRequestViewerPermissions { + return { + // Arming the merge and taking the arming back are the merge, deferred, so they answer to + // the same `can_merge` the merge itself does. + actions: CAPABILITIES.actions.filter( + (action) => !MERGE_ACTIONS.has(action) || input.viewerCanMerge, + ), + comment: true, + resolve: true, + verdicts: CAPABILITIES.review.verdicts, + requestReviewers: true, + ...(input.viewerCanMerge ? { updateMethods: CAPABILITIES.updateMethods } : {}), + }; +} + +/** The CLI tags that mean the tool itself is unusable, rather than one request failing. */ +export function gitLabProviderFailure( + error: GitLabPullRequestCli.GitLabPullRequestCliError, +): PullRequestProviderFailure { + if (error._tag === "GitLabCliUnavailableError") return { reason: "missing-tool" }; + if (error._tag === "GitLabCliAuthenticationError") return { reason: "unauthenticated" }; + if (error._tag === "GitLabCliRateLimitError") return { reason: "rate-limited" }; + if (error._tag === "GitLabMergeRequestNotFoundError") return { reason: "not-found" }; + return { reason: "failed" }; +} + +export const make = Effect.gen(function* () { + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + const fail = (operation: string) => (error: GitLabPullRequestCli.GitLabPullRequestCliError) => + new PullRequestProviderError({ + provider: "gitlab", + operation, + ...gitLabProviderFailure(error), + detail: error.detail, + cause: error, + }); + + const provider: PullRequestProviderApi = { + kind: "gitlab", + capabilities: CAPABILITIES, + + getViewer: (input) => + cli.getViewerUsername({ cwd: input.cwd }).pipe(Effect.mapError(fail("getViewer"))), + + listChangeRequests: (input) => + cli + .listMergeRequests({ + cwd: input.cwd, + repository: input.repository, + state: input.state, + involvement: input.involvement, + viewer: input.viewer, + limit: input.limit, + query: input.query, + cursor: input.cursor, + }) + .pipe( + Effect.mapError(fail("listChangeRequests")), + // GitLab is asked for its merge requests by update, newest first, whether or not it is + // being carried on from — so every page it answers is one a cursor can continue. + Effect.map((batch) => ({ ...batch, continues: true })), + ), + + getChangeRequestChecks: (input) => + cli.getMergeRequestDetail(input).pipe( + Effect.map(({ state, checks }) => ({ state, checks })), + Effect.mapError(fail("getChangeRequestChecks")), + ), + + getChangeRequest: (input) => + Effect.all( + [ + cli.getMergeRequestDetail(input), + cli.getProjectMergeCapabilities({ cwd: input.cwd, repository: input.repository }), + ], + { concurrency: 2 }, + ).pipe( + Effect.mapError(fail("getChangeRequest")), + Effect.map(([mergeRequest, mergeCapabilities]): ProviderChangeRequestDetail => ({ + ...mergeRequest, + mergeCapabilities, + viewerPermissions: gitLabViewerPermissions(mergeRequest), + // A GitLab too old to count the divergence says nothing here rather than "up to + // date": the banner is worth missing, and a wrong all-clear is not worth showing. + baseComparison: + mergeRequest.divergedCommits === undefined + ? "unknown" + : mergeRequest.divergedCommits > 0 + ? "behind" + : "up-to-date", + ...(mergeRequest.divergedCommits === undefined + ? {} + : { behindBy: mergeRequest.divergedCommits }), + })), + ), + + getChangeRequestActivity: (input) => + Effect.all( + [ + cli + .listNotes(input) + .pipe(Effect.orElseSucceed(() => ({ comments: [], truncated: true }))), + cli.listCommits(input).pipe(Effect.orElseSucceed(() => [])), + cli + .listDiscussions(input) + .pipe(Effect.orElseSucceed(() => ({ threads: [], truncated: true }))), + // The notes endpoint carries no award of any kind, so they are read alongside it. A + // failed read costs the conversation its reactions rather than its words. + cli.listReactions(input).pipe( + Effect.orElseSucceed(() => ({ + reactions: [] as ReadonlyArray, + reactionsByNoteId: new Map>(), + })), + ), + ], + { concurrency: 4 }, + ).pipe( + Effect.mapError(fail("getChangeRequestActivity")), + Effect.map(([notes, commits, discussions, awards]): ProviderChangeRequestActivity => ({ + reactions: awards.reactions, + comments: notes.comments.map((comment) => ({ + ...comment, + reactions: awards.reactionsByNoteId.get(comment.id) ?? [], + })), + // GitLab reports no count of its own, so the walk's own total is the host's: the + // notes endpoint carries every comment on the merge request, including the ones + // written under a discussion, and it is read until GitLab runs out. + commentCount: notes.comments.length, + commentsTruncated: notes.truncated || discussions.truncated, + reviewThreads: discussions.threads.map((thread) => ({ + ...thread, + comments: thread.comments.map((comment) => ({ + ...comment, + reactions: awards.reactionsByNoteId.get(comment.id) ?? [], + })), + })), + commits, + })), + ), + + // The same read the detail takes it from, on its own: `user.can_merge` lives on the merge + // request, so there is no cheaper thing to ask GitLab. + getViewerPermissions: (input) => + cli + .getMergeRequestDetail(input) + .pipe(Effect.mapError(fail("getViewerPermissions")), Effect.map(gitLabViewerPermissions)), + + getDiff: (input) => cli.getMergeRequestDiff(input).pipe(Effect.mapError(fail("getDiff"))), + + // What each marked file is at the head, which is what tells a mark that still stands from one + // the branch has moved past. GitLab's own local-storage marks are keyed on the blob id too, + // so this stales at the same moment its web UI would. + getFileRevisions: (input) => + cli.getFileRevisions(input).pipe( + Effect.mapError(fail("getFileRevisions")), + Effect.map((revisions) => ({ revisions })), + ), + + // Users only: GitLab requests a review of a person, and the groups that can stand in for one + // appear in approval rules rather than in a merge request's reviewers. + listReviewerCandidates: (input) => + cli + .listReviewerCandidates({ + cwd: input.cwd, + repository: input.repository, + number: input.number, + }) + .pipe(Effect.mapError(fail("listReviewerCandidates"))), + + setReviewerRequest: (input) => + cli + .setReviewerRequest({ + cwd: input.cwd, + repository: input.repository, + number: input.number, + reviewers: input.reviewers, + requested: input.requested, + }) + .pipe(Effect.mapError(fail("setReviewerRequest"))), + + runAction: (input) => + cli + .runMergeRequestAction({ + cwd: input.cwd, + repository: input.repository, + number: input.number, + action: input.action, + ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), + }) + .pipe(Effect.mapError(fail("runAction"))), + + updateChangeRequest: (input) => + cli + .updateMergeRequest({ + cwd: input.cwd, + repository: input.repository, + number: input.number, + ...(input.title === undefined ? {} : { title: input.title }), + ...(input.body === undefined ? {} : { description: input.body }), + }) + .pipe(Effect.mapError(fail("updateChangeRequest"))), + + comment: (input) => cli.commentOnMergeRequest(input).pipe(Effect.mapError(fail("comment"))), + + // The kind is not read: every comment this provider hands out, positioned or not, carries a + // plain REST note id, and one endpoint rewrites both. + updateComment: (input) => + cli + .updateNote({ + cwd: input.cwd, + repository: input.repository, + number: input.number, + noteId: input.commentId, + body: input.body, + }) + .pipe(Effect.mapError(fail("updateComment"))), + + submitReview: (input) => cli.submitReview(input).pipe(Effect.mapError(fail("submitReview"))), + + replyToThread: (input) => + cli + .replyToDiscussion({ + cwd: input.cwd, + repository: input.repository, + number: input.number, + discussionId: input.threadId, + body: input.body, + }) + .pipe(Effect.mapError(fail("replyToThread"))), + + setReaction: (input) => + cli + .setReaction({ + cwd: input.cwd, + repository: input.repository, + number: input.number, + ...(input.subjectId === undefined ? {} : { noteId: input.subjectId }), + content: input.content, + reacted: input.reacted, + }) + .pipe(Effect.mapError(fail("setReaction"))), + + setThreadResolution: (input) => + cli + .setDiscussionResolution({ + cwd: input.cwd, + repository: input.repository, + number: input.number, + discussionId: input.threadId, + resolved: input.resolved, + }) + .pipe(Effect.mapError(fail("setThreadResolution"))), + }; + + return provider; +}); diff --git a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..513c2e2fc3b5070f14ed7b0ebefff489441f5517 --- /dev/null +++ b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts @@ -0,0 +1,309 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { ChildProcessSpawner } from "effect/process"; + +import * as GitLabCli from "./GitLabCli.ts"; +import { parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts"; +import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; + +function makeProvider(gitlab: Partial) { + return GitLabSourceControlProvider.make.pipe( + Effect.provide(Layer.mock(GitLabCli.GitLabCli)(gitlab)), + ); +} + +it.effect("maps GitLab MR summaries into provider-neutral change requests", () => + Effect.gen(function* () { + const provider = yield* makeProvider({ + getMergeRequest: () => + Effect.succeed({ + number: 42, + title: "Add GitLab provider", + url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", + baseRefName: "main", + headRefName: "feature/source-control", + state: "closed", + closedAt: "2026-08-23T10:00:00Z", + isCrossRepository: true, + headRepositoryNameWithOwner: "fork/t3code", + headRepositoryOwnerLogin: "fork", + }), + }); + + const changeRequest = yield* provider.getChangeRequest({ + cwd: "/repo", + reference: "42", + }); + + assert.deepStrictEqual(changeRequest, { + provider: "gitlab", + number: 42, + title: "Add GitLab provider", + url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", + baseRefName: "main", + headRefName: "feature/source-control", + state: "closed", + closedAt: "2026-08-23T10:00:00Z", + mergedAt: null, + updatedAt: Option.none(), + isCrossRepository: true, + headRepositoryNameWithOwner: "fork/t3code", + headRepositoryOwnerLogin: "fork", + }); + }), +); + +it.effect("adds repository context while retaining GitLab CLI causes", () => + Effect.gen(function* () { + const cause = new GitLabCli.GitLabCliCommandError({ + operation: "execute", + command: "glab", + cwd: "/repo", + cause: new Error("raw upstream detail that should remain in the cause"), + }); + const provider = yield* makeProvider({ + createRepository: () => Effect.fail(cause), + }); + + const error = yield* provider + .createRepository({ + cwd: "/repo", + repository: "owner/repo", + visibility: "private", + }) + .pipe(Effect.flip); + + assert.deepStrictEqual( + { + provider: error.provider, + operation: error.operation, + command: error.command, + cwd: error.cwd, + repository: error.repository, + detail: error.detail, + }, + { + provider: "gitlab", + operation: "createRepository", + command: "glab", + cwd: "/repo", + repository: "owner/repo", + detail: "GitLab CLI command failed.", + }, + ); + assert.strictEqual(error.cause, cause); + assert.equal(error.message.includes("raw upstream detail"), false); + }), +); + +it.effect("lists GitLab MRs through provider-neutral input names", () => + Effect.gen(function* () { + let listInput: Parameters[0] | null = null; + const provider = yield* makeProvider({ + listMergeRequests: (input) => { + listInput = input; + return Effect.succeed([]); + }, + }); + + yield* provider.listChangeRequests({ + cwd: "/repo", + headSelector: "feature/provider", + state: "all", + limit: 10, + }); + + assert.deepStrictEqual(listInput, { + cwd: "/repo", + headSelector: "feature/provider", + state: "all", + limit: 10, + }); + }), +); + +it.effect("creates GitLab MRs through provider-neutral input names", () => + Effect.gen(function* () { + let createInput: Parameters[0] | null = + null; + const provider = yield* makeProvider({ + createMergeRequest: (input) => { + createInput = input; + return Effect.void; + }, + }); + + yield* provider.createChangeRequest({ + cwd: "/repo", + baseRefName: "main", + headSelector: "owner:feature/provider", + title: "Provider MR", + bodyFile: "/tmp/body.md", + }); + + assert.deepStrictEqual(createInput, { + cwd: "/repo", + baseBranch: "main", + headSelector: "owner:feature/provider", + source: { + owner: "owner", + refName: "feature/provider", + }, + title: "Provider MR", + bodyFile: "/tmp/body.md", + }); + }), +); + +it("accepts authenticated GitLab hosts when another configured host fails", () => { + const auth = GitLabSourceControlProvider.discovery.parseAuth({ + exitCode: ChildProcessSpawner.ExitCode(1), + stdout: `gitlab.com + x gitlab.com: API call failed: 401 Unauthorized + ! No token found +self-hosted.example.test + ✓ Logged in to self-hosted.example.test as gitlab-user + ✓ Token found: ****** +`, + stderr: "", + }); + + assert.deepStrictEqual( + { + status: auth.status, + account: auth.account, + host: auth.host, + }, + { + status: "authenticated", + account: Option.some("gitlab-user"), + host: Option.some("self-hosted.example.test"), + }, + ); +}); + +it("refines unknown GitLab remotes with mixed-case provider hosts", () => { + const provider = GitLabSourceControlProvider.discovery.refineUnknownRemote?.({ + cwd: "/repo", + context: { + provider: { + kind: "unknown", + name: "Self-Hosted.Example.Test", + baseUrl: "https://Self-Hosted.Example.Test", + }, + remoteName: "origin", + remoteUrl: "https://Self-Hosted.Example.Test/group/project.git", + }, + auth: { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: `self-hosted.example.test + ✓ Logged in to self-hosted.example.test as gitlab-user + ✓ Token found: ****** +`, + stderr: "", + }, + }); + + assert.deepStrictEqual(provider, { + kind: "gitlab", + name: "GitLab Self-Hosted", + baseUrl: "https://Self-Hosted.Example.Test", + }); +}); + +it("parses authenticated GitLab auth status hosts with ports and single-label names", () => { + assert.deepStrictEqual( + parseGitLabAuthStatusHosts(`localhost:8080 + ✓ Logged in to localhost:8080 as local-user +selfhosted + ✓ Logged in to selfhosted as single-label-user +`), + [ + { host: "localhost:8080", account: "local-user" }, + { host: "selfhosted", account: "single-label-user" }, + ], + ); +}); + +it.effect.each(["merge_requests", "issues"])( + "resolves %s subjects on the linked host without using the checkout", + (kind) => + Effect.gen(function* () { + const provider = yield* makeProvider({ + execute: (input) => { + assert.deepStrictEqual(input.args, [ + "api", + "--hostname", + "gitlab.com", + `projects/group%2Fsubgroup%2Fproject/${kind}/42`, + ]); + assert.strictEqual(input.maxOutputBytes, 32_000); + assert.strictEqual(input.timeoutMs, 3_000); + return Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: JSON.stringify({ + title: "Pairing expiry", + description: "Preserve remote access", + }), + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }); + }, + }); + const lookup = provider.resolveLink?.({ + cwd: "/unrelated", + url: new URL(`https://gitlab.com/group/subgroup/project/-/${kind}/42`), + }); + assert.ok(lookup); + assert.deepStrictEqual(yield* lookup, { + title: "Pairing expiry", + body: "Preserve remote access", + }); + assert.strictEqual( + provider.resolveLink?.({ + cwd: "/unrelated", + url: new URL("https://gitlab.com/owner/repo"), + }), + undefined, + ); + }), +); + +it.effect.each(["read", "decode"] as const)( + "retains the %s failure without exposing its raw contents", + (stage) => + Effect.gen(function* () { + const cause = new GitLabCli.GitLabCliCommandError({ + command: "glab", + cwd: "/repo", + operation: "execute", + cause: new Error("private response text"), + }); + const provider = yield* makeProvider({ + execute: () => + stage === "read" + ? Effect.fail(cause) + : Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: "private response text", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }), + }); + const lookup = provider.resolveLink?.({ + cwd: "/repo", + url: new URL("https://gitlab.com/owner/repo/-/issues/42"), + }); + assert.ok(lookup); + const error = yield* Effect.flip(lookup); + assert.strictEqual(error.operation, stage === "read" ? "resolveLink" : "resolveLink.decode"); + assert.strictEqual(error.detail, "The linked subject could not be read."); + assert.notInclude(error.message, "private response text"); + if (stage === "read") assert.strictEqual(error.cause, cause); + else assert.propertyVal(error.cause, "_tag", "SchemaError"); + }), +); diff --git a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts new file mode 100644 index 0000000000000000000000000000000000000000..ef63f11cfdf24553ef43de44df9d89d29c0eb77b --- /dev/null +++ b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts @@ -0,0 +1,308 @@ +import * as Schema from "effect/Schema"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; + +import * as GitLabCli from "./GitLabCli.ts"; +import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; +import { + combinedAuthOutput, + firstSafeAuthLine, + matchFirst, + parseCliHost, + providerAuth, + type SourceControlAuthProbeInput, + type SourceControlCliDiscoverySpec, + type SourceControlUnknownRemoteRefinementInput, +} from "@t3tools/source-control-core/server/discovery"; +import { findAuthenticatedGitLabHost, parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts"; + +const decodeLinkSubject = Schema.decodeUnknownEffect( + Schema.fromJsonString( + Schema.Struct({ title: Schema.String, description: Schema.NullOr(Schema.String) }), + ), +); + +function toChangeRequest(summary: GitLabCli.GitLabMergeRequestSummary): ChangeRequest { + return { + provider: "gitlab", + number: summary.number, + title: summary.title, + url: summary.url, + baseRefName: summary.baseRefName, + headRefName: summary.headRefName, + state: summary.state ?? "open", + ...(summary.isDraft === true ? { isDraft: true } : {}), + closedAt: summary.closedAt ?? null, + mergedAt: summary.mergedAt ?? null, + updatedAt: summary.updatedAt ?? Option.none(), + ...(summary.isCrossRepository !== undefined + ? { isCrossRepository: summary.isCrossRepository } + : {}), + ...(summary.headRepositoryNameWithOwner !== undefined + ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner } + : {}), + ...(summary.headRepositoryOwnerLogin !== undefined + ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin } + : {}), + }; +} + +function parseGitLabAuth(input: SourceControlAuthProbeInput) { + const output = combinedAuthOutput(input); + const authenticatedHost = findAuthenticatedGitLabHost(parseGitLabAuthStatusHosts(output)); + const account = + authenticatedHost?.account ?? + matchFirst(output, [ + /Logged in to .* as\s+([^\s(]+)/iu, + /Logged in to .* account\s+([^\s(]+)/iu, + /account:\s*([^\s(]+)/iu, + ]); + const host = authenticatedHost?.host ?? parseCliHost(output); + + if (account) { + return providerAuth({ status: "authenticated", account, host }); + } + + if (input.exitCode !== 0) { + return providerAuth({ + status: "unauthenticated", + host, + detail: firstSafeAuthLine(output) ?? "Run `glab auth login` to authenticate GitLab CLI.", + }); + } + + return providerAuth({ + status: "unknown", + host, + detail: firstSafeAuthLine(output) ?? "GitLab CLI auth status could not be parsed.", + }); +} + +function refineUnknownGitLabRemote(input: SourceControlUnknownRemoteRefinementInput) { + const host = input.context.provider.name.toLowerCase(); + const authenticated = parseGitLabAuthStatusHosts(combinedAuthOutput(input.auth)).some( + (entry) => entry.account !== null && entry.host === host, + ); + + if (!authenticated) { + return null; + } + + return { + kind: "gitlab", + name: "GitLab Self-Hosted", + baseUrl: input.context.provider.baseUrl, + } as const; +} + +export const discovery = { + type: "cli", + kind: "gitlab", + label: "GitLab", + executable: "glab", + versionArgs: ["--version"], + authArgs: ["auth", "status"], + parseAuth: parseGitLabAuth, + refineUnknownRemote: refineUnknownGitLabRemote, + installHint: + "Install the GitLab command-line tool (`glab`) from https://gitlab.com/gitlab-org/cli or your package manager (for example `brew install glab`).", +} satisfies SourceControlCliDiscoverySpec; + +export const make = Effect.gen(function* () { + const gitlab = yield* GitLabCli.GitLabCli; + + const readLinkSubject = Effect.fn("GitLabSourceControlProvider.readLinkSubject")(function* ( + input: { readonly cwd: string; readonly url: URL }, + endpoint: string, + ) { + const result = yield* gitlab + .execute({ + cwd: input.cwd, + args: ["api", "--hostname", input.url.host, endpoint], + timeoutMs: 3_000, + maxOutputBytes: 32_000, + }) + .pipe( + Effect.mapError( + (cause) => + new SourceControlProviderError({ + provider: "gitlab", + operation: "resolveLink", + cwd: input.cwd, + detail: "The linked subject could not be read.", + cause, + }), + ), + ); + const subject = yield* decodeLinkSubject(result.stdout).pipe( + Effect.mapError( + (cause) => + new SourceControlProviderError({ + provider: "gitlab", + operation: "resolveLink.decode", + cwd: input.cwd, + detail: "The linked subject could not be read.", + cause, + }), + ), + ); + return { title: subject.title, body: subject.description }; + }); + + return SourceControlProvider.SourceControlProvider.of({ + kind: "gitlab", + resolveLink: (input) => { + // Automatic enrichment must not send ambient CLI credentials to a host from message text. + if (input.url.host !== "gitlab.com") return undefined; + const match = /^\/(.+)\/-\/(merge_requests|issues)\/([1-9]\d*)(?:\/.*)?$/.exec( + input.url.pathname, + ); + if (!match) return undefined; + return readLinkSubject( + input, + `projects/${encodeURIComponent(match[1]!)}/${match[2]}/${match[3]}`, + ); + }, + listChangeRequests: (input) => { + const source = SourceControlProvider.sourceControlRefFromInput(input); + return gitlab + .listMergeRequests({ + cwd: input.cwd, + headSelector: input.headSelector, + ...(source ? { source } : {}), + state: input.state, + ...(input.limit !== undefined ? { limit: input.limit } : {}), + }) + .pipe( + Effect.map((items) => items.map(toChangeRequest)), + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "gitlab", + operation: "listChangeRequests", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.headSelector, + ), + detail: error.detail, + cause: error, + }), + ), + ); + }, + getChangeRequest: (input) => + gitlab.getMergeRequest(input).pipe( + Effect.map(toChangeRequest), + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "gitlab", + operation: "getChangeRequest", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.reference, + ), + detail: error.detail, + cause: error, + }), + ), + ), + createChangeRequest: (input) => { + const source = SourceControlProvider.sourceControlRefFromInput(input); + return gitlab + .createMergeRequest({ + cwd: input.cwd, + baseBranch: input.baseRefName, + headSelector: input.headSelector, + ...(source ? { source } : {}), + ...(input.target ? { target: input.target } : {}), + title: input.title, + bodyFile: input.bodyFile, + }) + .pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "gitlab", + operation: "createChangeRequest", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.headSelector, + ), + detail: error.detail, + cause: error, + }), + ), + ); + }, + getRepositoryCloneUrls: (input) => + gitlab.getRepositoryCloneUrls(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "gitlab", + operation: "getRepositoryCloneUrls", + command: error.command, + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue( + input.repository, + ), + detail: error.detail, + cause: error, + }), + ), + ), + createRepository: (input) => + gitlab.createRepository(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "gitlab", + operation: "createRepository", + command: error.command, + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue( + input.repository, + ), + detail: error.detail, + cause: error, + }), + ), + ), + getDefaultBranch: (input) => + gitlab.getDefaultBranch(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "gitlab", + operation: "getDefaultBranch", + command: error.command, + cwd: input.cwd, + detail: error.detail, + cause: error, + }), + ), + ), + checkoutChangeRequest: (input) => + gitlab.checkoutMergeRequest(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "gitlab", + operation: "checkoutChangeRequest", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.reference, + ), + detail: error.detail, + cause: error, + }), + ), + ), + }); +}); diff --git a/packages/source-control-gitlab/src/server/driver.ts b/packages/source-control-gitlab/src/server/driver.ts new file mode 100644 index 0000000000000000000000000000000000000000..29327a35019b2b84da2dfb2370daff02436449b7 --- /dev/null +++ b/packages/source-control-gitlab/src/server/driver.ts @@ -0,0 +1,19 @@ +/** + * The GitLab driver: repository operations, discovery, and merge requests through `glab`. + * + * @module source-control-gitlab/server/driver + */ +import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; +import * as Effect from "effect/Effect"; + +import * as GitLabPullRequestProvider from "./GitLabPullRequestProvider.ts"; +import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; + +export const driver = defineSourceControlDriver({ + kind: "gitlab", + make: Effect.all({ + sourceControl: GitLabSourceControlProvider.make, + discovery: Effect.succeed(GitLabSourceControlProvider.discovery), + pullRequests: GitLabPullRequestProvider.make, + }), +}); diff --git a/packages/source-control-gitlab/src/server/gitLabAuthStatus.ts b/packages/source-control-gitlab/src/server/gitLabAuthStatus.ts new file mode 100644 index 0000000000000000000000000000000000000000..f54e300353ad41d6c47de75b38719195eb43bae6 --- /dev/null +++ b/packages/source-control-gitlab/src/server/gitLabAuthStatus.ts @@ -0,0 +1,48 @@ +const HOST_LINE_PATTERN = /^(?:[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?|\[[a-f0-9:.]+\])(?::\d+)?$/iu; +const LOGGED_IN_PATTERN = /Logged in to .+? as\s+([^\s(]+)/iu; + +export interface GitLabAuthStatusHost { + readonly host: string; + readonly account: string | null; +} + +export function parseGitLabAuthStatusHosts(text: string): ReadonlyArray { + const hosts: GitLabAuthStatusHost[] = []; + let currentHost: string | null = null; + let currentLines: string[] = []; + + const flush = () => { + if (currentHost === null) return; + + const account = LOGGED_IN_PATTERN.exec(currentLines.join("\n"))?.[1]?.trim() || null; + hosts.push({ host: currentHost, account }); + currentHost = null; + currentLines = []; + }; + + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim(); + if (line.length === 0) continue; + + const isHostLine = + rawLine.length === rawLine.trimStart().length && HOST_LINE_PATTERN.test(line); + if (isHostLine) { + flush(); + currentHost = line.toLowerCase(); + continue; + } + + if (currentHost !== null) { + currentLines.push(line); + } + } + + flush(); + return hosts; +} + +export function findAuthenticatedGitLabHost( + hosts: ReadonlyArray, +): GitLabAuthStatusHost | undefined { + return hosts.find((host) => host.account !== null); +} diff --git a/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.test.ts b/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c9bc12743756c2593c55348a13c4b3c9edd8c918 --- /dev/null +++ b/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.test.ts @@ -0,0 +1,803 @@ +import * as Result from "effect/Result"; +import { describe, expect, it } from "vite-plus/test"; + +import { + decodeAwardEmojiJson, + decodeRepositoryBlobsJson, + decodeCommitsJson, + decodeMergeRequestDetailJson, + decodeMergeRequestDiffsJson, + decodeMergeRequestListJson, + decodeNotesJson, + decodeOwnAwardIdJson, + decodeViewerJson, + gitLabAwardName, +} from "./gitLabMergeRequestJson.ts"; + +function listJson(entries: ReadonlyArray>): string { + return JSON.stringify( + entries.map((entry) => ({ + iid: 1, + title: "Add the merge requests page", + web_url: "https://gitlab.com/acme/web/-/merge_requests/1", + source_branch: "feat/page", + target_branch: "main", + created_at: "2026-07-01T00:00:00Z", + updated_at: "2026-07-02T00:00:00Z", + ...entry, + })), + ); +} + +function detailJson(entry: Record): string { + return JSON.stringify({ + iid: 1, + title: "Add the merge requests page", + web_url: "https://gitlab.com/acme/web/-/merge_requests/1", + source_branch: "feat/page", + target_branch: "main", + created_at: "2026-07-01T00:00:00Z", + updated_at: "2026-07-02T00:00:00Z", + ...entry, + }); +} + +function expectSuccess(result: Result.Result): A { + expect(Result.isSuccess(result)).toBe(true); + if (!Result.isSuccess(result)) throw new Error("expected a successful decode"); + return result.success; +} + +describe("decodeMergeRequestListJson", () => { + it("reads a merge request as a change request", () => { + const batch = expectSuccess( + decodeMergeRequestListJson( + listJson([ + { + iid: 42, + author: { username: "bilal", name: "Bilal" }, + state: "opened", + merge_status: "can_be_merged", + draft: false, + reviewers: [{ username: "julius" }], + labels: ["backend", " "], + }, + ]), + ), + ); + + expect(batch.items).toHaveLength(1); + expect(batch.items[0]).toMatchObject({ + number: 42, + author: { login: "bilal", name: "Bilal" }, + headBranch: "feat/page", + baseBranch: "main", + state: "open", + isDraft: false, + mergeability: "mergeable", + reviewRequestLogins: ["julius"], + labels: [{ name: "backend", color: null }], + }); + }); + + it("reports no line counts, which GitLab does not expose", () => { + const batch = expectSuccess(decodeMergeRequestListJson(listJson([{}]))); + + expect(batch.items[0]).toMatchObject({ additions: 0, deletions: 0 }); + }); + + it("treats a merged timestamp as merged whatever the state says", () => { + const batch = expectSuccess( + decodeMergeRequestListJson( + listJson([{ state: "opened", merged_at: "2026-07-03T00:00:00Z" }]), + ), + ); + + expect(batch.items[0]?.state).toBe("merged"); + }); + + it("keeps a locked merge request open", () => { + const batch = expectSuccess(decodeMergeRequestListJson(listJson([{ state: "locked" }]))); + + expect(batch.items[0]?.state).toBe("open"); + }); + + it("reads the legacy draft flag", () => { + const batch = expectSuccess(decodeMergeRequestListJson(listJson([{ work_in_progress: true }]))); + + expect(batch.items[0]?.isDraft).toBe(true); + }); + + it("calls a conflicted merge request conflicting even while the merge check is pending", () => { + const batch = expectSuccess( + decodeMergeRequestListJson(listJson([{ merge_status: "checking", has_conflicts: true }])), + ); + + expect(batch.items[0]?.mergeability).toBe("conflicting"); + }); + + it("leaves an unfinished merge check unknown", () => { + const batch = expectSuccess( + decodeMergeRequestListJson(listJson([{ merge_status: "checking" }])), + ); + + expect(batch.items[0]?.mergeability).toBe("unknown"); + }); + + it("skips a malformed row but still counts it, so paging does not stop early", () => { + const batch = expectSuccess( + decodeMergeRequestListJson( + JSON.stringify([{ iid: "not a number" }, ...JSON.parse(listJson([{}]))]), + ), + ); + + expect(batch.items).toHaveLength(1); + expect(batch.rawIndexes).toEqual([1]); + expect(batch.rawCount).toBe(2); + }); +}); + +describe("decodeMergeRequestDetailJson", () => { + it("reads the description, file count and pipeline", () => { + const detail = expectSuccess( + decodeMergeRequestDetailJson( + detailJson({ + description: "Ships the page.", + changes_count: "3", + reviewers: [{ username: "julius", name: "Julius" }], + head_pipeline: { + status: "success", + web_url: "https://gitlab.com/acme/web/-/pipelines/9", + source: "merge_request_event", + }, + }), + ), + ); + + expect(detail.body).toBe("Ships the page."); + expect(detail.changedFiles).toBe(3); + expect(detail.reviewers).toEqual([{ login: "julius", name: "Julius", avatarUrl: null }]); + expect(detail.checks).toEqual([ + { + name: "Pipeline", + status: "success", + description: "merge_request_event", + url: "https://gitlab.com/acme/web/-/pipelines/9", + }, + ]); + }); + + it("reads an uncounted change set as its floor", () => { + const detail = expectSuccess( + decodeMergeRequestDetailJson(detailJson({ changes_count: "1000+" })), + ); + + expect(detail.changedFiles).toBe(1000); + }); + + it("falls back to no file count when GitLab omits one", () => { + const detail = expectSuccess(decodeMergeRequestDetailJson(detailJson({}))); + + expect(detail.changedFiles).toBe(0); + }); + + it("reads either auto-merge field, and says nothing where GitLab named neither", () => { + const armed = (entry: Record) => + expectSuccess(decodeMergeRequestDetailJson(detailJson(entry))).autoMergeEnabled; + + expect(armed({ merge_when_pipeline_succeeds: true })).toBe(true); + // The newer name for the same fact, which older GitLab installs do not send. + expect(armed({ auto_merge_enabled: true })).toBe(true); + expect(armed({ merge_when_pipeline_succeeds: false })).toBe(false); + // Absent is GitLab not saying, which the page must not read as "not armed". + expect(armed({})).toBeUndefined(); + }); + + it("keeps the squash choice stored with auto-merge", () => { + expect( + expectSuccess( + decodeMergeRequestDetailJson( + detailJson({ auto_merge_enabled: true, squash_on_merge: true }), + ), + ), + ).toMatchObject({ autoMergeEnabled: true, autoMergeMethod: "squash" }); + + expect( + expectSuccess( + decodeMergeRequestDetailJson( + detailJson({ + auto_merge_enabled: true, + squash: true, + squash_on_merge: false, + }), + ), + ).autoMergeMethod, + ).toBeUndefined(); + }); + + it("keeps a divergence GitLab did not count apart from a divergence of none", () => { + const behind = (entry: Record) => + expectSuccess(decodeMergeRequestDetailJson(detailJson(entry))).divergedCommits; + + expect(behind({ diverged_commits_count: 3 })).toBe(3); + // Counted and found level, which is the one answer that entitles the page to say so. + expect(behind({ diverged_commits_count: 0 })).toBe(0); + // An install that does not answer, and a null where the answer would have gone, are both + // silence: reading either as zero would tell a stale branch it is current. + expect(behind({})).toBeUndefined(); + expect(behind({ diverged_commits_count: null })).toBeUndefined(); + }); + + it("maps a pipeline waiting on a person to neutral, not failure", () => { + const detail = expectSuccess( + decodeMergeRequestDetailJson(detailJson({ head_pipeline: { status: "manual" } })), + ); + + expect(detail.checks[0]?.status).toBe("neutral"); + }); +}); + +describe("decodeViewerJson", () => { + it("reads the signed-in username", () => { + expect(expectSuccess(decodeViewerJson(JSON.stringify({ username: "bilal" })))).toBe("bilal"); + }); + + it("returns nothing when the account has no username", () => { + expect(expectSuccess(decodeViewerJson(JSON.stringify({ username: " " })))).toBeNull(); + }); +}); + +describe("decodeNotesJson", () => { + it("keeps comments and drops GitLab's own activity notes", () => { + const notes = expectSuccess( + decodeNotesJson( + JSON.stringify([ + { + id: 1, + body: "assigned to @bilal", + system: true, + created_at: "2026-07-01T00:00:00Z", + }, + { + id: 2, + body: "Looks good.", + author: { username: "julius" }, + created_at: "2026-07-02T00:00:00Z", + }, + { id: 3, body: " ", created_at: "2026-07-03T00:00:00Z" }, + ]), + ), + ); + + expect(notes.comments).toHaveLength(1); + expect(notes.comments[0]).toMatchObject({ + id: "2", + kind: "issue-comment", + body: "Looks good.", + }); + // The raw count keeps the dropped notes visible to the caller, which needs them to page. + expect(notes.rawCount).toBe(3); + }); + + it("reads a line note as a review comment on its file", () => { + const notes = expectSuccess( + decodeNotesJson( + JSON.stringify([ + { + id: 7, + type: "DiffNote", + body: "Rename this.", + created_at: "2026-07-02T00:00:00Z", + position: { new_path: "src/app.ts", old_path: "src/old.ts" }, + }, + ]), + ), + ); + + expect(notes.comments[0]).toMatchObject({ kind: "review-comment", path: "src/app.ts" }); + }); + + it("falls back to the old path for a note on a deleted line", () => { + const notes = expectSuccess( + decodeNotesJson( + JSON.stringify([ + { + id: 8, + type: "DiffNote", + body: "Gone.", + created_at: "2026-07-02T00:00:00Z", + position: { new_path: null, old_path: "src/old.ts" }, + }, + ]), + ), + ); + + expect(notes.comments[0]?.path).toBe("src/old.ts"); + }); +}); + +describe("decodeCommitsJson", () => { + it("returns commits oldest first", () => { + const commits = expectSuccess( + decodeCommitsJson( + JSON.stringify([ + { id: "bbb", title: "second", committed_date: "2026-07-02T00:00:00Z" }, + { id: "aaa", title: "first", committed_date: "2026-07-01T00:00:00Z" }, + ]), + ), + ); + + expect(commits.map((commit) => commit.oid)).toEqual(["aaa", "bbb"]); + }); + + it("skips commits whose id is empty", () => { + const commits = expectSuccess( + decodeCommitsJson( + JSON.stringify([ + { id: " ", title: "invalid", committed_date: "2026-07-02T00:00:00Z" }, + { id: "aaa", committed_date: "2026-07-01T00:00:00Z" }, + ]), + ), + ); + + expect(commits.map((commit) => commit.oid)).toEqual(["aaa"]); + }); + + it("falls back to the creation timestamp when there is no commit date", () => { + const commits = expectSuccess( + decodeCommitsJson( + JSON.stringify([ + { + id: "aaa", + created_at: "2026-07-01T00:00:00+08:00", + author_name: "Ada Lovelace", + author_email: "ada@example.com", + }, + ]), + ), + ); + + expect(commits[0]).toMatchObject({ + oid: "aaa", + committedDate: "2026-07-01T00:00:00+08:00", + authors: [{ login: "Ada Lovelace", name: "Ada Lovelace", avatarUrl: null }], + }); + }); + + it("carries commit additions and deletions when GitLab returns stats", () => { + const commits = expectSuccess( + decodeCommitsJson( + JSON.stringify([ + { + id: "aaa", + committed_date: "2026-07-01T00:00:00Z", + stats: { additions: 21, deletions: 8, total: 29 }, + }, + ]), + ), + ); + + expect(commits[0]).toMatchObject({ additions: 21, deletions: 8 }); + }); +}); + +describe("decodeMergeRequestDiffsJson", () => { + it("quotes literal backslashes without interpreting them as escapes", () => { + const result = expectSuccess( + decodeMergeRequestDiffsJson( + JSON.stringify([ + { + old_path: String.raw`src\notes.ts`, + new_path: String.raw`src\notes.ts`, + diff: "@@ -1 +1 @@\n-old\n+new", + }, + ]), + ), + ); + + expect(result.patch).toBe( + [ + String.raw`diff --git "a/src\\notes.ts" "b/src\\notes.ts"`, + String.raw`--- "a/src\\notes.ts"`, + String.raw`+++ "b/src\\notes.ts"`, + "@@ -1 +1 @@", + "-old", + "+new", + "", + ].join("\n"), + ); + }); + + it("preserves spaces and literal backslashes in both rename paths", () => { + const result = expectSuccess( + decodeMergeRequestDiffsJson( + JSON.stringify([ + { + old_path: String.raw` old\name.ts `, + new_path: String.raw` new\name.ts `, + renamed_file: true, + diff: "", + }, + ]), + ), + ); + + expect(result.patch).toBe( + [ + String.raw`diff --git "a/ old\\name.ts " "b/ new\\name.ts "`, + String.raw`rename from " old\\name.ts "`, + String.raw`rename to " new\\name.ts "`, + String.raw`--- "a/ old\\name.ts "`, + String.raw`+++ "b/ new\\name.ts "`, + ].join("\n"), + ); + }); + + it("assembles a unified patch GitLab does not return", () => { + const result = expectSuccess( + decodeMergeRequestDiffsJson( + JSON.stringify([ + { + old_path: "src/app.ts", + new_path: "src/app.ts", + diff: "@@ -1 +1 @@\n-old\n+new\n", + }, + ]), + ), + ); + + expect(result.patch).toBe( + [ + "diff --git a/src/app.ts b/src/app.ts", + "--- a/src/app.ts", + "+++ b/src/app.ts", + "@@ -1 +1 @@", + "-old", + "+new", + "", + ].join("\n"), + ); + expect(result.truncated).toBe(false); + }); + + it("points a new file at /dev/null on the left and a deleted file on the right", () => { + const result = expectSuccess( + decodeMergeRequestDiffsJson( + JSON.stringify([ + { + old_path: "src/new.ts", + new_path: "src/new.ts", + new_file: true, + b_mode: "100755", + diff: "@@ -0,0 +1 @@\n+hello\n", + }, + { + old_path: "src/gone.ts", + new_path: "src/gone.ts", + deleted_file: true, + diff: "@@ -1 +0,0 @@\n-bye\n", + }, + ]), + ), + ); + + expect(result.patch).toContain("new file mode 100755"); + expect(result.patch).toContain("--- /dev/null"); + expect(result.patch).toContain("deleted file mode 100644"); + expect(result.patch).toContain("+++ /dev/null"); + }); + + it("records a rename so the patch names both paths", () => { + const result = expectSuccess( + decodeMergeRequestDiffsJson( + JSON.stringify([ + { old_path: "src/old.ts", new_path: "src/new.ts", renamed_file: true, diff: "" }, + ]), + ), + ); + + expect(result.patch).toContain("rename from src/old.ts"); + expect(result.patch).toContain("rename to src/new.ts"); + }); + + it("reports truncation for a file GitLab refused to inline", () => { + const result = expectSuccess( + decodeMergeRequestDiffsJson( + JSON.stringify([{ old_path: "big.bin", new_path: "big.bin", diff: "", too_large: true }]), + ), + ); + + expect(result.truncated).toBe(true); + expect(result.patch).toContain("diff --git a/big.bin b/big.bin"); + }); + + it("reports how many files GitLab returned, so the caller can page", () => { + const result = expectSuccess( + decodeMergeRequestDiffsJson( + JSON.stringify( + Array.from({ length: 3 }, (_, index) => ({ + old_path: `src/${index}.ts`, + new_path: `src/${index}.ts`, + diff: "@@ -1 +1 @@\n-a\n+b\n", + })), + ), + ), + ); + + expect(result.rawCount).toBe(3); + expect(result.truncated).toBe(false); + expect(result.patch).toContain("src/2.ts"); + }); + + it("fails when GitLab did not return a list", () => { + expect(Result.isFailure(decodeMergeRequestDiffsJson('{"message":"404"}'))).toBe(true); + }); +}); + +describe("merge request viewer fields", () => { + it("carries GitLab's own answer for whether this viewer can merge", () => { + expect( + expectSuccess(decodeMergeRequestDetailJson(detailJson({ user: { can_merge: false } }))) + .viewerCanMerge, + ).toBe(false); + expect( + expectSuccess(decodeMergeRequestDetailJson(detailJson({ user: { can_merge: true } }))) + .viewerCanMerge, + ).toBe(true); + }); + + it("leaves merging permitted where GitLab answered without the field", () => { + // Only the single-merge-request endpoint carries `user`, and an install that answers without + // it has said nothing about the viewer rather than said no. + expect(expectSuccess(decodeMergeRequestDetailJson(detailJson({}))).viewerCanMerge).toBe(true); + expect( + expectSuccess(decodeMergeRequestDetailJson(detailJson({ user: null }))).viewerCanMerge, + ).toBe(true); + }); +}); + +describe("decodeAwardEmojiJson", () => { + it("reads MR-level awards, keys per-note awards by the REST id inside their gid, and ignores an award outside the eight", () => { + const result = expectSuccess( + decodeAwardEmojiJson( + JSON.stringify({ + data: { + currentUser: { username: "bilal" }, + project: { + mergeRequest: { + awardEmoji: { + nodes: [ + { name: "thumbsup", user: { username: "bilal" } }, + { name: "thumbsup", user: { username: "julius" } }, + ], + }, + notes: { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [ + { + id: "gid://gitlab/DiffNote/42", + awardEmoji: { nodes: [{ name: "heart", user: { username: "julius" } }] }, + }, + { + id: "gid://gitlab/Note/7", + // Not one of the eight the contract carries. + awardEmoji: { + nodes: [{ name: "partyparrot", user: { username: "bilal" } }], + }, + }, + ], + }, + }, + }, + }, + }), + ), + ); + + // `bilal` is `currentUser`, so the group they are in reads back as reacted, but their own + // username is left out of `actors` — the page names them "You" instead — while `count` still + // counts them; `julius` alone does not turn a group's own `viewerHasReacted` on. + expect(result.reactions).toEqual([ + { content: "thumbs-up", count: 2, actors: ["julius"], viewerHasReacted: true }, + ]); + // Note 7's only award named nobody the eight recognise, so it carries no reactions and is + // left out of the map rather than kept empty. + expect([...result.reactionsByNoteId]).toEqual([ + ["42", [{ content: "heart", count: 1, actors: ["julius"], viewerHasReacted: false }]], + ]); + expect(result.nextCursor).toBeNull(); + }); + + it("hands back a cursor when GitLab has more notes to page", () => { + const result = expectSuccess( + decodeAwardEmojiJson( + JSON.stringify({ + data: { + currentUser: null, + project: { + mergeRequest: { + awardEmoji: { nodes: [] }, + notes: { pageInfo: { hasNextPage: true, endCursor: "Y3Vyc29yOjE" }, nodes: [] }, + }, + }, + }, + }), + ), + ); + + expect(result.nextCursor).toBe("Y3Vyc29yOjE"); + }); + + it("matches the viewer's username case-insensitively, since GitLab is not consistent about case", () => { + const result = expectSuccess( + decodeAwardEmojiJson( + JSON.stringify({ + data: { + currentUser: { username: "Bilal" }, + project: { + mergeRequest: { + awardEmoji: { + nodes: [ + { name: "heart", user: { username: "bilal" } }, + { name: "heart", user: { username: "julius" } }, + ], + }, + notes: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, + }, + }, + }, + }), + ), + ); + + expect(result.reactions).toEqual([ + { content: "heart", count: 2, actors: ["julius"], viewerHasReacted: true }, + ]); + }); +}); + +describe("decodeOwnAwardIdJson", () => { + const awards = JSON.stringify([ + { id: 101, name: "thumbsup", user: { username: "julius" } }, + { id: 102, name: "thumbsup", user: { username: "bilal" } }, + ]); + + it("finds the reader's own award of that name, which is the one a removal deletes", () => { + expect( + expectSuccess(decodeOwnAwardIdJson(awards, { content: "thumbs-up", viewer: "bilal" })), + ).toBe(102); + }); + + it("returns nothing where the reader has no award of that name", () => { + expect( + expectSuccess(decodeOwnAwardIdJson(awards, { content: "heart", viewer: "bilal" })), + ).toBeNull(); + }); +}); + +describe("gitLabAwardName", () => { + it("spells the contents whose GitLab award name is not their own kebab-case", () => { + expect(gitLabAwardName("thumbs-up")).toBe("thumbsup"); + expect(gitLabAwardName("laugh")).toBe("laughing"); + expect(gitLabAwardName("hooray")).toBe("tada"); + }); +}); + +describe("decodeRepositoryBlobsJson", () => { + /** Null is the query going unanswered, which these cases are not about. */ + function expectBlobs(result: Result.Result | null, unknown>) { + const blobs = expectSuccess(result); + expect(blobs).not.toBe(null); + if (blobs === null) throw new Error("expected an answered blobs query"); + return blobs; + } + + it("reads a blob id per path", () => { + const blobs = expectBlobs( + decodeRepositoryBlobsJson( + JSON.stringify({ + data: { + project: { + repository: { + blobs: { + nodes: [ + { path: "src/a.ts", oid: "aaa111" }, + { path: "src/b.ts", oid: "bbb222" }, + ], + }, + }, + }, + }, + }), + ), + ); + + expect([...blobs]).toEqual([ + ["src/a.ts", "aaa111"], + ["src/b.ts", "bbb222"], + ]); + }); + + it("leaves out a node missing either half, which names no version", () => { + const blobs = expectBlobs( + decodeRepositoryBlobsJson( + JSON.stringify({ + data: { + project: { + repository: { + blobs: { + nodes: [ + { path: "src/a.ts", oid: null }, + { path: null, oid: "bbb222" }, + null, + { path: "src/c.ts", oid: "ccc333" }, + ], + }, + }, + }, + }, + }), + ), + ); + + expect([...blobs]).toEqual([["src/c.ts", "ccc333"]]); + }); + + it("keys a blob by the path the host spelled, spaces and all", () => { + // A leading or trailing space is a legal part of a file's name. Trimmed here, the id lands + // under a key the asked-for path is not spelled with, and the caller fills that path in as + // the empty revision: a mark on the file then never compares against the real head blob. + const blobs = expectBlobs( + decodeRepositoryBlobsJson( + JSON.stringify({ + data: { + project: { + repository: { + blobs: { + nodes: [ + { path: " leading.ts", oid: "aaa111" }, + { path: "trailing.ts ", oid: "bbb222" }, + { path: " ", oid: "ccc333" }, + { path: "", oid: "ddd444" }, + ], + }, + }, + }, + }, + }), + ), + ); + + expect([...blobs]).toEqual([ + [" leading.ts", "aaa111"], + ["trailing.ts ", "bbb222"], + // A name that is only spaces is one Git carries too, so it is a path like any other. + [" ", "ccc333"], + ]); + }); + + it("tells a project the reader cannot see from a revision with none of the files", () => { + // Null is the query going unanswered. Read as an empty answer it would say the head has none + // of the asked-for files, which reports every file a reader has cleared as changed. + expect( + expectSuccess(decodeRepositoryBlobsJson(JSON.stringify({ data: { project: null } }))), + ).toBe(null); + expect( + expectSuccess( + decodeRepositoryBlobsJson(JSON.stringify({ data: { project: { repository: null } } })), + ), + ).toBe(null); + expect( + expectSuccess( + decodeRepositoryBlobsJson( + JSON.stringify({ data: { project: { repository: { blobs: { nodes: [] } } } } }), + ), + ), + ).toEqual(new Map()); + }); + + it("fails on output that is not the query's shape", () => { + expect(Result.isSuccess(decodeRepositoryBlobsJson("not json"))).toBe(false); + expect(Result.isSuccess(decodeRepositoryBlobsJson(JSON.stringify({ errors: [] })))).toBe(false); + }); +}); diff --git a/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts b/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts new file mode 100644 index 0000000000000000000000000000000000000000..b9991920d0d3a880bcf1edb12b0bb777f87d012a --- /dev/null +++ b/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts @@ -0,0 +1,1032 @@ +import * as Cause from "effect/Cause"; +import * as Exit from "effect/Exit"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import type { + PullRequestActor, + PullRequestCheck, + PullRequestCheckStatus, + PullRequestComment, + PullRequestCommit, + PullRequestLabel, + PullRequestMergeability, + PullRequestMergeCapabilities, + PullRequestMergeMethod, + PullRequestReaction, + PullRequestReactionContent, + PullRequestReviewThread, + PullRequestReviewerCandidate, + PullRequestState, +} from "@t3tools/contracts"; +import { TrimmedNonEmptyString } from "@t3tools/contracts"; +import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; + +/** + * GitLab's REST enums are decoded as plain strings and normalized here: a GitLab release that + * adds a pipeline status or a merge status must not fail the whole payload. + */ +const RawUserSchema = Schema.Struct({ + /** + * GitLab writes a merge request's reviewers as numeric ids and takes no usernames there, so the + * id is carried alongside the handle rather than looked up again when a review is asked for. + */ + id: Schema.optional(Schema.Int), + username: Schema.String, + name: Schema.optional(Schema.NullOr(Schema.String)), + avatar_url: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawPipelineSchema = Schema.Struct({ + status: Schema.optional(Schema.NullOr(Schema.String)), + web_url: Schema.optional(Schema.NullOr(Schema.String)), + source: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawMergeRequestSchema = Schema.Struct({ + iid: Schema.Int, + title: Schema.String, + web_url: Schema.String, + description: Schema.optional(Schema.NullOr(Schema.String)), + author: Schema.optional(Schema.NullOr(RawUserSchema)), + source_branch: Schema.String, + target_branch: Schema.String, + state: Schema.optional(Schema.NullOr(Schema.String)), + draft: Schema.optional(Schema.Boolean), + work_in_progress: Schema.optional(Schema.Boolean), + merge_status: Schema.optional(Schema.NullOr(Schema.String)), + has_conflicts: Schema.optional(Schema.NullOr(Schema.Boolean)), + created_at: Schema.String, + updated_at: Schema.String, + merged_at: Schema.optional(Schema.NullOr(Schema.String)), + closed_at: Schema.optional(Schema.NullOr(Schema.String)), + reviewers: Schema.optional(Schema.NullOr(Schema.Array(RawUserSchema))), + labels: Schema.optional(Schema.NullOr(Schema.Array(Schema.String))), + // A string, and "1000+" past GitLab's counting limit, so it is parsed rather than decoded. + changes_count: Schema.optional(Schema.NullOr(Schema.String)), + head_pipeline: Schema.optional(Schema.NullOr(RawPipelineSchema)), + /** + * What the requesting account may do, which only the single-merge-request endpoint carries. + * GitLab answers `can_merge` for this viewer against this merge request, so it already accounts + * for the role, the approval rules and a protected target branch — none of which a project's + * access level on its own would tell apart. + */ + user: Schema.optional( + Schema.NullOr(Schema.Struct({ can_merge: Schema.optional(Schema.Boolean) })), + ), + /** + * Whether GitLab is holding this merge request to merge it once its pipeline goes green. + * `merge_when_pipeline_succeeds` is the field every version answers with; newer ones also + * carry `auto_merge_enabled`, which is the same fact under the name GitLab settled on, so + * either one saying yes is a yes. + */ + merge_when_pipeline_succeeds: Schema.optional(Schema.NullOr(Schema.Boolean)), + auto_merge_enabled: Schema.optional(Schema.NullOr(Schema.Boolean)), + /** The merge request's stored squash choice, including project-policy overrides. */ + squash_on_merge: Schema.optional(Schema.NullOr(Schema.Boolean)), + squash: Schema.optional(Schema.NullOr(Schema.Boolean)), + /** + * How far the target branch has moved on since this one left it, which is the same number + * GitLab's own "out of date" wording counts. It costs a walk of the two branches, so GitLab + * withholds it unless `include_diverged_commits_count` asks for it, and answers it only for a + * single merge request — a list never carries it, however it is asked for. + */ + diverged_commits_count: Schema.optional(Schema.NullOr(Schema.Int)), +}); + +const RawNoteSchema = Schema.Struct({ + id: Schema.Int, + body: Schema.optional(Schema.NullOr(Schema.String)), + author: Schema.optional(Schema.NullOr(RawUserSchema)), + created_at: Schema.String, + /** True for notes GitLab writes itself ("assigned to…"), which are events, not comments. */ + system: Schema.optional(Schema.Boolean), + type: Schema.optional(Schema.NullOr(Schema.String)), + position: Schema.optional( + Schema.NullOr( + Schema.Struct({ + new_path: Schema.optional(Schema.NullOr(Schema.String)), + old_path: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), +}); + +/** + * A discussion note carrying its place in the diff, which is the shape the whole thread view + * is built from. `resolved` lives on the note rather than on the discussion: GitLab calls a + * discussion resolved once every resolvable note in it is. + */ +const RawDiscussionNoteSchema = Schema.Struct({ + id: Schema.Int, + body: Schema.optional(Schema.NullOr(Schema.String)), + author: Schema.optional(Schema.NullOr(RawUserSchema)), + created_at: Schema.String, + system: Schema.optional(Schema.Boolean), + resolvable: Schema.optional(Schema.Boolean), + resolved: Schema.optional(Schema.NullOr(Schema.Boolean)), + position: Schema.optional( + Schema.NullOr( + Schema.Struct({ + position_type: Schema.optional(Schema.NullOr(Schema.String)), + new_path: Schema.optional(Schema.NullOr(Schema.String)), + old_path: Schema.optional(Schema.NullOr(Schema.String)), + new_line: Schema.optional(Schema.NullOr(Schema.Int)), + old_line: Schema.optional(Schema.NullOr(Schema.Int)), + }), + ), + ), +}); + +const RawDiscussionSchema = Schema.Struct({ + id: Schema.String, + notes: Schema.optional(Schema.NullOr(Schema.Array(RawDiscussionNoteSchema))), +}); + +const RawDiffRefsSchema = Schema.Struct({ + diff_refs: Schema.optional( + Schema.NullOr( + Schema.Struct({ + base_sha: Schema.String, + head_sha: Schema.String, + start_sha: Schema.String, + }), + ), + ), +}); + +const RawCommitSchema = Schema.Struct({ + id: TrimmedNonEmptyString, + title: Schema.optional(Schema.NullOr(Schema.String)), + committed_date: Schema.optional(Schema.NullOr(Schema.String)), + created_at: Schema.optional(Schema.NullOr(Schema.String)), + parent_ids: Schema.optional(Schema.Array(Schema.String)), + author_name: Schema.optional(Schema.NullOr(Schema.String)), + author_email: Schema.optional(Schema.NullOr(Schema.String)), + stats: Schema.optional( + Schema.NullOr( + Schema.Struct({ + additions: Schema.optional(Schema.Int), + deletions: Schema.optional(Schema.Int), + }), + ), + ), +}); + +const RawDiffSchema = Schema.Struct({ + old_path: Schema.String, + new_path: Schema.String, + a_mode: Schema.optional(Schema.NullOr(Schema.String)), + b_mode: Schema.optional(Schema.NullOr(Schema.String)), + new_file: Schema.optional(Schema.Boolean), + renamed_file: Schema.optional(Schema.Boolean), + deleted_file: Schema.optional(Schema.Boolean), + diff: Schema.optional(Schema.NullOr(Schema.String)), + /** GitLab omits the hunks for a file it considers too large to inline. */ + too_large: Schema.optional(Schema.NullOr(Schema.Boolean)), + /** And for one it collapsed, which withholds them the same way. */ + collapsed: Schema.optional(Schema.NullOr(Schema.Boolean)), +}); + +const RawViewerSchema = Schema.Struct({ + username: Schema.optional(Schema.NullOr(Schema.String)), +}); + +/** A GitLab project settles on one merge strategy plus an optional squash. */ +const RawProjectMergeSettingsSchema = Schema.Struct({ + merge_method: Schema.optional(Schema.NullOr(Schema.String)), + squash_option: Schema.optional(Schema.NullOr(Schema.String)), +}); + +export interface GitLabMergeRequestListItem { + readonly number: number; + readonly title: string; + readonly url: string; + readonly author: PullRequestActor | null; + readonly headBranch: string; + readonly baseBranch: string; + readonly state: PullRequestState; + readonly isDraft: boolean; + readonly mergeability: PullRequestMergeability; + /** + * GitLab reports neither added nor removed lines on a merge request, so both stay zero and + * the surface omits the stat. The Code tab counts them from the patch it already fetched. + */ + readonly additions: number; + readonly deletions: number; + readonly createdAt: string; + readonly updatedAt: string; + readonly reviewRequestLogins: ReadonlyArray; + readonly labels: ReadonlyArray; +} + +export interface GitLabMergeRequestDetail extends GitLabMergeRequestListItem { + readonly body: string; + readonly changedFiles: number; + readonly mergedAt: string | null; + readonly closedAt: string | null; + readonly reviewers: ReadonlyArray; + readonly checks: ReadonlyArray; + /** False only where GitLab said so; an answer without the field leaves merging permitted. */ + readonly viewerCanMerge: boolean; + /** The reviewers as GitLab addresses them, which is what writing the set back takes. */ + readonly reviewerIds: ReadonlyArray; + /** Absent where GitLab named neither auto-merge field, which is not the same as off. */ + readonly autoMergeEnabled?: boolean; + /** GitLab only exposes the stored strategy separately when that strategy is squash. */ + readonly autoMergeMethod?: PullRequestMergeMethod; + /** + * Absent where GitLab did not count, which is not the same as a branch that has nothing behind + * it: an install too old to answer must not be read as saying the branch is current. + */ + readonly divergedCommits?: number; +} + +function trimmed(value: string | null | undefined): string | null { + const text = value?.trim() ?? ""; + return text.length > 0 ? text : null; +} + +function toActor(raw: Schema.Schema.Type | null | undefined) { + const login = trimmed(raw?.username); + return login === null + ? null + : { login, name: trimmed(raw?.name), avatarUrl: trimmed(raw?.avatar_url) }; +} + +function toState(raw: Schema.Schema.Type): PullRequestState { + if (trimmed(raw.merged_at) !== null) return "merged"; + switch (raw.state?.trim().toLowerCase()) { + case "merged": + return "merged"; + case "closed": + return "closed"; + default: + // `locked` is an open merge request whose discussion is locked. + return "open"; + } +} + +function toMergeability( + raw: Schema.Schema.Type, +): PullRequestMergeability { + if (raw.has_conflicts === true) return "conflicting"; + switch (raw.merge_status?.trim().toLowerCase()) { + case "can_be_merged": + return "mergeable"; + case "cannot_be_merged": + return "conflicting"; + default: + // `unchecked` and `checking` mean GitLab has not finished the merge check yet. + return "unknown"; + } +} + +function toLabels(raw: ReadonlyArray | null | undefined): ReadonlyArray { + // GitLab returns label names only, so there is no colour to carry. + return (raw ?? []).flatMap((label) => { + const name = trimmed(label); + return name === null ? [] : [{ name, color: null }]; + }); +} + +/** + * "3" for a counted change set, "1000+" once GitLab gives up counting. The leading number is + * the floor either way, which reads better than dropping an uncounted change set to nothing. + */ +function toChangedFiles(value: string | null | undefined): number { + const parsed = Number.parseInt(value?.trim() ?? "", 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; +} + +function toPipelineStatus(value: string | null | undefined): PullRequestCheckStatus { + switch (value?.trim().toLowerCase()) { + case "success": + return "success"; + case "failed": + return "failure"; + case "canceled": + case "cancelling": + return "cancelled"; + case "skipped": + return "skipped"; + // A pipeline waiting on a person is not progress, and it is not a failure either. + case "manual": + case "scheduled": + return "neutral"; + default: + return "pending"; + } +} + +/** + * GitLab has no per-job check list on a merge request, so its pipeline is reported as the one + * check. The jobs behind it stay one click away through the pipeline URL. + */ +function toChecks( + raw: Schema.Schema.Type, +): ReadonlyArray { + const pipeline = raw.head_pipeline; + if (!pipeline) return []; + return [ + { + name: "Pipeline", + status: toPipelineStatus(pipeline.status), + description: trimmed(pipeline.source), + url: trimmed(pipeline.web_url), + }, + ]; +} + +function toListItem( + raw: Schema.Schema.Type, +): GitLabMergeRequestListItem { + return { + number: raw.iid, + title: raw.title, + url: raw.web_url, + author: toActor(raw.author), + headBranch: raw.source_branch, + baseBranch: raw.target_branch, + state: toState(raw), + isDraft: raw.draft ?? raw.work_in_progress ?? false, + mergeability: toMergeability(raw), + additions: 0, + deletions: 0, + createdAt: raw.created_at, + updatedAt: raw.updated_at, + reviewRequestLogins: (raw.reviewers ?? []).flatMap((reviewer) => { + const login = trimmed(reviewer.username); + return login === null ? [] : [login]; + }), + labels: toLabels(raw.labels), + }; +} + +function toDetail(raw: Schema.Schema.Type): GitLabMergeRequestDetail { + const listItem = toListItem(raw); + const autoMerge = + raw.merge_when_pipeline_succeeds == null && raw.auto_merge_enabled == null + ? undefined + : raw.merge_when_pipeline_succeeds === true || raw.auto_merge_enabled === true; + return { + ...listItem, + body: raw.description ?? "", + changedFiles: toChangedFiles(raw.changes_count), + mergedAt: trimmed(raw.merged_at), + closedAt: trimmed(raw.closed_at), + // Built from the reviewers themselves rather than from their logins, so the avatars survive. + reviewers: (raw.reviewers ?? []).flatMap((reviewer) => { + const actor = toActor(reviewer); + return actor === null ? [] : [actor]; + }), + checks: toChecks(raw), + viewerCanMerge: raw.user?.can_merge !== false, + reviewerIds: (raw.reviewers ?? []).flatMap((reviewer) => + reviewer.id === undefined ? [] : [reviewer.id], + ), + ...(autoMerge === undefined ? {} : { autoMergeEnabled: autoMerge }), + ...(autoMerge === true && raw.squash_on_merge === true + ? { autoMergeMethod: "squash" as const } + : {}), + ...(raw.diverged_commits_count == null ? {} : { divergedCommits: raw.diverged_commits_count }), + }; +} + +const decodeUnknownList = decodeJsonResult(Schema.Array(Schema.Unknown)); +const decodeMergeRequestEntry = Schema.decodeUnknownExit(RawMergeRequestSchema); +const decodeMergeRequest = decodeJsonResult(RawMergeRequestSchema); +const decodeNoteEntry = Schema.decodeUnknownExit(RawNoteSchema); +const decodeUserEntry = Schema.decodeUnknownExit(RawUserSchema); +const decodeCommitEntry = Schema.decodeUnknownExit(RawCommitSchema); +const decodeCommit = decodeJsonResult(RawCommitSchema); +const decodeDiffEntry = Schema.decodeUnknownExit(RawDiffSchema); +const decodeDiscussionEntry = Schema.decodeUnknownExit(RawDiscussionSchema); +const decodeDiffRefs = decodeJsonResult(RawDiffRefsSchema); +const decodeViewer = decodeJsonResult(RawViewerSchema); +const decodeProjectMergeSettings = decodeJsonResult(RawProjectMergeSettingsSchema); + +type DecodeFailure = Cause.Cause; + +export interface GitLabProjectUsers { + readonly candidates: ReadonlyArray; + /** Rows GitLab returned, counted before decoding, so a skipped row cannot hide a next page. */ + readonly rawCount: number; +} + +export interface GitLabMergeRequestListBatch { + readonly items: ReadonlyArray; + /** Zero-based positions of the decoded items in GitLab's raw page. */ + readonly rawIndexes: ReadonlyArray; + /** Rows GitLab returned, counted before decoding, so a skipped row cannot hide a next page. */ + readonly rawCount: number; +} + +/** Malformed entries are skipped rather than failing the batch: one unexpected merge request + * must not blank the whole list. */ +export function decodeMergeRequestListJson( + raw: string, +): Result.Result { + const decoded = decodeUnknownList(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const items: GitLabMergeRequestListItem[] = []; + const rawIndexes: number[] = []; + for (const [rawIndex, entry] of decoded.success.entries()) { + const item = decodeMergeRequestEntry(entry); + if (Exit.isSuccess(item)) { + items.push(toListItem(item.value)); + rawIndexes.push(rawIndex); + } + } + return Result.succeed({ items, rawIndexes, rawCount: decoded.success.length }); +} + +export function decodeMergeRequestDetailJson( + raw: string, +): Result.Result { + const decoded = decodeMergeRequest(raw); + return Result.isSuccess(decoded) + ? Result.succeed(toDetail(decoded.success)) + : Result.fail(decoded.failure); +} + +export function decodeViewerJson(raw: string): Result.Result { + const decoded = decodeViewer(raw); + return Result.isSuccess(decoded) + ? Result.succeed(trimmed(decoded.success.username)) + : Result.fail(decoded.failure); +} + +/** + * The people with access to the project, which `GET /projects/:id/users` answers with — the same + * list GitLab's own reviewer field is filled from, including the members a group above the project + * lends it. A malformed row is skipped rather than failing the menu it belongs to. + * + * Nobody is marked requested here: who has been asked lives on the merge request, and only the + * caller holds both. + */ +export function decodeProjectUsersJson( + raw: string, +): Result.Result { + const decoded = decodeUnknownList(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const candidates: PullRequestReviewerCandidate[] = []; + for (const entry of decoded.success) { + const user = decodeUserEntry(entry); + if (Exit.isFailure(user) || user.value.id === undefined) continue; + const actor = toActor(user.value); + if (actor === null) continue; + candidates.push({ + ...actor, + id: String(user.value.id), + kind: "user", + isRequested: false, + }); + } + return Result.succeed({ candidates, rawCount: decoded.success.length }); +} + +/** + * GitLab settles the strategy per project rather than offering all three per merge request: + * `merge_method` picks one of merge commit, semi-linear or fast-forward, and squashing is a + * separate switch. An unrecognized setting offers nothing rather than offering a strategy the + * project forbids. + */ +export function decodeProjectMergeCapabilitiesJson( + raw: string, +): Result.Result { + const decoded = decodeProjectMergeSettings(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const mergeMethod = decoded.success.merge_method?.trim().toLowerCase(); + const squashOption = decoded.success.squash_option?.trim().toLowerCase(); + return Result.succeed({ + merge: mergeMethod === "merge", + // Both semi-linear and fast-forward histories are reached by rebasing onto the target. + rebase: mergeMethod === "rebase_merge" || mergeMethod === "ff", + // Only GitLab's own enabling values. An absent or unrecognized setting offers nothing, + // rather than offering a squash the project may forbid. + squash: + squashOption === "always" || squashOption === "default_on" || squashOption === "default_off", + }); +} + +/** + * Comments only. System notes are GitLab's own activity feed entries, and a `DiffNote` is the + * root of a line-level discussion, which is what the review-comment kind means. + * + * The raw note count comes back alongside, because dropping notes hides whether the page was + * full: a caller cannot tell "no more notes" from "a page of activity entries" without it. + */ +/** The three revisions a positioned comment is written against. */ +export interface GitLabDiffRefs { + readonly baseSha: string; + readonly headSha: string; + readonly startSha: string; +} + +export interface GitLabDiscussions { + readonly threads: ReadonlyArray; + /** Discussions GitLab returned, counted before decoding, so a skipped one still counts. */ + readonly rawCount: number; +} + +/** + * Positioned discussions only. GitLab returns the merge request's whole conversation here, + * including the plain notes the timeline already shows, and only a positioned one belongs + * against a line of the diff. + */ +export function decodeDiscussionsJson( + raw: string, +): Result.Result { + const decoded = decodeUnknownList(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const threads: PullRequestReviewThread[] = []; + for (const entry of decoded.success) { + const discussion = decodeDiscussionEntry(entry); + if (!Exit.isSuccess(discussion)) continue; + const notes = (discussion.value.notes ?? []).filter((note) => note.system !== true); + const root = notes[0]; + const position = root?.position; + if (root === undefined || !position || position.position_type !== "text") continue; + // A comment on an added or context line carries `new_line`; one on a removed line carries + // only `old_line`, and belongs against the file as it was. + const side = position.new_line === null || position.new_line === undefined ? "left" : "right"; + const path = trimmed(side === "left" ? position.old_path : position.new_path); + const line = side === "left" ? position.old_line : position.new_line; + if (path === null) continue; + threads.push({ + id: discussion.value.id, + path, + line: typeof line === "number" && line > 0 ? line : null, + side, + isResolved: root.resolved === true, + // GitLab reports no equivalent of "written against a line that has since moved", so a + // thread the diff cannot place is worked out from the diff itself rather than claimed + // here. + isOutdated: false, + comments: notes.map((note) => ({ + id: String(note.id), + author: toActor(note.author), + body: note.body ?? "", + createdAt: note.created_at, + url: null, + })), + }); + } + return Result.succeed({ threads, rawCount: decoded.success.length }); +} + +export function decodeDiffRefsJson( + raw: string, +): Result.Result { + const decoded = decodeDiffRefs(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const refs = decoded.success.diff_refs; + return Result.succeed( + refs ? { baseSha: refs.base_sha, headSha: refs.head_sha, startSha: refs.start_sha } : null, + ); +} + +export function decodeNotesJson( + raw: string, +): Result.Result< + { readonly comments: ReadonlyArray; readonly rawCount: number }, + DecodeFailure +> { + const decoded = decodeUnknownList(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const comments: PullRequestComment[] = []; + for (const entry of decoded.success) { + const note = decodeNoteEntry(entry); + if (Exit.isFailure(note)) continue; + const value = note.value; + if (value.system === true) continue; + const body = value.body ?? ""; + if (body.trim().length === 0) continue; + const isDiffNote = value.type?.trim() === "DiffNote"; + comments.push({ + id: String(value.id), + kind: isDiffNote ? "review-comment" : "issue-comment", + author: toActor(value.author), + body, + createdAt: value.created_at, + url: null, + path: trimmed(value.position?.new_path) ?? trimmed(value.position?.old_path), + reviewState: null, + }); + } + return Result.succeed({ comments, rawCount: decoded.success.length }); +} + +export function decodeCommitsJson( + raw: string, +): Result.Result, DecodeFailure> { + const decoded = decodeUnknownList(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const commits: PullRequestCommit[] = []; + for (const entry of decoded.success) { + const commit = decodeCommitEntry(entry); + if (Exit.isFailure(commit)) continue; + const committedDate = trimmed(commit.value.committed_date) ?? trimmed(commit.value.created_at); + if (committedDate === null) continue; + commits.push({ + oid: commit.value.id, + messageHeadline: commit.value.title ?? "", + committedDate, + ...(commit.value.stats === null || commit.value.stats === undefined + ? {} + : { + additions: Math.max(0, commit.value.stats.additions ?? 0), + deletions: Math.max(0, commit.value.stats.deletions ?? 0), + }), + authors: (() => { + const login = trimmed(commit.value.author_name) ?? trimmed(commit.value.author_email); + return login === null + ? [] + : [{ login, name: trimmed(commit.value.author_name), avatarUrl: null }]; + })(), + }); + } + // GitLab lists a merge request's commits newest first; the timeline reads oldest first. + return Result.succeed(commits.toReversed()); +} + +/** The exact comparison GitLab uses for a commit-scoped diff. */ +export function decodeCommitDiffRefsJson( + raw: string, +): Result.Result { + const decoded = decodeCommit(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const baseSha = trimmed(decoded.success.parent_ids?.[0]); + const headSha = trimmed(decoded.success.id); + return Result.succeed( + baseSha === null || headSha === null ? null : { baseSha, headSha, startSha: baseSha }, + ); +} + +function diffHeaderPaths(raw: Schema.Schema.Type): { + readonly from: string; + readonly to: string; +} { + return { + from: raw.new_file === true ? "/dev/null" : quoteGitPatchPath(`a/${raw.old_path}`), + to: raw.deleted_file === true ? "/dev/null" : quoteGitPatchPath(`b/${raw.new_path}`), + }; +} + +export interface GitLabMergeRequestPatch { + readonly patch: string; + /** At least one file's hunks were withheld by GitLab as too large to inline. */ + readonly truncated: boolean; + /** Files GitLab returned, counted before decoding, so the caller can page. */ + readonly rawCount: number; +} + +/** + * GitLab returns hunks per file with no `diff --git` header, so the unified patch every diff + * viewer expects is assembled here. This decodes one page; walking pages is the caller's job, + * which is why the raw file count comes back with the patch. + */ +export function decodeMergeRequestDiffsJson( + raw: string, +): Result.Result { + const decoded = decodeUnknownList(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const sections: string[] = []; + let truncated = false; + for (const entry of decoded.success) { + const file = decodeDiffEntry(entry); + if (Exit.isFailure(file)) continue; + const value = file.value; + const hunks = value.diff ?? ""; + if (hunks.length === 0) { + // A file GitLab declined to inline still belongs in the file list, header only. + truncated = truncated || value.too_large === true || value.collapsed === true; + } + const { from, to } = diffHeaderPaths(value); + const header = [ + `diff --git ${quoteGitPatchPath(`a/${value.old_path}`)} ${quoteGitPatchPath(`b/${value.new_path}`)}`, + ...(value.new_file === true ? [`new file mode ${value.b_mode ?? "100644"}`] : []), + ...(value.deleted_file === true ? [`deleted file mode ${value.a_mode ?? "100644"}`] : []), + ...(value.renamed_file === true + ? [ + `rename from ${quoteGitPatchPath(value.old_path)}`, + `rename to ${quoteGitPatchPath(value.new_path)}`, + ] + : []), + `--- ${from}`, + `+++ ${to}`, + ].join("\n"); + sections.push(hunks.length === 0 ? header : `${header}\n${hunks.replace(/\n?$/, "\n")}`); + } + return Result.succeed({ + patch: sections.join("\n"), + truncated, + rawCount: decoded.success.length, + }); +} + +/** GitLab's award names for the eight reactions the contract carries. */ +const GITLAB_AWARD_BY_CONTENT: Readonly> = { + "thumbs-up": "thumbsup", + "thumbs-down": "thumbsdown", + laugh: "laughing", + hooray: "tada", + confused: "confused", + heart: "heart", + rocket: "rocket", + eyes: "eyes", +}; + +const CONTENT_BY_GITLAB_AWARD: Readonly> = + Object.fromEntries( + Object.entries(GITLAB_AWARD_BY_CONTENT).map(([content, name]) => [name, content]), + ) as Readonly>; + +export function gitLabAwardName(content: PullRequestReactionContent): string { + return GITLAB_AWARD_BY_CONTENT[content]; +} + +/** + * Awards on the merge request and on every note of it, in one read. The REST notes endpoint the + * conversation comes from carries no award at all, and asking per note would be a request each. + * + * `currentUser` rides along because GitLab names who awarded but never says whether that is the + * reader — so the comparison is made here rather than paid for with a request of its own. + */ +export const AWARD_EMOJI_GRAPHQL_QUERY = `query($fullPath: ID!, $iid: String!, $cursor: String) { + currentUser { username } + project(fullPath: $fullPath) { + mergeRequest(iid: $iid) { + awardEmoji { nodes { name user { username } } } + notes(first: 100, after: $cursor) { + pageInfo { hasNextPage endCursor } + nodes { id awardEmoji { nodes { name user { username } } } } + } + } + } +}`; + +const RawAwardEmojiNodesSchema = Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.optional( + Schema.NullOr( + Schema.Array( + Schema.NullOr( + Schema.Struct({ + name: Schema.optional(Schema.NullOr(Schema.String)), + user: Schema.optional( + Schema.NullOr( + Schema.Struct({ username: Schema.optional(Schema.NullOr(Schema.String)) }), + ), + ), + }), + ), + ), + ), + ), + }), + ), +); + +const RawAwardEmojiPageSchema = Schema.Struct({ + data: Schema.Struct({ + currentUser: Schema.optional( + Schema.NullOr(Schema.Struct({ username: Schema.optional(Schema.NullOr(Schema.String)) })), + ), + project: Schema.NullOr( + Schema.Struct({ + mergeRequest: Schema.NullOr( + Schema.Struct({ + awardEmoji: RawAwardEmojiNodesSchema, + notes: Schema.optional( + Schema.NullOr( + Schema.Struct({ + pageInfo: Schema.optional( + Schema.Struct({ + hasNextPage: Schema.optional(Schema.Boolean), + endCursor: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + nodes: Schema.Array( + Schema.NullOr( + Schema.Struct({ + id: Schema.optional(Schema.NullOr(Schema.String)), + awardEmoji: RawAwardEmojiNodesSchema, + }), + ), + ), + }), + ), + ), + }), + ), + }), + ), + }), +}); + +const decodeAwardEmojiPage = decodeJsonResult(RawAwardEmojiPageSchema); + +/** + * The awards on one subject, grouped the way a reaction pill is drawn. The viewer's own username + * is left out of `actors` — the page names them "You" instead, and leaving it in would name them + * twice — but `count` still counts them along with everyone else. + */ +function toReactions( + nodes: Schema.Schema.Type, + viewer: string | null, +): ReadonlyArray { + const normalizedViewer = viewer?.toLowerCase() ?? null; + const groups = new Map< + PullRequestReactionContent, + { count: number; actors: string[]; viewer: boolean } + >(); + for (const node of nodes?.nodes ?? []) { + // An award outside the eight is left out rather than shown under a name the picker has no + // way to take back: GitLab accepts any emoji, and the other hosts accept none of them. + const content = CONTENT_BY_GITLAB_AWARD[trimmed(node?.name)?.toLowerCase() ?? ""]; + if (content === undefined) continue; + const username = trimmed(node?.user?.username); + if (username === null) continue; + const group = groups.get(content) ?? { count: 0, actors: [], viewer: false }; + group.count++; + if (normalizedViewer !== null && username.toLowerCase() === normalizedViewer) { + group.viewer = true; + } else { + group.actors.push(username); + } + groups.set(content, group); + } + return [...groups].flatMap(([content, group]) => + group.count === 0 + ? [] + : [{ content, count: group.count, actors: group.actors, viewerHasReacted: group.viewer }], + ); +} + +/** `gid://gitlab/DiffNote/42` is note 42, which is the id the REST conversation carries. */ +function noteIdOf(gid: string | null | undefined): string | null { + const id = trimmed(gid)?.split("/").at(-1); + return id !== undefined && /^\d+$/.test(id) ? id : null; +} + +export interface GitLabAwardEmojiPage { + /** The merge request's own awards, which are the ones on its description. */ + readonly reactions: ReadonlyArray; + readonly reactionsByNoteId: ReadonlyMap>; + readonly nextCursor: string | null; +} + +export function decodeAwardEmojiJson( + raw: string, +): Result.Result { + const decoded = decodeAwardEmojiPage(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const data = decoded.success.data; + const viewer = trimmed(data.currentUser?.username); + const mergeRequest = data.project?.mergeRequest; + const reactionsByNoteId = new Map>(); + for (const node of mergeRequest?.notes?.nodes ?? []) { + const id = noteIdOf(node?.id); + if (id === null) continue; + const reactions = toReactions(node?.awardEmoji, viewer); + if (reactions.length > 0) reactionsByNoteId.set(id, reactions); + } + const pageInfo = mergeRequest?.notes?.pageInfo; + return Result.succeed({ + reactions: toReactions(mergeRequest?.awardEmoji, viewer), + reactionsByNoteId, + nextCursor: pageInfo?.hasNextPage === true ? (trimmed(pageInfo.endCursor) ?? null) : null, + }); +} + +const RawAwardSchema = Schema.Struct({ + id: Schema.Int, + name: Schema.optional(Schema.NullOr(Schema.String)), + user: Schema.optional( + Schema.NullOr(Schema.Struct({ username: Schema.optional(Schema.NullOr(Schema.String)) })), + ), +}); + +const decodeAward = Schema.decodeUnknownExit(RawAwardSchema); + +/** + * The reader's own award of one name on a subject, which is what taking a reaction back is + * addressed by: GitLab deletes an award by its id and has no way to name one by its emoji. + */ +export function decodeOwnAwardIdJson( + raw: string, + input: { readonly content: PullRequestReactionContent; readonly viewer: string }, +): Result.Result { + const decoded = decodeUnknownList(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const name = gitLabAwardName(input.content); + for (const entry of decoded.success) { + const award = decodeAward(entry); + if (Exit.isFailure(award)) continue; + const value = award.value; + if (trimmed(value.name)?.toLowerCase() !== name) continue; + if (trimmed(value.user?.username) !== input.viewer) continue; + return Result.succeed(value.id); + } + return Result.succeed(null); +} + +/** + * What the given paths are at one revision, as blob ids. Asked for by path rather than by walking + * the tree, since GitLab charges this query by how many paths it is given. A path the revision + * does not have comes back missing rather than as an error, which is the answer for a deleted file. + */ +export const REPOSITORY_BLOBS_GRAPHQL_QUERY = `query($fullPath: ID!, $ref: String!, $paths: [String!]!) { + project(fullPath: $fullPath) { + repository { + blobs(ref: $ref, paths: $paths) { + nodes { path oid } + } + } + } +}`; + +const RawRepositoryBlobsSchema = Schema.Struct({ + data: Schema.Struct({ + project: Schema.NullOr( + Schema.Struct({ + repository: Schema.optional( + Schema.NullOr( + Schema.Struct({ + blobs: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.optional( + Schema.NullOr( + Schema.Array( + Schema.NullOr( + Schema.Struct({ + path: Schema.optional(Schema.NullOr(Schema.String)), + oid: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), + ), + ), + }), + ), + ), + }), + ), + ), + }), + ), + }), +}); + +const decodeRepositoryBlobs = decodeJsonResult(RawRepositoryBlobsSchema); + +/** + * Blob ids by path, or null where GitLab did not answer the query at all (a project the token + * cannot see, or a repository with no blobs connection). That case must be told apart from an + * empty answer: read as "the revision has none of these files", it would report every cleared + * file as changed again. A node missing either half is left out, since the caller treats an + * absent path as one the revision does not carry. + */ +export function decodeRepositoryBlobsJson( + raw: string, +): Result.Result | null, DecodeFailure> { + const decoded = decodeRepositoryBlobs(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const nodes = decoded.success.data.project?.repository?.blobs?.nodes; + if (nodes === undefined || nodes === null) return Result.succeed(null); + const blobs = new Map(); + for (const node of nodes) { + // Not trimmed, unlike everything else read out of this payload: a leading or trailing space + // is a legal part of a file's name, and trimming it would key this map under a name the + // caller's asked-for path never matches. + const path = node?.path; + const oid = trimmed(node?.oid); + if (path === undefined || path === null || path.length === 0 || oid === null) continue; + blobs.set(path, oid); + } + return Result.succeed(blobs); +} diff --git a/packages/source-control-gitlab/src/server/gitLabMergeRequests.ts b/packages/source-control-gitlab/src/server/gitLabMergeRequests.ts new file mode 100644 index 0000000000000000000000000000000000000000..d03d43411224af8ec3d24b9bda88454377e57524 --- /dev/null +++ b/packages/source-control-gitlab/src/server/gitLabMergeRequests.ts @@ -0,0 +1,161 @@ +import * as Cause from "effect/Cause"; +import * as DateTime from "effect/DateTime"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; + +export interface NormalizedGitLabMergeRequestRecord { + readonly number: number; + readonly title: string; + readonly url: string; + readonly baseRefName: string; + readonly headRefName: string; + readonly state: "open" | "closed" | "merged"; + readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; + readonly updatedAt: Option.Option; + readonly isCrossRepository?: boolean; + readonly headRepositoryNameWithOwner?: string | null; + readonly headRepositoryOwnerLogin?: string | null; +} + +const GitLabProjectReferenceSchema = Schema.Struct({ + path_with_namespace: Schema.optional(Schema.String), + pathWithNamespace: Schema.optional(Schema.String), + namespace: Schema.optional( + Schema.NullOr( + Schema.Struct({ + path: Schema.optional(Schema.String), + full_path: Schema.optional(Schema.String), + fullPath: Schema.optional(Schema.String), + }), + ), + ), +}); + +const GitLabMergeRequestSchema = Schema.Struct({ + iid: PositiveInt, + title: TrimmedNonEmptyString, + web_url: TrimmedNonEmptyString, + source_branch: TrimmedNonEmptyString, + target_branch: TrimmedNonEmptyString, + state: Schema.optional(Schema.NullOr(Schema.String)), + draft: Schema.optional(Schema.Boolean), + work_in_progress: Schema.optional(Schema.Boolean), + closed_at: Schema.optional(Schema.NullOr(Schema.String)), + merged_at: Schema.optional(Schema.NullOr(Schema.String)), + updated_at: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), + source_project_id: Schema.optional(Schema.NullOr(Schema.Number)), + target_project_id: Schema.optional(Schema.NullOr(Schema.Number)), + source_project: Schema.optional(Schema.NullOr(GitLabProjectReferenceSchema)), + target_project: Schema.optional(Schema.NullOr(GitLabProjectReferenceSchema)), +}); + +function trimOptionalString(value: string | null | undefined): string | null { + const trimmed = value?.trim() ?? ""; + return trimmed.length > 0 ? trimmed : null; +} + +function normalizeGitLabMergeRequestState( + state: string | null | undefined, +): "open" | "closed" | "merged" { + const normalized = state?.trim().toLowerCase(); + if (normalized === "merged") { + return "merged"; + } + if (normalized === "closed") { + return "closed"; + } + return "open"; +} + +function projectPathWithNamespace( + project: Schema.Schema.Type | null | undefined, +): string | null { + const explicit = + trimOptionalString(project?.path_with_namespace) ?? + trimOptionalString(project?.pathWithNamespace); + if (explicit) { + return explicit; + } + + const namespacePath = + trimOptionalString(project?.namespace?.full_path) ?? + trimOptionalString(project?.namespace?.fullPath) ?? + trimOptionalString(project?.namespace?.path); + return namespacePath; +} + +function ownerLoginFromPathWithNamespace(pathWithNamespace: string | null): string | null { + const [owner] = pathWithNamespace?.split("/") ?? []; + return trimOptionalString(owner); +} + +function normalizeGitLabMergeRequestRecord( + raw: Schema.Schema.Type, +): NormalizedGitLabMergeRequestRecord { + const sourceProjectPath = projectPathWithNamespace(raw.source_project); + const targetProjectPath = projectPathWithNamespace(raw.target_project); + const isCrossRepository = + typeof raw.source_project_id === "number" && typeof raw.target_project_id === "number" + ? raw.source_project_id !== raw.target_project_id + : sourceProjectPath !== null && targetProjectPath !== null + ? sourceProjectPath.toLowerCase() !== targetProjectPath.toLowerCase() + : undefined; + const headRepositoryOwnerLogin = ownerLoginFromPathWithNamespace(sourceProjectPath); + + return { + number: raw.iid, + title: raw.title, + url: raw.web_url, + baseRefName: raw.target_branch, + headRefName: raw.source_branch, + state: normalizeGitLabMergeRequestState(raw.state), + ...(raw.draft === true || raw.work_in_progress === true ? { isDraft: true } : {}), + closedAt: raw.closed_at ?? null, + mergedAt: raw.merged_at ?? null, + updatedAt: raw.updated_at ?? Option.none(), + ...(typeof isCrossRepository === "boolean" ? { isCrossRepository } : {}), + ...(sourceProjectPath ? { headRepositoryNameWithOwner: sourceProjectPath } : {}), + ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}), + }; +} + +const decodeGitLabMergeRequestList = decodeJsonResult(Schema.Array(Schema.Unknown)); +const decodeGitLabMergeRequest = decodeJsonResult(GitLabMergeRequestSchema); +const decodeGitLabMergeRequestEntry = Schema.decodeUnknownExit(GitLabMergeRequestSchema); + +export function decodeGitLabMergeRequestListJson( + raw: string, +): Result.Result< + ReadonlyArray, + Cause.Cause +> { + const result = decodeGitLabMergeRequestList(raw); + if (Result.isSuccess(result)) { + const mergeRequests: NormalizedGitLabMergeRequestRecord[] = []; + for (const entry of result.success) { + const decodedEntry = decodeGitLabMergeRequestEntry(entry); + if (Exit.isFailure(decodedEntry)) { + continue; + } + mergeRequests.push(normalizeGitLabMergeRequestRecord(decodedEntry.value)); + } + return Result.succeed(mergeRequests); + } + return Result.fail(result.failure); +} + +export function decodeGitLabMergeRequestJson( + raw: string, +): Result.Result> { + const result = decodeGitLabMergeRequest(raw); + if (Result.isSuccess(result)) { + return Result.succeed(normalizeGitLabMergeRequestRecord(result.success)); + } + return Result.fail(result.failure); +} diff --git a/packages/source-control-gitlab/tsconfig.json b/packages/source-control-gitlab/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..374bac55202ddcad695ede1c567ec219a61e9c48 --- /dev/null +++ b/packages/source-control-gitlab/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 54036c86d8626778e14492383cd9ff556d2e5a97..13c583be13f07dd763f20fb41a61ff0e4fa948bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -657,6 +657,9 @@ importers: '@t3tools/source-control-forgejo': specifier: workspace:* version: link:../../packages/source-control-forgejo + '@t3tools/source-control-gitlab': + specifier: workspace:* + version: link:../../packages/source-control-gitlab effect: specifier: 4.0.2 version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688) @@ -700,6 +703,9 @@ importers: '@t3tools/shared': specifier: workspace:* version: link:../../packages/shared + '@t3tools/source-control-testing': + specifier: workspace:* + version: link:../../packages/source-control-testing '@t3tools/ssh': specifier: workspace:* version: link:../../packages/ssh @@ -1603,6 +1609,34 @@ importers: specifier: 1.0.0 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/source-control-gitlab: + dependencies: + '@t3tools/contracts': + specifier: workspace:* + version: link:../contracts + '@t3tools/shared': + specifier: workspace:* + version: link:../shared + '@t3tools/source-control-core': + specifier: workspace:* + version: link:../source-control-core + effect: + specifier: 4.0.2 + version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688) + devDependencies: + '@effect/vitest': + specifier: 4.0.2 + version: 4.0.2(patch_hash=595b7d26fb635caba9c3d39fcb189bb78a000192f8d3b60ead7174b310d1ade9)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + '@t3tools/source-control-testing': + specifier: workspace:* + version: link:../source-control-testing + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + vite-plus: + specifier: 1.0.0 + version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/source-control-testing: dependencies: '@t3tools/contracts': diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 7f29acf844a0dedb36c054e9e6c94ff80c91b3f1..c19a1ee9cac4b73fd498f50bd264a469425ecf86 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -40,6 +40,7 @@ const workspaceFiles = [ "packages/source-control-azure-devops/package.json", "packages/source-control-core/package.json", "packages/source-control-forgejo/package.json", + "packages/source-control-gitlab/package.json", "packages/source-control-testing/package.json", "packages/effect-acp/package.json", "packages/effect-codex-app-server/package.json",