| author | |
| committer | |
| log | eb459b5afaf42621a6cb2e698715fa017e26e5d7 |
| tree | 78bb581272aad3f8c5324cef792debf4b08e9a0e |
| parent | d01febb509a376d93af2d298a7787449792b5fcb |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>42 files changed, 7431 insertions(+), 7324 deletions(-)
apps/server/package.json+2| ... | @@ -48,6 +48,7 @@ | ... | @@ -48,6 +48,7 @@ |
| 48 | "@t3tools/source-control-azure-devops": "workspace:*", | 48 | "@t3tools/source-control-azure-devops": "workspace:*", |
| 49 | "@t3tools/source-control-core": "workspace:*", | 49 | "@t3tools/source-control-core": "workspace:*", |
| 50 | "@t3tools/source-control-forgejo": "workspace:*", | 50 | "@t3tools/source-control-forgejo": "workspace:*", |
| 51 | "@t3tools/source-control-gitlab": "workspace:*", | ||
| 51 | "effect": "catalog:", | 52 | "effect": "catalog:", |
| 52 | "jose": "catalog:", | 53 | "jose": "catalog:", |
| 53 | "node-pty": "^1.2.0-beta.15", | 54 | "node-pty": "^1.2.0-beta.15", |
| ... | @@ -64,6 +65,7 @@ | ... | @@ -64,6 +65,7 @@ |
| 64 | "@t3tools/contracts": "workspace:*", | 65 | "@t3tools/contracts": "workspace:*", |
| 65 | "@t3tools/provider-testing": "workspace:*", | 66 | "@t3tools/provider-testing": "workspace:*", |
| 66 | "@t3tools/shared": "workspace:*", | 67 | "@t3tools/shared": "workspace:*", |
| 68 | "@t3tools/source-control-testing": "workspace:*", | ||
| 67 | "@t3tools/ssh": "workspace:*", | 69 | "@t3tools/ssh": "workspace:*", |
| 68 | "@t3tools/tailscale": "workspace:*", | 70 | "@t3tools/tailscale": "workspace:*", |
| 69 | "@t3tools/web": "workspace:*", | 71 | "@t3tools/web": "workspace:*", |
apps/server/scripts/evaluate-thread-titles.ts-2| ... | @@ -24,7 +24,6 @@ import { | ... | @@ -24,7 +24,6 @@ import { |
| 24 | import * as ThreadTitleLinks from "../src/textGeneration/ThreadTitleLinks.ts"; | 24 | import * as ThreadTitleLinks from "../src/textGeneration/ThreadTitleLinks.ts"; |
| 25 | import * as SourceControlProviderRegistry from "../src/sourceControl/SourceControlProviderRegistry.ts"; | 25 | import * as SourceControlProviderRegistry from "../src/sourceControl/SourceControlProviderRegistry.ts"; |
| 26 | import * as GitHubApi from "../src/sourceControl/GitHubApi.ts"; | 26 | import * as GitHubApi from "../src/sourceControl/GitHubApi.ts"; |
| 27 | import * as GitLabCli from "../src/sourceControl/GitLabCli.ts"; | ||
| 28 | import * as SourceControlBuiltInDrivers from "../src/sourceControl/builtInDrivers.ts"; | 27 | import * as SourceControlBuiltInDrivers from "../src/sourceControl/builtInDrivers.ts"; |
| 29 | import * as BitbucketApi from "../src/sourceControl/BitbucketApi.ts"; | 28 | import * as BitbucketApi from "../src/sourceControl/BitbucketApi.ts"; |
| 30 | import * as ServerSettings from "../src/serverSettings.ts"; | 29 | import * as ServerSettings from "../src/serverSettings.ts"; |
| ... | @@ -152,7 +151,6 @@ await Effect.runPromise( | ... | @@ -152,7 +151,6 @@ await Effect.runPromise( |
| 152 | Layer.provide( | 151 | Layer.provide( |
| 153 | Layer.mergeAll( | 152 | Layer.mergeAll( |
| 154 | GitHubApi.layerWithDependencies, | 153 | GitHubApi.layerWithDependencies, |
| 155 | GitLabCli.layer, | ||
| 156 | SourceControlBuiltInDrivers.layer, | 154 | SourceControlBuiltInDrivers.layer, |
| 157 | // No saved credentials here; Bitbucket falls back to T3CODE_BITBUCKET_* variables. | 155 | // No saved credentials here; Bitbucket falls back to T3CODE_BITBUCKET_* variables. |
| 158 | BitbucketApi.layer, | 156 | BitbucketApi.layer, |
apps/server/src/git/GitManager.test.ts+13-9| ... | @@ -44,7 +44,9 @@ import { | ... | @@ -44,7 +44,9 @@ import { |
| 44 | } from "@t3tools/contracts"; | 44 | } from "@t3tools/contracts"; |
| 45 | import * as DateTime from "effect/DateTime"; | 45 | import * as DateTime from "effect/DateTime"; |
| 46 | import { decodeGitHubPullRequestListJson } from "../sourceControl/gitHubPullRequests.ts"; | 46 | import { decodeGitHubPullRequestListJson } from "../sourceControl/gitHubPullRequests.ts"; |
| 47 | import * as GitLabCli from "../sourceControl/GitLabCli.ts"; | 47 | import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; |
| 48 | import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; | ||
| 49 | import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; | ||
| 48 | import * as TextGeneration from "../textGeneration/TextGeneration.ts"; | 50 | import * as TextGeneration from "../textGeneration/TextGeneration.ts"; |
| 49 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | 51 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; |
| 50 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | 52 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| ... | @@ -53,7 +55,7 @@ import * as VcsProjectConfig from "../vcs/VcsProjectConfig.ts"; | ... | @@ -53,7 +55,7 @@ import * as VcsProjectConfig from "../vcs/VcsProjectConfig.ts"; |
| 53 | import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts"; | 55 | import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts"; |
| 54 | import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; | 56 | import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; |
| 55 | import * as GitWorkflowService from "./GitWorkflowService.ts"; | 57 | import * as GitWorkflowService from "./GitWorkflowService.ts"; |
| 56 | import * as GitLabSourceControlProvider from "../sourceControl/GitLabSourceControlProvider.ts"; | 58 | import * as GitLabSourceControlProvider from "@t3tools/source-control-gitlab/server/GitLabSourceControlProvider"; |
| 57 | import { | 59 | import { |
| 58 | ForgejoPullRequestSchema, | 60 | ForgejoPullRequestSchema, |
| 59 | toForgejoChangeRequest, | 61 | toForgejoChangeRequest, |
| ... | @@ -2108,17 +2110,19 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { | ... | @@ -2108,17 +2110,19 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { |
| 2108 | source_project: { path_with_namespace: "Group/Subgroup/Fork" }, | 2110 | source_project: { path_with_namespace: "Group/Subgroup/Fork" }, |
| 2109 | }, | 2111 | }, |
| 2110 | ]); | 2112 | ]); |
| 2111 | const calls: VcsProcess.VcsProcessInput[] = []; | 2113 | const calls: SourceControlHost.SourceControlProcessInput[] = []; |
| 2112 | const provider = yield* GitLabSourceControlProvider.make.pipe( | 2114 | const provider = yield* GitLabSourceControlProvider.make.pipe( |
| 2113 | Effect.provide( | 2115 | Effect.provide( |
| 2114 | GitLabCli.layer.pipe( | 2116 | GitLabCli.layer.pipe( |
| 2115 | Layer.provide( | 2117 | Layer.provide( |
| 2116 | Layer.mock(VcsProcess.VcsProcess)({ | 2118 | TestSourceControlHost.layer({ |
| 2117 | run: (input) => | 2119 | process: { |
| 2118 | Effect.sync(() => { | 2120 | run: (input) => |
| 2119 | calls.push(input); | 2121 | Effect.sync(() => { |
| 2120 | return fakeGhOutput(output); | 2122 | calls.push(input); |
| 2121 | }), | 2123 | return fakeGhOutput(output); |
| 2124 | }), | ||
| 2125 | }, | ||
| 2122 | }), | 2126 | }), |
| 2123 | ), | 2127 | ), |
| 2124 | ), | 2128 | ), |
apps/server/src/pullRequest/GitLabPullRequestCli.test.ts deleted-1536| ... | @@ -1,1536 +0,0 @@ | ||
| 1 | import { afterEach, assert, expect, it, vi } from "@effect/vitest"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Layer from "effect/Layer"; | ||
| 4 | import { ChildProcessSpawner } from "effect/process"; | ||
| 5 | |||
| 6 | import * as GitLabCli from "../sourceControl/GitLabCli.ts"; | ||
| 7 | import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts"; | ||
| 8 | |||
| 9 | const mockedExecute = vi.fn<GitLabCli.GitLabCli["Service"]["execute"]>(); | ||
| 10 | |||
| 11 | const layer = it.layer( | ||
| 12 | GitLabPullRequestCli.layer.pipe( | ||
| 13 | Layer.provide( | ||
| 14 | Layer.mock(GitLabCli.GitLabCli)({ | ||
| 15 | execute: mockedExecute, | ||
| 16 | }), | ||
| 17 | ), | ||
| 18 | ), | ||
| 19 | ); | ||
| 20 | |||
| 21 | function output(stdout: string, stdoutTruncated = false, stdoutInvalidUtf8 = false) { | ||
| 22 | return { | ||
| 23 | exitCode: ChildProcessSpawner.ExitCode(0), | ||
| 24 | stdout, | ||
| 25 | stderr: "", | ||
| 26 | stdoutTruncated, | ||
| 27 | stderrTruncated: false, | ||
| 28 | stdoutInvalidUtf8, | ||
| 29 | }; | ||
| 30 | } | ||
| 31 | |||
| 32 | function mergeRequests(count: number, firstNumber: number): string { | ||
| 33 | return JSON.stringify( | ||
| 34 | Array.from({ length: count }, (_, index) => ({ | ||
| 35 | iid: firstNumber + index, | ||
| 36 | title: `Merge request ${firstNumber + index}`, | ||
| 37 | web_url: `https://gitlab.com/acme/web/-/merge_requests/${firstNumber + index}`, | ||
| 38 | source_branch: "feat/page", | ||
| 39 | target_branch: "main", | ||
| 40 | created_at: "2026-07-01T00:00:00Z", | ||
| 41 | updated_at: "2026-07-02T00:00:00Z", | ||
| 42 | })), | ||
| 43 | ); | ||
| 44 | } | ||
| 45 | |||
| 46 | /** A page of `/diffs` as GitLab serves it, a full one unless the count says otherwise. */ | ||
| 47 | function diffPage(firstIndex: number, count = 100): string { | ||
| 48 | return JSON.stringify( | ||
| 49 | Array.from({ length: count }, (_, index) => ({ | ||
| 50 | old_path: `src/${firstIndex + index}.ts`, | ||
| 51 | new_path: `src/${firstIndex + index}.ts`, | ||
| 52 | diff: "@@ -1 +1 @@\n-a\n+b\n", | ||
| 53 | })), | ||
| 54 | ); | ||
| 55 | } | ||
| 56 | |||
| 57 | /** A page of merge request notes, which is what the flat conversation is read from. */ | ||
| 58 | function notes(count: number, firstId: number): string { | ||
| 59 | return JSON.stringify( | ||
| 60 | Array.from({ length: count }, (_, index) => ({ | ||
| 61 | id: firstId + index, | ||
| 62 | body: `note ${firstId + index}`, | ||
| 63 | author: { username: "bilal" }, | ||
| 64 | created_at: "2026-07-01T00:00:00Z", | ||
| 65 | })), | ||
| 66 | ); | ||
| 67 | } | ||
| 68 | |||
| 69 | /** Who opened the merge request, and somebody already reviewing it. */ | ||
| 70 | const author = { id: 1, username: "bilal" }; | ||
| 71 | const reviewer = { id: 5, username: "octocat" }; | ||
| 72 | |||
| 73 | /** One merge request as `/merge_requests/:iid` answers with it. */ | ||
| 74 | function mergeRequestJson(overrides: Record<string, unknown>): string { | ||
| 75 | return JSON.stringify({ | ||
| 76 | iid: 7, | ||
| 77 | title: "Merge request 7", | ||
| 78 | web_url: "https://gitlab.com/acme/web/-/merge_requests/7", | ||
| 79 | source_branch: "feat/page", | ||
| 80 | target_branch: "main", | ||
| 81 | created_at: "2026-07-01T00:00:00Z", | ||
| 82 | updated_at: "2026-07-02T00:00:00Z", | ||
| 83 | author, | ||
| 84 | ...overrides, | ||
| 85 | }); | ||
| 86 | } | ||
| 87 | |||
| 88 | /** The endpoint or subcommand of the nth glab invocation. */ | ||
| 89 | function argsOfCall(index: number): ReadonlyArray<string> { | ||
| 90 | return callAt(index).args; | ||
| 91 | } | ||
| 92 | |||
| 93 | /** The whole nth invocation, so a request body can be asserted alongside its path. */ | ||
| 94 | function callAt(index: number) { | ||
| 95 | const call = mockedExecute.mock.calls[index]; | ||
| 96 | assert.isDefined(call); | ||
| 97 | return call[0]; | ||
| 98 | } | ||
| 99 | |||
| 100 | afterEach(() => { | ||
| 101 | mockedExecute.mockReset(); | ||
| 102 | }); | ||
| 103 | |||
| 104 | layer("GitLabPullRequestCli.layer", (it) => { | ||
| 105 | it.effect("asks GitLab for one row more than the page, to probe for a next page", () => | ||
| 106 | Effect.gen(function* () { | ||
| 107 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(mergeRequests(3, 1)))); | ||
| 108 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 109 | |||
| 110 | const batch = yield* cli.listMergeRequests({ | ||
| 111 | cwd: "/w", | ||
| 112 | repository: "acme/web", | ||
| 113 | state: "open", | ||
| 114 | involvement: "all", | ||
| 115 | viewer: "bilal", | ||
| 116 | limit: 10, | ||
| 117 | }); | ||
| 118 | |||
| 119 | assert.strictEqual(batch.items.length, 3); | ||
| 120 | assert.isFalse(batch.truncated); | ||
| 121 | assert.strictEqual(batch.cursorAdvance, 3); | ||
| 122 | const path = argsOfCall(0)[1] ?? ""; | ||
| 123 | expect(path).toContain("projects/acme%2Fweb/merge_requests"); | ||
| 124 | expect(path).toContain("per_page=11"); | ||
| 125 | expect(path).toContain("state=opened"); | ||
| 126 | }), | ||
| 127 | ); | ||
| 128 | |||
| 129 | it.effect("walks pages at a fixed size, because GitLab pages by offset", () => | ||
| 130 | Effect.gen(function* () { | ||
| 131 | mockedExecute | ||
| 132 | .mockReturnValueOnce(Effect.succeed(output(mergeRequests(100, 1)))) | ||
| 133 | .mockReturnValueOnce(Effect.succeed(output(mergeRequests(100, 101)))); | ||
| 134 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 135 | |||
| 136 | const batch = yield* cli.listMergeRequests({ | ||
| 137 | cwd: "/w", | ||
| 138 | repository: "acme/web", | ||
| 139 | state: "open", | ||
| 140 | involvement: "all", | ||
| 141 | viewer: "bilal", | ||
| 142 | limit: 150, | ||
| 143 | }); | ||
| 144 | |||
| 145 | assert.strictEqual(batch.items.length, 150); | ||
| 146 | assert.isTrue(batch.truncated); | ||
| 147 | for (const index of [0, 1]) { | ||
| 148 | expect(argsOfCall(index)[1]).toContain("per_page=100"); | ||
| 149 | } | ||
| 150 | expect(argsOfCall(0)[1]).toContain("page=1"); | ||
| 151 | expect(argsOfCall(1)[1]).toContain("page=2"); | ||
| 152 | }), | ||
| 153 | ); | ||
| 154 | |||
| 155 | it.effect("hands a search to GitLab's own search parameter", () => | ||
| 156 | Effect.gen(function* () { | ||
| 157 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | ||
| 158 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 159 | |||
| 160 | yield* cli.listMergeRequests({ | ||
| 161 | cwd: "/w", | ||
| 162 | repository: "acme/web", | ||
| 163 | state: "open", | ||
| 164 | involvement: "all", | ||
| 165 | viewer: "bilal", | ||
| 166 | limit: 10, | ||
| 167 | query: "page", | ||
| 168 | }); | ||
| 169 | |||
| 170 | // GitLab matches `search` against title and description, which is more than the row shows. | ||
| 171 | expect(argsOfCall(0)[1]).toContain("search=page"); | ||
| 172 | }), | ||
| 173 | ); | ||
| 174 | |||
| 175 | it.effect("carries on from the number of rows already delivered", () => | ||
| 176 | Effect.gen(function* () { | ||
| 177 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(mergeRequests(3, 1)))); | ||
| 178 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 179 | |||
| 180 | yield* cli.listMergeRequests({ | ||
| 181 | cwd: "/w", | ||
| 182 | repository: "acme/web", | ||
| 183 | state: "open", | ||
| 184 | involvement: "all", | ||
| 185 | viewer: "bilal", | ||
| 186 | limit: 10, | ||
| 187 | cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 10 }, | ||
| 188 | }); | ||
| 189 | |||
| 190 | // GitLab's timestamp filter has no tie-breaker, so an offset is what advances through a | ||
| 191 | // boundary shared by more rows than one page can hold. | ||
| 192 | const path = argsOfCall(0)[1] ?? ""; | ||
| 193 | expect(path).not.toContain("updated_before="); | ||
| 194 | expect(path).toContain("order_by=updated_at"); | ||
| 195 | expect(path).toContain("per_page=11"); | ||
| 196 | expect(path).toContain("page=1"); | ||
| 197 | }), | ||
| 198 | ); | ||
| 199 | |||
| 200 | it.effect("advances beyond several pages sharing the cursor timestamp", () => | ||
| 201 | Effect.gen(function* () { | ||
| 202 | mockedExecute | ||
| 203 | .mockReturnValueOnce(Effect.succeed(output(mergeRequests(11, 144)))) | ||
| 204 | .mockReturnValueOnce(Effect.succeed(output(mergeRequests(11, 155)))); | ||
| 205 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 206 | |||
| 207 | const batch = yield* cli.listMergeRequests({ | ||
| 208 | cwd: "/w", | ||
| 209 | repository: "acme/web", | ||
| 210 | state: "open", | ||
| 211 | involvement: "all", | ||
| 212 | viewer: "bilal", | ||
| 213 | limit: 10, | ||
| 214 | cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 150 }, | ||
| 215 | }); | ||
| 216 | |||
| 217 | expect(argsOfCall(0)[1]).toContain("per_page=11"); | ||
| 218 | expect(argsOfCall(0)[1]).toContain("page=14"); | ||
| 219 | expect(argsOfCall(1)[1]).toContain("page=15"); | ||
| 220 | expect(batch.items.map((item) => item.number)).toEqual([ | ||
| 221 | 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, | ||
| 222 | ]); | ||
| 223 | assert.isTrue(batch.truncated); | ||
| 224 | }), | ||
| 225 | ); | ||
| 226 | |||
| 227 | it.effect("advances the cursor through malformed raw rows", () => | ||
| 228 | Effect.gen(function* () { | ||
| 229 | const rows = JSON.parse(mergeRequests(2, 1)) as ReadonlyArray<unknown>; | ||
| 230 | mockedExecute.mockReturnValueOnce( | ||
| 231 | Effect.succeed(output(JSON.stringify([{ iid: "malformed" }, ...rows]))), | ||
| 232 | ); | ||
| 233 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 234 | |||
| 235 | const batch = yield* cli.listMergeRequests({ | ||
| 236 | cwd: "/w", | ||
| 237 | repository: "acme/web", | ||
| 238 | state: "open", | ||
| 239 | involvement: "all", | ||
| 240 | viewer: "bilal", | ||
| 241 | limit: 2, | ||
| 242 | }); | ||
| 243 | |||
| 244 | expect(batch.items.map((item) => item.number)).toEqual([1, 2]); | ||
| 245 | assert.strictEqual(batch.cursorAdvance, 3); | ||
| 246 | assert.isTrue(batch.truncated); | ||
| 247 | }), | ||
| 248 | ); | ||
| 249 | |||
| 250 | it.effect("URL-encodes a search, so it cannot add a parameter of its own", () => | ||
| 251 | Effect.gen(function* () { | ||
| 252 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | ||
| 253 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 254 | |||
| 255 | yield* cli.listMergeRequests({ | ||
| 256 | cwd: "/w", | ||
| 257 | repository: "acme/web", | ||
| 258 | state: "open", | ||
| 259 | involvement: "all", | ||
| 260 | viewer: "bilal", | ||
| 261 | limit: 10, | ||
| 262 | query: '-a&per_page=1 "b"', | ||
| 263 | }); | ||
| 264 | |||
| 265 | const path = argsOfCall(0)[1] ?? ""; | ||
| 266 | expect(path).toContain("search=-a%26per_page%3D1%20%22b%22"); | ||
| 267 | // The page size the walk fixed is still the only one in the query. | ||
| 268 | assert.strictEqual(path.match(/per_page=/g)?.length, 1); | ||
| 269 | }), | ||
| 270 | ); | ||
| 271 | |||
| 272 | it.effect("asks for no search at all when the reader typed only spaces", () => | ||
| 273 | Effect.gen(function* () { | ||
| 274 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | ||
| 275 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 276 | |||
| 277 | yield* cli.listMergeRequests({ | ||
| 278 | cwd: "/w", | ||
| 279 | repository: "acme/web", | ||
| 280 | state: "open", | ||
| 281 | involvement: "all", | ||
| 282 | viewer: "bilal", | ||
| 283 | limit: 10, | ||
| 284 | query: " ", | ||
| 285 | }); | ||
| 286 | |||
| 287 | expect(argsOfCall(0)[1]).not.toContain("search="); | ||
| 288 | }), | ||
| 289 | ); | ||
| 290 | |||
| 291 | it.effect("stops walking on a short page", () => | ||
| 292 | Effect.gen(function* () { | ||
| 293 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(mergeRequests(40, 1)))); | ||
| 294 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 295 | |||
| 296 | const batch = yield* cli.listMergeRequests({ | ||
| 297 | cwd: "/w", | ||
| 298 | repository: "acme/web", | ||
| 299 | state: "open", | ||
| 300 | involvement: "all", | ||
| 301 | viewer: "bilal", | ||
| 302 | limit: 150, | ||
| 303 | }); | ||
| 304 | |||
| 305 | assert.strictEqual(batch.items.length, 40); | ||
| 306 | assert.isFalse(batch.truncated); | ||
| 307 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | ||
| 308 | }), | ||
| 309 | ); | ||
| 310 | |||
| 311 | it.effect("stops walking when every row on a page fails to decode", () => | ||
| 312 | Effect.gen(function* () { | ||
| 313 | // Full pages of unusable rows: nothing is collected, so the collected-count bound never | ||
| 314 | // trips and only the page bound can end the walk. | ||
| 315 | const unusable = JSON.stringify(Array.from({ length: 100 }, () => ({ iid: "nope" }))); | ||
| 316 | mockedExecute.mockReturnValue(Effect.succeed(output(unusable))); | ||
| 317 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 318 | |||
| 319 | const batch = yield* cli.listMergeRequests({ | ||
| 320 | cwd: "/w", | ||
| 321 | repository: "acme/web", | ||
| 322 | state: "open", | ||
| 323 | involvement: "all", | ||
| 324 | viewer: "bilal", | ||
| 325 | limit: 150, | ||
| 326 | }); | ||
| 327 | |||
| 328 | assert.strictEqual(batch.items.length, 0); | ||
| 329 | // ceil((150 + 1) / 100) pages, not one request per page forever. | ||
| 330 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | ||
| 331 | }), | ||
| 332 | ); | ||
| 333 | |||
| 334 | it.effect("asks GitLab for every state on the All tab", () => | ||
| 335 | Effect.gen(function* () { | ||
| 336 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | ||
| 337 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 338 | |||
| 339 | yield* cli.listMergeRequests({ | ||
| 340 | cwd: "/w", | ||
| 341 | repository: "acme/web", | ||
| 342 | state: "all", | ||
| 343 | involvement: "all", | ||
| 344 | viewer: "bilal", | ||
| 345 | limit: 10, | ||
| 346 | }); | ||
| 347 | |||
| 348 | expect(argsOfCall(0)[1]).toContain("state=all"); | ||
| 349 | }), | ||
| 350 | ); | ||
| 351 | |||
| 352 | it.effect("filters by the reviewer when the viewer is reviewing", () => | ||
| 353 | Effect.gen(function* () { | ||
| 354 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | ||
| 355 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 356 | |||
| 357 | yield* cli.listMergeRequests({ | ||
| 358 | cwd: "/w", | ||
| 359 | repository: "acme/web", | ||
| 360 | state: "open", | ||
| 361 | involvement: "reviewing", | ||
| 362 | viewer: "bilal", | ||
| 363 | limit: 10, | ||
| 364 | }); | ||
| 365 | |||
| 366 | expect(argsOfCall(0)[1]).toContain("reviewer_username=bilal"); | ||
| 367 | }), | ||
| 368 | ); | ||
| 369 | |||
| 370 | it.effect("addresses a nested group project by its encoded full path", () => | ||
| 371 | Effect.gen(function* () { | ||
| 372 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | ||
| 373 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 374 | |||
| 375 | yield* cli.listMergeRequests({ | ||
| 376 | cwd: "/w", | ||
| 377 | repository: "acme/platform/web", | ||
| 378 | state: "open", | ||
| 379 | involvement: "all", | ||
| 380 | viewer: "bilal", | ||
| 381 | limit: 10, | ||
| 382 | }); | ||
| 383 | |||
| 384 | expect(argsOfCall(0)[1]).toContain("projects/acme%2Fplatform%2Fweb/merge_requests"); | ||
| 385 | }), | ||
| 386 | ); | ||
| 387 | |||
| 388 | it.effect("merges immediately rather than leaving auto-merge armed", () => | ||
| 389 | Effect.gen(function* () { | ||
| 390 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(""))); | ||
| 391 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 392 | |||
| 393 | yield* cli.runMergeRequestAction({ | ||
| 394 | cwd: "/w", | ||
| 395 | repository: "acme/web", | ||
| 396 | number: 7, | ||
| 397 | action: "merge", | ||
| 398 | mergeMethod: "squash", | ||
| 399 | }); | ||
| 400 | |||
| 401 | expect(argsOfCall(0)).toEqual([ | ||
| 402 | "mr", | ||
| 403 | "merge", | ||
| 404 | "7", | ||
| 405 | "--repo", | ||
| 406 | "acme/web", | ||
| 407 | "--auto-merge=false", | ||
| 408 | "--yes", | ||
| 409 | "--squash", | ||
| 410 | ]); | ||
| 411 | }), | ||
| 412 | ); | ||
| 413 | |||
| 414 | it.effect("arms auto-merge with the same strategy a merge would have used", () => | ||
| 415 | Effect.gen(function* () { | ||
| 416 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(""))); | ||
| 417 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 418 | |||
| 419 | yield* cli.runMergeRequestAction({ | ||
| 420 | cwd: "/w", | ||
| 421 | repository: "acme/web", | ||
| 422 | number: 7, | ||
| 423 | action: "enable-auto-merge", | ||
| 424 | mergeMethod: "squash", | ||
| 425 | }); | ||
| 426 | |||
| 427 | expect(argsOfCall(0)).toEqual([ | ||
| 428 | "mr", | ||
| 429 | "merge", | ||
| 430 | "7", | ||
| 431 | "--repo", | ||
| 432 | "acme/web", | ||
| 433 | "--auto-merge=true", | ||
| 434 | "--yes", | ||
| 435 | "--squash", | ||
| 436 | ]); | ||
| 437 | }), | ||
| 438 | ); | ||
| 439 | |||
| 440 | it.effect("cancels an armed auto-merge through the API glab has no flag for", () => | ||
| 441 | Effect.gen(function* () { | ||
| 442 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("{}"))); | ||
| 443 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 444 | |||
| 445 | yield* cli.runMergeRequestAction({ | ||
| 446 | cwd: "/w", | ||
| 447 | repository: "acme/platform/web", | ||
| 448 | number: 7, | ||
| 449 | action: "disable-auto-merge", | ||
| 450 | }); | ||
| 451 | |||
| 452 | expect(argsOfCall(0)).toEqual([ | ||
| 453 | "api", | ||
| 454 | "projects/acme%2Fplatform%2Fweb/merge_requests/7/cancel_merge_when_pipeline_succeeds", | ||
| 455 | "--method", | ||
| 456 | "POST", | ||
| 457 | ]); | ||
| 458 | }), | ||
| 459 | ); | ||
| 460 | |||
| 461 | it.effect("brings a stale branch up to date by rebasing it, the only way GitLab has", () => | ||
| 462 | Effect.gen(function* () { | ||
| 463 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(""))); | ||
| 464 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 465 | |||
| 466 | yield* cli.runMergeRequestAction({ | ||
| 467 | cwd: "/w", | ||
| 468 | repository: "acme/web", | ||
| 469 | number: 7, | ||
| 470 | action: "update-branch", | ||
| 471 | }); | ||
| 472 | |||
| 473 | expect(argsOfCall(0)).toEqual(["mr", "rebase", "7", "--repo", "acme/web"]); | ||
| 474 | }), | ||
| 475 | ); | ||
| 476 | |||
| 477 | it.effect("moves a merge request back to draft through glab", () => | ||
| 478 | Effect.gen(function* () { | ||
| 479 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(""))); | ||
| 480 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 481 | |||
| 482 | yield* cli.runMergeRequestAction({ | ||
| 483 | cwd: "/w", | ||
| 484 | repository: "acme/web", | ||
| 485 | number: 7, | ||
| 486 | action: "draft", | ||
| 487 | }); | ||
| 488 | |||
| 489 | expect(argsOfCall(0)).toEqual(["mr", "update", "7", "--repo", "acme/web", "--draft"]); | ||
| 490 | }), | ||
| 491 | ); | ||
| 492 | |||
| 493 | it.effect("sends a comment body over stdin, never in argv", () => | ||
| 494 | Effect.gen(function* () { | ||
| 495 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(""))); | ||
| 496 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 497 | |||
| 498 | yield* cli.commentOnMergeRequest({ | ||
| 499 | cwd: "/w", | ||
| 500 | repository: "acme/web", | ||
| 501 | number: 7, | ||
| 502 | body: "true", | ||
| 503 | }); | ||
| 504 | |||
| 505 | const call = mockedExecute.mock.calls[0]; | ||
| 506 | assert.isDefined(call); | ||
| 507 | expect(call[0].args).toEqual([ | ||
| 508 | "api", | ||
| 509 | "projects/acme%2Fweb/merge_requests/7/notes", | ||
| 510 | "--method", | ||
| 511 | "POST", | ||
| 512 | "--input", | ||
| 513 | "-", | ||
| 514 | "--header", | ||
| 515 | "Content-Type: application/json", | ||
| 516 | ]); | ||
| 517 | // A JSON body, so a comment reading as a literal `true` stays text. | ||
| 518 | expect(call[0].stdin).toBe('{"body":"true"}'); | ||
| 519 | }), | ||
| 520 | ); | ||
| 521 | |||
| 522 | it.effect("reads one diff page and hands back the cursor for the next", () => | ||
| 523 | Effect.gen(function* () { | ||
| 524 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(diffPage(0)))); | ||
| 525 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 526 | |||
| 527 | const diff = yield* cli.getMergeRequestDiff({ | ||
| 528 | cwd: "/w", | ||
| 529 | repository: "acme/web", | ||
| 530 | number: 7, | ||
| 531 | }); | ||
| 532 | |||
| 533 | // One page per call: the reader asks for the rest, the walk does not run on by itself. | ||
| 534 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | ||
| 535 | assert.isNotNull(diff.nextCursor); | ||
| 536 | // A full page means more files, not a slice with something missing from it. | ||
| 537 | assert.isFalse(diff.truncated); | ||
| 538 | expect(argsOfCall(0)[1]).toContain("merge_requests/7/diffs?per_page=100&page=1"); | ||
| 539 | }), | ||
| 540 | ); | ||
| 541 | |||
| 542 | it.effect("carries on from a cursor at the page it names", () => | ||
| 543 | Effect.gen(function* () { | ||
| 544 | mockedExecute | ||
| 545 | .mockReturnValueOnce(Effect.succeed(output(diffPage(0)))) | ||
| 546 | .mockReturnValueOnce(Effect.succeed(output(diffPage(100, 3)))); | ||
| 547 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 548 | const target = { cwd: "/w", repository: "acme/web", number: 7 }; | ||
| 549 | |||
| 550 | const first = yield* cli.getMergeRequestDiff(target); | ||
| 551 | assert.isNotNull(first.nextCursor); | ||
| 552 | const second = yield* cli.getMergeRequestDiff({ ...target, cursor: first.nextCursor }); | ||
| 553 | |||
| 554 | expect(argsOfCall(1)[1]).toContain("page=2"); | ||
| 555 | // A short page is the end of the change set, so there is nothing to carry on from. | ||
| 556 | assert.isNull(second.nextCursor); | ||
| 557 | expect(second.patch).toContain("diff --git a/src/100.ts b/src/100.ts"); | ||
| 558 | }), | ||
| 559 | ); | ||
| 560 | |||
| 561 | it.effect("refuses a cursor it never handed out rather than reading it into a query", () => | ||
| 562 | Effect.gen(function* () { | ||
| 563 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 564 | |||
| 565 | const error = yield* Effect.flip( | ||
| 566 | cli.getMergeRequestDiff({ | ||
| 567 | cwd: "/w", | ||
| 568 | repository: "acme/web", | ||
| 569 | number: 7, | ||
| 570 | cursor: "1&per_page=1", | ||
| 571 | }), | ||
| 572 | ); | ||
| 573 | |||
| 574 | assert.strictEqual(error._tag, "GitLabDiffCursorError"); | ||
| 575 | assert.strictEqual(mockedExecute.mock.calls.length, 0); | ||
| 576 | }), | ||
| 577 | ); | ||
| 578 | |||
| 579 | it.effect("reads a named commit from its own diff, and pages inside it", () => | ||
| 580 | Effect.gen(function* () { | ||
| 581 | mockedExecute | ||
| 582 | .mockReturnValueOnce(Effect.succeed(output(diffPage(0)))) | ||
| 583 | .mockReturnValueOnce(Effect.succeed(output(diffPage(100, 3)))); | ||
| 584 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 585 | const target = { | ||
| 586 | cwd: "/w", | ||
| 587 | repository: "acme/web", | ||
| 588 | number: 7, | ||
| 589 | commit: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", | ||
| 590 | }; | ||
| 591 | |||
| 592 | const first = yield* cli.getMergeRequestDiff(target); | ||
| 593 | assert.isNotNull(first.nextCursor); | ||
| 594 | const second = yield* cli.getMergeRequestDiff({ ...target, cursor: first.nextCursor }); | ||
| 595 | |||
| 596 | const commitPath = | ||
| 597 | "projects/acme%2Fweb/repository/commits/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0/diff"; | ||
| 598 | expect(argsOfCall(0)[1]).toBe(`${commitPath}?per_page=100&page=1`); | ||
| 599 | // The whole path, not just the page: a cursor branch that dropped the commit would still | ||
| 600 | // ask for page 2, of the merge request's own diff. | ||
| 601 | expect(argsOfCall(1)[1]).toBe(`${commitPath}?per_page=100&page=2`); | ||
| 602 | assert.isNull(second.nextCursor); | ||
| 603 | }), | ||
| 604 | ); | ||
| 605 | |||
| 606 | it.effect("refuses a commit that is not a sha rather than reading it into a path", () => | ||
| 607 | Effect.gen(function* () { | ||
| 608 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 609 | |||
| 610 | const error = yield* Effect.flip( | ||
| 611 | cli.getMergeRequestDiff({ | ||
| 612 | cwd: "/w", | ||
| 613 | repository: "acme/web", | ||
| 614 | number: 7, | ||
| 615 | commit: "../../merge_requests/8/diffs", | ||
| 616 | }), | ||
| 617 | ); | ||
| 618 | |||
| 619 | assert.strictEqual(error._tag, "GitLabDiffCommitError"); | ||
| 620 | assert.strictEqual(mockedExecute.mock.calls.length, 0); | ||
| 621 | }), | ||
| 622 | ); | ||
| 623 | |||
| 624 | it.effect("reports a commit with no parent as a structured error", () => | ||
| 625 | Effect.gen(function* () { | ||
| 626 | mockedExecute.mockReturnValueOnce( | ||
| 627 | Effect.succeed(output(JSON.stringify({ id: "a1b2c3d", parent_ids: [] }))), | ||
| 628 | ); | ||
| 629 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 630 | |||
| 631 | const error = yield* Effect.flip( | ||
| 632 | cli.getMergeRequestDiffFileContents({ | ||
| 633 | cwd: "/w", | ||
| 634 | repository: "acme/web", | ||
| 635 | number: 7, | ||
| 636 | commit: "a1b2c3d", | ||
| 637 | changeType: "change", | ||
| 638 | oldPath: "src/a.ts", | ||
| 639 | newPath: "src/a.ts", | ||
| 640 | }), | ||
| 641 | ); | ||
| 642 | |||
| 643 | assert.strictEqual(error._tag, "GitLabDiffCommitParentUnavailableError"); | ||
| 644 | if (error._tag === "GitLabDiffCommitParentUnavailableError") { | ||
| 645 | assert.strictEqual(error.commit, "a1b2c3d"); | ||
| 646 | } | ||
| 647 | }), | ||
| 648 | ); | ||
| 649 | |||
| 650 | it.effect("expands a new file from a root commit without requiring a parent", () => | ||
| 651 | Effect.gen(function* () { | ||
| 652 | mockedExecute.mockReturnValueOnce( | ||
| 653 | Effect.succeed(output(JSON.stringify({ id: "a1b2c3d", parent_ids: [] }))), | ||
| 654 | ); | ||
| 655 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("first contents\n"))); | ||
| 656 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 657 | |||
| 658 | const contents = yield* cli.getMergeRequestDiffFileContents({ | ||
| 659 | cwd: "/w", | ||
| 660 | repository: "acme/web", | ||
| 661 | number: 7, | ||
| 662 | commit: "a1b2c3d", | ||
| 663 | changeType: "new", | ||
| 664 | oldPath: "src/first.ts", | ||
| 665 | newPath: "src/first.ts", | ||
| 666 | }); | ||
| 667 | |||
| 668 | expect(contents).toEqual({ oldContents: "", newContents: "first contents\n" }); | ||
| 669 | expect(argsOfCall(1)[1]).toContain("raw?ref=a1b2c3d"); | ||
| 670 | }), | ||
| 671 | ); | ||
| 672 | |||
| 673 | it.effect("reports an oversized diff file with its path and reason", () => | ||
| 674 | Effect.gen(function* () { | ||
| 675 | mockedExecute.mockReturnValueOnce( | ||
| 676 | Effect.succeed( | ||
| 677 | output( | ||
| 678 | JSON.stringify({ | ||
| 679 | diff_refs: { | ||
| 680 | base_sha: "a1b2c3d", | ||
| 681 | head_sha: "b1c2d3e", | ||
| 682 | start_sha: "a1b2c3d", | ||
| 683 | }, | ||
| 684 | }), | ||
| 685 | ), | ||
| 686 | ), | ||
| 687 | ); | ||
| 688 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("partial", true))); | ||
| 689 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 690 | |||
| 691 | const error = yield* Effect.flip( | ||
| 692 | cli.getMergeRequestDiffFileContents({ | ||
| 693 | cwd: "/w", | ||
| 694 | repository: "acme/web", | ||
| 695 | number: 7, | ||
| 696 | changeType: "deleted", | ||
| 697 | oldPath: "src/large.ts", | ||
| 698 | newPath: "src/large.ts", | ||
| 699 | }), | ||
| 700 | ); | ||
| 701 | |||
| 702 | assert.strictEqual(error._tag, "GitLabDiffFileContentsUnavailableError"); | ||
| 703 | if (error._tag === "GitLabDiffFileContentsUnavailableError") { | ||
| 704 | assert.strictEqual(error.path, "src/large.ts"); | ||
| 705 | assert.strictEqual(error.reason, "oversized"); | ||
| 706 | } | ||
| 707 | }), | ||
| 708 | ); | ||
| 709 | |||
| 710 | it.effect("reports undecodable diff file contents as binary", () => | ||
| 711 | Effect.gen(function* () { | ||
| 712 | mockedExecute.mockReturnValueOnce( | ||
| 713 | Effect.succeed( | ||
| 714 | output( | ||
| 715 | JSON.stringify({ | ||
| 716 | diff_refs: { | ||
| 717 | base_sha: "a1b2c3d", | ||
| 718 | head_sha: "b1c2d3e", | ||
| 719 | start_sha: "a1b2c3d", | ||
| 720 | }, | ||
| 721 | }), | ||
| 722 | ), | ||
| 723 | ), | ||
| 724 | ); | ||
| 725 | mockedExecute.mockReturnValueOnce( | ||
| 726 | Effect.succeed(output("binary\uFFFDcontents", false, true)), | ||
| 727 | ); | ||
| 728 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 729 | |||
| 730 | const error = yield* Effect.flip( | ||
| 731 | cli.getMergeRequestDiffFileContents({ | ||
| 732 | cwd: "/w", | ||
| 733 | repository: "acme/web", | ||
| 734 | number: 7, | ||
| 735 | changeType: "deleted", | ||
| 736 | oldPath: "assets/logo.png", | ||
| 737 | newPath: "assets/logo.png", | ||
| 738 | }), | ||
| 739 | ); | ||
| 740 | |||
| 741 | assert.strictEqual(error._tag, "GitLabDiffFileContentsUnavailableError"); | ||
| 742 | if (error._tag === "GitLabDiffFileContentsUnavailableError") { | ||
| 743 | assert.strictEqual(error.path, "assets/logo.png"); | ||
| 744 | assert.strictEqual(error.reason, "binary"); | ||
| 745 | } | ||
| 746 | }), | ||
| 747 | ); | ||
| 748 | |||
| 749 | it.effect("returns valid text containing a literal replacement character", () => | ||
| 750 | Effect.gen(function* () { | ||
| 751 | mockedExecute.mockReturnValueOnce( | ||
| 752 | Effect.succeed( | ||
| 753 | output( | ||
| 754 | JSON.stringify({ | ||
| 755 | diff_refs: { | ||
| 756 | base_sha: "a1b2c3d", | ||
| 757 | head_sha: "b1c2d3e", | ||
| 758 | start_sha: "a1b2c3d", | ||
| 759 | }, | ||
| 760 | }), | ||
| 761 | ), | ||
| 762 | ), | ||
| 763 | ); | ||
| 764 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("before\uFFFDafter"))); | ||
| 765 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 766 | |||
| 767 | const contents = yield* cli.getMergeRequestDiffFileContents({ | ||
| 768 | cwd: "/w", | ||
| 769 | repository: "acme/web", | ||
| 770 | number: 7, | ||
| 771 | changeType: "deleted", | ||
| 772 | oldPath: "docs/encoding.md", | ||
| 773 | newPath: "docs/encoding.md", | ||
| 774 | }); | ||
| 775 | |||
| 776 | assert.strictEqual(contents.oldContents, "before\uFFFDafter"); | ||
| 777 | }), | ||
| 778 | ); | ||
| 779 | |||
| 780 | it.effect("ends the diff on a page with no files rather than asking for it again", () => | ||
| 781 | Effect.gen(function* () { | ||
| 782 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | ||
| 783 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 784 | |||
| 785 | const diff = yield* cli.getMergeRequestDiff({ | ||
| 786 | cwd: "/w", | ||
| 787 | repository: "acme/web", | ||
| 788 | number: 7, | ||
| 789 | cursor: "4", | ||
| 790 | }); | ||
| 791 | |||
| 792 | assert.strictEqual(diff.patch, ""); | ||
| 793 | assert.isNull(diff.nextCursor); | ||
| 794 | }), | ||
| 795 | ); | ||
| 796 | |||
| 797 | it.effect("fails a diff page cut off mid-JSON rather than calling the diff whole", () => | ||
| 798 | Effect.gen(function* () { | ||
| 799 | mockedExecute.mockReturnValueOnce( | ||
| 800 | // A byte-truncated prefix: valid JSON never survives the cut. | ||
| 801 | Effect.succeed({ ...output('[{"old_path":"src/x.ts","new_p'), stdoutTruncated: true }), | ||
| 802 | ); | ||
| 803 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 804 | |||
| 805 | const error = yield* Effect.flip( | ||
| 806 | cli.getMergeRequestDiff({ cwd: "/w", repository: "acme/web", number: 7 }), | ||
| 807 | ); | ||
| 808 | |||
| 809 | // An empty slice with no cursor would report every file from this page on as already | ||
| 810 | // read, which is the one answer that loses a change without saying so. | ||
| 811 | assert.strictEqual(error._tag, "GitLabMergeRequestReadError"); | ||
| 812 | }), | ||
| 813 | ); | ||
| 814 | |||
| 815 | it.effect("offers no squash when the project does not say it allows one", () => | ||
| 816 | Effect.gen(function* () { | ||
| 817 | mockedExecute.mockReturnValueOnce( | ||
| 818 | Effect.succeed(output(JSON.stringify({ merge_method: "merge" }))), | ||
| 819 | ); | ||
| 820 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 821 | |||
| 822 | const capabilities = yield* cli.getProjectMergeCapabilities({ | ||
| 823 | cwd: "/w", | ||
| 824 | repository: "acme/web", | ||
| 825 | }); | ||
| 826 | |||
| 827 | assert.deepStrictEqual(capabilities, { merge: true, squash: false, rebase: false }); | ||
| 828 | }), | ||
| 829 | ); | ||
| 830 | |||
| 831 | it.effect("reads the project's merge settings as its merge capabilities", () => | ||
| 832 | Effect.gen(function* () { | ||
| 833 | mockedExecute.mockReturnValueOnce( | ||
| 834 | Effect.succeed(output(JSON.stringify({ merge_method: "ff", squash_option: "never" }))), | ||
| 835 | ); | ||
| 836 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 837 | |||
| 838 | const capabilities = yield* cli.getProjectMergeCapabilities({ | ||
| 839 | cwd: "/w", | ||
| 840 | repository: "acme/web", | ||
| 841 | }); | ||
| 842 | |||
| 843 | assert.deepStrictEqual(capabilities, { merge: false, squash: false, rebase: true }); | ||
| 844 | }), | ||
| 845 | ); | ||
| 846 | |||
| 847 | it.effect("asks the detail read for the divergence GitLab withholds by default", () => | ||
| 848 | Effect.gen(function* () { | ||
| 849 | mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"message":"404 Not Found"}'))); | ||
| 850 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 851 | |||
| 852 | yield* Effect.ignore( | ||
| 853 | cli.getMergeRequestDetail({ cwd: "/w", repository: "acme/web", number: 7 }), | ||
| 854 | ); | ||
| 855 | |||
| 856 | expect(argsOfCall(0)[1]).toBe( | ||
| 857 | "projects/acme%2Fweb/merge_requests/7?include_diverged_commits_count=true", | ||
| 858 | ); | ||
| 859 | }), | ||
| 860 | ); | ||
| 861 | |||
| 862 | it.effect("fails the read when GitLab returns something unreadable", () => | ||
| 863 | Effect.gen(function* () { | ||
| 864 | mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"message":"404 Not Found"}'))); | ||
| 865 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 866 | |||
| 867 | const error = yield* Effect.flip( | ||
| 868 | cli.getMergeRequestDetail({ cwd: "/w", repository: "acme/web", number: 7 }), | ||
| 869 | ); | ||
| 870 | |||
| 871 | assert.strictEqual(error._tag, "GitLabMergeRequestReadError"); | ||
| 872 | }), | ||
| 873 | ); | ||
| 874 | |||
| 875 | it.effect("fails when the authenticated account has no username", () => | ||
| 876 | Effect.gen(function* () { | ||
| 877 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(JSON.stringify({ username: "" })))); | ||
| 878 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 879 | |||
| 880 | const error = yield* Effect.flip(cli.getViewerUsername({ cwd: "/w" })); | ||
| 881 | |||
| 882 | assert.strictEqual(error._tag, "GitLabViewerUnavailableError"); | ||
| 883 | }), | ||
| 884 | ); | ||
| 885 | |||
| 886 | it.effect("walks the notes until GitLab answers with a short page", () => | ||
| 887 | Effect.gen(function* () { | ||
| 888 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(notes(100, 1)))); | ||
| 889 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(notes(2, 101)))); | ||
| 890 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 891 | |||
| 892 | const { comments, truncated } = yield* cli.listNotes({ | ||
| 893 | cwd: "/w", | ||
| 894 | repository: "acme/web", | ||
| 895 | number: 7, | ||
| 896 | }); | ||
| 897 | |||
| 898 | expect(argsOfCall(0).join(" ")).toContain("page=1"); | ||
| 899 | expect(argsOfCall(1).join(" ")).toContain("page=2"); | ||
| 900 | assert.strictEqual(comments.length, 102); | ||
| 901 | assert.isFalse(truncated); | ||
| 902 | }), | ||
| 903 | ); | ||
| 904 | |||
| 905 | it.effect("stops the note walk at its bound and says the conversation was cut short", () => | ||
| 906 | Effect.gen(function* () { | ||
| 907 | // GitLab that never answers short: the walk has to end itself. | ||
| 908 | mockedExecute.mockReturnValue(Effect.succeed(output(notes(100, 1)))); | ||
| 909 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 910 | |||
| 911 | const { truncated } = yield* cli.listNotes({ | ||
| 912 | cwd: "/w", | ||
| 913 | repository: "acme/web", | ||
| 914 | number: 7, | ||
| 915 | }); | ||
| 916 | |||
| 917 | assert.strictEqual(mockedExecute.mock.calls.length, 10); | ||
| 918 | assert.isTrue(truncated); | ||
| 919 | }), | ||
| 920 | ); | ||
| 921 | |||
| 922 | it.effect("reads a positioned discussion as a thread anchored to its line", () => | ||
| 923 | Effect.gen(function* () { | ||
| 924 | mockedExecute.mockReturnValueOnce( | ||
| 925 | Effect.succeed( | ||
| 926 | output( | ||
| 927 | JSON.stringify([ | ||
| 928 | { | ||
| 929 | id: "abc123", | ||
| 930 | notes: [ | ||
| 931 | { | ||
| 932 | id: 1, | ||
| 933 | body: "rename this", | ||
| 934 | author: { username: "bilal", avatar_url: "https://avatars/b.png" }, | ||
| 935 | created_at: "2026-07-01T00:00:00Z", | ||
| 936 | resolvable: true, | ||
| 937 | resolved: true, | ||
| 938 | position: { | ||
| 939 | position_type: "text", | ||
| 940 | new_path: "src/a.ts", | ||
| 941 | old_path: "src/a.ts", | ||
| 942 | new_line: 12, | ||
| 943 | old_line: null, | ||
| 944 | }, | ||
| 945 | }, | ||
| 946 | { | ||
| 947 | id: 2, | ||
| 948 | body: "done", | ||
| 949 | author: { username: "julius" }, | ||
| 950 | created_at: "2026-07-01T01:00:00Z", | ||
| 951 | }, | ||
| 952 | ], | ||
| 953 | }, | ||
| 954 | // A plain note is the timeline's business, not the diff's. | ||
| 955 | { id: "def456", notes: [{ id: 3, body: "ship it", created_at: "2026-07-01Z" }] }, | ||
| 956 | ]), | ||
| 957 | ), | ||
| 958 | ), | ||
| 959 | ); | ||
| 960 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 961 | |||
| 962 | const { threads } = yield* cli.listDiscussions({ | ||
| 963 | cwd: "/w", | ||
| 964 | repository: "acme/web", | ||
| 965 | number: 7, | ||
| 966 | }); | ||
| 967 | |||
| 968 | assert.strictEqual(threads.length, 1); | ||
| 969 | expect(threads[0]).toMatchObject({ | ||
| 970 | id: "abc123", | ||
| 971 | path: "src/a.ts", | ||
| 972 | line: 12, | ||
| 973 | side: "right", | ||
| 974 | isResolved: true, | ||
| 975 | }); | ||
| 976 | assert.strictEqual(threads[0]?.comments.length, 2); | ||
| 977 | }), | ||
| 978 | ); | ||
| 979 | |||
| 980 | it.effect("sends a review as its comments, then its summary, then the verdict", () => | ||
| 981 | Effect.gen(function* () { | ||
| 982 | mockedExecute.mockReturnValueOnce( | ||
| 983 | Effect.succeed( | ||
| 984 | output( | ||
| 985 | JSON.stringify({ | ||
| 986 | iid: 7, | ||
| 987 | title: "t", | ||
| 988 | web_url: "https://gitlab.com/acme/web/-/merge_requests/7", | ||
| 989 | source_branch: "feat", | ||
| 990 | target_branch: "main", | ||
| 991 | created_at: "2026-07-01T00:00:00Z", | ||
| 992 | updated_at: "2026-07-01T00:00:00Z", | ||
| 993 | diff_refs: { base_sha: "base", head_sha: "head", start_sha: "start" }, | ||
| 994 | }), | ||
| 995 | ), | ||
| 996 | ), | ||
| 997 | ); | ||
| 998 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | ||
| 999 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 1000 | |||
| 1001 | yield* cli.submitReview({ | ||
| 1002 | cwd: "/w", | ||
| 1003 | repository: "acme/web", | ||
| 1004 | number: 7, | ||
| 1005 | verdict: "approve", | ||
| 1006 | body: "Looks right.", | ||
| 1007 | comments: [ | ||
| 1008 | { | ||
| 1009 | path: "src/b.ts", | ||
| 1010 | oldPath: "src/a.ts", | ||
| 1011 | position: { kind: "deleted", oldLine: 4 }, | ||
| 1012 | body: "why remove?", | ||
| 1013 | }, | ||
| 1014 | ], | ||
| 1015 | }); | ||
| 1016 | |||
| 1017 | // The diff revisions first, because a positioned comment cannot be placed without them. | ||
| 1018 | expect(argsOfCall(0)[1]).toContain("merge_requests/7"); | ||
| 1019 | expect(argsOfCall(1)[1]).toContain("/discussions"); | ||
| 1020 | expect(JSON.parse(callAt(1).stdin ?? "")).toEqual({ | ||
| 1021 | body: "why remove?", | ||
| 1022 | position: { | ||
| 1023 | base_sha: "base", | ||
| 1024 | head_sha: "head", | ||
| 1025 | start_sha: "start", | ||
| 1026 | position_type: "text", | ||
| 1027 | // A renamed file is the only case the two differ, and GitLab cannot place a | ||
| 1028 | // position that names the same path on both sides of the rename. | ||
| 1029 | old_path: "src/a.ts", | ||
| 1030 | new_path: "src/b.ts", | ||
| 1031 | old_line: 4, | ||
| 1032 | }, | ||
| 1033 | }); | ||
| 1034 | expect(argsOfCall(2)[1]).toContain("/notes"); | ||
| 1035 | // The verdict goes last, so a review that failed part-way is never an approval. | ||
| 1036 | expect(argsOfCall(3)[1]).toContain("/approve"); | ||
| 1037 | }), | ||
| 1038 | ); | ||
| 1039 | |||
| 1040 | it.effect("does not ask for diff revisions when a review carries no line comments", () => | ||
| 1041 | Effect.gen(function* () { | ||
| 1042 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | ||
| 1043 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 1044 | |||
| 1045 | yield* cli.submitReview({ | ||
| 1046 | cwd: "/w", | ||
| 1047 | repository: "acme/web", | ||
| 1048 | number: 7, | ||
| 1049 | verdict: "comment", | ||
| 1050 | body: "One thought.", | ||
| 1051 | comments: [], | ||
| 1052 | }); | ||
| 1053 | |||
| 1054 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | ||
| 1055 | expect(argsOfCall(0)[1]).toContain("/notes"); | ||
| 1056 | }), | ||
| 1057 | ); | ||
| 1058 | |||
| 1059 | it.effect("resolves a discussion in place rather than posting to it", () => | ||
| 1060 | Effect.gen(function* () { | ||
| 1061 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | ||
| 1062 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 1063 | |||
| 1064 | yield* cli.setDiscussionResolution({ | ||
| 1065 | cwd: "/w", | ||
| 1066 | repository: "acme/web", | ||
| 1067 | number: 7, | ||
| 1068 | discussionId: "abc123", | ||
| 1069 | resolved: true, | ||
| 1070 | }); | ||
| 1071 | |||
| 1072 | expect(argsOfCall(0)).toContain("--method"); | ||
| 1073 | expect(argsOfCall(0)).toContain("PUT"); | ||
| 1074 | expect(argsOfCall(0)[1]).toContain("/discussions/abc123"); | ||
| 1075 | expect(JSON.parse(callAt(0).stdin ?? "")).toEqual({ resolved: true }); | ||
| 1076 | }), | ||
| 1077 | ); | ||
| 1078 | |||
| 1079 | it.effect("awards an emoji through a POST naming it, not a body", () => | ||
| 1080 | Effect.gen(function* () { | ||
| 1081 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | ||
| 1082 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 1083 | |||
| 1084 | yield* cli.setReaction({ | ||
| 1085 | cwd: "/w", | ||
| 1086 | repository: "acme/web", | ||
| 1087 | number: 7, | ||
| 1088 | content: "thumbs-up", | ||
| 1089 | reacted: true, | ||
| 1090 | }); | ||
| 1091 | |||
| 1092 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | ||
| 1093 | expect(argsOfCall(0)).toEqual([ | ||
| 1094 | "api", | ||
| 1095 | "projects/acme%2Fweb/merge_requests/7/award_emoji?name=thumbsup", | ||
| 1096 | "--method", | ||
| 1097 | "POST", | ||
| 1098 | ]); | ||
| 1099 | }), | ||
| 1100 | ); | ||
| 1101 | |||
| 1102 | it.effect("removes an award by listing them and deleting the reader's own id", () => | ||
| 1103 | Effect.gen(function* () { | ||
| 1104 | mockedExecute.mockReturnValueOnce( | ||
| 1105 | Effect.succeed(output(JSON.stringify({ username: "bilal" }))), | ||
| 1106 | ); | ||
| 1107 | mockedExecute.mockReturnValueOnce( | ||
| 1108 | Effect.succeed( | ||
| 1109 | output( | ||
| 1110 | JSON.stringify([ | ||
| 1111 | { id: 5, name: "thumbsup", user: { username: "bilal" } }, | ||
| 1112 | { id: 6, name: "thumbsup", user: { username: "julius" } }, | ||
| 1113 | ]), | ||
| 1114 | ), | ||
| 1115 | ), | ||
| 1116 | ); | ||
| 1117 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("{}"))); | ||
| 1118 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 1119 | |||
| 1120 | yield* cli.setReaction({ | ||
| 1121 | cwd: "/w", | ||
| 1122 | repository: "acme/web", | ||
| 1123 | number: 7, | ||
| 1124 | content: "thumbs-up", | ||
| 1125 | reacted: false, | ||
| 1126 | }); | ||
| 1127 | |||
| 1128 | assert.strictEqual(mockedExecute.mock.calls.length, 3); | ||
| 1129 | expect(argsOfCall(2)).toEqual([ | ||
| 1130 | "api", | ||
| 1131 | "projects/acme%2Fweb/merge_requests/7/award_emoji/5", | ||
| 1132 | "--method", | ||
| 1133 | "DELETE", | ||
| 1134 | ]); | ||
| 1135 | }), | ||
| 1136 | ); | ||
| 1137 | |||
| 1138 | it.effect("does nothing when the reader has no award of that name to take back", () => | ||
| 1139 | Effect.gen(function* () { | ||
| 1140 | mockedExecute.mockReturnValueOnce( | ||
| 1141 | Effect.succeed(output(JSON.stringify({ username: "bilal" }))), | ||
| 1142 | ); | ||
| 1143 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | ||
| 1144 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 1145 | |||
| 1146 | yield* cli.setReaction({ | ||
| 1147 | cwd: "/w", | ||
| 1148 | repository: "acme/web", | ||
| 1149 | number: 7, | ||
| 1150 | content: "thumbs-up", | ||
| 1151 | reacted: false, | ||
| 1152 | }); | ||
| 1153 | |||
| 1154 | // Nothing to delete: the reaction the caller asked to take back is already gone. | ||
| 1155 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | ||
| 1156 | }), | ||
| 1157 | ); | ||
| 1158 | |||
| 1159 | it.effect("names a merge request with no diff revisions rather than calling it unreadable", () => | ||
| 1160 | Effect.gen(function* () { | ||
| 1161 | mockedExecute.mockReturnValueOnce( | ||
| 1162 | Effect.succeed( | ||
| 1163 | output( | ||
| 1164 | JSON.stringify({ | ||
| 1165 | iid: 7, | ||
| 1166 | title: "t", | ||
| 1167 | web_url: "https://gitlab.com/acme/web/-/merge_requests/7", | ||
| 1168 | source_branch: "feat", | ||
| 1169 | target_branch: "main", | ||
| 1170 | created_at: "2026-07-01T00:00:00Z", | ||
| 1171 | updated_at: "2026-07-01T00:00:00Z", | ||
| 1172 | diff_refs: null, | ||
| 1173 | }), | ||
| 1174 | ), | ||
| 1175 | ), | ||
| 1176 | ); | ||
| 1177 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 1178 | |||
| 1179 | const error = yield* Effect.flip( | ||
| 1180 | cli.submitReview({ | ||
| 1181 | cwd: "/w", | ||
| 1182 | repository: "acme/web", | ||
| 1183 | number: 7, | ||
| 1184 | verdict: "comment", | ||
| 1185 | body: "", | ||
| 1186 | comments: [{ path: "src/a.ts", position: { kind: "added", newLine: 4 }, body: "nit" }], | ||
| 1187 | }), | ||
| 1188 | ); | ||
| 1189 | |||
| 1190 | // Nothing failed to decode: GitLab answered, and the answer has nowhere to put a | ||
| 1191 | // positioned comment. | ||
| 1192 | assert.strictEqual(error._tag, "GitLabDiffRefsUnavailableError"); | ||
| 1193 | }), | ||
| 1194 | ); | ||
| 1195 | |||
| 1196 | it.effect("reads who has access to the project and who is already on the merge request", () => | ||
| 1197 | Effect.gen(function* () { | ||
| 1198 | mockedExecute | ||
| 1199 | .mockReturnValueOnce(Effect.succeed(output(mergeRequestJson({ reviewers: [reviewer] })))) | ||
| 1200 | .mockReturnValueOnce( | ||
| 1201 | Effect.succeed(output(JSON.stringify([author, reviewer, { id: 9, username: "hubot" }]))), | ||
| 1202 | ); | ||
| 1203 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 1204 | |||
| 1205 | const list = yield* cli.listReviewerCandidates({ | ||
| 1206 | cwd: "/w", | ||
| 1207 | repository: "acme/web", | ||
| 1208 | number: 7, | ||
| 1209 | }); | ||
| 1210 | |||
| 1211 | expect(argsOfCall(1)[1]).toBe("projects/acme%2Fweb/users?per_page=100"); | ||
| 1212 | // The author is left out, and whoever GitLab already has as a reviewer is marked. | ||
| 1213 | expect(list.candidates.map((candidate) => [candidate.id, candidate.isRequested])).toEqual([ | ||
| 1214 | ["5", true], | ||
| 1215 | ["9", false], | ||
| 1216 | ]); | ||
| 1217 | assert.isFalse(list.truncated); | ||
| 1218 | }), | ||
| 1219 | ); | ||
| 1220 | |||
| 1221 | it.effect("writes the reviewer set back with the one being asked added to it", () => | ||
| 1222 | Effect.gen(function* () { | ||
| 1223 | mockedExecute | ||
| 1224 | .mockReturnValueOnce(Effect.succeed(output(mergeRequestJson({ reviewers: [reviewer] })))) | ||
| 1225 | .mockReturnValueOnce(Effect.succeed(output("{}"))); | ||
| 1226 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 1227 | |||
| 1228 | yield* cli.setReviewerRequest({ | ||
| 1229 | cwd: "/w", | ||
| 1230 | repository: "acme/web", | ||
| 1231 | number: 7, | ||
| 1232 | reviewers: [{ id: "9" }], | ||
| 1233 | requested: true, | ||
| 1234 | }); | ||
| 1235 | |||
| 1236 | // GitLab replaces the whole set, so the reviewer already on the merge request has to be | ||
| 1237 | // sent back with the new one or the request would take them off it. | ||
| 1238 | expect(argsOfCall(1)).toContain("PUT"); | ||
| 1239 | expect(JSON.parse(callAt(1).stdin ?? "")).toEqual({ reviewer_ids: [5, 9] }); | ||
| 1240 | }), | ||
| 1241 | ); | ||
| 1242 | |||
| 1243 | it.effect("takes a reviewer out of the set rather than clearing it", () => | ||
| 1244 | Effect.gen(function* () { | ||
| 1245 | mockedExecute | ||
| 1246 | .mockReturnValueOnce( | ||
| 1247 | Effect.succeed( | ||
| 1248 | output(mergeRequestJson({ reviewers: [reviewer, { id: 9, username: "hubot" }] })), | ||
| 1249 | ), | ||
| 1250 | ) | ||
| 1251 | .mockReturnValueOnce(Effect.succeed(output("{}"))); | ||
| 1252 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 1253 | |||
| 1254 | yield* cli.setReviewerRequest({ | ||
| 1255 | cwd: "/w", | ||
| 1256 | repository: "acme/web", | ||
| 1257 | number: 7, | ||
| 1258 | reviewers: [{ id: "9" }], | ||
| 1259 | requested: false, | ||
| 1260 | }); | ||
| 1261 | |||
| 1262 | expect(JSON.parse(callAt(1).stdin ?? "")).toEqual({ reviewer_ids: [5] }); | ||
| 1263 | }), | ||
| 1264 | ); | ||
| 1265 | |||
| 1266 | it.effect("ignores an id GitLab could not have handed out, which names nobody", () => | ||
| 1267 | Effect.gen(function* () { | ||
| 1268 | mockedExecute | ||
| 1269 | .mockReturnValueOnce(Effect.succeed(output(mergeRequestJson({ reviewers: [reviewer] })))) | ||
| 1270 | .mockReturnValueOnce(Effect.succeed(output("{}"))); | ||
| 1271 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 1272 | |||
| 1273 | yield* cli.setReviewerRequest({ | ||
| 1274 | cwd: "/w", | ||
| 1275 | repository: "acme/web", | ||
| 1276 | number: 7, | ||
| 1277 | reviewers: [{ id: "octocat" }], | ||
| 1278 | requested: true, | ||
| 1279 | }); | ||
| 1280 | |||
| 1281 | // Sending it as a number would rewrite the reviewer set around something nobody chose. | ||
| 1282 | expect(JSON.parse(callAt(1).stdin ?? "")).toEqual({ reviewer_ids: [5] }); | ||
| 1283 | }), | ||
| 1284 | ); | ||
| 1285 | |||
| 1286 | it.effect("rewrites a title without touching the description", () => | ||
| 1287 | Effect.gen(function* () { | ||
| 1288 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | ||
| 1289 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 1290 | |||
| 1291 | yield* cli.updateMergeRequest({ | ||
| 1292 | cwd: "/w", | ||
| 1293 | repository: "acme/web", | ||
| 1294 | number: 7, | ||
| 1295 | title: "A better title", | ||
| 1296 | }); | ||
| 1297 | |||
| 1298 | expect(argsOfCall(0)).toEqual([ | ||
| 1299 | "api", | ||
| 1300 | "projects/acme%2Fweb/merge_requests/7", | ||
| 1301 | "--method", | ||
| 1302 | "PUT", | ||
| 1303 | "--input", | ||
| 1304 | "-", | ||
| 1305 | "--header", | ||
| 1306 | "Content-Type: application/json", | ||
| 1307 | ]); | ||
| 1308 | expect(JSON.parse(callAt(0).stdin ?? "")).toEqual({ title: "A better title" }); | ||
| 1309 | }), | ||
| 1310 | ); | ||
| 1311 | |||
| 1312 | it.effect("sends a rewritten body as GitLab's description, and nothing else", () => | ||
| 1313 | Effect.gen(function* () { | ||
| 1314 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | ||
| 1315 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 1316 | |||
| 1317 | yield* cli.updateMergeRequest({ | ||
| 1318 | cwd: "/w", | ||
| 1319 | repository: "acme/web", | ||
| 1320 | number: 7, | ||
| 1321 | description: "What this changes.", | ||
| 1322 | }); | ||
| 1323 | |||
| 1324 | // A title sent as an empty string would wipe the one the merge request already has. | ||
| 1325 | expect(JSON.parse(callAt(0).stdin ?? "")).toEqual({ description: "What this changes." }); | ||
| 1326 | }), | ||
| 1327 | ); | ||
| 1328 | |||
| 1329 | it.effect("rewrites title and description together in one request", () => | ||
| 1330 | Effect.gen(function* () { | ||
| 1331 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | ||
| 1332 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 1333 | |||
| 1334 | yield* cli.updateMergeRequest({ | ||
| 1335 | cwd: "/w", | ||
| 1336 | repository: "acme/web", | ||
| 1337 | number: 7, | ||
| 1338 | title: "A better title", | ||
| 1339 | description: "What this changes.", | ||
| 1340 | }); | ||
| 1341 | |||
| 1342 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | ||
| 1343 | expect(JSON.parse(callAt(0).stdin ?? "")).toEqual({ | ||
| 1344 | title: "A better title", | ||
| 1345 | description: "What this changes.", | ||
| 1346 | }); | ||
| 1347 | }), | ||
| 1348 | ); | ||
| 1349 | |||
| 1350 | it.effect("rewrites a note in place through the note it names", () => | ||
| 1351 | Effect.gen(function* () { | ||
| 1352 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | ||
| 1353 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 1354 | |||
| 1355 | yield* cli.updateNote({ | ||
| 1356 | cwd: "/w", | ||
| 1357 | repository: "acme/web", | ||
| 1358 | number: 7, | ||
| 1359 | noteId: "42", | ||
| 1360 | body: "true", | ||
| 1361 | }); | ||
| 1362 | |||
| 1363 | expect(argsOfCall(0)).toEqual([ | ||
| 1364 | "api", | ||
| 1365 | "projects/acme%2Fweb/merge_requests/7/notes/42", | ||
| 1366 | "--method", | ||
| 1367 | "PUT", | ||
| 1368 | "--input", | ||
| 1369 | "-", | ||
| 1370 | "--header", | ||
| 1371 | "Content-Type: application/json", | ||
| 1372 | ]); | ||
| 1373 | // A JSON body, so a note rewritten to a literal `true` stays text. | ||
| 1374 | expect(callAt(0).stdin).toBe('{"body":"true"}'); | ||
| 1375 | }), | ||
| 1376 | ); | ||
| 1377 | it.effect("reads blob ids for the marked paths at the merge request's head", () => | ||
| 1378 | Effect.gen(function* () { | ||
| 1379 | mockedExecute.mockReturnValueOnce( | ||
| 1380 | Effect.succeed( | ||
| 1381 | output( | ||
| 1382 | JSON.stringify({ | ||
| 1383 | iid: 7, | ||
| 1384 | title: "t", | ||
| 1385 | web_url: "https://gitlab.com/acme/web/-/merge_requests/7", | ||
| 1386 | source_branch: "feat", | ||
| 1387 | target_branch: "main", | ||
| 1388 | created_at: "2026-07-01T00:00:00Z", | ||
| 1389 | updated_at: "2026-07-01T00:00:00Z", | ||
| 1390 | diff_refs: { base_sha: "base", head_sha: "head", start_sha: "start" }, | ||
| 1391 | }), | ||
| 1392 | ), | ||
| 1393 | ), | ||
| 1394 | ); | ||
| 1395 | mockedExecute.mockReturnValueOnce( | ||
| 1396 | Effect.succeed( | ||
| 1397 | output( | ||
| 1398 | JSON.stringify({ | ||
| 1399 | data: { | ||
| 1400 | project: { repository: { blobs: { nodes: [{ path: "src/a.ts", oid: "aaa" }] } } }, | ||
| 1401 | }, | ||
| 1402 | }), | ||
| 1403 | ), | ||
| 1404 | ), | ||
| 1405 | ); | ||
| 1406 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 1407 | |||
| 1408 | const revisions = yield* cli.getFileRevisions({ | ||
| 1409 | cwd: "/w", | ||
| 1410 | repository: "acme/web", | ||
| 1411 | number: 7, | ||
| 1412 | paths: ["src/a.ts", "src/gone.ts"], | ||
| 1413 | }); | ||
| 1414 | |||
| 1415 | // Every path was looked for at the head, so one the head does not have is one the merge | ||
| 1416 | // request removed: said as the empty version, which a mark on it was stamped with too. | ||
| 1417 | expect([...revisions]).toEqual([ | ||
| 1418 | ["src/a.ts", "aaa"], | ||
| 1419 | ["src/gone.ts", ""], | ||
| 1420 | ]); | ||
| 1421 | // The head the reader is looking at, not whatever the source branch has moved on to. | ||
| 1422 | const body: unknown = JSON.parse(callAt(1).stdin ?? "{}"); | ||
| 1423 | expect(body).toMatchObject({ | ||
| 1424 | variables: { fullPath: "acme/web", ref: "head", paths: ["src/a.ts", "src/gone.ts"] }, | ||
| 1425 | }); | ||
| 1426 | }), | ||
| 1427 | ); | ||
| 1428 | |||
| 1429 | it.effect("leaves the paths out when GitLab did not answer the blobs query", () => | ||
| 1430 | Effect.gen(function* () { | ||
| 1431 | mockedExecute.mockReturnValueOnce( | ||
| 1432 | Effect.succeed( | ||
| 1433 | output( | ||
| 1434 | JSON.stringify({ | ||
| 1435 | iid: 7, | ||
| 1436 | title: "t", | ||
| 1437 | web_url: "https://gitlab.com/acme/web/-/merge_requests/7", | ||
| 1438 | source_branch: "feat", | ||
| 1439 | target_branch: "main", | ||
| 1440 | created_at: "2026-07-01T00:00:00Z", | ||
| 1441 | updated_at: "2026-07-01T00:00:00Z", | ||
| 1442 | diff_refs: { base_sha: "base", head_sha: "head", start_sha: "start" }, | ||
| 1443 | }), | ||
| 1444 | ), | ||
| 1445 | ), | ||
| 1446 | ); | ||
| 1447 | // What GitLab says of a project the token cannot see. It is not the head having none of | ||
| 1448 | // these files, and reading it that way would report every file the reader has cleared as | ||
| 1449 | // changed on nothing worse than a permission. | ||
| 1450 | mockedExecute.mockReturnValueOnce( | ||
| 1451 | Effect.succeed(output(JSON.stringify({ data: { project: null } }))), | ||
| 1452 | ); | ||
| 1453 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 1454 | |||
| 1455 | const revisions = yield* cli.getFileRevisions({ | ||
| 1456 | cwd: "/w", | ||
| 1457 | repository: "acme/web", | ||
| 1458 | number: 7, | ||
| 1459 | paths: ["src/a.ts", "src/b.ts"], | ||
| 1460 | }); | ||
| 1461 | |||
| 1462 | expect([...revisions]).toEqual([]); | ||
| 1463 | }), | ||
| 1464 | ); | ||
| 1465 | |||
| 1466 | it.effect("asks GitLab nothing when no file is marked", () => | ||
| 1467 | Effect.gen(function* () { | ||
| 1468 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 1469 | |||
| 1470 | const revisions = yield* cli.getFileRevisions({ | ||
| 1471 | cwd: "/w", | ||
| 1472 | repository: "acme/web", | ||
| 1473 | number: 7, | ||
| 1474 | paths: [], | ||
| 1475 | }); | ||
| 1476 | |||
| 1477 | expect([...revisions]).toEqual([]); | ||
| 1478 | expect(mockedExecute).not.toHaveBeenCalled(); | ||
| 1479 | }), | ||
| 1480 | ); | ||
| 1481 | |||
| 1482 | it.effect("splits the paths across requests, because GitLab charges the query by how many", () => | ||
| 1483 | Effect.gen(function* () { | ||
| 1484 | const paths = Array.from({ length: 150 }, (_, index) => `src/${index}.ts`); | ||
| 1485 | mockedExecute.mockReturnValueOnce( | ||
| 1486 | Effect.succeed( | ||
| 1487 | output( | ||
| 1488 | JSON.stringify({ | ||
| 1489 | iid: 7, | ||
| 1490 | title: "t", | ||
| 1491 | web_url: "https://gitlab.com/acme/web/-/merge_requests/7", | ||
| 1492 | source_branch: "feat", | ||
| 1493 | target_branch: "main", | ||
| 1494 | created_at: "2026-07-01T00:00:00Z", | ||
| 1495 | updated_at: "2026-07-01T00:00:00Z", | ||
| 1496 | diff_refs: { base_sha: "base", head_sha: "head", start_sha: "start" }, | ||
| 1497 | }), | ||
| 1498 | ), | ||
| 1499 | ), | ||
| 1500 | ); | ||
| 1501 | mockedExecute.mockImplementation((request) => { | ||
| 1502 | const body = JSON.parse(request.stdin ?? "{}") as { | ||
| 1503 | readonly variables: { readonly paths: ReadonlyArray<string> }; | ||
| 1504 | }; | ||
| 1505 | return Effect.succeed( | ||
| 1506 | output( | ||
| 1507 | JSON.stringify({ | ||
| 1508 | data: { | ||
| 1509 | project: { | ||
| 1510 | repository: { | ||
| 1511 | blobs: { | ||
| 1512 | nodes: body.variables.paths.map((path) => ({ path, oid: `oid-${path}` })), | ||
| 1513 | }, | ||
| 1514 | }, | ||
| 1515 | }, | ||
| 1516 | }, | ||
| 1517 | }), | ||
| 1518 | ), | ||
| 1519 | ); | ||
| 1520 | }); | ||
| 1521 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 1522 | |||
| 1523 | const revisions = yield* cli.getFileRevisions({ | ||
| 1524 | cwd: "/w", | ||
| 1525 | repository: "acme/web", | ||
| 1526 | number: 7, | ||
| 1527 | paths, | ||
| 1528 | }); | ||
| 1529 | |||
| 1530 | assert.strictEqual(revisions.size, 150); | ||
| 1531 | assert.strictEqual(revisions.get("src/149.ts"), "oid-src/149.ts"); | ||
| 1532 | // The diff refs, then two batches: a hundred paths and the fifty left over. | ||
| 1533 | assert.strictEqual(mockedExecute.mock.calls.length, 3); | ||
| 1534 | }), | ||
| 1535 | ); | ||
| 1536 | }); | ||
apps/server/src/pullRequest/GitLabPullRequestCli.ts deleted-1495| ... | @@ -1,1495 +0,0 @@ | ||
| 1 | import * as Context from "effect/Context"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Layer from "effect/Layer"; | ||
| 4 | import * as Result from "effect/Result"; | ||
| 5 | import * as Schema from "effect/Schema"; | ||
| 6 | import type { | ||
| 7 | PullRequestAction, | ||
| 8 | PullRequestComment, | ||
| 9 | PullRequestCommit, | ||
| 10 | PullRequestInvolvement, | ||
| 11 | PullRequestListState, | ||
| 12 | PullRequestMergeCapabilities, | ||
| 13 | PullRequestMergeMethod, | ||
| 14 | PullRequestReaction, | ||
| 15 | PullRequestReactionContent, | ||
| 16 | PullRequestReviewCommentDraft, | ||
| 17 | PullRequestReviewPosition, | ||
| 18 | PullRequestReviewThread, | ||
| 19 | PullRequestReviewVerdict, | ||
| 20 | PullRequestReviewerCandidateList, | ||
| 21 | } from "@t3tools/contracts"; | ||
| 22 | |||
| 23 | import * as GitLabCli from "../sourceControl/GitLabCli.ts"; | ||
| 24 | import { | ||
| 25 | AWARD_EMOJI_GRAPHQL_QUERY, | ||
| 26 | decodeAwardEmojiJson, | ||
| 27 | decodeCommitDiffRefsJson, | ||
| 28 | decodeCommitsJson, | ||
| 29 | decodeDiffRefsJson, | ||
| 30 | decodeDiscussionsJson, | ||
| 31 | decodeMergeRequestDetailJson, | ||
| 32 | decodeMergeRequestDiffsJson, | ||
| 33 | decodeMergeRequestListJson, | ||
| 34 | decodeNotesJson, | ||
| 35 | decodeOwnAwardIdJson, | ||
| 36 | decodeProjectMergeCapabilitiesJson, | ||
| 37 | decodeProjectUsersJson, | ||
| 38 | decodeRepositoryBlobsJson, | ||
| 39 | decodeViewerJson, | ||
| 40 | gitLabAwardName, | ||
| 41 | REPOSITORY_BLOBS_GRAPHQL_QUERY, | ||
| 42 | type GitLabDiffRefs, | ||
| 43 | type GitLabMergeRequestDetail, | ||
| 44 | type GitLabMergeRequestListItem, | ||
| 45 | type GitLabProjectUsers, | ||
| 46 | } from "./gitLabMergeRequestJson.ts"; | ||
| 47 | import type { ProviderListCursor } from "@t3tools/source-control-core/server/PullRequestProvider"; | ||
| 48 | |||
| 49 | /** | ||
| 50 | * Names the read that produced unusable output, so a failure reports the call it came from | ||
| 51 | * rather than borrowing another operation's message. | ||
| 52 | */ | ||
| 53 | export class GitLabMergeRequestReadError extends Schema.TaggedError<GitLabMergeRequestReadError>()( | ||
| 54 | "GitLabMergeRequestReadError", | ||
| 55 | { | ||
| 56 | command: Schema.Literal("glab"), | ||
| 57 | cwd: Schema.String, | ||
| 58 | operation: Schema.String, | ||
| 59 | cause: Schema.Defect(), | ||
| 60 | }, | ||
| 61 | ) { | ||
| 62 | get detail(): string { | ||
| 63 | return `GitLab CLI returned an unreadable ${this.operation} response.`; | ||
| 64 | } | ||
| 65 | |||
| 66 | override get message(): string { | ||
| 67 | return `GitLab CLI failed in ${this.operation}: ${this.detail}`; | ||
| 68 | } | ||
| 69 | } | ||
| 70 | |||
| 71 | /** Not a decode failure: glab answered, the account it answered for just has no username. */ | ||
| 72 | export class GitLabViewerUnavailableError extends Schema.TaggedError<GitLabViewerUnavailableError>()( | ||
| 73 | "GitLabViewerUnavailableError", | ||
| 74 | { | ||
| 75 | command: Schema.Literal("glab"), | ||
| 76 | cwd: Schema.String, | ||
| 77 | }, | ||
| 78 | ) { | ||
| 79 | get detail(): string { | ||
| 80 | return "GitLab CLI returned no username for the authenticated account."; | ||
| 81 | } | ||
| 82 | |||
| 83 | override get message(): string { | ||
| 84 | return `GitLab CLI failed in getViewerUsername: ${this.detail}`; | ||
| 85 | } | ||
| 86 | } | ||
| 87 | |||
| 88 | /** Not a decode failure: GitLab answered, the merge request just has no revisions to place a | ||
| 89 | * comment against. */ | ||
| 90 | export class GitLabDiffRefsUnavailableError extends Schema.TaggedError<GitLabDiffRefsUnavailableError>()( | ||
| 91 | "GitLabDiffRefsUnavailableError", | ||
| 92 | { | ||
| 93 | command: Schema.Literal("glab"), | ||
| 94 | cwd: Schema.String, | ||
| 95 | number: Schema.Int, | ||
| 96 | }, | ||
| 97 | ) { | ||
| 98 | get detail(): string { | ||
| 99 | return "The merge request reported no diff revisions."; | ||
| 100 | } | ||
| 101 | |||
| 102 | override get message(): string { | ||
| 103 | return `GitLab CLI failed in getDiffRefs: ${this.detail}`; | ||
| 104 | } | ||
| 105 | } | ||
| 106 | |||
| 107 | /** Not a decode failure: the reader asked to carry on from a cursor this walk never handed out. */ | ||
| 108 | export class GitLabDiffCursorError extends Schema.TaggedError<GitLabDiffCursorError>()( | ||
| 109 | "GitLabDiffCursorError", | ||
| 110 | { | ||
| 111 | command: Schema.Literal("glab"), | ||
| 112 | cwd: Schema.String, | ||
| 113 | }, | ||
| 114 | ) { | ||
| 115 | get detail(): string { | ||
| 116 | return "The diff cursor was not one this merge request handed out."; | ||
| 117 | } | ||
| 118 | |||
| 119 | override get message(): string { | ||
| 120 | return `GitLab CLI failed in getMergeRequestDiff: ${this.detail}`; | ||
| 121 | } | ||
| 122 | } | ||
| 123 | |||
| 124 | /** Not a decode failure: the reader named a commit that is not a sha this project could hold. */ | ||
| 125 | export class GitLabDiffCommitError extends Schema.TaggedError<GitLabDiffCommitError>()( | ||
| 126 | "GitLabDiffCommitError", | ||
| 127 | { | ||
| 128 | command: Schema.Literal("glab"), | ||
| 129 | cwd: Schema.String, | ||
| 130 | }, | ||
| 131 | ) { | ||
| 132 | get detail(): string { | ||
| 133 | return "The named commit was not a commit sha."; | ||
| 134 | } | ||
| 135 | |||
| 136 | override get message(): string { | ||
| 137 | return `GitLab CLI failed in getMergeRequestDiff: ${this.detail}`; | ||
| 138 | } | ||
| 139 | } | ||
| 140 | |||
| 141 | /** The commit exists and decoded, but it has no parent to use as the old revision. */ | ||
| 142 | export class GitLabDiffCommitParentUnavailableError extends Schema.TaggedError<GitLabDiffCommitParentUnavailableError>()( | ||
| 143 | "GitLabDiffCommitParentUnavailableError", | ||
| 144 | { | ||
| 145 | command: Schema.Literal("glab"), | ||
| 146 | cwd: Schema.String, | ||
| 147 | commit: Schema.String, | ||
| 148 | }, | ||
| 149 | ) { | ||
| 150 | get detail(): string { | ||
| 151 | return `Commit ${this.commit} reported no parent revision.`; | ||
| 152 | } | ||
| 153 | |||
| 154 | override get message(): string { | ||
| 155 | return `GitLab CLI failed in getMergeRequestDiffFileContents: ${this.detail}`; | ||
| 156 | } | ||
| 157 | } | ||
| 158 | |||
| 159 | /** A blob exists, but expanding it would be unsafe or would not produce text. */ | ||
| 160 | export class GitLabDiffFileContentsUnavailableError extends Schema.TaggedError<GitLabDiffFileContentsUnavailableError>()( | ||
| 161 | "GitLabDiffFileContentsUnavailableError", | ||
| 162 | { | ||
| 163 | command: Schema.Literal("glab"), | ||
| 164 | cwd: Schema.String, | ||
| 165 | path: Schema.String, | ||
| 166 | reason: Schema.Literals(["oversized", "binary"]), | ||
| 167 | }, | ||
| 168 | ) { | ||
| 169 | get detail(): string { | ||
| 170 | return this.reason === "oversized" | ||
| 171 | ? `The diff file '${this.path}' exceeds the 1 MB expansion limit.` | ||
| 172 | : `The diff file '${this.path}' is binary.`; | ||
| 173 | } | ||
| 174 | |||
| 175 | override get message(): string { | ||
| 176 | return `GitLab CLI failed in getMergeRequestDiffFileContents: ${this.detail}`; | ||
| 177 | } | ||
| 178 | } | ||
| 179 | |||
| 180 | export type GitLabPullRequestCliError = | ||
| 181 | | GitLabCli.GitLabCliError | ||
| 182 | | GitLabMergeRequestReadError | ||
| 183 | | GitLabDiffCursorError | ||
| 184 | | GitLabDiffCommitError | ||
| 185 | | GitLabDiffCommitParentUnavailableError | ||
| 186 | | GitLabDiffFileContentsUnavailableError | ||
| 187 | | GitLabDiffRefsUnavailableError | ||
| 188 | | GitLabViewerUnavailableError; | ||
| 189 | |||
| 190 | /** GitLab's own ceiling on `per_page`, so a larger page has to be walked. */ | ||
| 191 | const MAX_PAGE_SIZE = 100; | ||
| 192 | /** Commit history is read one page deep; the rest of a long history stays on GitLab. */ | ||
| 193 | const COMMIT_PAGE_SIZE = 100; | ||
| 194 | /** | ||
| 195 | * Pages of the conversation to follow before it is reported as truncated. GitLab caps a page at | ||
| 196 | * a hundred, so this is a thousand notes and a thousand discussions — more than any merge | ||
| 197 | * request a person is reading holds, and a walk that ends whatever the host has. | ||
| 198 | */ | ||
| 199 | const CONVERSATION_PAGES = 10; | ||
| 200 | const DIFF_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; | ||
| 201 | const DIFF_TIMEOUT_MS = 60_000; | ||
| 202 | const DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024; | ||
| 203 | |||
| 204 | export interface GitLabMergeRequestListBatch { | ||
| 205 | readonly items: ReadonlyArray<GitLabMergeRequestListItem>; | ||
| 206 | readonly truncated: boolean; | ||
| 207 | /** Raw GitLab rows consumed to produce this page, including malformed rows. */ | ||
| 208 | readonly cursorAdvance: number; | ||
| 209 | } | ||
| 210 | |||
| 211 | export interface GitLabMergeRequestDiffSlice { | ||
| 212 | readonly patch: string; | ||
| 213 | /** Files in this slice had their hunks withheld, as opposed to there being more slices. */ | ||
| 214 | readonly truncated: boolean; | ||
| 215 | /** Where the next slice starts, or null once the patch is whole. */ | ||
| 216 | readonly nextCursor: string | null; | ||
| 217 | } | ||
| 218 | |||
| 219 | export class GitLabPullRequestCli extends Context.Service< | ||
| 220 | GitLabPullRequestCli, | ||
| 221 | { | ||
| 222 | readonly getViewerUsername: (input: { | ||
| 223 | readonly cwd: string; | ||
| 224 | }) => Effect.Effect<string, GitLabPullRequestCliError>; | ||
| 225 | |||
| 226 | readonly listMergeRequests: (input: { | ||
| 227 | readonly cwd: string; | ||
| 228 | readonly repository: string; | ||
| 229 | readonly state: PullRequestListState; | ||
| 230 | readonly involvement: PullRequestInvolvement; | ||
| 231 | readonly viewer: string; | ||
| 232 | readonly limit: number; | ||
| 233 | /** Free text for GitLab's own `search`, which matches title and description. */ | ||
| 234 | readonly query?: string | undefined; | ||
| 235 | /** Where to carry on from in GitLab's stable update-ordered row set. */ | ||
| 236 | readonly cursor?: ProviderListCursor | undefined; | ||
| 237 | }) => Effect.Effect<GitLabMergeRequestListBatch, GitLabPullRequestCliError>; | ||
| 238 | |||
| 239 | readonly getMergeRequestDetail: (input: { | ||
| 240 | readonly cwd: string; | ||
| 241 | readonly repository: string; | ||
| 242 | readonly number: number; | ||
| 243 | }) => Effect.Effect<GitLabMergeRequestDetail, GitLabPullRequestCliError>; | ||
| 244 | |||
| 245 | readonly listNotes: (input: { | ||
| 246 | readonly cwd: string; | ||
| 247 | readonly repository: string; | ||
| 248 | readonly number: number; | ||
| 249 | }) => Effect.Effect< | ||
| 250 | { readonly comments: ReadonlyArray<PullRequestComment>; readonly truncated: boolean }, | ||
| 251 | GitLabPullRequestCliError | ||
| 252 | >; | ||
| 253 | |||
| 254 | readonly listCommits: (input: { | ||
| 255 | readonly cwd: string; | ||
| 256 | readonly repository: string; | ||
| 257 | readonly number: number; | ||
| 258 | }) => Effect.Effect<ReadonlyArray<PullRequestCommit>, GitLabPullRequestCliError>; | ||
| 259 | |||
| 260 | readonly getMergeRequestDiff: (input: { | ||
| 261 | readonly cwd: string; | ||
| 262 | readonly repository: string; | ||
| 263 | readonly number: number; | ||
| 264 | /** Absent asks for the first slice; anything else is a cursor a slice handed back. */ | ||
| 265 | readonly cursor?: string | undefined; | ||
| 266 | /** One commit's own changes, rather than everything the merge request carries. */ | ||
| 267 | readonly commit?: string | undefined; | ||
| 268 | }) => Effect.Effect<GitLabMergeRequestDiffSlice, GitLabPullRequestCliError>; | ||
| 269 | |||
| 270 | readonly getMergeRequestDiffFileContents: (input: { | ||
| 271 | readonly cwd: string; | ||
| 272 | readonly repository: string; | ||
| 273 | readonly number: number; | ||
| 274 | readonly commit?: string | undefined; | ||
| 275 | readonly changeType: "change" | "rename-pure" | "rename-changed" | "new" | "deleted"; | ||
| 276 | readonly oldPath: string; | ||
| 277 | readonly newPath: string; | ||
| 278 | }) => Effect.Effect< | ||
| 279 | { readonly oldContents: string; readonly newContents: string }, | ||
| 280 | GitLabPullRequestCliError | ||
| 281 | >; | ||
| 282 | |||
| 283 | readonly getProjectMergeCapabilities: (input: { | ||
| 284 | readonly cwd: string; | ||
| 285 | readonly repository: string; | ||
| 286 | }) => Effect.Effect<PullRequestMergeCapabilities, GitLabPullRequestCliError>; | ||
| 287 | |||
| 288 | /** | ||
| 289 | * What the merge request's head has of each of these paths, as blob ids. | ||
| 290 | * | ||
| 291 | * The head sha comes from the merge request's own diff refs, so the answer is the version a | ||
| 292 | * reader is looking at rather than whatever the source branch has moved on to. A path the | ||
| 293 | * head does not have is answered as the empty revision, since the batch it was asked in was | ||
| 294 | * looked at; a batch GitLab did not answer for is left out instead. | ||
| 295 | */ | ||
| 296 | readonly getFileRevisions: (input: { | ||
| 297 | readonly cwd: string; | ||
| 298 | readonly repository: string; | ||
| 299 | readonly number: number; | ||
| 300 | readonly paths: ReadonlyArray<string>; | ||
| 301 | }) => Effect.Effect<ReadonlyMap<string, string>, GitLabPullRequestCliError>; | ||
| 302 | |||
| 303 | /** | ||
| 304 | * Who this merge request may be sent to, and who it has already been sent to. Two reads at | ||
| 305 | * once, because GitLab keeps the people with access on the project and the reviewers on the | ||
| 306 | * merge request, and neither answers for the other. | ||
| 307 | */ | ||
| 308 | readonly listReviewerCandidates: (input: { | ||
| 309 | readonly cwd: string; | ||
| 310 | readonly repository: string; | ||
| 311 | readonly number: number; | ||
| 312 | }) => Effect.Effect<PullRequestReviewerCandidateList, GitLabPullRequestCliError>; | ||
| 313 | |||
| 314 | readonly setReviewerRequest: (input: { | ||
| 315 | readonly cwd: string; | ||
| 316 | readonly repository: string; | ||
| 317 | readonly number: number; | ||
| 318 | readonly reviewers: ReadonlyArray<{ readonly id: string }>; | ||
| 319 | readonly requested: boolean; | ||
| 320 | }) => Effect.Effect<void, GitLabPullRequestCliError>; | ||
| 321 | |||
| 322 | readonly runMergeRequestAction: (input: { | ||
| 323 | readonly cwd: string; | ||
| 324 | readonly repository: string; | ||
| 325 | readonly number: number; | ||
| 326 | readonly action: PullRequestAction; | ||
| 327 | readonly mergeMethod?: PullRequestMergeMethod; | ||
| 328 | }) => Effect.Effect<void, GitLabPullRequestCliError>; | ||
| 329 | |||
| 330 | /** Whichever of the two is given is sent. GitLab calls a merge request's body its description. */ | ||
| 331 | readonly updateMergeRequest: (input: { | ||
| 332 | readonly cwd: string; | ||
| 333 | readonly repository: string; | ||
| 334 | readonly number: number; | ||
| 335 | readonly title?: string | undefined; | ||
| 336 | readonly description?: string | undefined; | ||
| 337 | }) => Effect.Effect<void, GitLabPullRequestCliError>; | ||
| 338 | |||
| 339 | readonly commentOnMergeRequest: (input: { | ||
| 340 | readonly cwd: string; | ||
| 341 | readonly repository: string; | ||
| 342 | readonly number: number; | ||
| 343 | readonly body: string; | ||
| 344 | }) => Effect.Effect<void, GitLabPullRequestCliError>; | ||
| 345 | |||
| 346 | readonly updateNote: (input: { | ||
| 347 | readonly cwd: string; | ||
| 348 | readonly repository: string; | ||
| 349 | readonly number: number; | ||
| 350 | readonly noteId: string; | ||
| 351 | readonly body: string; | ||
| 352 | }) => Effect.Effect<void, GitLabPullRequestCliError>; | ||
| 353 | |||
| 354 | readonly listDiscussions: (input: { | ||
| 355 | readonly cwd: string; | ||
| 356 | readonly repository: string; | ||
| 357 | readonly number: number; | ||
| 358 | }) => Effect.Effect< | ||
| 359 | { readonly threads: ReadonlyArray<PullRequestReviewThread>; readonly truncated: boolean }, | ||
| 360 | GitLabPullRequestCliError | ||
| 361 | >; | ||
| 362 | |||
| 363 | readonly submitReview: (input: { | ||
| 364 | readonly cwd: string; | ||
| 365 | readonly repository: string; | ||
| 366 | readonly number: number; | ||
| 367 | readonly verdict: PullRequestReviewVerdict; | ||
| 368 | readonly body: string; | ||
| 369 | readonly comments: ReadonlyArray<PullRequestReviewCommentDraft>; | ||
| 370 | }) => Effect.Effect<void, GitLabPullRequestCliError>; | ||
| 371 | |||
| 372 | readonly replyToDiscussion: (input: { | ||
| 373 | readonly cwd: string; | ||
| 374 | readonly repository: string; | ||
| 375 | readonly number: number; | ||
| 376 | readonly discussionId: string; | ||
| 377 | readonly body: string; | ||
| 378 | }) => Effect.Effect<void, GitLabPullRequestCliError>; | ||
| 379 | |||
| 380 | readonly setDiscussionResolution: (input: { | ||
| 381 | readonly cwd: string; | ||
| 382 | readonly repository: string; | ||
| 383 | readonly number: number; | ||
| 384 | readonly discussionId: string; | ||
| 385 | readonly resolved: boolean; | ||
| 386 | }) => Effect.Effect<void, GitLabPullRequestCliError>; | ||
| 387 | |||
| 388 | /** The awards on the merge request and on every note of it, keyed by the note's REST id. */ | ||
| 389 | readonly listReactions: (input: { | ||
| 390 | readonly cwd: string; | ||
| 391 | readonly repository: string; | ||
| 392 | readonly number: number; | ||
| 393 | }) => Effect.Effect< | ||
| 394 | { | ||
| 395 | readonly reactions: ReadonlyArray<PullRequestReaction>; | ||
| 396 | readonly reactionsByNoteId: ReadonlyMap<string, ReadonlyArray<PullRequestReaction>>; | ||
| 397 | }, | ||
| 398 | GitLabPullRequestCliError | ||
| 399 | >; | ||
| 400 | |||
| 401 | /** | ||
| 402 | * Awards an emoji, or takes the award back. `noteId` is a note of the merge request; absent | ||
| 403 | * awards the merge request itself, which is where its description's reactions live. | ||
| 404 | */ | ||
| 405 | readonly setReaction: (input: { | ||
| 406 | readonly cwd: string; | ||
| 407 | readonly repository: string; | ||
| 408 | readonly number: number; | ||
| 409 | readonly noteId?: string | undefined; | ||
| 410 | readonly content: PullRequestReactionContent; | ||
| 411 | readonly reacted: boolean; | ||
| 412 | }) => Effect.Effect<void, GitLabPullRequestCliError>; | ||
| 413 | } | ||
| 414 | >()("t3/pullRequest/GitLabPullRequestCli") {} | ||
| 415 | |||
| 416 | /** The REST API addresses a project by its URL-encoded full path. */ | ||
| 417 | function projectPath(repository: string): string { | ||
| 418 | return encodeURIComponent(repository.trim()); | ||
| 419 | } | ||
| 420 | |||
| 421 | function gitLabReviewPositionLines( | ||
| 422 | position: PullRequestReviewPosition, | ||
| 423 | ): | ||
| 424 | | { readonly new_line: number } | ||
| 425 | | { readonly old_line: number } | ||
| 426 | | { readonly old_line: number; readonly new_line: number } { | ||
| 427 | switch (position.kind) { | ||
| 428 | case "added": | ||
| 429 | return { new_line: position.newLine }; | ||
| 430 | case "deleted": | ||
| 431 | return { old_line: position.oldLine }; | ||
| 432 | case "context": | ||
| 433 | return { old_line: position.oldLine, new_line: position.newLine }; | ||
| 434 | } | ||
| 435 | } | ||
| 436 | |||
| 437 | function stateParam(state: PullRequestListState): string { | ||
| 438 | // GitLab's `closed` already excludes merged merge requests, so no extra filter is needed, | ||
| 439 | // and it spans every state under `all`. | ||
| 440 | return state === "open" ? "opened" : state; | ||
| 441 | } | ||
| 442 | |||
| 443 | function involvementParams(input: { | ||
| 444 | readonly involvement: PullRequestInvolvement; | ||
| 445 | readonly viewer: string; | ||
| 446 | }): ReadonlyArray<readonly [string, string]> { | ||
| 447 | switch (input.involvement) { | ||
| 448 | case "authored": | ||
| 449 | return [["author_username", input.viewer]]; | ||
| 450 | case "reviewing": | ||
| 451 | return [["reviewer_username", input.viewer]]; | ||
| 452 | case "all": | ||
| 453 | return []; | ||
| 454 | } | ||
| 455 | } | ||
| 456 | |||
| 457 | /** | ||
| 458 | * The page a diff cursor names, or null for anything this walk cannot have issued. The cursor | ||
| 459 | * arrives from the reader as a string and goes straight into a query, so it is parsed rather | ||
| 460 | * than trusted; the length bound keeps a page number out of exponential notation. | ||
| 461 | */ | ||
| 462 | function diffCursorPage(cursor: string): number | null { | ||
| 463 | return /^[1-9][0-9]{0,6}$/.test(cursor) ? Number(cursor) : null; | ||
| 464 | } | ||
| 465 | |||
| 466 | /** | ||
| 467 | * A commit sha arrives from the reader and goes straight into a request path, so it is checked | ||
| 468 | * rather than trusted: hexadecimal only, from the shortest abbreviation a host prints up to a | ||
| 469 | * whole sha. | ||
| 470 | */ | ||
| 471 | function isCommitSha(value: string): boolean { | ||
| 472 | return /^[0-9a-f]{7,64}$/i.test(value); | ||
| 473 | } | ||
| 474 | |||
| 475 | function searchParams(search: string | undefined): ReadonlyArray<readonly [string, string]> { | ||
| 476 | const trimmed = search?.trim() ?? ""; | ||
| 477 | return trimmed.length === 0 ? [] : [["search", trimmed]]; | ||
| 478 | } | ||
| 479 | |||
| 480 | function query(params: ReadonlyArray<readonly [string, string]>): string { | ||
| 481 | return params.map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join("&"); | ||
| 482 | } | ||
| 483 | |||
| 484 | function actionArgs( | ||
| 485 | action: PullRequestAction, | ||
| 486 | mergeMethod: PullRequestMergeMethod | undefined, | ||
| 487 | ): ReadonlyArray<string> { | ||
| 488 | switch (action) { | ||
| 489 | case "merge": | ||
| 490 | return [ | ||
| 491 | "merge", | ||
| 492 | // glab turns on auto-merge whenever a pipeline is running. The button means merge now. | ||
| 493 | "--auto-merge=false", | ||
| 494 | "--yes", | ||
| 495 | ...(mergeMethod === "squash" ? ["--squash"] : []), | ||
| 496 | ...(mergeMethod === "rebase" ? ["--rebase"] : []), | ||
| 497 | ]; | ||
| 498 | // The same command with the flag the other way up: here the wait is the whole point, so | ||
| 499 | // glab is told to arm the merge rather than talked out of it. | ||
| 500 | case "enable-auto-merge": | ||
| 501 | return [ | ||
| 502 | "merge", | ||
| 503 | "--auto-merge=true", | ||
| 504 | "--yes", | ||
| 505 | ...(mergeMethod === "squash" ? ["--squash"] : []), | ||
| 506 | ...(mergeMethod === "rebase" ? ["--rebase"] : []), | ||
| 507 | ]; | ||
| 508 | // Never reached: taking the arming back has no `glab mr` command, so it goes to the API. | ||
| 509 | case "disable-auto-merge": | ||
| 510 | return []; | ||
| 511 | case "ready": | ||
| 512 | return ["update", "--ready"]; | ||
| 513 | case "draft": | ||
| 514 | return ["update", "--draft"]; | ||
| 515 | case "close": | ||
| 516 | return ["close"]; | ||
| 517 | // A rebase, because GitLab has no other way to move a branch onto its target: there is no | ||
| 518 | // merge-the-target-in equivalent of GitHub's update button, which is why this host declares | ||
| 519 | // `rebase` alone and never has to read the method it was handed. | ||
| 520 | case "update-branch": | ||
| 521 | return ["rebase"]; | ||
| 522 | case "reopen": | ||
| 523 | return ["reopen"]; | ||
| 524 | // Never reached: this host does not declare the action, so the service refuses it first. | ||
| 525 | case "revert": | ||
| 526 | case "approve-workflows": | ||
| 527 | throw new Error(`GitLab merge request action ${action} is unsupported`); | ||
| 528 | } | ||
| 529 | } | ||
| 530 | |||
| 531 | /** @public Service construction is part of the canonical Effect module API. */ | ||
| 532 | export const make = Effect.gen(function* () { | ||
| 533 | const gitlab = yield* GitLabCli.GitLabCli; | ||
| 534 | |||
| 535 | const api = (input: { | ||
| 536 | readonly cwd: string; | ||
| 537 | readonly path: string; | ||
| 538 | readonly method?: string; | ||
| 539 | readonly stdin?: string; | ||
| 540 | readonly maxOutputBytes?: number; | ||
| 541 | readonly timeoutMs?: number; | ||
| 542 | }) => | ||
| 543 | gitlab.execute({ | ||
| 544 | cwd: input.cwd, | ||
| 545 | args: [ | ||
| 546 | "api", | ||
| 547 | input.path, | ||
| 548 | ...(input.method === undefined ? [] : ["--method", input.method]), | ||
| 549 | // A raw body from stdin: argv is visible in process listings and is echoed back | ||
| 550 | // inside process-runner failure messages. Unlike `gh`, `glab api --input` sends no | ||
| 551 | // Content-Type at all, and GitLab answers a bodyless content type with HTTP 415. | ||
| 552 | ...(input.stdin === undefined | ||
| 553 | ? [] | ||
| 554 | : ["--input", "-", "--header", "Content-Type: application/json"]), | ||
| 555 | ], | ||
| 556 | ...(input.stdin === undefined ? {} : { stdin: input.stdin }), | ||
| 557 | ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), | ||
| 558 | ...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }), | ||
| 559 | }); | ||
| 560 | |||
| 561 | /** | ||
| 562 | * `per_page` stops at 100, so a larger page is walked one request at a time. The walk is | ||
| 563 | * bounded twice over: it stops on a short page or once the extra row that reveals a next | ||
| 564 | * page has been read, and it never asks for more pages than the caller's page needs. The | ||
| 565 | * second bound is what makes it terminate when every row on a page fails to decode, which | ||
| 566 | * leaves nothing collected but does not mean GitLab has run out of rows. | ||
| 567 | */ | ||
| 568 | const listPage = (input: { | ||
| 569 | readonly cwd: string; | ||
| 570 | readonly repository: string; | ||
| 571 | readonly state: PullRequestListState; | ||
| 572 | readonly involvement: PullRequestInvolvement; | ||
| 573 | readonly viewer: string; | ||
| 574 | readonly limit: number; | ||
| 575 | readonly query?: string | undefined; | ||
| 576 | readonly cursor?: ProviderListCursor | undefined; | ||
| 577 | readonly page: number; | ||
| 578 | readonly collected: ReadonlyArray<GitLabMergeRequestListItem>; | ||
| 579 | readonly cursorAdvance: number; | ||
| 580 | }): Effect.Effect<GitLabMergeRequestListBatch, GitLabPullRequestCliError> => { | ||
| 581 | // A continuation uses GitLab's offset pagination. Its timestamp filter is inclusive and has | ||
| 582 | // no tie-breaker, so a page where many rows share the boundary would otherwise return the | ||
| 583 | // same prefix forever. `delivered` is the stable offset the service has already handed over. | ||
| 584 | const delivered = input.cursor?.delivered ?? 0; | ||
| 585 | const perPage = Math.min(input.limit + 1, MAX_PAGE_SIZE); | ||
| 586 | const firstPage = Math.floor(delivered / perPage) + 1; | ||
| 587 | const skipOnFirstPage = input.page === firstPage ? delivered % perPage : 0; | ||
| 588 | // A page made entirely of malformed rows has no item from which the service can build a | ||
| 589 | // continuation. Bound the walk to the raw span this request asked for rather than recursing | ||
| 590 | // forever on a host that keeps returning full unusable pages. | ||
| 591 | const lastPage = Math.floor((delivered + input.limit) / perPage) + 1; | ||
| 592 | return api({ | ||
| 593 | cwd: input.cwd, | ||
| 594 | path: `projects/${projectPath(input.repository)}/merge_requests?${query([ | ||
| 595 | ["state", stateParam(input.state)], | ||
| 596 | ...involvementParams(input), | ||
| 597 | // The listing is read through `glab api` rather than `glab mr list`, so the search is | ||
| 598 | // the REST API's own `search` parameter — the one `mr list --search` passes on. It | ||
| 599 | // matches title and description, and travels URL-encoded like every other value here, | ||
| 600 | // so no text in it can become a parameter of its own. | ||
| 601 | ...searchParams(input.query), | ||
| 602 | ["order_by", "updated_at"], | ||
| 603 | ["sort", "desc"], | ||
| 604 | ["per_page", String(perPage)], | ||
| 605 | ["page", String(input.page)], | ||
| 606 | ])}`, | ||
| 607 | }).pipe( | ||
| 608 | Effect.flatMap((result) => { | ||
| 609 | const raw = result.stdout.trim(); | ||
| 610 | if (raw.length === 0) { | ||
| 611 | return Effect.succeed({ | ||
| 612 | items: input.collected, | ||
| 613 | truncated: false, | ||
| 614 | cursorAdvance: input.cursorAdvance, | ||
| 615 | }); | ||
| 616 | } | ||
| 617 | const decoded = decodeMergeRequestListJson(raw); | ||
| 618 | if (!Result.isSuccess(decoded)) { | ||
| 619 | return Effect.fail( | ||
| 620 | new GitLabMergeRequestReadError({ | ||
| 621 | command: "glab", | ||
| 622 | cwd: input.cwd, | ||
| 623 | operation: "listMergeRequests", | ||
| 624 | cause: decoded.failure, | ||
| 625 | }), | ||
| 626 | ); | ||
| 627 | } | ||
| 628 | const pageItems: GitLabMergeRequestListItem[] = []; | ||
| 629 | const pageRawIndexes: number[] = []; | ||
| 630 | for (const [index, item] of decoded.success.items.entries()) { | ||
| 631 | const rawIndex = decoded.success.rawIndexes[index]!; | ||
| 632 | if (rawIndex < skipOnFirstPage) continue; | ||
| 633 | pageItems.push(item); | ||
| 634 | pageRawIndexes.push(rawIndex); | ||
| 635 | } | ||
| 636 | const remaining = input.limit - input.collected.length; | ||
| 637 | const lastItemRawIndex = pageRawIndexes[remaining - 1]; | ||
| 638 | if (lastItemRawIndex !== undefined) { | ||
| 639 | const consumed = lastItemRawIndex + 1 - skipOnFirstPage; | ||
| 640 | return Effect.succeed({ | ||
| 641 | items: [...input.collected, ...pageItems.slice(0, remaining)], | ||
| 642 | truncated: | ||
| 643 | lastItemRawIndex + 1 < decoded.success.rawCount || | ||
| 644 | decoded.success.rawCount === perPage, | ||
| 645 | cursorAdvance: input.cursorAdvance + consumed, | ||
| 646 | }); | ||
| 647 | } | ||
| 648 | const collected = [...input.collected, ...pageItems]; | ||
| 649 | const consumed = Math.max(0, decoded.success.rawCount - skipOnFirstPage); | ||
| 650 | // Counted before decoding, so a skipped malformed row cannot end paging early. | ||
| 651 | const exhausted = decoded.success.rawCount < perPage; | ||
| 652 | if (exhausted) { | ||
| 653 | return Effect.succeed({ | ||
| 654 | items: collected, | ||
| 655 | truncated: false, | ||
| 656 | cursorAdvance: input.cursorAdvance + consumed, | ||
| 657 | }); | ||
| 658 | } | ||
| 659 | if (input.page >= lastPage) { | ||
| 660 | return Effect.succeed({ | ||
| 661 | items: collected, | ||
| 662 | truncated: true, | ||
| 663 | cursorAdvance: input.cursorAdvance + consumed, | ||
| 664 | }); | ||
| 665 | } | ||
| 666 | return listPage({ | ||
| 667 | ...input, | ||
| 668 | page: input.page + 1, | ||
| 669 | collected, | ||
| 670 | cursorAdvance: input.cursorAdvance + consumed, | ||
| 671 | }); | ||
| 672 | }), | ||
| 673 | ); | ||
| 674 | }; | ||
| 675 | |||
| 676 | /** | ||
| 677 | * One page of a merge request's files, as a patch that stands on its own. GitLab pages | ||
| 678 | * `/diffs` by offset and has no cursor of its own, so the page number is the cursor; the | ||
| 679 | * caller carries on from it for as long as GitLab keeps handing full pages back. | ||
| 680 | * | ||
| 681 | * A named commit is read from the commit's own diff, which answers in the same shape and pages | ||
| 682 | * the same way. | ||
| 683 | */ | ||
| 684 | const diffPage = (input: { | ||
| 685 | readonly cwd: string; | ||
| 686 | readonly repository: string; | ||
| 687 | readonly number: number; | ||
| 688 | readonly page: number; | ||
| 689 | readonly commit?: string | undefined; | ||
| 690 | }): Effect.Effect<GitLabMergeRequestDiffSlice, GitLabPullRequestCliError> => | ||
| 691 | api({ | ||
| 692 | cwd: input.cwd, | ||
| 693 | path: `projects/${projectPath(input.repository)}/${ | ||
| 694 | input.commit === undefined | ||
| 695 | ? `merge_requests/${input.number}/diffs` | ||
| 696 | : `repository/commits/${input.commit}/diff` | ||
| 697 | }?${query([ | ||
| 698 | ["per_page", String(MAX_PAGE_SIZE)], | ||
| 699 | ["page", String(input.page)], | ||
| 700 | ])}`, | ||
| 701 | maxOutputBytes: DIFF_MAX_OUTPUT_BYTES, | ||
| 702 | timeoutMs: DIFF_TIMEOUT_MS, | ||
| 703 | }).pipe( | ||
| 704 | Effect.flatMap((result) => { | ||
| 705 | // A byte-truncated response is a JSON prefix, so this page cannot be read at all. | ||
| 706 | // Answering with no cursor would call the diff whole while silently dropping this page | ||
| 707 | // and every one after it, so the read fails and says which page could not be had. | ||
| 708 | if (result.stdoutTruncated) { | ||
| 709 | return Effect.fail( | ||
| 710 | new GitLabMergeRequestReadError({ | ||
| 711 | command: "glab", | ||
| 712 | cwd: input.cwd, | ||
| 713 | operation: "getMergeRequestDiff", | ||
| 714 | cause: new Error( | ||
| 715 | `Page ${input.page} of the merge request diff was too large to read.`, | ||
| 716 | ), | ||
| 717 | }), | ||
| 718 | ); | ||
| 719 | } | ||
| 720 | const decoded = decodeMergeRequestDiffsJson(result.stdout.trim()); | ||
| 721 | if (!Result.isSuccess(decoded)) { | ||
| 722 | return Effect.fail( | ||
| 723 | new GitLabMergeRequestReadError({ | ||
| 724 | command: "glab", | ||
| 725 | cwd: input.cwd, | ||
| 726 | operation: "getMergeRequestDiff", | ||
| 727 | cause: decoded.failure, | ||
| 728 | }), | ||
| 729 | ); | ||
| 730 | } | ||
| 731 | const patch = decoded.success.patch; | ||
| 732 | // Counted before decoding, so a page whose files all failed to decode still moves on | ||
| 733 | // rather than pointing the reader back at the page it just read. | ||
| 734 | const morePages = decoded.success.rawCount >= MAX_PAGE_SIZE; | ||
| 735 | return Effect.succeed({ | ||
| 736 | // The slice ends on a newline, so a file GitLab gave a header and no hunks for does | ||
| 737 | // not run into the first line of the next slice. | ||
| 738 | patch: patch.length === 0 ? patch : patch.replace(/\n?$/, "\n"), | ||
| 739 | truncated: decoded.success.truncated, | ||
| 740 | nextCursor: morePages ? String(input.page + 1) : null, | ||
| 741 | }); | ||
| 742 | }), | ||
| 743 | ); | ||
| 744 | |||
| 745 | /** | ||
| 746 | * The conversation, a page at a time. GitLab pages by offset and reports no total, so a short | ||
| 747 | * page is the only thing that says it is done — and the raw count decides, not the kept one: | ||
| 748 | * the notes GitLab wrote itself are dropped, and a whole page of them still means there is | ||
| 749 | * more to read. | ||
| 750 | */ | ||
| 751 | const notesPage = (input: { | ||
| 752 | readonly cwd: string; | ||
| 753 | readonly repository: string; | ||
| 754 | readonly number: number; | ||
| 755 | readonly page: number; | ||
| 756 | readonly collected: ReadonlyArray<PullRequestComment>; | ||
| 757 | }): Effect.Effect< | ||
| 758 | { readonly comments: ReadonlyArray<PullRequestComment>; readonly truncated: boolean }, | ||
| 759 | GitLabPullRequestCliError | ||
| 760 | > => | ||
| 761 | api({ | ||
| 762 | cwd: input.cwd, | ||
| 763 | path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/notes?${query( | ||
| 764 | [ | ||
| 765 | ["per_page", String(MAX_PAGE_SIZE)], | ||
| 766 | ["page", String(input.page)], | ||
| 767 | ["order_by", "created_at"], | ||
| 768 | ["sort", "asc"], | ||
| 769 | ], | ||
| 770 | )}`, | ||
| 771 | }).pipe( | ||
| 772 | Effect.flatMap((result) => { | ||
| 773 | const decoded = decodeNotesJson(result.stdout.trim()); | ||
| 774 | if (!Result.isSuccess(decoded)) { | ||
| 775 | return Effect.fail( | ||
| 776 | new GitLabMergeRequestReadError({ | ||
| 777 | command: "glab", | ||
| 778 | cwd: input.cwd, | ||
| 779 | operation: "listNotes", | ||
| 780 | cause: decoded.failure, | ||
| 781 | }), | ||
| 782 | ); | ||
| 783 | } | ||
| 784 | const collected = [...input.collected, ...decoded.success.comments]; | ||
| 785 | if (decoded.success.rawCount < MAX_PAGE_SIZE) { | ||
| 786 | return Effect.succeed({ comments: collected, truncated: false }); | ||
| 787 | } | ||
| 788 | return input.page >= CONVERSATION_PAGES | ||
| 789 | ? Effect.succeed({ comments: collected, truncated: true }) | ||
| 790 | : notesPage({ ...input, page: input.page + 1, collected }); | ||
| 791 | }), | ||
| 792 | ); | ||
| 793 | |||
| 794 | /** The positioned discussions, walked the same way and stopped by the same bound. */ | ||
| 795 | const discussionsPage = (input: { | ||
| 796 | readonly cwd: string; | ||
| 797 | readonly repository: string; | ||
| 798 | readonly number: number; | ||
| 799 | readonly page: number; | ||
| 800 | readonly collected: ReadonlyArray<PullRequestReviewThread>; | ||
| 801 | }): Effect.Effect< | ||
| 802 | { readonly threads: ReadonlyArray<PullRequestReviewThread>; readonly truncated: boolean }, | ||
| 803 | GitLabPullRequestCliError | ||
| 804 | > => | ||
| 805 | api({ | ||
| 806 | cwd: input.cwd, | ||
| 807 | path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/discussions?${query( | ||
| 808 | [ | ||
| 809 | ["per_page", String(MAX_PAGE_SIZE)], | ||
| 810 | ["page", String(input.page)], | ||
| 811 | ], | ||
| 812 | )}`, | ||
| 813 | }).pipe( | ||
| 814 | Effect.flatMap((result) => { | ||
| 815 | const decoded = decodeDiscussionsJson(result.stdout.trim()); | ||
| 816 | if (!Result.isSuccess(decoded)) { | ||
| 817 | return Effect.fail( | ||
| 818 | new GitLabMergeRequestReadError({ | ||
| 819 | command: "glab", | ||
| 820 | cwd: input.cwd, | ||
| 821 | operation: "listDiscussions", | ||
| 822 | cause: decoded.failure, | ||
| 823 | }), | ||
| 824 | ); | ||
| 825 | } | ||
| 826 | const collected = [...input.collected, ...decoded.success.threads]; | ||
| 827 | // The raw count again: this endpoint returns the plain notes too, so a full page of | ||
| 828 | // those is not the end of the positioned ones. | ||
| 829 | if (decoded.success.rawCount < MAX_PAGE_SIZE) { | ||
| 830 | return Effect.succeed({ threads: collected, truncated: false }); | ||
| 831 | } | ||
| 832 | return input.page >= CONVERSATION_PAGES | ||
| 833 | ? Effect.succeed({ threads: collected, truncated: true }) | ||
| 834 | : discussionsPage({ ...input, page: input.page + 1, collected }); | ||
| 835 | }), | ||
| 836 | ); | ||
| 837 | |||
| 838 | /** | ||
| 839 | * The revisions a positioned comment is written against. GitLab resolves a comment's line | ||
| 840 | * against these three shas, so a review with line comments cannot be sent without them. | ||
| 841 | */ | ||
| 842 | const getDiffRefs = (input: { | ||
| 843 | readonly cwd: string; | ||
| 844 | readonly repository: string; | ||
| 845 | readonly number: number; | ||
| 846 | }): Effect.Effect<GitLabDiffRefs, GitLabPullRequestCliError> => | ||
| 847 | api({ | ||
| 848 | cwd: input.cwd, | ||
| 849 | path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}`, | ||
| 850 | }).pipe( | ||
| 851 | Effect.flatMap((result): Effect.Effect<GitLabDiffRefs, GitLabPullRequestCliError> => { | ||
| 852 | const decoded = decodeDiffRefsJson(result.stdout.trim()); | ||
| 853 | if (!Result.isSuccess(decoded)) { | ||
| 854 | return Effect.fail( | ||
| 855 | new GitLabMergeRequestReadError({ | ||
| 856 | command: "glab", | ||
| 857 | cwd: input.cwd, | ||
| 858 | operation: "getDiffRefs", | ||
| 859 | cause: decoded.failure, | ||
| 860 | }), | ||
| 861 | ); | ||
| 862 | } | ||
| 863 | // A merge request with no diff refs is a well-formed answer that cannot carry a | ||
| 864 | // positioned comment — a dead end, but not something that failed to be read. | ||
| 865 | return decoded.success === null | ||
| 866 | ? Effect.fail( | ||
| 867 | new GitLabDiffRefsUnavailableError({ | ||
| 868 | command: "glab", | ||
| 869 | cwd: input.cwd, | ||
| 870 | number: input.number, | ||
| 871 | }), | ||
| 872 | ) | ||
| 873 | : Effect.succeed(decoded.success); | ||
| 874 | }), | ||
| 875 | ); | ||
| 876 | |||
| 877 | const getCommitDiffRefs = (input: { | ||
| 878 | readonly cwd: string; | ||
| 879 | readonly repository: string; | ||
| 880 | readonly commit: string; | ||
| 881 | readonly allowRoot: boolean; | ||
| 882 | }): Effect.Effect<GitLabDiffRefs, GitLabPullRequestCliError> => | ||
| 883 | api({ | ||
| 884 | cwd: input.cwd, | ||
| 885 | path: `projects/${projectPath(input.repository)}/repository/commits/${input.commit}`, | ||
| 886 | }).pipe( | ||
| 887 | Effect.flatMap((result): Effect.Effect<GitLabDiffRefs, GitLabPullRequestCliError> => { | ||
| 888 | const decoded = decodeCommitDiffRefsJson(result.stdout.trim()); | ||
| 889 | if (!Result.isSuccess(decoded)) { | ||
| 890 | return Effect.fail( | ||
| 891 | new GitLabMergeRequestReadError({ | ||
| 892 | command: "glab", | ||
| 893 | cwd: input.cwd, | ||
| 894 | operation: "getMergeRequestDiffFileContents", | ||
| 895 | cause: decoded.failure, | ||
| 896 | }), | ||
| 897 | ); | ||
| 898 | } | ||
| 899 | return decoded.success === null | ||
| 900 | ? input.allowRoot | ||
| 901 | ? Effect.succeed({ | ||
| 902 | baseSha: "", | ||
| 903 | headSha: input.commit, | ||
| 904 | startSha: "", | ||
| 905 | }) | ||
| 906 | : Effect.fail( | ||
| 907 | new GitLabDiffCommitParentUnavailableError({ | ||
| 908 | command: "glab", | ||
| 909 | cwd: input.cwd, | ||
| 910 | commit: input.commit, | ||
| 911 | }), | ||
| 912 | ) | ||
| 913 | : Effect.succeed(decoded.success); | ||
| 914 | }), | ||
| 915 | ); | ||
| 916 | |||
| 917 | /** | ||
| 918 | * The merge request itself, which several calls need for different parts of it: the detail for | ||
| 919 | * everything, and the reviewer paths for the ids GitLab writes a reviewer set with. | ||
| 920 | */ | ||
| 921 | const mergeRequestDetail = (input: { | ||
| 922 | readonly cwd: string; | ||
| 923 | readonly repository: string; | ||
| 924 | readonly number: number; | ||
| 925 | }): Effect.Effect<GitLabMergeRequestDetail, GitLabPullRequestCliError> => | ||
| 926 | api({ | ||
| 927 | cwd: input.cwd, | ||
| 928 | // How far behind the target branch this one is comes only when asked for by name, and it | ||
| 929 | // is asked for here rather than on a second read because it is the same merge request. | ||
| 930 | path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}?${query([ | ||
| 931 | ["include_diverged_commits_count", "true"], | ||
| 932 | ])}`, | ||
| 933 | }).pipe( | ||
| 934 | Effect.flatMap((result) => { | ||
| 935 | const decoded = decodeMergeRequestDetailJson(result.stdout.trim()); | ||
| 936 | return Result.isSuccess(decoded) | ||
| 937 | ? Effect.succeed(decoded.success) | ||
| 938 | : Effect.fail( | ||
| 939 | new GitLabMergeRequestReadError({ | ||
| 940 | command: "glab", | ||
| 941 | cwd: input.cwd, | ||
| 942 | operation: "getMergeRequestDetail", | ||
| 943 | cause: decoded.failure, | ||
| 944 | }), | ||
| 945 | ); | ||
| 946 | }), | ||
| 947 | ); | ||
| 948 | |||
| 949 | /** The people with access to the project, one page deep. */ | ||
| 950 | const projectUsers = (input: { | ||
| 951 | readonly cwd: string; | ||
| 952 | readonly repository: string; | ||
| 953 | }): Effect.Effect<GitLabProjectUsers, GitLabPullRequestCliError> => | ||
| 954 | api({ | ||
| 955 | cwd: input.cwd, | ||
| 956 | path: `projects/${projectPath(input.repository)}/users?${query([ | ||
| 957 | ["per_page", String(MAX_PAGE_SIZE)], | ||
| 958 | ])}`, | ||
| 959 | }).pipe( | ||
| 960 | Effect.flatMap((result) => { | ||
| 961 | const decoded = decodeProjectUsersJson(result.stdout.trim()); | ||
| 962 | return Result.isSuccess(decoded) | ||
| 963 | ? Effect.succeed(decoded.success) | ||
| 964 | : Effect.fail( | ||
| 965 | new GitLabMergeRequestReadError({ | ||
| 966 | command: "glab", | ||
| 967 | cwd: input.cwd, | ||
| 968 | operation: "listReviewerCandidates", | ||
| 969 | cause: decoded.failure, | ||
| 970 | }), | ||
| 971 | ); | ||
| 972 | }), | ||
| 973 | ); | ||
| 974 | |||
| 975 | /** Where an award is written: a note of the merge request, or the merge request itself. */ | ||
| 976 | const awardSubjectPath = (input: { | ||
| 977 | readonly repository: string; | ||
| 978 | readonly number: number; | ||
| 979 | readonly noteId?: string | undefined; | ||
| 980 | }) => { | ||
| 981 | const mergeRequest = `projects/${projectPath(input.repository)}/merge_requests/${input.number}`; | ||
| 982 | return input.noteId === undefined | ||
| 983 | ? `${mergeRequest}/award_emoji` | ||
| 984 | : `${mergeRequest}/notes/${encodeURIComponent(input.noteId)}/award_emoji`; | ||
| 985 | }; | ||
| 986 | |||
| 987 | /** | ||
| 988 | * The awards on the merge request and its notes, a page of notes at a time. Bounded by the same | ||
| 989 | * count as the conversation itself: awards past the notes that were read belong to notes the | ||
| 990 | * page is not showing. | ||
| 991 | */ | ||
| 992 | const awardsPage = (input: { | ||
| 993 | readonly cwd: string; | ||
| 994 | readonly repository: string; | ||
| 995 | readonly number: number; | ||
| 996 | readonly cursor: string | null; | ||
| 997 | readonly page: number; | ||
| 998 | readonly collected: { | ||
| 999 | readonly reactions: ReadonlyArray<PullRequestReaction>; | ||
| 1000 | readonly reactionsByNoteId: Map<string, ReadonlyArray<PullRequestReaction>>; | ||
| 1001 | } | null; | ||
| 1002 | }): Effect.Effect< | ||
| 1003 | { | ||
| 1004 | readonly reactions: ReadonlyArray<PullRequestReaction>; | ||
| 1005 | readonly reactionsByNoteId: ReadonlyMap<string, ReadonlyArray<PullRequestReaction>>; | ||
| 1006 | }, | ||
| 1007 | GitLabPullRequestCliError | ||
| 1008 | > => | ||
| 1009 | api({ | ||
| 1010 | cwd: input.cwd, | ||
| 1011 | path: "graphql", | ||
| 1012 | method: "POST", | ||
| 1013 | stdin: JSON.stringify({ | ||
| 1014 | query: AWARD_EMOJI_GRAPHQL_QUERY, | ||
| 1015 | variables: { | ||
| 1016 | fullPath: input.repository, | ||
| 1017 | iid: String(input.number), | ||
| 1018 | cursor: input.cursor, | ||
| 1019 | }, | ||
| 1020 | }), | ||
| 1021 | }).pipe( | ||
| 1022 | Effect.flatMap((result) => { | ||
| 1023 | const decoded = decodeAwardEmojiJson(result.stdout.trim()); | ||
| 1024 | if (!Result.isSuccess(decoded)) { | ||
| 1025 | return Effect.fail( | ||
| 1026 | new GitLabMergeRequestReadError({ | ||
| 1027 | command: "glab", | ||
| 1028 | cwd: input.cwd, | ||
| 1029 | operation: "listReactions", | ||
| 1030 | cause: decoded.failure, | ||
| 1031 | }), | ||
| 1032 | ); | ||
| 1033 | } | ||
| 1034 | const collected = input.collected ?? { | ||
| 1035 | reactions: decoded.success.reactions, | ||
| 1036 | reactionsByNoteId: new Map<string, ReadonlyArray<PullRequestReaction>>(), | ||
| 1037 | }; | ||
| 1038 | for (const [id, reactions] of decoded.success.reactionsByNoteId) | ||
| 1039 | collected.reactionsByNoteId.set(id, reactions); | ||
| 1040 | return decoded.success.nextCursor === null || input.page >= CONVERSATION_PAGES | ||
| 1041 | ? Effect.succeed(collected) | ||
| 1042 | : awardsPage({ | ||
| 1043 | ...input, | ||
| 1044 | cursor: decoded.success.nextCursor, | ||
| 1045 | page: input.page + 1, | ||
| 1046 | collected, | ||
| 1047 | }); | ||
| 1048 | }), | ||
| 1049 | ); | ||
| 1050 | |||
| 1051 | /** | ||
| 1052 | * GitLab charges the blobs query by how many paths it is handed, and its connection hands back | ||
| 1053 | * one page. A hundred at a time keeps each request inside both. | ||
| 1054 | */ | ||
| 1055 | const BLOB_PATHS_PER_REQUEST = 100; | ||
| 1056 | |||
| 1057 | const blobsAt = (input: { | ||
| 1058 | readonly cwd: string; | ||
| 1059 | readonly repository: string; | ||
| 1060 | readonly ref: string; | ||
| 1061 | readonly paths: ReadonlyArray<string>; | ||
| 1062 | }): Effect.Effect<ReadonlyMap<string, string> | null, GitLabPullRequestCliError> => | ||
| 1063 | api({ | ||
| 1064 | cwd: input.cwd, | ||
| 1065 | path: "graphql", | ||
| 1066 | method: "POST", | ||
| 1067 | stdin: JSON.stringify({ | ||
| 1068 | query: REPOSITORY_BLOBS_GRAPHQL_QUERY, | ||
| 1069 | variables: { fullPath: input.repository, ref: input.ref, paths: input.paths }, | ||
| 1070 | }), | ||
| 1071 | }).pipe( | ||
| 1072 | Effect.flatMap( | ||
| 1073 | (result): Effect.Effect<ReadonlyMap<string, string> | null, GitLabPullRequestCliError> => { | ||
| 1074 | const decoded = decodeRepositoryBlobsJson(result.stdout.trim()); | ||
| 1075 | return Result.isSuccess(decoded) | ||
| 1076 | ? Effect.succeed(decoded.success) | ||
| 1077 | : Effect.fail( | ||
| 1078 | new GitLabMergeRequestReadError({ | ||
| 1079 | command: "glab", | ||
| 1080 | cwd: input.cwd, | ||
| 1081 | operation: "getFileRevisions", | ||
| 1082 | cause: decoded.failure, | ||
| 1083 | }), | ||
| 1084 | ); | ||
| 1085 | }, | ||
| 1086 | ), | ||
| 1087 | ); | ||
| 1088 | |||
| 1089 | const fileRevisions = (input: { | ||
| 1090 | readonly cwd: string; | ||
| 1091 | readonly repository: string; | ||
| 1092 | readonly number: number; | ||
| 1093 | readonly paths: ReadonlyArray<string>; | ||
| 1094 | }): Effect.Effect<ReadonlyMap<string, string>, GitLabPullRequestCliError> => | ||
| 1095 | input.paths.length === 0 | ||
| 1096 | ? Effect.succeed(new Map()) | ||
| 1097 | : getDiffRefs(input).pipe( | ||
| 1098 | Effect.flatMap((refs) => { | ||
| 1099 | const batches: Array<ReadonlyArray<string>> = []; | ||
| 1100 | for (let at = 0; at < input.paths.length; at += BLOB_PATHS_PER_REQUEST) { | ||
| 1101 | batches.push(input.paths.slice(at, at + BLOB_PATHS_PER_REQUEST)); | ||
| 1102 | } | ||
| 1103 | return Effect.forEach( | ||
| 1104 | batches, | ||
| 1105 | (paths) => | ||
| 1106 | blobsAt({ ...input, ref: refs.headSha, paths }).pipe( | ||
| 1107 | Effect.map((page) => ({ paths, page })), | ||
| 1108 | ), | ||
| 1109 | { concurrency: 2 }, | ||
| 1110 | ).pipe( | ||
| 1111 | Effect.map((pages) => { | ||
| 1112 | const revisions = new Map<string, string>(); | ||
| 1113 | for (const { paths, page } of pages) { | ||
| 1114 | // A batch GitLab did not answer says nothing about its paths, so they are left | ||
| 1115 | // out and the caller reads them as versions it could not learn, which leaves | ||
| 1116 | // the marks on them alone. Filling them in as removed would report every file | ||
| 1117 | // a reader has cleared as changed over a project the token cannot see. | ||
| 1118 | if (page === null) continue; | ||
| 1119 | for (const [path, oid] of page) revisions.set(path, oid); | ||
| 1120 | // Within a batch that was answered, every path was looked for at the head, so | ||
| 1121 | // one that is not there is one the merge request removed. Said as the empty | ||
| 1122 | // revision, which is an answer the caller can compare against and keep. | ||
| 1123 | for (const path of paths) { | ||
| 1124 | if (!revisions.has(path)) revisions.set(path, ""); | ||
| 1125 | } | ||
| 1126 | } | ||
| 1127 | return revisions as ReadonlyMap<string, string>; | ||
| 1128 | }), | ||
| 1129 | ); | ||
| 1130 | }), | ||
| 1131 | ); | ||
| 1132 | |||
| 1133 | const viewerUsername = (input: { readonly cwd: string }) => | ||
| 1134 | api({ cwd: input.cwd, path: "user" }).pipe( | ||
| 1135 | Effect.flatMap((result): Effect.Effect<string, GitLabPullRequestCliError> => { | ||
| 1136 | const decoded = decodeViewerJson(result.stdout.trim()); | ||
| 1137 | if (!Result.isSuccess(decoded)) { | ||
| 1138 | return Effect.fail( | ||
| 1139 | new GitLabMergeRequestReadError({ | ||
| 1140 | command: "glab", | ||
| 1141 | cwd: input.cwd, | ||
| 1142 | operation: "getViewerUsername", | ||
| 1143 | cause: decoded.failure, | ||
| 1144 | }), | ||
| 1145 | ); | ||
| 1146 | } | ||
| 1147 | return decoded.success === null | ||
| 1148 | ? Effect.fail(new GitLabViewerUnavailableError({ command: "glab", cwd: input.cwd })) | ||
| 1149 | : Effect.succeed(decoded.success); | ||
| 1150 | }), | ||
| 1151 | ); | ||
| 1152 | |||
| 1153 | return GitLabPullRequestCli.of({ | ||
| 1154 | getViewerUsername: viewerUsername, | ||
| 1155 | |||
| 1156 | listMergeRequests: (input) => { | ||
| 1157 | const perPage = Math.min(input.limit + 1, MAX_PAGE_SIZE); | ||
| 1158 | const page = Math.floor((input.cursor?.delivered ?? 0) / perPage) + 1; | ||
| 1159 | return listPage({ ...input, page, collected: [], cursorAdvance: 0 }); | ||
| 1160 | }, | ||
| 1161 | |||
| 1162 | getMergeRequestDetail: mergeRequestDetail, | ||
| 1163 | |||
| 1164 | listNotes: (input) => notesPage({ ...input, page: 1, collected: [] }), | ||
| 1165 | |||
| 1166 | listReactions: (input) => awardsPage({ ...input, cursor: null, page: 1, collected: null }), | ||
| 1167 | |||
| 1168 | getFileRevisions: fileRevisions, | ||
| 1169 | |||
| 1170 | setReaction: (input) => | ||
| 1171 | Effect.gen(function* () { | ||
| 1172 | const subject = awardSubjectPath(input); | ||
| 1173 | if (input.reacted) { | ||
| 1174 | yield* api({ | ||
| 1175 | cwd: input.cwd, | ||
| 1176 | path: `${subject}?${query([["name", gitLabAwardName(input.content)]])}`, | ||
| 1177 | method: "POST", | ||
| 1178 | }); | ||
| 1179 | return; | ||
| 1180 | } | ||
| 1181 | // GitLab deletes an award by its id and takes no emoji name there, so the reader's own | ||
| 1182 | // award of that name is looked up first. Nothing to delete is success: the reaction the | ||
| 1183 | // caller asked to take back is already gone. | ||
| 1184 | const viewer = yield* viewerUsername({ cwd: input.cwd }); | ||
| 1185 | const listed = yield* api({ cwd: input.cwd, path: subject }); | ||
| 1186 | const own = decodeOwnAwardIdJson(listed.stdout.trim(), { | ||
| 1187 | content: input.content, | ||
| 1188 | viewer, | ||
| 1189 | }); | ||
| 1190 | if (!Result.isSuccess(own)) { | ||
| 1191 | return yield* new GitLabMergeRequestReadError({ | ||
| 1192 | command: "glab", | ||
| 1193 | cwd: input.cwd, | ||
| 1194 | operation: "setReaction", | ||
| 1195 | cause: own.failure, | ||
| 1196 | }); | ||
| 1197 | } | ||
| 1198 | if (own.success === null) return; | ||
| 1199 | yield* api({ | ||
| 1200 | cwd: input.cwd, | ||
| 1201 | path: `${subject}/${own.success}`, | ||
| 1202 | method: "DELETE", | ||
| 1203 | }); | ||
| 1204 | }), | ||
| 1205 | |||
| 1206 | listCommits: (input) => | ||
| 1207 | api({ | ||
| 1208 | cwd: input.cwd, | ||
| 1209 | path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/commits?${query( | ||
| 1210 | [ | ||
| 1211 | ["per_page", String(COMMIT_PAGE_SIZE)], | ||
| 1212 | ["with_stats", "true"], | ||
| 1213 | ], | ||
| 1214 | )}`, | ||
| 1215 | }).pipe( | ||
| 1216 | Effect.flatMap((result) => { | ||
| 1217 | const decoded = decodeCommitsJson(result.stdout.trim()); | ||
| 1218 | return Result.isSuccess(decoded) | ||
| 1219 | ? Effect.succeed(decoded.success) | ||
| 1220 | : Effect.fail( | ||
| 1221 | new GitLabMergeRequestReadError({ | ||
| 1222 | command: "glab", | ||
| 1223 | cwd: input.cwd, | ||
| 1224 | operation: "listCommits", | ||
| 1225 | cause: decoded.failure, | ||
| 1226 | }), | ||
| 1227 | ); | ||
| 1228 | }), | ||
| 1229 | ), | ||
| 1230 | |||
| 1231 | getMergeRequestDiff: (input) => { | ||
| 1232 | if (input.commit !== undefined && !isCommitSha(input.commit)) { | ||
| 1233 | return Effect.fail(new GitLabDiffCommitError({ command: "glab", cwd: input.cwd })); | ||
| 1234 | } | ||
| 1235 | const target = { | ||
| 1236 | cwd: input.cwd, | ||
| 1237 | repository: input.repository, | ||
| 1238 | number: input.number, | ||
| 1239 | ...(input.commit === undefined ? {} : { commit: input.commit }), | ||
| 1240 | }; | ||
| 1241 | if (input.cursor === undefined) { | ||
| 1242 | return diffPage({ ...target, page: 1 }); | ||
| 1243 | } | ||
| 1244 | const page = diffCursorPage(input.cursor); | ||
| 1245 | return page === null | ||
| 1246 | ? Effect.fail(new GitLabDiffCursorError({ command: "glab", cwd: input.cwd })) | ||
| 1247 | : diffPage({ ...target, page }); | ||
| 1248 | }, | ||
| 1249 | |||
| 1250 | getMergeRequestDiffFileContents: (input) => | ||
| 1251 | Effect.gen(function* () { | ||
| 1252 | if (input.commit !== undefined && !isCommitSha(input.commit)) { | ||
| 1253 | return yield* new GitLabDiffCommitError({ command: "glab", cwd: input.cwd }); | ||
| 1254 | } | ||
| 1255 | const refs = yield* input.commit === undefined | ||
| 1256 | ? getDiffRefs(input) | ||
| 1257 | : getCommitDiffRefs({ | ||
| 1258 | cwd: input.cwd, | ||
| 1259 | repository: input.repository, | ||
| 1260 | commit: input.commit, | ||
| 1261 | allowRoot: input.changeType === "new", | ||
| 1262 | }); | ||
| 1263 | |||
| 1264 | const readFile = (revision: string, filePath: string) => | ||
| 1265 | api({ | ||
| 1266 | cwd: input.cwd, | ||
| 1267 | path: `projects/${projectPath(input.repository)}/repository/files/${encodeURIComponent( | ||
| 1268 | filePath, | ||
| 1269 | )}/raw?ref=${encodeURIComponent(revision)}`, | ||
| 1270 | maxOutputBytes: DIFF_FILE_MAX_OUTPUT_BYTES, | ||
| 1271 | timeoutMs: DIFF_TIMEOUT_MS, | ||
| 1272 | }).pipe( | ||
| 1273 | Effect.flatMap((result) => | ||
| 1274 | result.stdoutTruncated || | ||
| 1275 | result.stdout.includes("\0") || | ||
| 1276 | result.stdoutInvalidUtf8 === true | ||
| 1277 | ? Effect.fail( | ||
| 1278 | new GitLabDiffFileContentsUnavailableError({ | ||
| 1279 | command: "glab", | ||
| 1280 | cwd: input.cwd, | ||
| 1281 | path: filePath, | ||
| 1282 | reason: result.stdoutTruncated ? "oversized" : "binary", | ||
| 1283 | }), | ||
| 1284 | ) | ||
| 1285 | : Effect.succeed(result.stdout), | ||
| 1286 | ), | ||
| 1287 | ); | ||
| 1288 | |||
| 1289 | const [oldContents, newContents] = yield* Effect.all( | ||
| 1290 | [ | ||
| 1291 | input.changeType === "new" ? Effect.succeed("") : readFile(refs.baseSha, input.oldPath), | ||
| 1292 | input.changeType === "deleted" | ||
| 1293 | ? Effect.succeed("") | ||
| 1294 | : readFile(refs.headSha, input.newPath), | ||
| 1295 | ], | ||
| 1296 | { concurrency: 2 }, | ||
| 1297 | ); | ||
| 1298 | return { oldContents, newContents }; | ||
| 1299 | }), | ||
| 1300 | |||
| 1301 | getProjectMergeCapabilities: (input) => | ||
| 1302 | api({ | ||
| 1303 | cwd: input.cwd, | ||
| 1304 | path: `projects/${projectPath(input.repository)}?license=false`, | ||
| 1305 | }).pipe( | ||
| 1306 | Effect.flatMap((result) => { | ||
| 1307 | const decoded = decodeProjectMergeCapabilitiesJson(result.stdout.trim()); | ||
| 1308 | return Result.isSuccess(decoded) | ||
| 1309 | ? Effect.succeed(decoded.success) | ||
| 1310 | : Effect.fail( | ||
| 1311 | new GitLabMergeRequestReadError({ | ||
| 1312 | command: "glab", | ||
| 1313 | cwd: input.cwd, | ||
| 1314 | operation: "getProjectMergeCapabilities", | ||
| 1315 | cause: decoded.failure, | ||
| 1316 | }), | ||
| 1317 | ); | ||
| 1318 | }), | ||
| 1319 | ), | ||
| 1320 | |||
| 1321 | listReviewerCandidates: (input) => | ||
| 1322 | Effect.all([mergeRequestDetail(input), projectUsers(input)], { concurrency: 2 }).pipe( | ||
| 1323 | Effect.map(([mergeRequest, users]) => { | ||
| 1324 | const author = mergeRequest.author?.login; | ||
| 1325 | const requested = new Set(mergeRequest.reviewRequestLogins); | ||
| 1326 | return { | ||
| 1327 | // The author is dropped rather than shown unusable: GitLab refuses to make the person | ||
| 1328 | // who opened a merge request its reviewer. | ||
| 1329 | candidates: users.candidates.flatMap((candidate) => | ||
| 1330 | candidate.login === author | ||
| 1331 | ? [] | ||
| 1332 | : [{ ...candidate, isRequested: requested.has(candidate.login) }], | ||
| 1333 | ), | ||
| 1334 | truncated: users.rawCount >= MAX_PAGE_SIZE, | ||
| 1335 | }; | ||
| 1336 | }), | ||
| 1337 | ), | ||
| 1338 | |||
| 1339 | setReviewerRequest: (input) => | ||
| 1340 | mergeRequestDetail(input).pipe( | ||
| 1341 | Effect.flatMap((mergeRequest) => { | ||
| 1342 | // GitLab has no endpoint that adds or removes one reviewer: `reviewer_ids` replaces the | ||
| 1343 | // whole set, so the set that is already there is read first and the change applied to | ||
| 1344 | // it. Asking again for somebody already on it writes the same set back, which is how | ||
| 1345 | // GitLab re-requests a review. | ||
| 1346 | const ids = new Set(mergeRequest.reviewerIds); | ||
| 1347 | for (const reviewer of input.reviewers) { | ||
| 1348 | const id = Number(reviewer.id); | ||
| 1349 | // A candidate GitLab did not name is not an id it would accept, and sending it would | ||
| 1350 | // rewrite the reviewer set around a number nobody chose. | ||
| 1351 | if (!Number.isSafeInteger(id) || id <= 0) continue; | ||
| 1352 | if (input.requested) ids.add(id); | ||
| 1353 | else ids.delete(id); | ||
| 1354 | } | ||
| 1355 | return api({ | ||
| 1356 | cwd: input.cwd, | ||
| 1357 | path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}`, | ||
| 1358 | method: "PUT", | ||
| 1359 | stdin: JSON.stringify({ reviewer_ids: [...ids] }), | ||
| 1360 | }); | ||
| 1361 | }), | ||
| 1362 | Effect.asVoid, | ||
| 1363 | ), | ||
| 1364 | |||
| 1365 | runMergeRequestAction: (input) => { | ||
| 1366 | // `glab mr merge` arms auto-merge and never disarms it, so the one direction the CLI has | ||
| 1367 | // no flag for is asked of GitLab directly through the same `api` passthrough the rest of | ||
| 1368 | // this module writes with. | ||
| 1369 | if (input.action === "disable-auto-merge") { | ||
| 1370 | return api({ | ||
| 1371 | cwd: input.cwd, | ||
| 1372 | path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/cancel_merge_when_pipeline_succeeds`, | ||
| 1373 | method: "POST", | ||
| 1374 | }).pipe(Effect.asVoid); | ||
| 1375 | } | ||
| 1376 | const [subcommand, ...flags] = actionArgs(input.action, input.mergeMethod); | ||
| 1377 | return gitlab | ||
| 1378 | .execute({ | ||
| 1379 | cwd: input.cwd, | ||
| 1380 | args: ["mr", subcommand!, String(input.number), "--repo", input.repository, ...flags], | ||
| 1381 | }) | ||
| 1382 | .pipe(Effect.asVoid); | ||
| 1383 | }, | ||
| 1384 | |||
| 1385 | updateMergeRequest: (input) => | ||
| 1386 | api({ | ||
| 1387 | cwd: input.cwd, | ||
| 1388 | path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}`, | ||
| 1389 | method: "PUT", | ||
| 1390 | // Only the fields the caller asked to change: GitLab leaves out what it is not sent, and | ||
| 1391 | // clears what it is sent empty — so a title corrected on its own must carry no | ||
| 1392 | // description at all. | ||
| 1393 | stdin: JSON.stringify({ | ||
| 1394 | ...(input.title === undefined ? {} : { title: input.title }), | ||
| 1395 | ...(input.description === undefined ? {} : { description: input.description }), | ||
| 1396 | }), | ||
| 1397 | }).pipe(Effect.asVoid), | ||
| 1398 | |||
| 1399 | commentOnMergeRequest: (input) => | ||
| 1400 | api({ | ||
| 1401 | cwd: input.cwd, | ||
| 1402 | path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/notes`, | ||
| 1403 | method: "POST", | ||
| 1404 | // A JSON body rather than a `--raw-field`: glab coerces a field that reads as a | ||
| 1405 | // literal `true` or a number, and a comment body is text either way. | ||
| 1406 | stdin: JSON.stringify({ body: input.body }), | ||
| 1407 | }).pipe(Effect.asVoid), | ||
| 1408 | |||
| 1409 | updateNote: (input) => | ||
| 1410 | api({ | ||
| 1411 | cwd: input.cwd, | ||
| 1412 | path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/notes/${encodeURIComponent( | ||
| 1413 | input.noteId, | ||
| 1414 | )}`, | ||
| 1415 | method: "PUT", | ||
| 1416 | stdin: JSON.stringify({ body: input.body }), | ||
| 1417 | }).pipe(Effect.asVoid), | ||
| 1418 | |||
| 1419 | listDiscussions: (input) => discussionsPage({ ...input, page: 1, collected: [] }), | ||
| 1420 | |||
| 1421 | submitReview: (input) => | ||
| 1422 | Effect.gen(function* () { | ||
| 1423 | const project = projectPath(input.repository); | ||
| 1424 | const mergeRequest = `projects/${project}/merge_requests/${input.number}`; | ||
| 1425 | // GitLab has no pending review to attach comments to, so a review is replayed as the | ||
| 1426 | // requests it is made of: the line comments, then the summary, then the verdict. A | ||
| 1427 | // failure part-way therefore leaves what was already posted in place, which is why | ||
| 1428 | // the verdict goes last — a half-sent review is never an approval. | ||
| 1429 | if (input.comments.length > 0) { | ||
| 1430 | const refs = yield* getDiffRefs(input); | ||
| 1431 | yield* Effect.forEach( | ||
| 1432 | input.comments, | ||
| 1433 | (comment) => | ||
| 1434 | api({ | ||
| 1435 | cwd: input.cwd, | ||
| 1436 | path: `${mergeRequest}/discussions`, | ||
| 1437 | method: "POST", | ||
| 1438 | stdin: JSON.stringify({ | ||
| 1439 | body: comment.body, | ||
| 1440 | position: { | ||
| 1441 | base_sha: refs.baseSha, | ||
| 1442 | head_sha: refs.headSha, | ||
| 1443 | start_sha: refs.startSha, | ||
| 1444 | position_type: "text", | ||
| 1445 | // Both paths are sent because GitLab resolves a position against both | ||
| 1446 | // sides of the diff. They differ only for a renamed file, which is why the | ||
| 1447 | // draft carries the name the file had before the change. | ||
| 1448 | old_path: comment.oldPath ?? comment.path, | ||
| 1449 | new_path: comment.path, | ||
| 1450 | ...gitLabReviewPositionLines(comment.position), | ||
| 1451 | }, | ||
| 1452 | }), | ||
| 1453 | }), | ||
| 1454 | { discard: true }, | ||
| 1455 | ); | ||
| 1456 | } | ||
| 1457 | if (input.body.trim().length > 0) { | ||
| 1458 | yield* api({ | ||
| 1459 | cwd: input.cwd, | ||
| 1460 | path: `${mergeRequest}/notes`, | ||
| 1461 | method: "POST", | ||
| 1462 | // A JSON body rather than a `--raw-field`, for the reason the plain comment gives: | ||
| 1463 | // glab coerces a field that reads as a literal `true` or a number. | ||
| 1464 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 1465 | stdin: JSON.stringify({ body: input.body }), | ||
| 1466 | }); | ||
| 1467 | } | ||
| 1468 | if (input.verdict === "approve") { | ||
| 1469 | yield* api({ cwd: input.cwd, path: `${mergeRequest}/approve`, method: "POST" }); | ||
| 1470 | } | ||
| 1471 | }), | ||
| 1472 | |||
| 1473 | replyToDiscussion: (input) => | ||
| 1474 | api({ | ||
| 1475 | cwd: input.cwd, | ||
| 1476 | path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/discussions/${encodeURIComponent( | ||
| 1477 | input.discussionId, | ||
| 1478 | )}/notes`, | ||
| 1479 | method: "POST", | ||
| 1480 | stdin: JSON.stringify({ body: input.body }), | ||
| 1481 | }).pipe(Effect.asVoid), | ||
| 1482 | |||
| 1483 | setDiscussionResolution: (input) => | ||
| 1484 | api({ | ||
| 1485 | cwd: input.cwd, | ||
| 1486 | path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/discussions/${encodeURIComponent( | ||
| 1487 | input.discussionId, | ||
| 1488 | )}`, | ||
| 1489 | method: "PUT", | ||
| 1490 | stdin: JSON.stringify({ resolved: input.resolved }), | ||
| 1491 | }).pipe(Effect.asVoid), | ||
| 1492 | }); | ||
| 1493 | }); | ||
| 1494 | |||
| 1495 | export const layer = Layer.effect(GitLabPullRequestCli, make); | ||
apps/server/src/pullRequest/GitLabPullRequestProvider.test.ts deleted-214| ... | @@ -1,214 +0,0 @@ | ||
| 1 | import { assert, describe, expect, it, vi } from "@effect/vitest"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Layer from "effect/Layer"; | ||
| 4 | |||
| 5 | import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts"; | ||
| 6 | import { gitLabViewerPermissions, make } from "./GitLabPullRequestProvider.ts"; | ||
| 7 | |||
| 8 | describe("gitLabViewerPermissions", () => { | ||
| 9 | it("offers everything to a viewer GitLab says can merge", () => { | ||
| 10 | expect(gitLabViewerPermissions({ viewerCanMerge: true })).toEqual({ | ||
| 11 | // Arming a merge for later and taking the arming back answer to the same `can_merge`. | ||
| 12 | actions: [ | ||
| 13 | "merge", | ||
| 14 | "ready", | ||
| 15 | "draft", | ||
| 16 | "close", | ||
| 17 | "reopen", | ||
| 18 | "update-branch", | ||
| 19 | "enable-auto-merge", | ||
| 20 | "disable-auto-merge", | ||
| 21 | ], | ||
| 22 | comment: true, | ||
| 23 | resolve: true, | ||
| 24 | verdicts: ["comment", "approve"], | ||
| 25 | // GitLab says nothing about who may set a reviewer, and an unreported permission is granted. | ||
| 26 | requestReviewers: true, | ||
| 27 | // Rebase and nothing else: GitLab cannot merge a target branch into a source branch, so | ||
| 28 | // offering the choice would be offering something no request could carry out. | ||
| 29 | updateMethods: ["rebase"], | ||
| 30 | }); | ||
| 31 | }); | ||
| 32 | |||
| 33 | it("keeps merge, now and later, from a viewer GitLab says cannot", () => { | ||
| 34 | // `user.can_merge` already accounts for the role, the approval rules and a protected target | ||
| 35 | // branch, so it is the one answer here that does not have to be inferred. | ||
| 36 | expect(gitLabViewerPermissions({ viewerCanMerge: false })).toEqual({ | ||
| 37 | actions: ["ready", "draft", "close", "reopen"], | ||
| 38 | comment: true, | ||
| 39 | resolve: true, | ||
| 40 | verdicts: ["comment", "approve"], | ||
| 41 | requestReviewers: true, | ||
| 42 | }); | ||
| 43 | }); | ||
| 44 | |||
| 45 | it("names no way of updating a branch it will not let this viewer update", () => { | ||
| 46 | // The action and the strategy behind it go together: a button offered with nothing to press | ||
| 47 | // it with, or a strategy left standing next to a withheld button, is a half-refusal. | ||
| 48 | expect(gitLabViewerPermissions({ viewerCanMerge: false }).updateMethods).toBeUndefined(); | ||
| 49 | }); | ||
| 50 | |||
| 51 | it("treats an author with read access as any other reader, which is all GitLab says", () => { | ||
| 52 | // Its REST API names no relationship between the viewer and the merge request beyond | ||
| 53 | // `can_merge`, so the four an author keeps stay offered to everyone rather than being taken | ||
| 54 | // from the one person entitled to them. | ||
| 55 | expect(gitLabViewerPermissions({ viewerCanMerge: false }).actions).toEqual([ | ||
| 56 | "ready", | ||
| 57 | "draft", | ||
| 58 | "close", | ||
| 59 | "reopen", | ||
| 60 | ]); | ||
| 61 | }); | ||
| 62 | }); | ||
| 63 | |||
| 64 | describe("getChangeRequest base freshness", () => { | ||
| 65 | const detail = { | ||
| 66 | number: 7, | ||
| 67 | title: "Merge request 7", | ||
| 68 | url: "https://gitlab.com/acme/web/-/merge_requests/7", | ||
| 69 | author: null, | ||
| 70 | headBranch: "feat/page", | ||
| 71 | baseBranch: "main", | ||
| 72 | state: "open" as const, | ||
| 73 | isDraft: false, | ||
| 74 | mergeability: "mergeable" as const, | ||
| 75 | additions: 0, | ||
| 76 | deletions: 0, | ||
| 77 | createdAt: "2026-07-01T00:00:00Z", | ||
| 78 | updatedAt: "2026-07-02T00:00:00Z", | ||
| 79 | reviewRequestLogins: [], | ||
| 80 | labels: [], | ||
| 81 | body: "", | ||
| 82 | changedFiles: 1, | ||
| 83 | mergedAt: null, | ||
| 84 | closedAt: null, | ||
| 85 | reviewers: [], | ||
| 86 | checks: [], | ||
| 87 | viewerCanMerge: true, | ||
| 88 | reviewerIds: [], | ||
| 89 | }; | ||
| 90 | |||
| 91 | it.effect("reads checks without fetching project settings", () => | ||
| 92 | Effect.gen(function* () { | ||
| 93 | const provider = yield* make.pipe( | ||
| 94 | Effect.provide( | ||
| 95 | Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({ | ||
| 96 | getMergeRequestDetail: () => Effect.succeed(detail), | ||
| 97 | }), | ||
| 98 | ), | ||
| 99 | ); | ||
| 100 | const read = provider.getChangeRequestChecks; | ||
| 101 | if (read === undefined) return yield* Effect.die("checks read missing"); | ||
| 102 | expect( | ||
| 103 | yield* read({ cwd: "/w", repository: "group/subgroup/web", host: "gitlab.com", number: 7 }), | ||
| 104 | ).toEqual({ state: "open", checks: [] }); | ||
| 105 | }), | ||
| 106 | ); | ||
| 107 | |||
| 108 | const readWith = (divergence: { readonly divergedCommits?: number }) => | ||
| 109 | Effect.gen(function* () { | ||
| 110 | const provider = yield* make; | ||
| 111 | return yield* provider.getChangeRequest({ | ||
| 112 | cwd: "/w", | ||
| 113 | repository: "acme/web", | ||
| 114 | host: "gitlab.com", | ||
| 115 | number: 7, | ||
| 116 | }); | ||
| 117 | }).pipe( | ||
| 118 | Effect.provide( | ||
| 119 | Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({ | ||
| 120 | getMergeRequestDetail: () => Effect.succeed({ ...detail, ...divergence }), | ||
| 121 | getProjectMergeCapabilities: () => | ||
| 122 | Effect.succeed({ merge: true, squash: true, rebase: true }), | ||
| 123 | }), | ||
| 124 | ), | ||
| 125 | ); | ||
| 126 | |||
| 127 | it.effect("reads a counted divergence as a branch that has fallen behind", () => | ||
| 128 | Effect.gen(function* () { | ||
| 129 | const changeRequest = yield* readWith({ divergedCommits: 3 }); | ||
| 130 | |||
| 131 | expect(changeRequest.baseComparison).toBe("behind"); | ||
| 132 | expect(changeRequest.behindBy).toBe(3); | ||
| 133 | }), | ||
| 134 | ); | ||
| 135 | |||
| 136 | it.effect("reads a divergence of none as a branch that is current", () => | ||
| 137 | Effect.gen(function* () { | ||
| 138 | const changeRequest = yield* readWith({ divergedCommits: 0 }); | ||
| 139 | |||
| 140 | expect(changeRequest.baseComparison).toBe("up-to-date"); | ||
| 141 | expect(changeRequest.behindBy).toBe(0); | ||
| 142 | }), | ||
| 143 | ); | ||
| 144 | |||
| 145 | it.effect("says nothing at all where GitLab counted nothing", () => | ||
| 146 | Effect.gen(function* () { | ||
| 147 | // An install too old to answer has to leave the page silent rather than let it claim the | ||
| 148 | // branch is current, which is the one wrong thing this banner could say. | ||
| 149 | const changeRequest = yield* readWith({}); | ||
| 150 | |||
| 151 | expect(changeRequest.baseComparison).toBe("unknown"); | ||
| 152 | expect(changeRequest.behindBy).toBeUndefined(); | ||
| 153 | }), | ||
| 154 | ); | ||
| 155 | }); | ||
| 156 | |||
| 157 | describe("rewriting what has already been said", () => { | ||
| 158 | const updateMergeRequest = vi.fn(() => Effect.void); | ||
| 159 | const updateNote = vi.fn(() => Effect.void); | ||
| 160 | |||
| 161 | const providerWith = make.pipe( | ||
| 162 | Effect.provide( | ||
| 163 | Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({ updateMergeRequest, updateNote }), | ||
| 164 | ), | ||
| 165 | ); | ||
| 166 | |||
| 167 | it.effect("sends only the half of the merge request the reader rewrote", () => | ||
| 168 | Effect.gen(function* () { | ||
| 169 | const provider = yield* providerWith; | ||
| 170 | assert.isDefined(provider.updateChangeRequest); | ||
| 171 | |||
| 172 | yield* provider.updateChangeRequest({ | ||
| 173 | cwd: "/w", | ||
| 174 | repository: "acme/web", | ||
| 175 | host: "gitlab.com", | ||
| 176 | number: 7, | ||
| 177 | body: "What this changes.", | ||
| 178 | }); | ||
| 179 | |||
| 180 | // GitLab calls it the description, and the title stays out of the request entirely. | ||
| 181 | expect(updateMergeRequest).toHaveBeenCalledWith({ | ||
| 182 | cwd: "/w", | ||
| 183 | repository: "acme/web", | ||
| 184 | number: 7, | ||
| 185 | description: "What this changes.", | ||
| 186 | }); | ||
| 187 | }), | ||
| 188 | ); | ||
| 189 | |||
| 190 | it.effect("rewrites a positioned comment through the same note as any other", () => | ||
| 191 | Effect.gen(function* () { | ||
| 192 | const provider = yield* providerWith; | ||
| 193 | assert.isDefined(provider.updateComment); | ||
| 194 | |||
| 195 | yield* provider.updateComment({ | ||
| 196 | cwd: "/w", | ||
| 197 | repository: "acme/web", | ||
| 198 | host: "gitlab.com", | ||
| 199 | number: 7, | ||
| 200 | commentId: "42", | ||
| 201 | kind: "review-comment", | ||
| 202 | body: "Reworded.", | ||
| 203 | }); | ||
| 204 | |||
| 205 | expect(updateNote).toHaveBeenCalledWith({ | ||
| 206 | cwd: "/w", | ||
| 207 | repository: "acme/web", | ||
| 208 | number: 7, | ||
| 209 | noteId: "42", | ||
| 210 | body: "Reworded.", | ||
| 211 | }); | ||
| 212 | }), | ||
| 213 | ); | ||
| 214 | }); | ||
apps/server/src/pullRequest/GitLabPullRequestProvider.ts deleted-339| ... | @@ -1,339 +0,0 @@ | ||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import type { | ||
| 3 | PullRequestCapabilities, | ||
| 4 | PullRequestReaction, | ||
| 5 | PullRequestViewerPermissions, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | |||
| 8 | import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts"; | ||
| 9 | import { | ||
| 10 | PullRequestProviderError, | ||
| 11 | type PullRequestProviderFailure, | ||
| 12 | type ProviderChangeRequestActivity, | ||
| 13 | type ProviderChangeRequestDetail, | ||
| 14 | type PullRequestProviderApi, | ||
| 15 | } from "@t3tools/source-control-core/server/PullRequestProvider"; | ||
| 16 | |||
| 17 | const CAPABILITIES: PullRequestCapabilities = { | ||
| 18 | diff: true, | ||
| 19 | comment: true, | ||
| 20 | actions: [ | ||
| 21 | "merge", | ||
| 22 | "ready", | ||
| 23 | "draft", | ||
| 24 | "close", | ||
| 25 | "reopen", | ||
| 26 | "update-branch", | ||
| 27 | "enable-auto-merge", | ||
| 28 | "disable-auto-merge", | ||
| 29 | ], | ||
| 30 | // GitLab offers all three, though a project settles on one; `mergeCapabilities` narrows it. | ||
| 31 | mergeMethods: ["merge", "squash", "rebase"], | ||
| 32 | // Rebase alone: GitLab moves a stale branch onto its target by replaying it, and has nothing | ||
| 33 | // that merges the target back in the way GitHub's update button can. Declaring only what it | ||
| 34 | // does is what lets a request to merge the target in be refused instead of quietly rebasing. | ||
| 35 | updateMethods: ["rebase"], | ||
| 36 | search: true, | ||
| 37 | reactions: true, | ||
| 38 | // GitLab keeps a reader's viewed files in one browser's local storage, where nothing outside | ||
| 39 | // that browser can read or write them. So the marks made here are this environment's own: they | ||
| 40 | // follow the reader between the clients connected to it, but they are not the ones gitlab.com | ||
| 41 | // shows, and the surface says so rather than implying a review can be carried on from there. | ||
| 42 | viewedFiles: "environment", | ||
| 43 | review: { | ||
| 44 | inlineComment: true, | ||
| 45 | reply: true, | ||
| 46 | resolve: true, | ||
| 47 | // No "changes requested": GitLab has approval and unresolved discussions, and nothing that | ||
| 48 | // says a merge request has been reviewed and rejected. | ||
| 49 | verdicts: ["comment", "approve"], | ||
| 50 | }, | ||
| 51 | reviewers: { request: true, listCandidates: true }, | ||
| 52 | edit: { changeRequest: true, comment: true }, | ||
| 53 | }; | ||
| 54 | |||
| 55 | /** | ||
| 56 | * The actions `user.can_merge` answers for. Rebasing writes to the source branch rather than to | ||
| 57 | * the target, so it is not literally the same permission — but GitLab reports nothing narrower, | ||
| 58 | * and someone it will not let land this change has no business rewriting its branch either. | ||
| 59 | */ | ||
| 60 | const MERGE_ACTIONS: ReadonlySet<string> = new Set([ | ||
| 61 | "merge", | ||
| 62 | "update-branch", | ||
| 63 | "enable-auto-merge", | ||
| 64 | "disable-auto-merge", | ||
| 65 | ]); | ||
| 66 | |||
| 67 | /** | ||
| 68 | * What the signed-in account may do here. GitLab answers exactly one of these questions per | ||
| 69 | * viewer, on the merge request itself: `user.can_merge`, which is why merging is the only thing | ||
| 70 | * narrowed. | ||
| 71 | * | ||
| 72 | * The rest stay granted. GitLab's REST API reports the viewer's role on the project but never | ||
| 73 | * whether they opened this merge request — and its author may close it, reopen it and move it in | ||
| 74 | * and out of draft whatever their role, just as the author of a note may resolve the discussion | ||
| 75 | * it started. Withholding those controls from the one person entitled to them is the worse of the | ||
| 76 | * two mistakes, so they are offered and GitLab explains any refusal itself. | ||
| 77 | * | ||
| 78 | * Asking for a review is granted for the same reason: GitLab takes a reviewer set from the author | ||
| 79 | * and from anyone with the Developer role, and states neither of those two facts here. | ||
| 80 | */ | ||
| 81 | export function gitLabViewerPermissions(input: { | ||
| 82 | readonly viewerCanMerge: boolean; | ||
| 83 | }): PullRequestViewerPermissions { | ||
| 84 | return { | ||
| 85 | // Arming the merge and taking the arming back are the merge, deferred, so they answer to | ||
| 86 | // the same `can_merge` the merge itself does. | ||
| 87 | actions: CAPABILITIES.actions.filter( | ||
| 88 | (action) => !MERGE_ACTIONS.has(action) || input.viewerCanMerge, | ||
| 89 | ), | ||
| 90 | comment: true, | ||
| 91 | resolve: true, | ||
| 92 | verdicts: CAPABILITIES.review.verdicts, | ||
| 93 | requestReviewers: true, | ||
| 94 | ...(input.viewerCanMerge ? { updateMethods: CAPABILITIES.updateMethods } : {}), | ||
| 95 | }; | ||
| 96 | } | ||
| 97 | |||
| 98 | /** The CLI tags that mean the tool itself is unusable, rather than one request failing. */ | ||
| 99 | export function gitLabProviderFailure( | ||
| 100 | error: GitLabPullRequestCli.GitLabPullRequestCliError, | ||
| 101 | ): PullRequestProviderFailure { | ||
| 102 | if (error._tag === "GitLabCliUnavailableError") return { reason: "missing-tool" }; | ||
| 103 | if (error._tag === "GitLabCliAuthenticationError") return { reason: "unauthenticated" }; | ||
| 104 | if (error._tag === "GitLabCliRateLimitError") return { reason: "rate-limited" }; | ||
| 105 | if (error._tag === "GitLabMergeRequestNotFoundError") return { reason: "not-found" }; | ||
| 106 | return { reason: "failed" }; | ||
| 107 | } | ||
| 108 | |||
| 109 | export const make = Effect.gen(function* () { | ||
| 110 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 111 | |||
| 112 | const fail = (operation: string) => (error: GitLabPullRequestCli.GitLabPullRequestCliError) => | ||
| 113 | new PullRequestProviderError({ | ||
| 114 | provider: "gitlab", | ||
| 115 | operation, | ||
| 116 | ...gitLabProviderFailure(error), | ||
| 117 | detail: error.detail, | ||
| 118 | cause: error, | ||
| 119 | }); | ||
| 120 | |||
| 121 | const provider: PullRequestProviderApi = { | ||
| 122 | kind: "gitlab", | ||
| 123 | capabilities: CAPABILITIES, | ||
| 124 | |||
| 125 | getViewer: (input) => | ||
| 126 | cli.getViewerUsername({ cwd: input.cwd }).pipe(Effect.mapError(fail("getViewer"))), | ||
| 127 | |||
| 128 | listChangeRequests: (input) => | ||
| 129 | cli | ||
| 130 | .listMergeRequests({ | ||
| 131 | cwd: input.cwd, | ||
| 132 | repository: input.repository, | ||
| 133 | state: input.state, | ||
| 134 | involvement: input.involvement, | ||
| 135 | viewer: input.viewer, | ||
| 136 | limit: input.limit, | ||
| 137 | query: input.query, | ||
| 138 | cursor: input.cursor, | ||
| 139 | }) | ||
| 140 | .pipe( | ||
| 141 | Effect.mapError(fail("listChangeRequests")), | ||
| 142 | // GitLab is asked for its merge requests by update, newest first, whether or not it is | ||
| 143 | // being carried on from — so every page it answers is one a cursor can continue. | ||
| 144 | Effect.map((batch) => ({ ...batch, continues: true })), | ||
| 145 | ), | ||
| 146 | |||
| 147 | getChangeRequestChecks: (input) => | ||
| 148 | cli.getMergeRequestDetail(input).pipe( | ||
| 149 | Effect.map(({ state, checks }) => ({ state, checks })), | ||
| 150 | Effect.mapError(fail("getChangeRequestChecks")), | ||
| 151 | ), | ||
| 152 | |||
| 153 | getChangeRequest: (input) => | ||
| 154 | Effect.all( | ||
| 155 | [ | ||
| 156 | cli.getMergeRequestDetail(input), | ||
| 157 | cli.getProjectMergeCapabilities({ cwd: input.cwd, repository: input.repository }), | ||
| 158 | ], | ||
| 159 | { concurrency: 2 }, | ||
| 160 | ).pipe( | ||
| 161 | Effect.mapError(fail("getChangeRequest")), | ||
| 162 | Effect.map(([mergeRequest, mergeCapabilities]): ProviderChangeRequestDetail => ({ | ||
| 163 | ...mergeRequest, | ||
| 164 | mergeCapabilities, | ||
| 165 | viewerPermissions: gitLabViewerPermissions(mergeRequest), | ||
| 166 | // A GitLab too old to count the divergence says nothing here rather than "up to | ||
| 167 | // date": the banner is worth missing, and a wrong all-clear is not worth showing. | ||
| 168 | baseComparison: | ||
| 169 | mergeRequest.divergedCommits === undefined | ||
| 170 | ? "unknown" | ||
| 171 | : mergeRequest.divergedCommits > 0 | ||
| 172 | ? "behind" | ||
| 173 | : "up-to-date", | ||
| 174 | ...(mergeRequest.divergedCommits === undefined | ||
| 175 | ? {} | ||
| 176 | : { behindBy: mergeRequest.divergedCommits }), | ||
| 177 | })), | ||
| 178 | ), | ||
| 179 | |||
| 180 | getChangeRequestActivity: (input) => | ||
| 181 | Effect.all( | ||
| 182 | [ | ||
| 183 | cli | ||
| 184 | .listNotes(input) | ||
| 185 | .pipe(Effect.orElseSucceed(() => ({ comments: [], truncated: true }))), | ||
| 186 | cli.listCommits(input).pipe(Effect.orElseSucceed(() => [])), | ||
| 187 | cli | ||
| 188 | .listDiscussions(input) | ||
| 189 | .pipe(Effect.orElseSucceed(() => ({ threads: [], truncated: true }))), | ||
| 190 | // The notes endpoint carries no award of any kind, so they are read alongside it. A | ||
| 191 | // failed read costs the conversation its reactions rather than its words. | ||
| 192 | cli.listReactions(input).pipe( | ||
| 193 | Effect.orElseSucceed(() => ({ | ||
| 194 | reactions: [] as ReadonlyArray<PullRequestReaction>, | ||
| 195 | reactionsByNoteId: new Map<string, ReadonlyArray<PullRequestReaction>>(), | ||
| 196 | })), | ||
| 197 | ), | ||
| 198 | ], | ||
| 199 | { concurrency: 4 }, | ||
| 200 | ).pipe( | ||
| 201 | Effect.mapError(fail("getChangeRequestActivity")), | ||
| 202 | Effect.map(([notes, commits, discussions, awards]): ProviderChangeRequestActivity => ({ | ||
| 203 | reactions: awards.reactions, | ||
| 204 | comments: notes.comments.map((comment) => ({ | ||
| 205 | ...comment, | ||
| 206 | reactions: awards.reactionsByNoteId.get(comment.id) ?? [], | ||
| 207 | })), | ||
| 208 | // GitLab reports no count of its own, so the walk's own total is the host's: the | ||
| 209 | // notes endpoint carries every comment on the merge request, including the ones | ||
| 210 | // written under a discussion, and it is read until GitLab runs out. | ||
| 211 | commentCount: notes.comments.length, | ||
| 212 | commentsTruncated: notes.truncated || discussions.truncated, | ||
| 213 | reviewThreads: discussions.threads.map((thread) => ({ | ||
| 214 | ...thread, | ||
| 215 | comments: thread.comments.map((comment) => ({ | ||
| 216 | ...comment, | ||
| 217 | reactions: awards.reactionsByNoteId.get(comment.id) ?? [], | ||
| 218 | })), | ||
| 219 | })), | ||
| 220 | commits, | ||
| 221 | })), | ||
| 222 | ), | ||
| 223 | |||
| 224 | // The same read the detail takes it from, on its own: `user.can_merge` lives on the merge | ||
| 225 | // request, so there is no cheaper thing to ask GitLab. | ||
| 226 | getViewerPermissions: (input) => | ||
| 227 | cli | ||
| 228 | .getMergeRequestDetail(input) | ||
| 229 | .pipe(Effect.mapError(fail("getViewerPermissions")), Effect.map(gitLabViewerPermissions)), | ||
| 230 | |||
| 231 | getDiff: (input) => cli.getMergeRequestDiff(input).pipe(Effect.mapError(fail("getDiff"))), | ||
| 232 | |||
| 233 | // What each marked file is at the head, which is what tells a mark that still stands from one | ||
| 234 | // the branch has moved past. GitLab's own local-storage marks are keyed on the blob id too, | ||
| 235 | // so this stales at the same moment its web UI would. | ||
| 236 | getFileRevisions: (input) => | ||
| 237 | cli.getFileRevisions(input).pipe( | ||
| 238 | Effect.mapError(fail("getFileRevisions")), | ||
| 239 | Effect.map((revisions) => ({ revisions })), | ||
| 240 | ), | ||
| 241 | |||
| 242 | // Users only: GitLab requests a review of a person, and the groups that can stand in for one | ||
| 243 | // appear in approval rules rather than in a merge request's reviewers. | ||
| 244 | listReviewerCandidates: (input) => | ||
| 245 | cli | ||
| 246 | .listReviewerCandidates({ | ||
| 247 | cwd: input.cwd, | ||
| 248 | repository: input.repository, | ||
| 249 | number: input.number, | ||
| 250 | }) | ||
| 251 | .pipe(Effect.mapError(fail("listReviewerCandidates"))), | ||
| 252 | |||
| 253 | setReviewerRequest: (input) => | ||
| 254 | cli | ||
| 255 | .setReviewerRequest({ | ||
| 256 | cwd: input.cwd, | ||
| 257 | repository: input.repository, | ||
| 258 | number: input.number, | ||
| 259 | reviewers: input.reviewers, | ||
| 260 | requested: input.requested, | ||
| 261 | }) | ||
| 262 | .pipe(Effect.mapError(fail("setReviewerRequest"))), | ||
| 263 | |||
| 264 | runAction: (input) => | ||
| 265 | cli | ||
| 266 | .runMergeRequestAction({ | ||
| 267 | cwd: input.cwd, | ||
| 268 | repository: input.repository, | ||
| 269 | number: input.number, | ||
| 270 | action: input.action, | ||
| 271 | ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), | ||
| 272 | }) | ||
| 273 | .pipe(Effect.mapError(fail("runAction"))), | ||
| 274 | |||
| 275 | updateChangeRequest: (input) => | ||
| 276 | cli | ||
| 277 | .updateMergeRequest({ | ||
| 278 | cwd: input.cwd, | ||
| 279 | repository: input.repository, | ||
| 280 | number: input.number, | ||
| 281 | ...(input.title === undefined ? {} : { title: input.title }), | ||
| 282 | ...(input.body === undefined ? {} : { description: input.body }), | ||
| 283 | }) | ||
| 284 | .pipe(Effect.mapError(fail("updateChangeRequest"))), | ||
| 285 | |||
| 286 | comment: (input) => cli.commentOnMergeRequest(input).pipe(Effect.mapError(fail("comment"))), | ||
| 287 | |||
| 288 | // The kind is not read: every comment this provider hands out, positioned or not, carries a | ||
| 289 | // plain REST note id, and one endpoint rewrites both. | ||
| 290 | updateComment: (input) => | ||
| 291 | cli | ||
| 292 | .updateNote({ | ||
| 293 | cwd: input.cwd, | ||
| 294 | repository: input.repository, | ||
| 295 | number: input.number, | ||
| 296 | noteId: input.commentId, | ||
| 297 | body: input.body, | ||
| 298 | }) | ||
| 299 | .pipe(Effect.mapError(fail("updateComment"))), | ||
| 300 | |||
| 301 | submitReview: (input) => cli.submitReview(input).pipe(Effect.mapError(fail("submitReview"))), | ||
| 302 | |||
| 303 | replyToThread: (input) => | ||
| 304 | cli | ||
| 305 | .replyToDiscussion({ | ||
| 306 | cwd: input.cwd, | ||
| 307 | repository: input.repository, | ||
| 308 | number: input.number, | ||
| 309 | discussionId: input.threadId, | ||
| 310 | body: input.body, | ||
| 311 | }) | ||
| 312 | .pipe(Effect.mapError(fail("replyToThread"))), | ||
| 313 | |||
| 314 | setReaction: (input) => | ||
| 315 | cli | ||
| 316 | .setReaction({ | ||
| 317 | cwd: input.cwd, | ||
| 318 | repository: input.repository, | ||
| 319 | number: input.number, | ||
| 320 | ...(input.subjectId === undefined ? {} : { noteId: input.subjectId }), | ||
| 321 | content: input.content, | ||
| 322 | reacted: input.reacted, | ||
| 323 | }) | ||
| 324 | .pipe(Effect.mapError(fail("setReaction"))), | ||
| 325 | |||
| 326 | setThreadResolution: (input) => | ||
| 327 | cli | ||
| 328 | .setDiscussionResolution({ | ||
| 329 | cwd: input.cwd, | ||
| 330 | repository: input.repository, | ||
| 331 | number: input.number, | ||
| 332 | discussionId: input.threadId, | ||
| 333 | resolved: input.resolved, | ||
| 334 | }) | ||
| 335 | .pipe(Effect.mapError(fail("setThreadResolution"))), | ||
| 336 | }; | ||
| 337 | |||
| 338 | return provider; | ||
| 339 | }); | ||
apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts+2-2| ... | @@ -4,11 +4,11 @@ import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/Azu | ... | @@ -4,11 +4,11 @@ import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/Azu |
| 4 | import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; | 4 | import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; |
| 5 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; | 5 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; |
| 6 | import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; | 6 | import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; |
| 7 | import * as GitLabCli from "../sourceControl/GitLabCli.ts"; | 7 | import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; |
| 8 | import { azureDevOpsProviderFailure } from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestProvider"; | 8 | import { azureDevOpsProviderFailure } from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestProvider"; |
| 9 | import { bitbucketProviderFailure } from "./BitbucketPullRequestProvider.ts"; | 9 | import { bitbucketProviderFailure } from "./BitbucketPullRequestProvider.ts"; |
| 10 | import { gitHubProviderFailure } from "./GitHubPullRequestProvider.ts"; | 10 | import { gitHubProviderFailure } from "./GitHubPullRequestProvider.ts"; |
| 11 | import { gitLabProviderFailure } from "./GitLabPullRequestProvider.ts"; | 11 | import { gitLabProviderFailure } from "@t3tools/source-control-gitlab/server/GitLabPullRequestProvider"; |
| 12 | 12 | ||
| 13 | const cause = new Error("redacted provider failure"); | 13 | const cause = new Error("redacted provider failure"); |
| 14 | 14 |
apps/server/src/pullRequest/PullRequestProviderRegistry.ts-5| ... | @@ -5,13 +5,10 @@ import type { SourceControlProviderKind } from "@t3tools/contracts"; | ... | @@ -5,13 +5,10 @@ import type { SourceControlProviderKind } from "@t3tools/contracts"; |
| 5 | 5 | ||
| 6 | import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; | 6 | import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; |
| 7 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; | 7 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; |
| 8 | import * as GitLabCli from "../sourceControl/GitLabCli.ts"; | ||
| 9 | import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts"; | 8 | import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts"; |
| 10 | import * as BitbucketPullRequestProvider from "./BitbucketPullRequestProvider.ts"; | 9 | import * as BitbucketPullRequestProvider from "./BitbucketPullRequestProvider.ts"; |
| 11 | import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; | 10 | import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; |
| 12 | import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts"; | 11 | import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts"; |
| 13 | import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts"; | ||
| 14 | import * as GitLabPullRequestProvider from "./GitLabPullRequestProvider.ts"; | ||
| 15 | import * as BuiltInDrivers from "../sourceControl/builtInDrivers.ts"; | 12 | import * as BuiltInDrivers from "../sourceControl/builtInDrivers.ts"; |
| 16 | import type { PullRequestProviderApi } from "@t3tools/source-control-core/server/PullRequestProvider"; | 13 | import type { PullRequestProviderApi } from "@t3tools/source-control-core/server/PullRequestProvider"; |
| 17 | 14 | ||
| ... | @@ -44,7 +41,6 @@ export function fromProviders( | ... | @@ -44,7 +41,6 @@ export function fromProviders( |
| 44 | export const make = Effect.gen(function* () { | 41 | export const make = Effect.gen(function* () { |
| 45 | const providers = yield* Effect.all([ | 42 | const providers = yield* Effect.all([ |
| 46 | GitHubPullRequestProvider.make, | 43 | GitHubPullRequestProvider.make, |
| 47 | GitLabPullRequestProvider.make, | ||
| 48 | BitbucketPullRequestProvider.make, | 44 | BitbucketPullRequestProvider.make, |
| 49 | ]); | 45 | ]); |
| 50 | const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) => | 46 | const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) => |
| ... | @@ -65,7 +61,6 @@ export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe( | ... | @@ -65,7 +61,6 @@ export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe( |
| 65 | ), | 61 | ), |
| 66 | ), | 62 | ), |
| 67 | ), | 63 | ), |
| 68 | Layer.provide(GitLabPullRequestCli.layer.pipe(Layer.provide(GitLabCli.layer))), | ||
| 69 | Layer.provide(BuiltInDrivers.layer), | 64 | Layer.provide(BuiltInDrivers.layer), |
| 70 | Layer.provide(BitbucketPullRequestApi.layer.pipe(Layer.provide(BitbucketApi.layer))), | 65 | Layer.provide(BitbucketPullRequestApi.layer.pipe(Layer.provide(BitbucketApi.layer))), |
| 71 | ); | 66 | ); |
apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts deleted-803| ... | @@ -1,803 +0,0 @@ | ||
| 1 | import * as Result from "effect/Result"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | decodeAwardEmojiJson, | ||
| 6 | decodeRepositoryBlobsJson, | ||
| 7 | decodeCommitsJson, | ||
| 8 | decodeMergeRequestDetailJson, | ||
| 9 | decodeMergeRequestDiffsJson, | ||
| 10 | decodeMergeRequestListJson, | ||
| 11 | decodeNotesJson, | ||
| 12 | decodeOwnAwardIdJson, | ||
| 13 | decodeViewerJson, | ||
| 14 | gitLabAwardName, | ||
| 15 | } from "./gitLabMergeRequestJson.ts"; | ||
| 16 | |||
| 17 | function listJson(entries: ReadonlyArray<Record<string, unknown>>): string { | ||
| 18 | return JSON.stringify( | ||
| 19 | entries.map((entry) => ({ | ||
| 20 | iid: 1, | ||
| 21 | title: "Add the merge requests page", | ||
| 22 | web_url: "https://gitlab.com/acme/web/-/merge_requests/1", | ||
| 23 | source_branch: "feat/page", | ||
| 24 | target_branch: "main", | ||
| 25 | created_at: "2026-07-01T00:00:00Z", | ||
| 26 | updated_at: "2026-07-02T00:00:00Z", | ||
| 27 | ...entry, | ||
| 28 | })), | ||
| 29 | ); | ||
| 30 | } | ||
| 31 | |||
| 32 | function detailJson(entry: Record<string, unknown>): string { | ||
| 33 | return JSON.stringify({ | ||
| 34 | iid: 1, | ||
| 35 | title: "Add the merge requests page", | ||
| 36 | web_url: "https://gitlab.com/acme/web/-/merge_requests/1", | ||
| 37 | source_branch: "feat/page", | ||
| 38 | target_branch: "main", | ||
| 39 | created_at: "2026-07-01T00:00:00Z", | ||
| 40 | updated_at: "2026-07-02T00:00:00Z", | ||
| 41 | ...entry, | ||
| 42 | }); | ||
| 43 | } | ||
| 44 | |||
| 45 | function expectSuccess<A>(result: Result.Result<A, unknown>): A { | ||
| 46 | expect(Result.isSuccess(result)).toBe(true); | ||
| 47 | if (!Result.isSuccess(result)) throw new Error("expected a successful decode"); | ||
| 48 | return result.success; | ||
| 49 | } | ||
| 50 | |||
| 51 | describe("decodeMergeRequestListJson", () => { | ||
| 52 | it("reads a merge request as a change request", () => { | ||
| 53 | const batch = expectSuccess( | ||
| 54 | decodeMergeRequestListJson( | ||
| 55 | listJson([ | ||
| 56 | { | ||
| 57 | iid: 42, | ||
| 58 | author: { username: "bilal", name: "Bilal" }, | ||
| 59 | state: "opened", | ||
| 60 | merge_status: "can_be_merged", | ||
| 61 | draft: false, | ||
| 62 | reviewers: [{ username: "julius" }], | ||
| 63 | labels: ["backend", " "], | ||
| 64 | }, | ||
| 65 | ]), | ||
| 66 | ), | ||
| 67 | ); | ||
| 68 | |||
| 69 | expect(batch.items).toHaveLength(1); | ||
| 70 | expect(batch.items[0]).toMatchObject({ | ||
| 71 | number: 42, | ||
| 72 | author: { login: "bilal", name: "Bilal" }, | ||
| 73 | headBranch: "feat/page", | ||
| 74 | baseBranch: "main", | ||
| 75 | state: "open", | ||
| 76 | isDraft: false, | ||
| 77 | mergeability: "mergeable", | ||
| 78 | reviewRequestLogins: ["julius"], | ||
| 79 | labels: [{ name: "backend", color: null }], | ||
| 80 | }); | ||
| 81 | }); | ||
| 82 | |||
| 83 | it("reports no line counts, which GitLab does not expose", () => { | ||
| 84 | const batch = expectSuccess(decodeMergeRequestListJson(listJson([{}]))); | ||
| 85 | |||
| 86 | expect(batch.items[0]).toMatchObject({ additions: 0, deletions: 0 }); | ||
| 87 | }); | ||
| 88 | |||
| 89 | it("treats a merged timestamp as merged whatever the state says", () => { | ||
| 90 | const batch = expectSuccess( | ||
| 91 | decodeMergeRequestListJson( | ||
| 92 | listJson([{ state: "opened", merged_at: "2026-07-03T00:00:00Z" }]), | ||
| 93 | ), | ||
| 94 | ); | ||
| 95 | |||
| 96 | expect(batch.items[0]?.state).toBe("merged"); | ||
| 97 | }); | ||
| 98 | |||
| 99 | it("keeps a locked merge request open", () => { | ||
| 100 | const batch = expectSuccess(decodeMergeRequestListJson(listJson([{ state: "locked" }]))); | ||
| 101 | |||
| 102 | expect(batch.items[0]?.state).toBe("open"); | ||
| 103 | }); | ||
| 104 | |||
| 105 | it("reads the legacy draft flag", () => { | ||
| 106 | const batch = expectSuccess(decodeMergeRequestListJson(listJson([{ work_in_progress: true }]))); | ||
| 107 | |||
| 108 | expect(batch.items[0]?.isDraft).toBe(true); | ||
| 109 | }); | ||
| 110 | |||
| 111 | it("calls a conflicted merge request conflicting even while the merge check is pending", () => { | ||
| 112 | const batch = expectSuccess( | ||
| 113 | decodeMergeRequestListJson(listJson([{ merge_status: "checking", has_conflicts: true }])), | ||
| 114 | ); | ||
| 115 | |||
| 116 | expect(batch.items[0]?.mergeability).toBe("conflicting"); | ||
| 117 | }); | ||
| 118 | |||
| 119 | it("leaves an unfinished merge check unknown", () => { | ||
| 120 | const batch = expectSuccess( | ||
| 121 | decodeMergeRequestListJson(listJson([{ merge_status: "checking" }])), | ||
| 122 | ); | ||
| 123 | |||
| 124 | expect(batch.items[0]?.mergeability).toBe("unknown"); | ||
| 125 | }); | ||
| 126 | |||
| 127 | it("skips a malformed row but still counts it, so paging does not stop early", () => { | ||
| 128 | const batch = expectSuccess( | ||
| 129 | decodeMergeRequestListJson( | ||
| 130 | JSON.stringify([{ iid: "not a number" }, ...JSON.parse(listJson([{}]))]), | ||
| 131 | ), | ||
| 132 | ); | ||
| 133 | |||
| 134 | expect(batch.items).toHaveLength(1); | ||
| 135 | expect(batch.rawIndexes).toEqual([1]); | ||
| 136 | expect(batch.rawCount).toBe(2); | ||
| 137 | }); | ||
| 138 | }); | ||
| 139 | |||
| 140 | describe("decodeMergeRequestDetailJson", () => { | ||
| 141 | it("reads the description, file count and pipeline", () => { | ||
| 142 | const detail = expectSuccess( | ||
| 143 | decodeMergeRequestDetailJson( | ||
| 144 | detailJson({ | ||
| 145 | description: "Ships the page.", | ||
| 146 | changes_count: "3", | ||
| 147 | reviewers: [{ username: "julius", name: "Julius" }], | ||
| 148 | head_pipeline: { | ||
| 149 | status: "success", | ||
| 150 | web_url: "https://gitlab.com/acme/web/-/pipelines/9", | ||
| 151 | source: "merge_request_event", | ||
| 152 | }, | ||
| 153 | }), | ||
| 154 | ), | ||
| 155 | ); | ||
| 156 | |||
| 157 | expect(detail.body).toBe("Ships the page."); | ||
| 158 | expect(detail.changedFiles).toBe(3); | ||
| 159 | expect(detail.reviewers).toEqual([{ login: "julius", name: "Julius", avatarUrl: null }]); | ||
| 160 | expect(detail.checks).toEqual([ | ||
| 161 | { | ||
| 162 | name: "Pipeline", | ||
| 163 | status: "success", | ||
| 164 | description: "merge_request_event", | ||
| 165 | url: "https://gitlab.com/acme/web/-/pipelines/9", | ||
| 166 | }, | ||
| 167 | ]); | ||
| 168 | }); | ||
| 169 | |||
| 170 | it("reads an uncounted change set as its floor", () => { | ||
| 171 | const detail = expectSuccess( | ||
| 172 | decodeMergeRequestDetailJson(detailJson({ changes_count: "1000+" })), | ||
| 173 | ); | ||
| 174 | |||
| 175 | expect(detail.changedFiles).toBe(1000); | ||
| 176 | }); | ||
| 177 | |||
| 178 | it("falls back to no file count when GitLab omits one", () => { | ||
| 179 | const detail = expectSuccess(decodeMergeRequestDetailJson(detailJson({}))); | ||
| 180 | |||
| 181 | expect(detail.changedFiles).toBe(0); | ||
| 182 | }); | ||
| 183 | |||
| 184 | it("reads either auto-merge field, and says nothing where GitLab named neither", () => { | ||
| 185 | const armed = (entry: Record<string, unknown>) => | ||
| 186 | expectSuccess(decodeMergeRequestDetailJson(detailJson(entry))).autoMergeEnabled; | ||
| 187 | |||
| 188 | expect(armed({ merge_when_pipeline_succeeds: true })).toBe(true); | ||
| 189 | // The newer name for the same fact, which older GitLab installs do not send. | ||
| 190 | expect(armed({ auto_merge_enabled: true })).toBe(true); | ||
| 191 | expect(armed({ merge_when_pipeline_succeeds: false })).toBe(false); | ||
| 192 | // Absent is GitLab not saying, which the page must not read as "not armed". | ||
| 193 | expect(armed({})).toBeUndefined(); | ||
| 194 | }); | ||
| 195 | |||
| 196 | it("keeps the squash choice stored with auto-merge", () => { | ||
| 197 | expect( | ||
| 198 | expectSuccess( | ||
| 199 | decodeMergeRequestDetailJson( | ||
| 200 | detailJson({ auto_merge_enabled: true, squash_on_merge: true }), | ||
| 201 | ), | ||
| 202 | ), | ||
| 203 | ).toMatchObject({ autoMergeEnabled: true, autoMergeMethod: "squash" }); | ||
| 204 | |||
| 205 | expect( | ||
| 206 | expectSuccess( | ||
| 207 | decodeMergeRequestDetailJson( | ||
| 208 | detailJson({ | ||
| 209 | auto_merge_enabled: true, | ||
| 210 | squash: true, | ||
| 211 | squash_on_merge: false, | ||
| 212 | }), | ||
| 213 | ), | ||
| 214 | ).autoMergeMethod, | ||
| 215 | ).toBeUndefined(); | ||
| 216 | }); | ||
| 217 | |||
| 218 | it("keeps a divergence GitLab did not count apart from a divergence of none", () => { | ||
| 219 | const behind = (entry: Record<string, unknown>) => | ||
| 220 | expectSuccess(decodeMergeRequestDetailJson(detailJson(entry))).divergedCommits; | ||
| 221 | |||
| 222 | expect(behind({ diverged_commits_count: 3 })).toBe(3); | ||
| 223 | // Counted and found level, which is the one answer that entitles the page to say so. | ||
| 224 | expect(behind({ diverged_commits_count: 0 })).toBe(0); | ||
| 225 | // An install that does not answer, and a null where the answer would have gone, are both | ||
| 226 | // silence: reading either as zero would tell a stale branch it is current. | ||
| 227 | expect(behind({})).toBeUndefined(); | ||
| 228 | expect(behind({ diverged_commits_count: null })).toBeUndefined(); | ||
| 229 | }); | ||
| 230 | |||
| 231 | it("maps a pipeline waiting on a person to neutral, not failure", () => { | ||
| 232 | const detail = expectSuccess( | ||
| 233 | decodeMergeRequestDetailJson(detailJson({ head_pipeline: { status: "manual" } })), | ||
| 234 | ); | ||
| 235 | |||
| 236 | expect(detail.checks[0]?.status).toBe("neutral"); | ||
| 237 | }); | ||
| 238 | }); | ||
| 239 | |||
| 240 | describe("decodeViewerJson", () => { | ||
| 241 | it("reads the signed-in username", () => { | ||
| 242 | expect(expectSuccess(decodeViewerJson(JSON.stringify({ username: "bilal" })))).toBe("bilal"); | ||
| 243 | }); | ||
| 244 | |||
| 245 | it("returns nothing when the account has no username", () => { | ||
| 246 | expect(expectSuccess(decodeViewerJson(JSON.stringify({ username: " " })))).toBeNull(); | ||
| 247 | }); | ||
| 248 | }); | ||
| 249 | |||
| 250 | describe("decodeNotesJson", () => { | ||
| 251 | it("keeps comments and drops GitLab's own activity notes", () => { | ||
| 252 | const notes = expectSuccess( | ||
| 253 | decodeNotesJson( | ||
| 254 | JSON.stringify([ | ||
| 255 | { | ||
| 256 | id: 1, | ||
| 257 | body: "assigned to @bilal", | ||
| 258 | system: true, | ||
| 259 | created_at: "2026-07-01T00:00:00Z", | ||
| 260 | }, | ||
| 261 | { | ||
| 262 | id: 2, | ||
| 263 | body: "Looks good.", | ||
| 264 | author: { username: "julius" }, | ||
| 265 | created_at: "2026-07-02T00:00:00Z", | ||
| 266 | }, | ||
| 267 | { id: 3, body: " ", created_at: "2026-07-03T00:00:00Z" }, | ||
| 268 | ]), | ||
| 269 | ), | ||
| 270 | ); | ||
| 271 | |||
| 272 | expect(notes.comments).toHaveLength(1); | ||
| 273 | expect(notes.comments[0]).toMatchObject({ | ||
| 274 | id: "2", | ||
| 275 | kind: "issue-comment", | ||
| 276 | body: "Looks good.", | ||
| 277 | }); | ||
| 278 | // The raw count keeps the dropped notes visible to the caller, which needs them to page. | ||
| 279 | expect(notes.rawCount).toBe(3); | ||
| 280 | }); | ||
| 281 | |||
| 282 | it("reads a line note as a review comment on its file", () => { | ||
| 283 | const notes = expectSuccess( | ||
| 284 | decodeNotesJson( | ||
| 285 | JSON.stringify([ | ||
| 286 | { | ||
| 287 | id: 7, | ||
| 288 | type: "DiffNote", | ||
| 289 | body: "Rename this.", | ||
| 290 | created_at: "2026-07-02T00:00:00Z", | ||
| 291 | position: { new_path: "src/app.ts", old_path: "src/old.ts" }, | ||
| 292 | }, | ||
| 293 | ]), | ||
| 294 | ), | ||
| 295 | ); | ||
| 296 | |||
| 297 | expect(notes.comments[0]).toMatchObject({ kind: "review-comment", path: "src/app.ts" }); | ||
| 298 | }); | ||
| 299 | |||
| 300 | it("falls back to the old path for a note on a deleted line", () => { | ||
| 301 | const notes = expectSuccess( | ||
| 302 | decodeNotesJson( | ||
| 303 | JSON.stringify([ | ||
| 304 | { | ||
| 305 | id: 8, | ||
| 306 | type: "DiffNote", | ||
| 307 | body: "Gone.", | ||
| 308 | created_at: "2026-07-02T00:00:00Z", | ||
| 309 | position: { new_path: null, old_path: "src/old.ts" }, | ||
| 310 | }, | ||
| 311 | ]), | ||
| 312 | ), | ||
| 313 | ); | ||
| 314 | |||
| 315 | expect(notes.comments[0]?.path).toBe("src/old.ts"); | ||
| 316 | }); | ||
| 317 | }); | ||
| 318 | |||
| 319 | describe("decodeCommitsJson", () => { | ||
| 320 | it("returns commits oldest first", () => { | ||
| 321 | const commits = expectSuccess( | ||
| 322 | decodeCommitsJson( | ||
| 323 | JSON.stringify([ | ||
| 324 | { id: "bbb", title: "second", committed_date: "2026-07-02T00:00:00Z" }, | ||
| 325 | { id: "aaa", title: "first", committed_date: "2026-07-01T00:00:00Z" }, | ||
| 326 | ]), | ||
| 327 | ), | ||
| 328 | ); | ||
| 329 | |||
| 330 | expect(commits.map((commit) => commit.oid)).toEqual(["aaa", "bbb"]); | ||
| 331 | }); | ||
| 332 | |||
| 333 | it("skips commits whose id is empty", () => { | ||
| 334 | const commits = expectSuccess( | ||
| 335 | decodeCommitsJson( | ||
| 336 | JSON.stringify([ | ||
| 337 | { id: " ", title: "invalid", committed_date: "2026-07-02T00:00:00Z" }, | ||
| 338 | { id: "aaa", committed_date: "2026-07-01T00:00:00Z" }, | ||
| 339 | ]), | ||
| 340 | ), | ||
| 341 | ); | ||
| 342 | |||
| 343 | expect(commits.map((commit) => commit.oid)).toEqual(["aaa"]); | ||
| 344 | }); | ||
| 345 | |||
| 346 | it("falls back to the creation timestamp when there is no commit date", () => { | ||
| 347 | const commits = expectSuccess( | ||
| 348 | decodeCommitsJson( | ||
| 349 | JSON.stringify([ | ||
| 350 | { | ||
| 351 | id: "aaa", | ||
| 352 | created_at: "2026-07-01T00:00:00+08:00", | ||
| 353 | author_name: "Ada Lovelace", | ||
| 354 | author_email: "ada@example.com", | ||
| 355 | }, | ||
| 356 | ]), | ||
| 357 | ), | ||
| 358 | ); | ||
| 359 | |||
| 360 | expect(commits[0]).toMatchObject({ | ||
| 361 | oid: "aaa", | ||
| 362 | committedDate: "2026-07-01T00:00:00+08:00", | ||
| 363 | authors: [{ login: "Ada Lovelace", name: "Ada Lovelace", avatarUrl: null }], | ||
| 364 | }); | ||
| 365 | }); | ||
| 366 | |||
| 367 | it("carries commit additions and deletions when GitLab returns stats", () => { | ||
| 368 | const commits = expectSuccess( | ||
| 369 | decodeCommitsJson( | ||
| 370 | JSON.stringify([ | ||
| 371 | { | ||
| 372 | id: "aaa", | ||
| 373 | committed_date: "2026-07-01T00:00:00Z", | ||
| 374 | stats: { additions: 21, deletions: 8, total: 29 }, | ||
| 375 | }, | ||
| 376 | ]), | ||
| 377 | ), | ||
| 378 | ); | ||
| 379 | |||
| 380 | expect(commits[0]).toMatchObject({ additions: 21, deletions: 8 }); | ||
| 381 | }); | ||
| 382 | }); | ||
| 383 | |||
| 384 | describe("decodeMergeRequestDiffsJson", () => { | ||
| 385 | it("quotes literal backslashes without interpreting them as escapes", () => { | ||
| 386 | const result = expectSuccess( | ||
| 387 | decodeMergeRequestDiffsJson( | ||
| 388 | JSON.stringify([ | ||
| 389 | { | ||
| 390 | old_path: String.raw`src\notes.ts`, | ||
| 391 | new_path: String.raw`src\notes.ts`, | ||
| 392 | diff: "@@ -1 +1 @@\n-old\n+new", | ||
| 393 | }, | ||
| 394 | ]), | ||
| 395 | ), | ||
| 396 | ); | ||
| 397 | |||
| 398 | expect(result.patch).toBe( | ||
| 399 | [ | ||
| 400 | String.raw`diff --git "a/src\\notes.ts" "b/src\\notes.ts"`, | ||
| 401 | String.raw`--- "a/src\\notes.ts"`, | ||
| 402 | String.raw`+++ "b/src\\notes.ts"`, | ||
| 403 | "@@ -1 +1 @@", | ||
| 404 | "-old", | ||
| 405 | "+new", | ||
| 406 | "", | ||
| 407 | ].join("\n"), | ||
| 408 | ); | ||
| 409 | }); | ||
| 410 | |||
| 411 | it("preserves spaces and literal backslashes in both rename paths", () => { | ||
| 412 | const result = expectSuccess( | ||
| 413 | decodeMergeRequestDiffsJson( | ||
| 414 | JSON.stringify([ | ||
| 415 | { | ||
| 416 | old_path: String.raw` old\name.ts `, | ||
| 417 | new_path: String.raw` new\name.ts `, | ||
| 418 | renamed_file: true, | ||
| 419 | diff: "", | ||
| 420 | }, | ||
| 421 | ]), | ||
| 422 | ), | ||
| 423 | ); | ||
| 424 | |||
| 425 | expect(result.patch).toBe( | ||
| 426 | [ | ||
| 427 | String.raw`diff --git "a/ old\\name.ts " "b/ new\\name.ts "`, | ||
| 428 | String.raw`rename from " old\\name.ts "`, | ||
| 429 | String.raw`rename to " new\\name.ts "`, | ||
| 430 | String.raw`--- "a/ old\\name.ts "`, | ||
| 431 | String.raw`+++ "b/ new\\name.ts "`, | ||
| 432 | ].join("\n"), | ||
| 433 | ); | ||
| 434 | }); | ||
| 435 | |||
| 436 | it("assembles a unified patch GitLab does not return", () => { | ||
| 437 | const result = expectSuccess( | ||
| 438 | decodeMergeRequestDiffsJson( | ||
| 439 | JSON.stringify([ | ||
| 440 | { | ||
| 441 | old_path: "src/app.ts", | ||
| 442 | new_path: "src/app.ts", | ||
| 443 | diff: "@@ -1 +1 @@\n-old\n+new\n", | ||
| 444 | }, | ||
| 445 | ]), | ||
| 446 | ), | ||
| 447 | ); | ||
| 448 | |||
| 449 | expect(result.patch).toBe( | ||
| 450 | [ | ||
| 451 | "diff --git a/src/app.ts b/src/app.ts", | ||
| 452 | "--- a/src/app.ts", | ||
| 453 | "+++ b/src/app.ts", | ||
| 454 | "@@ -1 +1 @@", | ||
| 455 | "-old", | ||
| 456 | "+new", | ||
| 457 | "", | ||
| 458 | ].join("\n"), | ||
| 459 | ); | ||
| 460 | expect(result.truncated).toBe(false); | ||
| 461 | }); | ||
| 462 | |||
| 463 | it("points a new file at /dev/null on the left and a deleted file on the right", () => { | ||
| 464 | const result = expectSuccess( | ||
| 465 | decodeMergeRequestDiffsJson( | ||
| 466 | JSON.stringify([ | ||
| 467 | { | ||
| 468 | old_path: "src/new.ts", | ||
| 469 | new_path: "src/new.ts", | ||
| 470 | new_file: true, | ||
| 471 | b_mode: "100755", | ||
| 472 | diff: "@@ -0,0 +1 @@\n+hello\n", | ||
| 473 | }, | ||
| 474 | { | ||
| 475 | old_path: "src/gone.ts", | ||
| 476 | new_path: "src/gone.ts", | ||
| 477 | deleted_file: true, | ||
| 478 | diff: "@@ -1 +0,0 @@\n-bye\n", | ||
| 479 | }, | ||
| 480 | ]), | ||
| 481 | ), | ||
| 482 | ); | ||
| 483 | |||
| 484 | expect(result.patch).toContain("new file mode 100755"); | ||
| 485 | expect(result.patch).toContain("--- /dev/null"); | ||
| 486 | expect(result.patch).toContain("deleted file mode 100644"); | ||
| 487 | expect(result.patch).toContain("+++ /dev/null"); | ||
| 488 | }); | ||
| 489 | |||
| 490 | it("records a rename so the patch names both paths", () => { | ||
| 491 | const result = expectSuccess( | ||
| 492 | decodeMergeRequestDiffsJson( | ||
| 493 | JSON.stringify([ | ||
| 494 | { old_path: "src/old.ts", new_path: "src/new.ts", renamed_file: true, diff: "" }, | ||
| 495 | ]), | ||
| 496 | ), | ||
| 497 | ); | ||
| 498 | |||
| 499 | expect(result.patch).toContain("rename from src/old.ts"); | ||
| 500 | expect(result.patch).toContain("rename to src/new.ts"); | ||
| 501 | }); | ||
| 502 | |||
| 503 | it("reports truncation for a file GitLab refused to inline", () => { | ||
| 504 | const result = expectSuccess( | ||
| 505 | decodeMergeRequestDiffsJson( | ||
| 506 | JSON.stringify([{ old_path: "big.bin", new_path: "big.bin", diff: "", too_large: true }]), | ||
| 507 | ), | ||
| 508 | ); | ||
| 509 | |||
| 510 | expect(result.truncated).toBe(true); | ||
| 511 | expect(result.patch).toContain("diff --git a/big.bin b/big.bin"); | ||
| 512 | }); | ||
| 513 | |||
| 514 | it("reports how many files GitLab returned, so the caller can page", () => { | ||
| 515 | const result = expectSuccess( | ||
| 516 | decodeMergeRequestDiffsJson( | ||
| 517 | JSON.stringify( | ||
| 518 | Array.from({ length: 3 }, (_, index) => ({ | ||
| 519 | old_path: `src/${index}.ts`, | ||
| 520 | new_path: `src/${index}.ts`, | ||
| 521 | diff: "@@ -1 +1 @@\n-a\n+b\n", | ||
| 522 | })), | ||
| 523 | ), | ||
| 524 | ), | ||
| 525 | ); | ||
| 526 | |||
| 527 | expect(result.rawCount).toBe(3); | ||
| 528 | expect(result.truncated).toBe(false); | ||
| 529 | expect(result.patch).toContain("src/2.ts"); | ||
| 530 | }); | ||
| 531 | |||
| 532 | it("fails when GitLab did not return a list", () => { | ||
| 533 | expect(Result.isFailure(decodeMergeRequestDiffsJson('{"message":"404"}'))).toBe(true); | ||
| 534 | }); | ||
| 535 | }); | ||
| 536 | |||
| 537 | describe("merge request viewer fields", () => { | ||
| 538 | it("carries GitLab's own answer for whether this viewer can merge", () => { | ||
| 539 | expect( | ||
| 540 | expectSuccess(decodeMergeRequestDetailJson(detailJson({ user: { can_merge: false } }))) | ||
| 541 | .viewerCanMerge, | ||
| 542 | ).toBe(false); | ||
| 543 | expect( | ||
| 544 | expectSuccess(decodeMergeRequestDetailJson(detailJson({ user: { can_merge: true } }))) | ||
| 545 | .viewerCanMerge, | ||
| 546 | ).toBe(true); | ||
| 547 | }); | ||
| 548 | |||
| 549 | it("leaves merging permitted where GitLab answered without the field", () => { | ||
| 550 | // Only the single-merge-request endpoint carries `user`, and an install that answers without | ||
| 551 | // it has said nothing about the viewer rather than said no. | ||
| 552 | expect(expectSuccess(decodeMergeRequestDetailJson(detailJson({}))).viewerCanMerge).toBe(true); | ||
| 553 | expect( | ||
| 554 | expectSuccess(decodeMergeRequestDetailJson(detailJson({ user: null }))).viewerCanMerge, | ||
| 555 | ).toBe(true); | ||
| 556 | }); | ||
| 557 | }); | ||
| 558 | |||
| 559 | describe("decodeAwardEmojiJson", () => { | ||
| 560 | it("reads MR-level awards, keys per-note awards by the REST id inside their gid, and ignores an award outside the eight", () => { | ||
| 561 | const result = expectSuccess( | ||
| 562 | decodeAwardEmojiJson( | ||
| 563 | JSON.stringify({ | ||
| 564 | data: { | ||
| 565 | currentUser: { username: "bilal" }, | ||
| 566 | project: { | ||
| 567 | mergeRequest: { | ||
| 568 | awardEmoji: { | ||
| 569 | nodes: [ | ||
| 570 | { name: "thumbsup", user: { username: "bilal" } }, | ||
| 571 | { name: "thumbsup", user: { username: "julius" } }, | ||
| 572 | ], | ||
| 573 | }, | ||
| 574 | notes: { | ||
| 575 | pageInfo: { hasNextPage: false, endCursor: null }, | ||
| 576 | nodes: [ | ||
| 577 | { | ||
| 578 | id: "gid://gitlab/DiffNote/42", | ||
| 579 | awardEmoji: { nodes: [{ name: "heart", user: { username: "julius" } }] }, | ||
| 580 | }, | ||
| 581 | { | ||
| 582 | id: "gid://gitlab/Note/7", | ||
| 583 | // Not one of the eight the contract carries. | ||
| 584 | awardEmoji: { | ||
| 585 | nodes: [{ name: "partyparrot", user: { username: "bilal" } }], | ||
| 586 | }, | ||
| 587 | }, | ||
| 588 | ], | ||
| 589 | }, | ||
| 590 | }, | ||
| 591 | }, | ||
| 592 | }, | ||
| 593 | }), | ||
| 594 | ), | ||
| 595 | ); | ||
| 596 | |||
| 597 | // `bilal` is `currentUser`, so the group they are in reads back as reacted, but their own | ||
| 598 | // username is left out of `actors` — the page names them "You" instead — while `count` still | ||
| 599 | // counts them; `julius` alone does not turn a group's own `viewerHasReacted` on. | ||
| 600 | expect(result.reactions).toEqual([ | ||
| 601 | { content: "thumbs-up", count: 2, actors: ["julius"], viewerHasReacted: true }, | ||
| 602 | ]); | ||
| 603 | // Note 7's only award named nobody the eight recognise, so it carries no reactions and is | ||
| 604 | // left out of the map rather than kept empty. | ||
| 605 | expect([...result.reactionsByNoteId]).toEqual([ | ||
| 606 | ["42", [{ content: "heart", count: 1, actors: ["julius"], viewerHasReacted: false }]], | ||
| 607 | ]); | ||
| 608 | expect(result.nextCursor).toBeNull(); | ||
| 609 | }); | ||
| 610 | |||
| 611 | it("hands back a cursor when GitLab has more notes to page", () => { | ||
| 612 | const result = expectSuccess( | ||
| 613 | decodeAwardEmojiJson( | ||
| 614 | JSON.stringify({ | ||
| 615 | data: { | ||
| 616 | currentUser: null, | ||
| 617 | project: { | ||
| 618 | mergeRequest: { | ||
| 619 | awardEmoji: { nodes: [] }, | ||
| 620 | notes: { pageInfo: { hasNextPage: true, endCursor: "Y3Vyc29yOjE" }, nodes: [] }, | ||
| 621 | }, | ||
| 622 | }, | ||
| 623 | }, | ||
| 624 | }), | ||
| 625 | ), | ||
| 626 | ); | ||
| 627 | |||
| 628 | expect(result.nextCursor).toBe("Y3Vyc29yOjE"); | ||
| 629 | }); | ||
| 630 | |||
| 631 | it("matches the viewer's username case-insensitively, since GitLab is not consistent about case", () => { | ||
| 632 | const result = expectSuccess( | ||
| 633 | decodeAwardEmojiJson( | ||
| 634 | JSON.stringify({ | ||
| 635 | data: { | ||
| 636 | currentUser: { username: "Bilal" }, | ||
| 637 | project: { | ||
| 638 | mergeRequest: { | ||
| 639 | awardEmoji: { | ||
| 640 | nodes: [ | ||
| 641 | { name: "heart", user: { username: "bilal" } }, | ||
| 642 | { name: "heart", user: { username: "julius" } }, | ||
| 643 | ], | ||
| 644 | }, | ||
| 645 | notes: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, | ||
| 646 | }, | ||
| 647 | }, | ||
| 648 | }, | ||
| 649 | }), | ||
| 650 | ), | ||
| 651 | ); | ||
| 652 | |||
| 653 | expect(result.reactions).toEqual([ | ||
| 654 | { content: "heart", count: 2, actors: ["julius"], viewerHasReacted: true }, | ||
| 655 | ]); | ||
| 656 | }); | ||
| 657 | }); | ||
| 658 | |||
| 659 | describe("decodeOwnAwardIdJson", () => { | ||
| 660 | const awards = JSON.stringify([ | ||
| 661 | { id: 101, name: "thumbsup", user: { username: "julius" } }, | ||
| 662 | { id: 102, name: "thumbsup", user: { username: "bilal" } }, | ||
| 663 | ]); | ||
| 664 | |||
| 665 | it("finds the reader's own award of that name, which is the one a removal deletes", () => { | ||
| 666 | expect( | ||
| 667 | expectSuccess(decodeOwnAwardIdJson(awards, { content: "thumbs-up", viewer: "bilal" })), | ||
| 668 | ).toBe(102); | ||
| 669 | }); | ||
| 670 | |||
| 671 | it("returns nothing where the reader has no award of that name", () => { | ||
| 672 | expect( | ||
| 673 | expectSuccess(decodeOwnAwardIdJson(awards, { content: "heart", viewer: "bilal" })), | ||
| 674 | ).toBeNull(); | ||
| 675 | }); | ||
| 676 | }); | ||
| 677 | |||
| 678 | describe("gitLabAwardName", () => { | ||
| 679 | it("spells the contents whose GitLab award name is not their own kebab-case", () => { | ||
| 680 | expect(gitLabAwardName("thumbs-up")).toBe("thumbsup"); | ||
| 681 | expect(gitLabAwardName("laugh")).toBe("laughing"); | ||
| 682 | expect(gitLabAwardName("hooray")).toBe("tada"); | ||
| 683 | }); | ||
| 684 | }); | ||
| 685 | |||
| 686 | describe("decodeRepositoryBlobsJson", () => { | ||
| 687 | /** Null is the query going unanswered, which these cases are not about. */ | ||
| 688 | function expectBlobs(result: Result.Result<ReadonlyMap<string, string> | null, unknown>) { | ||
| 689 | const blobs = expectSuccess(result); | ||
| 690 | expect(blobs).not.toBe(null); | ||
| 691 | if (blobs === null) throw new Error("expected an answered blobs query"); | ||
| 692 | return blobs; | ||
| 693 | } | ||
| 694 | |||
| 695 | it("reads a blob id per path", () => { | ||
| 696 | const blobs = expectBlobs( | ||
| 697 | decodeRepositoryBlobsJson( | ||
| 698 | JSON.stringify({ | ||
| 699 | data: { | ||
| 700 | project: { | ||
| 701 | repository: { | ||
| 702 | blobs: { | ||
| 703 | nodes: [ | ||
| 704 | { path: "src/a.ts", oid: "aaa111" }, | ||
| 705 | { path: "src/b.ts", oid: "bbb222" }, | ||
| 706 | ], | ||
| 707 | }, | ||
| 708 | }, | ||
| 709 | }, | ||
| 710 | }, | ||
| 711 | }), | ||
| 712 | ), | ||
| 713 | ); | ||
| 714 | |||
| 715 | expect([...blobs]).toEqual([ | ||
| 716 | ["src/a.ts", "aaa111"], | ||
| 717 | ["src/b.ts", "bbb222"], | ||
| 718 | ]); | ||
| 719 | }); | ||
| 720 | |||
| 721 | it("leaves out a node missing either half, which names no version", () => { | ||
| 722 | const blobs = expectBlobs( | ||
| 723 | decodeRepositoryBlobsJson( | ||
| 724 | JSON.stringify({ | ||
| 725 | data: { | ||
| 726 | project: { | ||
| 727 | repository: { | ||
| 728 | blobs: { | ||
| 729 | nodes: [ | ||
| 730 | { path: "src/a.ts", oid: null }, | ||
| 731 | { path: null, oid: "bbb222" }, | ||
| 732 | null, | ||
| 733 | { path: "src/c.ts", oid: "ccc333" }, | ||
| 734 | ], | ||
| 735 | }, | ||
| 736 | }, | ||
| 737 | }, | ||
| 738 | }, | ||
| 739 | }), | ||
| 740 | ), | ||
| 741 | ); | ||
| 742 | |||
| 743 | expect([...blobs]).toEqual([["src/c.ts", "ccc333"]]); | ||
| 744 | }); | ||
| 745 | |||
| 746 | it("keys a blob by the path the host spelled, spaces and all", () => { | ||
| 747 | // A leading or trailing space is a legal part of a file's name. Trimmed here, the id lands | ||
| 748 | // under a key the asked-for path is not spelled with, and the caller fills that path in as | ||
| 749 | // the empty revision: a mark on the file then never compares against the real head blob. | ||
| 750 | const blobs = expectBlobs( | ||
| 751 | decodeRepositoryBlobsJson( | ||
| 752 | JSON.stringify({ | ||
| 753 | data: { | ||
| 754 | project: { | ||
| 755 | repository: { | ||
| 756 | blobs: { | ||
| 757 | nodes: [ | ||
| 758 | { path: " leading.ts", oid: "aaa111" }, | ||
| 759 | { path: "trailing.ts ", oid: "bbb222" }, | ||
| 760 | { path: " ", oid: "ccc333" }, | ||
| 761 | { path: "", oid: "ddd444" }, | ||
| 762 | ], | ||
| 763 | }, | ||
| 764 | }, | ||
| 765 | }, | ||
| 766 | }, | ||
| 767 | }), | ||
| 768 | ), | ||
| 769 | ); | ||
| 770 | |||
| 771 | expect([...blobs]).toEqual([ | ||
| 772 | [" leading.ts", "aaa111"], | ||
| 773 | ["trailing.ts ", "bbb222"], | ||
| 774 | // A name that is only spaces is one Git carries too, so it is a path like any other. | ||
| 775 | [" ", "ccc333"], | ||
| 776 | ]); | ||
| 777 | }); | ||
| 778 | |||
| 779 | it("tells a project the reader cannot see from a revision with none of the files", () => { | ||
| 780 | // Null is the query going unanswered. Read as an empty answer it would say the head has none | ||
| 781 | // of the asked-for files, which reports every file a reader has cleared as changed. | ||
| 782 | expect( | ||
| 783 | expectSuccess(decodeRepositoryBlobsJson(JSON.stringify({ data: { project: null } }))), | ||
| 784 | ).toBe(null); | ||
| 785 | expect( | ||
| 786 | expectSuccess( | ||
| 787 | decodeRepositoryBlobsJson(JSON.stringify({ data: { project: { repository: null } } })), | ||
| 788 | ), | ||
| 789 | ).toBe(null); | ||
| 790 | expect( | ||
| 791 | expectSuccess( | ||
| 792 | decodeRepositoryBlobsJson( | ||
| 793 | JSON.stringify({ data: { project: { repository: { blobs: { nodes: [] } } } } }), | ||
| 794 | ), | ||
| 795 | ), | ||
| 796 | ).toEqual(new Map()); | ||
| 797 | }); | ||
| 798 | |||
| 799 | it("fails on output that is not the query's shape", () => { | ||
| 800 | expect(Result.isSuccess(decodeRepositoryBlobsJson("not json"))).toBe(false); | ||
| 801 | expect(Result.isSuccess(decodeRepositoryBlobsJson(JSON.stringify({ errors: [] })))).toBe(false); | ||
| 802 | }); | ||
| 803 | }); | ||
apps/server/src/pullRequest/gitLabMergeRequestJson.ts deleted-1032| ... | @@ -1,1032 +0,0 @@ | ||
| 1 | import * as Cause from "effect/Cause"; | ||
| 2 | import * as Exit from "effect/Exit"; | ||
| 3 | import * as Result from "effect/Result"; | ||
| 4 | import * as Schema from "effect/Schema"; | ||
| 5 | import type { | ||
| 6 | PullRequestActor, | ||
| 7 | PullRequestCheck, | ||
| 8 | PullRequestCheckStatus, | ||
| 9 | PullRequestComment, | ||
| 10 | PullRequestCommit, | ||
| 11 | PullRequestLabel, | ||
| 12 | PullRequestMergeability, | ||
| 13 | PullRequestMergeCapabilities, | ||
| 14 | PullRequestMergeMethod, | ||
| 15 | PullRequestReaction, | ||
| 16 | PullRequestReactionContent, | ||
| 17 | PullRequestReviewThread, | ||
| 18 | PullRequestReviewerCandidate, | ||
| 19 | PullRequestState, | ||
| 20 | } from "@t3tools/contracts"; | ||
| 21 | import { TrimmedNonEmptyString } from "@t3tools/contracts"; | ||
| 22 | import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; | ||
| 23 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; | ||
| 24 | |||
| 25 | /** | ||
| 26 | * GitLab's REST enums are decoded as plain strings and normalized here: a GitLab release that | ||
| 27 | * adds a pipeline status or a merge status must not fail the whole payload. | ||
| 28 | */ | ||
| 29 | const RawUserSchema = Schema.Struct({ | ||
| 30 | /** | ||
| 31 | * GitLab writes a merge request's reviewers as numeric ids and takes no usernames there, so the | ||
| 32 | * id is carried alongside the handle rather than looked up again when a review is asked for. | ||
| 33 | */ | ||
| 34 | id: Schema.optional(Schema.Int), | ||
| 35 | username: Schema.String, | ||
| 36 | name: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 37 | avatar_url: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 38 | }); | ||
| 39 | |||
| 40 | const RawPipelineSchema = Schema.Struct({ | ||
| 41 | status: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 42 | web_url: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 43 | source: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 44 | }); | ||
| 45 | |||
| 46 | const RawMergeRequestSchema = Schema.Struct({ | ||
| 47 | iid: Schema.Int, | ||
| 48 | title: Schema.String, | ||
| 49 | web_url: Schema.String, | ||
| 50 | description: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 51 | author: Schema.optional(Schema.NullOr(RawUserSchema)), | ||
| 52 | source_branch: Schema.String, | ||
| 53 | target_branch: Schema.String, | ||
| 54 | state: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 55 | draft: Schema.optional(Schema.Boolean), | ||
| 56 | work_in_progress: Schema.optional(Schema.Boolean), | ||
| 57 | merge_status: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 58 | has_conflicts: Schema.optional(Schema.NullOr(Schema.Boolean)), | ||
| 59 | created_at: Schema.String, | ||
| 60 | updated_at: Schema.String, | ||
| 61 | merged_at: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 62 | closed_at: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 63 | reviewers: Schema.optional(Schema.NullOr(Schema.Array(RawUserSchema))), | ||
| 64 | labels: Schema.optional(Schema.NullOr(Schema.Array(Schema.String))), | ||
| 65 | // A string, and "1000+" past GitLab's counting limit, so it is parsed rather than decoded. | ||
| 66 | changes_count: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 67 | head_pipeline: Schema.optional(Schema.NullOr(RawPipelineSchema)), | ||
| 68 | /** | ||
| 69 | * What the requesting account may do, which only the single-merge-request endpoint carries. | ||
| 70 | * GitLab answers `can_merge` for this viewer against this merge request, so it already accounts | ||
| 71 | * for the role, the approval rules and a protected target branch — none of which a project's | ||
| 72 | * access level on its own would tell apart. | ||
| 73 | */ | ||
| 74 | user: Schema.optional( | ||
| 75 | Schema.NullOr(Schema.Struct({ can_merge: Schema.optional(Schema.Boolean) })), | ||
| 76 | ), | ||
| 77 | /** | ||
| 78 | * Whether GitLab is holding this merge request to merge it once its pipeline goes green. | ||
| 79 | * `merge_when_pipeline_succeeds` is the field every version answers with; newer ones also | ||
| 80 | * carry `auto_merge_enabled`, which is the same fact under the name GitLab settled on, so | ||
| 81 | * either one saying yes is a yes. | ||
| 82 | */ | ||
| 83 | merge_when_pipeline_succeeds: Schema.optional(Schema.NullOr(Schema.Boolean)), | ||
| 84 | auto_merge_enabled: Schema.optional(Schema.NullOr(Schema.Boolean)), | ||
| 85 | /** The merge request's stored squash choice, including project-policy overrides. */ | ||
| 86 | squash_on_merge: Schema.optional(Schema.NullOr(Schema.Boolean)), | ||
| 87 | squash: Schema.optional(Schema.NullOr(Schema.Boolean)), | ||
| 88 | /** | ||
| 89 | * How far the target branch has moved on since this one left it, which is the same number | ||
| 90 | * GitLab's own "out of date" wording counts. It costs a walk of the two branches, so GitLab | ||
| 91 | * withholds it unless `include_diverged_commits_count` asks for it, and answers it only for a | ||
| 92 | * single merge request — a list never carries it, however it is asked for. | ||
| 93 | */ | ||
| 94 | diverged_commits_count: Schema.optional(Schema.NullOr(Schema.Int)), | ||
| 95 | }); | ||
| 96 | |||
| 97 | const RawNoteSchema = Schema.Struct({ | ||
| 98 | id: Schema.Int, | ||
| 99 | body: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 100 | author: Schema.optional(Schema.NullOr(RawUserSchema)), | ||
| 101 | created_at: Schema.String, | ||
| 102 | /** True for notes GitLab writes itself ("assigned to…"), which are events, not comments. */ | ||
| 103 | system: Schema.optional(Schema.Boolean), | ||
| 104 | type: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 105 | position: Schema.optional( | ||
| 106 | Schema.NullOr( | ||
| 107 | Schema.Struct({ | ||
| 108 | new_path: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 109 | old_path: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 110 | }), | ||
| 111 | ), | ||
| 112 | ), | ||
| 113 | }); | ||
| 114 | |||
| 115 | /** | ||
| 116 | * A discussion note carrying its place in the diff, which is the shape the whole thread view | ||
| 117 | * is built from. `resolved` lives on the note rather than on the discussion: GitLab calls a | ||
| 118 | * discussion resolved once every resolvable note in it is. | ||
| 119 | */ | ||
| 120 | const RawDiscussionNoteSchema = Schema.Struct({ | ||
| 121 | id: Schema.Int, | ||
| 122 | body: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 123 | author: Schema.optional(Schema.NullOr(RawUserSchema)), | ||
| 124 | created_at: Schema.String, | ||
| 125 | system: Schema.optional(Schema.Boolean), | ||
| 126 | resolvable: Schema.optional(Schema.Boolean), | ||
| 127 | resolved: Schema.optional(Schema.NullOr(Schema.Boolean)), | ||
| 128 | position: Schema.optional( | ||
| 129 | Schema.NullOr( | ||
| 130 | Schema.Struct({ | ||
| 131 | position_type: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 132 | new_path: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 133 | old_path: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 134 | new_line: Schema.optional(Schema.NullOr(Schema.Int)), | ||
| 135 | old_line: Schema.optional(Schema.NullOr(Schema.Int)), | ||
| 136 | }), | ||
| 137 | ), | ||
| 138 | ), | ||
| 139 | }); | ||
| 140 | |||
| 141 | const RawDiscussionSchema = Schema.Struct({ | ||
| 142 | id: Schema.String, | ||
| 143 | notes: Schema.optional(Schema.NullOr(Schema.Array(RawDiscussionNoteSchema))), | ||
| 144 | }); | ||
| 145 | |||
| 146 | const RawDiffRefsSchema = Schema.Struct({ | ||
| 147 | diff_refs: Schema.optional( | ||
| 148 | Schema.NullOr( | ||
| 149 | Schema.Struct({ | ||
| 150 | base_sha: Schema.String, | ||
| 151 | head_sha: Schema.String, | ||
| 152 | start_sha: Schema.String, | ||
| 153 | }), | ||
| 154 | ), | ||
| 155 | ), | ||
| 156 | }); | ||
| 157 | |||
| 158 | const RawCommitSchema = Schema.Struct({ | ||
| 159 | id: TrimmedNonEmptyString, | ||
| 160 | title: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 161 | committed_date: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 162 | created_at: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 163 | parent_ids: Schema.optional(Schema.Array(Schema.String)), | ||
| 164 | author_name: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 165 | author_email: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 166 | stats: Schema.optional( | ||
| 167 | Schema.NullOr( | ||
| 168 | Schema.Struct({ | ||
| 169 | additions: Schema.optional(Schema.Int), | ||
| 170 | deletions: Schema.optional(Schema.Int), | ||
| 171 | }), | ||
| 172 | ), | ||
| 173 | ), | ||
| 174 | }); | ||
| 175 | |||
| 176 | const RawDiffSchema = Schema.Struct({ | ||
| 177 | old_path: Schema.String, | ||
| 178 | new_path: Schema.String, | ||
| 179 | a_mode: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 180 | b_mode: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 181 | new_file: Schema.optional(Schema.Boolean), | ||
| 182 | renamed_file: Schema.optional(Schema.Boolean), | ||
| 183 | deleted_file: Schema.optional(Schema.Boolean), | ||
| 184 | diff: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 185 | /** GitLab omits the hunks for a file it considers too large to inline. */ | ||
| 186 | too_large: Schema.optional(Schema.NullOr(Schema.Boolean)), | ||
| 187 | /** And for one it collapsed, which withholds them the same way. */ | ||
| 188 | collapsed: Schema.optional(Schema.NullOr(Schema.Boolean)), | ||
| 189 | }); | ||
| 190 | |||
| 191 | const RawViewerSchema = Schema.Struct({ | ||
| 192 | username: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 193 | }); | ||
| 194 | |||
| 195 | /** A GitLab project settles on one merge strategy plus an optional squash. */ | ||
| 196 | const RawProjectMergeSettingsSchema = Schema.Struct({ | ||
| 197 | merge_method: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 198 | squash_option: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 199 | }); | ||
| 200 | |||
| 201 | export interface GitLabMergeRequestListItem { | ||
| 202 | readonly number: number; | ||
| 203 | readonly title: string; | ||
| 204 | readonly url: string; | ||
| 205 | readonly author: PullRequestActor | null; | ||
| 206 | readonly headBranch: string; | ||
| 207 | readonly baseBranch: string; | ||
| 208 | readonly state: PullRequestState; | ||
| 209 | readonly isDraft: boolean; | ||
| 210 | readonly mergeability: PullRequestMergeability; | ||
| 211 | /** | ||
| 212 | * GitLab reports neither added nor removed lines on a merge request, so both stay zero and | ||
| 213 | * the surface omits the stat. The Code tab counts them from the patch it already fetched. | ||
| 214 | */ | ||
| 215 | readonly additions: number; | ||
| 216 | readonly deletions: number; | ||
| 217 | readonly createdAt: string; | ||
| 218 | readonly updatedAt: string; | ||
| 219 | readonly reviewRequestLogins: ReadonlyArray<string>; | ||
| 220 | readonly labels: ReadonlyArray<PullRequestLabel>; | ||
| 221 | } | ||
| 222 | |||
| 223 | export interface GitLabMergeRequestDetail extends GitLabMergeRequestListItem { | ||
| 224 | readonly body: string; | ||
| 225 | readonly changedFiles: number; | ||
| 226 | readonly mergedAt: string | null; | ||
| 227 | readonly closedAt: string | null; | ||
| 228 | readonly reviewers: ReadonlyArray<PullRequestActor>; | ||
| 229 | readonly checks: ReadonlyArray<PullRequestCheck>; | ||
| 230 | /** False only where GitLab said so; an answer without the field leaves merging permitted. */ | ||
| 231 | readonly viewerCanMerge: boolean; | ||
| 232 | /** The reviewers as GitLab addresses them, which is what writing the set back takes. */ | ||
| 233 | readonly reviewerIds: ReadonlyArray<number>; | ||
| 234 | /** Absent where GitLab named neither auto-merge field, which is not the same as off. */ | ||
| 235 | readonly autoMergeEnabled?: boolean; | ||
| 236 | /** GitLab only exposes the stored strategy separately when that strategy is squash. */ | ||
| 237 | readonly autoMergeMethod?: PullRequestMergeMethod; | ||
| 238 | /** | ||
| 239 | * Absent where GitLab did not count, which is not the same as a branch that has nothing behind | ||
| 240 | * it: an install too old to answer must not be read as saying the branch is current. | ||
| 241 | */ | ||
| 242 | readonly divergedCommits?: number; | ||
| 243 | } | ||
| 244 | |||
| 245 | function trimmed(value: string | null | undefined): string | null { | ||
| 246 | const text = value?.trim() ?? ""; | ||
| 247 | return text.length > 0 ? text : null; | ||
| 248 | } | ||
| 249 | |||
| 250 | function toActor(raw: Schema.Schema.Type<typeof RawUserSchema> | null | undefined) { | ||
| 251 | const login = trimmed(raw?.username); | ||
| 252 | return login === null | ||
| 253 | ? null | ||
| 254 | : { login, name: trimmed(raw?.name), avatarUrl: trimmed(raw?.avatar_url) }; | ||
| 255 | } | ||
| 256 | |||
| 257 | function toState(raw: Schema.Schema.Type<typeof RawMergeRequestSchema>): PullRequestState { | ||
| 258 | if (trimmed(raw.merged_at) !== null) return "merged"; | ||
| 259 | switch (raw.state?.trim().toLowerCase()) { | ||
| 260 | case "merged": | ||
| 261 | return "merged"; | ||
| 262 | case "closed": | ||
| 263 | return "closed"; | ||
| 264 | default: | ||
| 265 | // `locked` is an open merge request whose discussion is locked. | ||
| 266 | return "open"; | ||
| 267 | } | ||
| 268 | } | ||
| 269 | |||
| 270 | function toMergeability( | ||
| 271 | raw: Schema.Schema.Type<typeof RawMergeRequestSchema>, | ||
| 272 | ): PullRequestMergeability { | ||
| 273 | if (raw.has_conflicts === true) return "conflicting"; | ||
| 274 | switch (raw.merge_status?.trim().toLowerCase()) { | ||
| 275 | case "can_be_merged": | ||
| 276 | return "mergeable"; | ||
| 277 | case "cannot_be_merged": | ||
| 278 | return "conflicting"; | ||
| 279 | default: | ||
| 280 | // `unchecked` and `checking` mean GitLab has not finished the merge check yet. | ||
| 281 | return "unknown"; | ||
| 282 | } | ||
| 283 | } | ||
| 284 | |||
| 285 | function toLabels(raw: ReadonlyArray<string> | null | undefined): ReadonlyArray<PullRequestLabel> { | ||
| 286 | // GitLab returns label names only, so there is no colour to carry. | ||
| 287 | return (raw ?? []).flatMap((label) => { | ||
| 288 | const name = trimmed(label); | ||
| 289 | return name === null ? [] : [{ name, color: null }]; | ||
| 290 | }); | ||
| 291 | } | ||
| 292 | |||
| 293 | /** | ||
| 294 | * "3" for a counted change set, "1000+" once GitLab gives up counting. The leading number is | ||
| 295 | * the floor either way, which reads better than dropping an uncounted change set to nothing. | ||
| 296 | */ | ||
| 297 | function toChangedFiles(value: string | null | undefined): number { | ||
| 298 | const parsed = Number.parseInt(value?.trim() ?? "", 10); | ||
| 299 | return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; | ||
| 300 | } | ||
| 301 | |||
| 302 | function toPipelineStatus(value: string | null | undefined): PullRequestCheckStatus { | ||
| 303 | switch (value?.trim().toLowerCase()) { | ||
| 304 | case "success": | ||
| 305 | return "success"; | ||
| 306 | case "failed": | ||
| 307 | return "failure"; | ||
| 308 | case "canceled": | ||
| 309 | case "cancelling": | ||
| 310 | return "cancelled"; | ||
| 311 | case "skipped": | ||
| 312 | return "skipped"; | ||
| 313 | // A pipeline waiting on a person is not progress, and it is not a failure either. | ||
| 314 | case "manual": | ||
| 315 | case "scheduled": | ||
| 316 | return "neutral"; | ||
| 317 | default: | ||
| 318 | return "pending"; | ||
| 319 | } | ||
| 320 | } | ||
| 321 | |||
| 322 | /** | ||
| 323 | * GitLab has no per-job check list on a merge request, so its pipeline is reported as the one | ||
| 324 | * check. The jobs behind it stay one click away through the pipeline URL. | ||
| 325 | */ | ||
| 326 | function toChecks( | ||
| 327 | raw: Schema.Schema.Type<typeof RawMergeRequestSchema>, | ||
| 328 | ): ReadonlyArray<PullRequestCheck> { | ||
| 329 | const pipeline = raw.head_pipeline; | ||
| 330 | if (!pipeline) return []; | ||
| 331 | return [ | ||
| 332 | { | ||
| 333 | name: "Pipeline", | ||
| 334 | status: toPipelineStatus(pipeline.status), | ||
| 335 | description: trimmed(pipeline.source), | ||
| 336 | url: trimmed(pipeline.web_url), | ||
| 337 | }, | ||
| 338 | ]; | ||
| 339 | } | ||
| 340 | |||
| 341 | function toListItem( | ||
| 342 | raw: Schema.Schema.Type<typeof RawMergeRequestSchema>, | ||
| 343 | ): GitLabMergeRequestListItem { | ||
| 344 | return { | ||
| 345 | number: raw.iid, | ||
| 346 | title: raw.title, | ||
| 347 | url: raw.web_url, | ||
| 348 | author: toActor(raw.author), | ||
| 349 | headBranch: raw.source_branch, | ||
| 350 | baseBranch: raw.target_branch, | ||
| 351 | state: toState(raw), | ||
| 352 | isDraft: raw.draft ?? raw.work_in_progress ?? false, | ||
| 353 | mergeability: toMergeability(raw), | ||
| 354 | additions: 0, | ||
| 355 | deletions: 0, | ||
| 356 | createdAt: raw.created_at, | ||
| 357 | updatedAt: raw.updated_at, | ||
| 358 | reviewRequestLogins: (raw.reviewers ?? []).flatMap((reviewer) => { | ||
| 359 | const login = trimmed(reviewer.username); | ||
| 360 | return login === null ? [] : [login]; | ||
| 361 | }), | ||
| 362 | labels: toLabels(raw.labels), | ||
| 363 | }; | ||
| 364 | } | ||
| 365 | |||
| 366 | function toDetail(raw: Schema.Schema.Type<typeof RawMergeRequestSchema>): GitLabMergeRequestDetail { | ||
| 367 | const listItem = toListItem(raw); | ||
| 368 | const autoMerge = | ||
| 369 | raw.merge_when_pipeline_succeeds == null && raw.auto_merge_enabled == null | ||
| 370 | ? undefined | ||
| 371 | : raw.merge_when_pipeline_succeeds === true || raw.auto_merge_enabled === true; | ||
| 372 | return { | ||
| 373 | ...listItem, | ||
| 374 | body: raw.description ?? "", | ||
| 375 | changedFiles: toChangedFiles(raw.changes_count), | ||
| 376 | mergedAt: trimmed(raw.merged_at), | ||
| 377 | closedAt: trimmed(raw.closed_at), | ||
| 378 | // Built from the reviewers themselves rather than from their logins, so the avatars survive. | ||
| 379 | reviewers: (raw.reviewers ?? []).flatMap((reviewer) => { | ||
| 380 | const actor = toActor(reviewer); | ||
| 381 | return actor === null ? [] : [actor]; | ||
| 382 | }), | ||
| 383 | checks: toChecks(raw), | ||
| 384 | viewerCanMerge: raw.user?.can_merge !== false, | ||
| 385 | reviewerIds: (raw.reviewers ?? []).flatMap((reviewer) => | ||
| 386 | reviewer.id === undefined ? [] : [reviewer.id], | ||
| 387 | ), | ||
| 388 | ...(autoMerge === undefined ? {} : { autoMergeEnabled: autoMerge }), | ||
| 389 | ...(autoMerge === true && raw.squash_on_merge === true | ||
| 390 | ? { autoMergeMethod: "squash" as const } | ||
| 391 | : {}), | ||
| 392 | ...(raw.diverged_commits_count == null ? {} : { divergedCommits: raw.diverged_commits_count }), | ||
| 393 | }; | ||
| 394 | } | ||
| 395 | |||
| 396 | const decodeUnknownList = decodeJsonResult(Schema.Array(Schema.Unknown)); | ||
| 397 | const decodeMergeRequestEntry = Schema.decodeUnknownExit(RawMergeRequestSchema); | ||
| 398 | const decodeMergeRequest = decodeJsonResult(RawMergeRequestSchema); | ||
| 399 | const decodeNoteEntry = Schema.decodeUnknownExit(RawNoteSchema); | ||
| 400 | const decodeUserEntry = Schema.decodeUnknownExit(RawUserSchema); | ||
| 401 | const decodeCommitEntry = Schema.decodeUnknownExit(RawCommitSchema); | ||
| 402 | const decodeCommit = decodeJsonResult(RawCommitSchema); | ||
| 403 | const decodeDiffEntry = Schema.decodeUnknownExit(RawDiffSchema); | ||
| 404 | const decodeDiscussionEntry = Schema.decodeUnknownExit(RawDiscussionSchema); | ||
| 405 | const decodeDiffRefs = decodeJsonResult(RawDiffRefsSchema); | ||
| 406 | const decodeViewer = decodeJsonResult(RawViewerSchema); | ||
| 407 | const decodeProjectMergeSettings = decodeJsonResult(RawProjectMergeSettingsSchema); | ||
| 408 | |||
| 409 | type DecodeFailure = Cause.Cause<Schema.SchemaError>; | ||
| 410 | |||
| 411 | export interface GitLabProjectUsers { | ||
| 412 | readonly candidates: ReadonlyArray<PullRequestReviewerCandidate>; | ||
| 413 | /** Rows GitLab returned, counted before decoding, so a skipped row cannot hide a next page. */ | ||
| 414 | readonly rawCount: number; | ||
| 415 | } | ||
| 416 | |||
| 417 | export interface GitLabMergeRequestListBatch { | ||
| 418 | readonly items: ReadonlyArray<GitLabMergeRequestListItem>; | ||
| 419 | /** Zero-based positions of the decoded items in GitLab's raw page. */ | ||
| 420 | readonly rawIndexes: ReadonlyArray<number>; | ||
| 421 | /** Rows GitLab returned, counted before decoding, so a skipped row cannot hide a next page. */ | ||
| 422 | readonly rawCount: number; | ||
| 423 | } | ||
| 424 | |||
| 425 | /** Malformed entries are skipped rather than failing the batch: one unexpected merge request | ||
| 426 | * must not blank the whole list. */ | ||
| 427 | export function decodeMergeRequestListJson( | ||
| 428 | raw: string, | ||
| 429 | ): Result.Result<GitLabMergeRequestListBatch, DecodeFailure> { | ||
| 430 | const decoded = decodeUnknownList(raw); | ||
| 431 | if (!Result.isSuccess(decoded)) { | ||
| 432 | return Result.fail(decoded.failure); | ||
| 433 | } | ||
| 434 | const items: GitLabMergeRequestListItem[] = []; | ||
| 435 | const rawIndexes: number[] = []; | ||
| 436 | for (const [rawIndex, entry] of decoded.success.entries()) { | ||
| 437 | const item = decodeMergeRequestEntry(entry); | ||
| 438 | if (Exit.isSuccess(item)) { | ||
| 439 | items.push(toListItem(item.value)); | ||
| 440 | rawIndexes.push(rawIndex); | ||
| 441 | } | ||
| 442 | } | ||
| 443 | return Result.succeed({ items, rawIndexes, rawCount: decoded.success.length }); | ||
| 444 | } | ||
| 445 | |||
| 446 | export function decodeMergeRequestDetailJson( | ||
| 447 | raw: string, | ||
| 448 | ): Result.Result<GitLabMergeRequestDetail, DecodeFailure> { | ||
| 449 | const decoded = decodeMergeRequest(raw); | ||
| 450 | return Result.isSuccess(decoded) | ||
| 451 | ? Result.succeed(toDetail(decoded.success)) | ||
| 452 | : Result.fail(decoded.failure); | ||
| 453 | } | ||
| 454 | |||
| 455 | export function decodeViewerJson(raw: string): Result.Result<string | null, DecodeFailure> { | ||
| 456 | const decoded = decodeViewer(raw); | ||
| 457 | return Result.isSuccess(decoded) | ||
| 458 | ? Result.succeed(trimmed(decoded.success.username)) | ||
| 459 | : Result.fail(decoded.failure); | ||
| 460 | } | ||
| 461 | |||
| 462 | /** | ||
| 463 | * The people with access to the project, which `GET /projects/:id/users` answers with — the same | ||
| 464 | * list GitLab's own reviewer field is filled from, including the members a group above the project | ||
| 465 | * lends it. A malformed row is skipped rather than failing the menu it belongs to. | ||
| 466 | * | ||
| 467 | * Nobody is marked requested here: who has been asked lives on the merge request, and only the | ||
| 468 | * caller holds both. | ||
| 469 | */ | ||
| 470 | export function decodeProjectUsersJson( | ||
| 471 | raw: string, | ||
| 472 | ): Result.Result<GitLabProjectUsers, DecodeFailure> { | ||
| 473 | const decoded = decodeUnknownList(raw); | ||
| 474 | if (!Result.isSuccess(decoded)) { | ||
| 475 | return Result.fail(decoded.failure); | ||
| 476 | } | ||
| 477 | const candidates: PullRequestReviewerCandidate[] = []; | ||
| 478 | for (const entry of decoded.success) { | ||
| 479 | const user = decodeUserEntry(entry); | ||
| 480 | if (Exit.isFailure(user) || user.value.id === undefined) continue; | ||
| 481 | const actor = toActor(user.value); | ||
| 482 | if (actor === null) continue; | ||
| 483 | candidates.push({ | ||
| 484 | ...actor, | ||
| 485 | id: String(user.value.id), | ||
| 486 | kind: "user", | ||
| 487 | isRequested: false, | ||
| 488 | }); | ||
| 489 | } | ||
| 490 | return Result.succeed({ candidates, rawCount: decoded.success.length }); | ||
| 491 | } | ||
| 492 | |||
| 493 | /** | ||
| 494 | * GitLab settles the strategy per project rather than offering all three per merge request: | ||
| 495 | * `merge_method` picks one of merge commit, semi-linear or fast-forward, and squashing is a | ||
| 496 | * separate switch. An unrecognized setting offers nothing rather than offering a strategy the | ||
| 497 | * project forbids. | ||
| 498 | */ | ||
| 499 | export function decodeProjectMergeCapabilitiesJson( | ||
| 500 | raw: string, | ||
| 501 | ): Result.Result<PullRequestMergeCapabilities, DecodeFailure> { | ||
| 502 | const decoded = decodeProjectMergeSettings(raw); | ||
| 503 | if (!Result.isSuccess(decoded)) { | ||
| 504 | return Result.fail(decoded.failure); | ||
| 505 | } | ||
| 506 | const mergeMethod = decoded.success.merge_method?.trim().toLowerCase(); | ||
| 507 | const squashOption = decoded.success.squash_option?.trim().toLowerCase(); | ||
| 508 | return Result.succeed({ | ||
| 509 | merge: mergeMethod === "merge", | ||
| 510 | // Both semi-linear and fast-forward histories are reached by rebasing onto the target. | ||
| 511 | rebase: mergeMethod === "rebase_merge" || mergeMethod === "ff", | ||
| 512 | // Only GitLab's own enabling values. An absent or unrecognized setting offers nothing, | ||
| 513 | // rather than offering a squash the project may forbid. | ||
| 514 | squash: | ||
| 515 | squashOption === "always" || squashOption === "default_on" || squashOption === "default_off", | ||
| 516 | }); | ||
| 517 | } | ||
| 518 | |||
| 519 | /** | ||
| 520 | * Comments only. System notes are GitLab's own activity feed entries, and a `DiffNote` is the | ||
| 521 | * root of a line-level discussion, which is what the review-comment kind means. | ||
| 522 | * | ||
| 523 | * The raw note count comes back alongside, because dropping notes hides whether the page was | ||
| 524 | * full: a caller cannot tell "no more notes" from "a page of activity entries" without it. | ||
| 525 | */ | ||
| 526 | /** The three revisions a positioned comment is written against. */ | ||
| 527 | export interface GitLabDiffRefs { | ||
| 528 | readonly baseSha: string; | ||
| 529 | readonly headSha: string; | ||
| 530 | readonly startSha: string; | ||
| 531 | } | ||
| 532 | |||
| 533 | export interface GitLabDiscussions { | ||
| 534 | readonly threads: ReadonlyArray<PullRequestReviewThread>; | ||
| 535 | /** Discussions GitLab returned, counted before decoding, so a skipped one still counts. */ | ||
| 536 | readonly rawCount: number; | ||
| 537 | } | ||
| 538 | |||
| 539 | /** | ||
| 540 | * Positioned discussions only. GitLab returns the merge request's whole conversation here, | ||
| 541 | * including the plain notes the timeline already shows, and only a positioned one belongs | ||
| 542 | * against a line of the diff. | ||
| 543 | */ | ||
| 544 | export function decodeDiscussionsJson( | ||
| 545 | raw: string, | ||
| 546 | ): Result.Result<GitLabDiscussions, DecodeFailure> { | ||
| 547 | const decoded = decodeUnknownList(raw); | ||
| 548 | if (!Result.isSuccess(decoded)) { | ||
| 549 | return Result.fail(decoded.failure); | ||
| 550 | } | ||
| 551 | const threads: PullRequestReviewThread[] = []; | ||
| 552 | for (const entry of decoded.success) { | ||
| 553 | const discussion = decodeDiscussionEntry(entry); | ||
| 554 | if (!Exit.isSuccess(discussion)) continue; | ||
| 555 | const notes = (discussion.value.notes ?? []).filter((note) => note.system !== true); | ||
| 556 | const root = notes[0]; | ||
| 557 | const position = root?.position; | ||
| 558 | if (root === undefined || !position || position.position_type !== "text") continue; | ||
| 559 | // A comment on an added or context line carries `new_line`; one on a removed line carries | ||
| 560 | // only `old_line`, and belongs against the file as it was. | ||
| 561 | const side = position.new_line === null || position.new_line === undefined ? "left" : "right"; | ||
| 562 | const path = trimmed(side === "left" ? position.old_path : position.new_path); | ||
| 563 | const line = side === "left" ? position.old_line : position.new_line; | ||
| 564 | if (path === null) continue; | ||
| 565 | threads.push({ | ||
| 566 | id: discussion.value.id, | ||
| 567 | path, | ||
| 568 | line: typeof line === "number" && line > 0 ? line : null, | ||
| 569 | side, | ||
| 570 | isResolved: root.resolved === true, | ||
| 571 | // GitLab reports no equivalent of "written against a line that has since moved", so a | ||
| 572 | // thread the diff cannot place is worked out from the diff itself rather than claimed | ||
| 573 | // here. | ||
| 574 | isOutdated: false, | ||
| 575 | comments: notes.map((note) => ({ | ||
| 576 | id: String(note.id), | ||
| 577 | author: toActor(note.author), | ||
| 578 | body: note.body ?? "", | ||
| 579 | createdAt: note.created_at, | ||
| 580 | url: null, | ||
| 581 | })), | ||
| 582 | }); | ||
| 583 | } | ||
| 584 | return Result.succeed({ threads, rawCount: decoded.success.length }); | ||
| 585 | } | ||
| 586 | |||
| 587 | export function decodeDiffRefsJson( | ||
| 588 | raw: string, | ||
| 589 | ): Result.Result<GitLabDiffRefs | null, DecodeFailure> { | ||
| 590 | const decoded = decodeDiffRefs(raw); | ||
| 591 | if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); | ||
| 592 | const refs = decoded.success.diff_refs; | ||
| 593 | return Result.succeed( | ||
| 594 | refs ? { baseSha: refs.base_sha, headSha: refs.head_sha, startSha: refs.start_sha } : null, | ||
| 595 | ); | ||
| 596 | } | ||
| 597 | |||
| 598 | export function decodeNotesJson( | ||
| 599 | raw: string, | ||
| 600 | ): Result.Result< | ||
| 601 | { readonly comments: ReadonlyArray<PullRequestComment>; readonly rawCount: number }, | ||
| 602 | DecodeFailure | ||
| 603 | > { | ||
| 604 | const decoded = decodeUnknownList(raw); | ||
| 605 | if (!Result.isSuccess(decoded)) { | ||
| 606 | return Result.fail(decoded.failure); | ||
| 607 | } | ||
| 608 | const comments: PullRequestComment[] = []; | ||
| 609 | for (const entry of decoded.success) { | ||
| 610 | const note = decodeNoteEntry(entry); | ||
| 611 | if (Exit.isFailure(note)) continue; | ||
| 612 | const value = note.value; | ||
| 613 | if (value.system === true) continue; | ||
| 614 | const body = value.body ?? ""; | ||
| 615 | if (body.trim().length === 0) continue; | ||
| 616 | const isDiffNote = value.type?.trim() === "DiffNote"; | ||
| 617 | comments.push({ | ||
| 618 | id: String(value.id), | ||
| 619 | kind: isDiffNote ? "review-comment" : "issue-comment", | ||
| 620 | author: toActor(value.author), | ||
| 621 | body, | ||
| 622 | createdAt: value.created_at, | ||
| 623 | url: null, | ||
| 624 | path: trimmed(value.position?.new_path) ?? trimmed(value.position?.old_path), | ||
| 625 | reviewState: null, | ||
| 626 | }); | ||
| 627 | } | ||
| 628 | return Result.succeed({ comments, rawCount: decoded.success.length }); | ||
| 629 | } | ||
| 630 | |||
| 631 | export function decodeCommitsJson( | ||
| 632 | raw: string, | ||
| 633 | ): Result.Result<ReadonlyArray<PullRequestCommit>, DecodeFailure> { | ||
| 634 | const decoded = decodeUnknownList(raw); | ||
| 635 | if (!Result.isSuccess(decoded)) { | ||
| 636 | return Result.fail(decoded.failure); | ||
| 637 | } | ||
| 638 | const commits: PullRequestCommit[] = []; | ||
| 639 | for (const entry of decoded.success) { | ||
| 640 | const commit = decodeCommitEntry(entry); | ||
| 641 | if (Exit.isFailure(commit)) continue; | ||
| 642 | const committedDate = trimmed(commit.value.committed_date) ?? trimmed(commit.value.created_at); | ||
| 643 | if (committedDate === null) continue; | ||
| 644 | commits.push({ | ||
| 645 | oid: commit.value.id, | ||
| 646 | messageHeadline: commit.value.title ?? "", | ||
| 647 | committedDate, | ||
| 648 | ...(commit.value.stats === null || commit.value.stats === undefined | ||
| 649 | ? {} | ||
| 650 | : { | ||
| 651 | additions: Math.max(0, commit.value.stats.additions ?? 0), | ||
| 652 | deletions: Math.max(0, commit.value.stats.deletions ?? 0), | ||
| 653 | }), | ||
| 654 | authors: (() => { | ||
| 655 | const login = trimmed(commit.value.author_name) ?? trimmed(commit.value.author_email); | ||
| 656 | return login === null | ||
| 657 | ? [] | ||
| 658 | : [{ login, name: trimmed(commit.value.author_name), avatarUrl: null }]; | ||
| 659 | })(), | ||
| 660 | }); | ||
| 661 | } | ||
| 662 | // GitLab lists a merge request's commits newest first; the timeline reads oldest first. | ||
| 663 | return Result.succeed(commits.toReversed()); | ||
| 664 | } | ||
| 665 | |||
| 666 | /** The exact comparison GitLab uses for a commit-scoped diff. */ | ||
| 667 | export function decodeCommitDiffRefsJson( | ||
| 668 | raw: string, | ||
| 669 | ): Result.Result<GitLabDiffRefs | null, DecodeFailure> { | ||
| 670 | const decoded = decodeCommit(raw); | ||
| 671 | if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); | ||
| 672 | const baseSha = trimmed(decoded.success.parent_ids?.[0]); | ||
| 673 | const headSha = trimmed(decoded.success.id); | ||
| 674 | return Result.succeed( | ||
| 675 | baseSha === null || headSha === null ? null : { baseSha, headSha, startSha: baseSha }, | ||
| 676 | ); | ||
| 677 | } | ||
| 678 | |||
| 679 | function diffHeaderPaths(raw: Schema.Schema.Type<typeof RawDiffSchema>): { | ||
| 680 | readonly from: string; | ||
| 681 | readonly to: string; | ||
| 682 | } { | ||
| 683 | return { | ||
| 684 | from: raw.new_file === true ? "/dev/null" : quoteGitPatchPath(`a/${raw.old_path}`), | ||
| 685 | to: raw.deleted_file === true ? "/dev/null" : quoteGitPatchPath(`b/${raw.new_path}`), | ||
| 686 | }; | ||
| 687 | } | ||
| 688 | |||
| 689 | export interface GitLabMergeRequestPatch { | ||
| 690 | readonly patch: string; | ||
| 691 | /** At least one file's hunks were withheld by GitLab as too large to inline. */ | ||
| 692 | readonly truncated: boolean; | ||
| 693 | /** Files GitLab returned, counted before decoding, so the caller can page. */ | ||
| 694 | readonly rawCount: number; | ||
| 695 | } | ||
| 696 | |||
| 697 | /** | ||
| 698 | * GitLab returns hunks per file with no `diff --git` header, so the unified patch every diff | ||
| 699 | * viewer expects is assembled here. This decodes one page; walking pages is the caller's job, | ||
| 700 | * which is why the raw file count comes back with the patch. | ||
| 701 | */ | ||
| 702 | export function decodeMergeRequestDiffsJson( | ||
| 703 | raw: string, | ||
| 704 | ): Result.Result<GitLabMergeRequestPatch, DecodeFailure> { | ||
| 705 | const decoded = decodeUnknownList(raw); | ||
| 706 | if (!Result.isSuccess(decoded)) { | ||
| 707 | return Result.fail(decoded.failure); | ||
| 708 | } | ||
| 709 | const sections: string[] = []; | ||
| 710 | let truncated = false; | ||
| 711 | for (const entry of decoded.success) { | ||
| 712 | const file = decodeDiffEntry(entry); | ||
| 713 | if (Exit.isFailure(file)) continue; | ||
| 714 | const value = file.value; | ||
| 715 | const hunks = value.diff ?? ""; | ||
| 716 | if (hunks.length === 0) { | ||
| 717 | // A file GitLab declined to inline still belongs in the file list, header only. | ||
| 718 | truncated = truncated || value.too_large === true || value.collapsed === true; | ||
| 719 | } | ||
| 720 | const { from, to } = diffHeaderPaths(value); | ||
| 721 | const header = [ | ||
| 722 | `diff --git ${quoteGitPatchPath(`a/${value.old_path}`)} ${quoteGitPatchPath(`b/${value.new_path}`)}`, | ||
| 723 | ...(value.new_file === true ? [`new file mode ${value.b_mode ?? "100644"}`] : []), | ||
| 724 | ...(value.deleted_file === true ? [`deleted file mode ${value.a_mode ?? "100644"}`] : []), | ||
| 725 | ...(value.renamed_file === true | ||
| 726 | ? [ | ||
| 727 | `rename from ${quoteGitPatchPath(value.old_path)}`, | ||
| 728 | `rename to ${quoteGitPatchPath(value.new_path)}`, | ||
| 729 | ] | ||
| 730 | : []), | ||
| 731 | `--- ${from}`, | ||
| 732 | `+++ ${to}`, | ||
| 733 | ].join("\n"); | ||
| 734 | sections.push(hunks.length === 0 ? header : `${header}\n${hunks.replace(/\n?$/, "\n")}`); | ||
| 735 | } | ||
| 736 | return Result.succeed({ | ||
| 737 | patch: sections.join("\n"), | ||
| 738 | truncated, | ||
| 739 | rawCount: decoded.success.length, | ||
| 740 | }); | ||
| 741 | } | ||
| 742 | |||
| 743 | /** GitLab's award names for the eight reactions the contract carries. */ | ||
| 744 | const GITLAB_AWARD_BY_CONTENT: Readonly<Record<PullRequestReactionContent, string>> = { | ||
| 745 | "thumbs-up": "thumbsup", | ||
| 746 | "thumbs-down": "thumbsdown", | ||
| 747 | laugh: "laughing", | ||
| 748 | hooray: "tada", | ||
| 749 | confused: "confused", | ||
| 750 | heart: "heart", | ||
| 751 | rocket: "rocket", | ||
| 752 | eyes: "eyes", | ||
| 753 | }; | ||
| 754 | |||
| 755 | const CONTENT_BY_GITLAB_AWARD: Readonly<Record<string, PullRequestReactionContent>> = | ||
| 756 | Object.fromEntries( | ||
| 757 | Object.entries(GITLAB_AWARD_BY_CONTENT).map(([content, name]) => [name, content]), | ||
| 758 | ) as Readonly<Record<string, PullRequestReactionContent>>; | ||
| 759 | |||
| 760 | export function gitLabAwardName(content: PullRequestReactionContent): string { | ||
| 761 | return GITLAB_AWARD_BY_CONTENT[content]; | ||
| 762 | } | ||
| 763 | |||
| 764 | /** | ||
| 765 | * Awards on the merge request and on every note of it, in one read. The REST notes endpoint the | ||
| 766 | * conversation comes from carries no award at all, and asking per note would be a request each. | ||
| 767 | * | ||
| 768 | * `currentUser` rides along because GitLab names who awarded but never says whether that is the | ||
| 769 | * reader — so the comparison is made here rather than paid for with a request of its own. | ||
| 770 | */ | ||
| 771 | export const AWARD_EMOJI_GRAPHQL_QUERY = `query($fullPath: ID!, $iid: String!, $cursor: String) { | ||
| 772 | currentUser { username } | ||
| 773 | project(fullPath: $fullPath) { | ||
| 774 | mergeRequest(iid: $iid) { | ||
| 775 | awardEmoji { nodes { name user { username } } } | ||
| 776 | notes(first: 100, after: $cursor) { | ||
| 777 | pageInfo { hasNextPage endCursor } | ||
| 778 | nodes { id awardEmoji { nodes { name user { username } } } } | ||
| 779 | } | ||
| 780 | } | ||
| 781 | } | ||
| 782 | }`; | ||
| 783 | |||
| 784 | const RawAwardEmojiNodesSchema = Schema.optional( | ||
| 785 | Schema.NullOr( | ||
| 786 | Schema.Struct({ | ||
| 787 | nodes: Schema.optional( | ||
| 788 | Schema.NullOr( | ||
| 789 | Schema.Array( | ||
| 790 | Schema.NullOr( | ||
| 791 | Schema.Struct({ | ||
| 792 | name: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 793 | user: Schema.optional( | ||
| 794 | Schema.NullOr( | ||
| 795 | Schema.Struct({ username: Schema.optional(Schema.NullOr(Schema.String)) }), | ||
| 796 | ), | ||
| 797 | ), | ||
| 798 | }), | ||
| 799 | ), | ||
| 800 | ), | ||
| 801 | ), | ||
| 802 | ), | ||
| 803 | }), | ||
| 804 | ), | ||
| 805 | ); | ||
| 806 | |||
| 807 | const RawAwardEmojiPageSchema = Schema.Struct({ | ||
| 808 | data: Schema.Struct({ | ||
| 809 | currentUser: Schema.optional( | ||
| 810 | Schema.NullOr(Schema.Struct({ username: Schema.optional(Schema.NullOr(Schema.String)) })), | ||
| 811 | ), | ||
| 812 | project: Schema.NullOr( | ||
| 813 | Schema.Struct({ | ||
| 814 | mergeRequest: Schema.NullOr( | ||
| 815 | Schema.Struct({ | ||
| 816 | awardEmoji: RawAwardEmojiNodesSchema, | ||
| 817 | notes: Schema.optional( | ||
| 818 | Schema.NullOr( | ||
| 819 | Schema.Struct({ | ||
| 820 | pageInfo: Schema.optional( | ||
| 821 | Schema.Struct({ | ||
| 822 | hasNextPage: Schema.optional(Schema.Boolean), | ||
| 823 | endCursor: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 824 | }), | ||
| 825 | ), | ||
| 826 | nodes: Schema.Array( | ||
| 827 | Schema.NullOr( | ||
| 828 | Schema.Struct({ | ||
| 829 | id: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 830 | awardEmoji: RawAwardEmojiNodesSchema, | ||
| 831 | }), | ||
| 832 | ), | ||
| 833 | ), | ||
| 834 | }), | ||
| 835 | ), | ||
| 836 | ), | ||
| 837 | }), | ||
| 838 | ), | ||
| 839 | }), | ||
| 840 | ), | ||
| 841 | }), | ||
| 842 | }); | ||
| 843 | |||
| 844 | const decodeAwardEmojiPage = decodeJsonResult(RawAwardEmojiPageSchema); | ||
| 845 | |||
| 846 | /** | ||
| 847 | * The awards on one subject, grouped the way a reaction pill is drawn. The viewer's own username | ||
| 848 | * is left out of `actors` — the page names them "You" instead, and leaving it in would name them | ||
| 849 | * twice — but `count` still counts them along with everyone else. | ||
| 850 | */ | ||
| 851 | function toReactions( | ||
| 852 | nodes: Schema.Schema.Type<typeof RawAwardEmojiNodesSchema>, | ||
| 853 | viewer: string | null, | ||
| 854 | ): ReadonlyArray<PullRequestReaction> { | ||
| 855 | const normalizedViewer = viewer?.toLowerCase() ?? null; | ||
| 856 | const groups = new Map< | ||
| 857 | PullRequestReactionContent, | ||
| 858 | { count: number; actors: string[]; viewer: boolean } | ||
| 859 | >(); | ||
| 860 | for (const node of nodes?.nodes ?? []) { | ||
| 861 | // An award outside the eight is left out rather than shown under a name the picker has no | ||
| 862 | // way to take back: GitLab accepts any emoji, and the other hosts accept none of them. | ||
| 863 | const content = CONTENT_BY_GITLAB_AWARD[trimmed(node?.name)?.toLowerCase() ?? ""]; | ||
| 864 | if (content === undefined) continue; | ||
| 865 | const username = trimmed(node?.user?.username); | ||
| 866 | if (username === null) continue; | ||
| 867 | const group = groups.get(content) ?? { count: 0, actors: [], viewer: false }; | ||
| 868 | group.count++; | ||
| 869 | if (normalizedViewer !== null && username.toLowerCase() === normalizedViewer) { | ||
| 870 | group.viewer = true; | ||
| 871 | } else { | ||
| 872 | group.actors.push(username); | ||
| 873 | } | ||
| 874 | groups.set(content, group); | ||
| 875 | } | ||
| 876 | return [...groups].flatMap(([content, group]) => | ||
| 877 | group.count === 0 | ||
| 878 | ? [] | ||
| 879 | : [{ content, count: group.count, actors: group.actors, viewerHasReacted: group.viewer }], | ||
| 880 | ); | ||
| 881 | } | ||
| 882 | |||
| 883 | /** `gid://gitlab/DiffNote/42` is note 42, which is the id the REST conversation carries. */ | ||
| 884 | function noteIdOf(gid: string | null | undefined): string | null { | ||
| 885 | const id = trimmed(gid)?.split("/").at(-1); | ||
| 886 | return id !== undefined && /^\d+$/.test(id) ? id : null; | ||
| 887 | } | ||
| 888 | |||
| 889 | export interface GitLabAwardEmojiPage { | ||
| 890 | /** The merge request's own awards, which are the ones on its description. */ | ||
| 891 | readonly reactions: ReadonlyArray<PullRequestReaction>; | ||
| 892 | readonly reactionsByNoteId: ReadonlyMap<string, ReadonlyArray<PullRequestReaction>>; | ||
| 893 | readonly nextCursor: string | null; | ||
| 894 | } | ||
| 895 | |||
| 896 | export function decodeAwardEmojiJson( | ||
| 897 | raw: string, | ||
| 898 | ): Result.Result<GitLabAwardEmojiPage, DecodeFailure> { | ||
| 899 | const decoded = decodeAwardEmojiPage(raw); | ||
| 900 | if (!Result.isSuccess(decoded)) { | ||
| 901 | return Result.fail(decoded.failure); | ||
| 902 | } | ||
| 903 | const data = decoded.success.data; | ||
| 904 | const viewer = trimmed(data.currentUser?.username); | ||
| 905 | const mergeRequest = data.project?.mergeRequest; | ||
| 906 | const reactionsByNoteId = new Map<string, ReadonlyArray<PullRequestReaction>>(); | ||
| 907 | for (const node of mergeRequest?.notes?.nodes ?? []) { | ||
| 908 | const id = noteIdOf(node?.id); | ||
| 909 | if (id === null) continue; | ||
| 910 | const reactions = toReactions(node?.awardEmoji, viewer); | ||
| 911 | if (reactions.length > 0) reactionsByNoteId.set(id, reactions); | ||
| 912 | } | ||
| 913 | const pageInfo = mergeRequest?.notes?.pageInfo; | ||
| 914 | return Result.succeed({ | ||
| 915 | reactions: toReactions(mergeRequest?.awardEmoji, viewer), | ||
| 916 | reactionsByNoteId, | ||
| 917 | nextCursor: pageInfo?.hasNextPage === true ? (trimmed(pageInfo.endCursor) ?? null) : null, | ||
| 918 | }); | ||
| 919 | } | ||
| 920 | |||
| 921 | const RawAwardSchema = Schema.Struct({ | ||
| 922 | id: Schema.Int, | ||
| 923 | name: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 924 | user: Schema.optional( | ||
| 925 | Schema.NullOr(Schema.Struct({ username: Schema.optional(Schema.NullOr(Schema.String)) })), | ||
| 926 | ), | ||
| 927 | }); | ||
| 928 | |||
| 929 | const decodeAward = Schema.decodeUnknownExit(RawAwardSchema); | ||
| 930 | |||
| 931 | /** | ||
| 932 | * The reader's own award of one name on a subject, which is what taking a reaction back is | ||
| 933 | * addressed by: GitLab deletes an award by its id and has no way to name one by its emoji. | ||
| 934 | */ | ||
| 935 | export function decodeOwnAwardIdJson( | ||
| 936 | raw: string, | ||
| 937 | input: { readonly content: PullRequestReactionContent; readonly viewer: string }, | ||
| 938 | ): Result.Result<number | null, DecodeFailure> { | ||
| 939 | const decoded = decodeUnknownList(raw); | ||
| 940 | if (!Result.isSuccess(decoded)) { | ||
| 941 | return Result.fail(decoded.failure); | ||
| 942 | } | ||
| 943 | const name = gitLabAwardName(input.content); | ||
| 944 | for (const entry of decoded.success) { | ||
| 945 | const award = decodeAward(entry); | ||
| 946 | if (Exit.isFailure(award)) continue; | ||
| 947 | const value = award.value; | ||
| 948 | if (trimmed(value.name)?.toLowerCase() !== name) continue; | ||
| 949 | if (trimmed(value.user?.username) !== input.viewer) continue; | ||
| 950 | return Result.succeed(value.id); | ||
| 951 | } | ||
| 952 | return Result.succeed(null); | ||
| 953 | } | ||
| 954 | |||
| 955 | /** | ||
| 956 | * What the given paths are at one revision, as blob ids. Asked for by path rather than by walking | ||
| 957 | * the tree, since GitLab charges this query by how many paths it is given. A path the revision | ||
| 958 | * does not have comes back missing rather than as an error, which is the answer for a deleted file. | ||
| 959 | */ | ||
| 960 | export const REPOSITORY_BLOBS_GRAPHQL_QUERY = `query($fullPath: ID!, $ref: String!, $paths: [String!]!) { | ||
| 961 | project(fullPath: $fullPath) { | ||
| 962 | repository { | ||
| 963 | blobs(ref: $ref, paths: $paths) { | ||
| 964 | nodes { path oid } | ||
| 965 | } | ||
| 966 | } | ||
| 967 | } | ||
| 968 | }`; | ||
| 969 | |||
| 970 | const RawRepositoryBlobsSchema = Schema.Struct({ | ||
| 971 | data: Schema.Struct({ | ||
| 972 | project: Schema.NullOr( | ||
| 973 | Schema.Struct({ | ||
| 974 | repository: Schema.optional( | ||
| 975 | Schema.NullOr( | ||
| 976 | Schema.Struct({ | ||
| 977 | blobs: Schema.optional( | ||
| 978 | Schema.NullOr( | ||
| 979 | Schema.Struct({ | ||
| 980 | nodes: Schema.optional( | ||
| 981 | Schema.NullOr( | ||
| 982 | Schema.Array( | ||
| 983 | Schema.NullOr( | ||
| 984 | Schema.Struct({ | ||
| 985 | path: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 986 | oid: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 987 | }), | ||
| 988 | ), | ||
| 989 | ), | ||
| 990 | ), | ||
| 991 | ), | ||
| 992 | }), | ||
| 993 | ), | ||
| 994 | ), | ||
| 995 | }), | ||
| 996 | ), | ||
| 997 | ), | ||
| 998 | }), | ||
| 999 | ), | ||
| 1000 | }), | ||
| 1001 | }); | ||
| 1002 | |||
| 1003 | const decodeRepositoryBlobs = decodeJsonResult(RawRepositoryBlobsSchema); | ||
| 1004 | |||
| 1005 | /** | ||
| 1006 | * Blob ids by path, or null where GitLab did not answer the query at all (a project the token | ||
| 1007 | * cannot see, or a repository with no blobs connection). That case must be told apart from an | ||
| 1008 | * empty answer: read as "the revision has none of these files", it would report every cleared | ||
| 1009 | * file as changed again. A node missing either half is left out, since the caller treats an | ||
| 1010 | * absent path as one the revision does not carry. | ||
| 1011 | */ | ||
| 1012 | export function decodeRepositoryBlobsJson( | ||
| 1013 | raw: string, | ||
| 1014 | ): Result.Result<ReadonlyMap<string, string> | null, DecodeFailure> { | ||
| 1015 | const decoded = decodeRepositoryBlobs(raw); | ||
| 1016 | if (!Result.isSuccess(decoded)) { | ||
| 1017 | return Result.fail(decoded.failure); | ||
| 1018 | } | ||
| 1019 | const nodes = decoded.success.data.project?.repository?.blobs?.nodes; | ||
| 1020 | if (nodes === undefined || nodes === null) return Result.succeed(null); | ||
| 1021 | const blobs = new Map<string, string>(); | ||
| 1022 | for (const node of nodes) { | ||
| 1023 | // Not trimmed, unlike everything else read out of this payload: a leading or trailing space | ||
| 1024 | // is a legal part of a file's name, and trimming it would key this map under a name the | ||
| 1025 | // caller's asked-for path never matches. | ||
| 1026 | const path = node?.path; | ||
| 1027 | const oid = trimmed(node?.oid); | ||
| 1028 | if (path === undefined || path === null || path.length === 0 || oid === null) continue; | ||
| 1029 | blobs.set(path, oid); | ||
| 1030 | } | ||
| 1031 | return Result.succeed(blobs); | ||
| 1032 | } | ||
apps/server/src/server.ts-2| ... | @@ -54,7 +54,6 @@ import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; | ... | @@ -54,7 +54,6 @@ import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; |
| 54 | import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; | 54 | import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; |
| 55 | import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; | 55 | import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; |
| 56 | import * as GitHubApi from "./sourceControl/GitHubApi.ts"; | 56 | import * as GitHubApi from "./sourceControl/GitHubApi.ts"; |
| 57 | import * as GitLabCli from "./sourceControl/GitLabCli.ts"; | ||
| 58 | import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; | 57 | import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; |
| 59 | import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts"; | 58 | import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts"; |
| 60 | import * as TextGeneration from "./textGeneration/TextGeneration.ts"; | 59 | import * as TextGeneration from "./textGeneration/TextGeneration.ts"; |
| ... | @@ -302,7 +301,6 @@ const layerSourceControlProviderRegistry = SourceControlProviderRegistry.layer.p | ... | @@ -302,7 +301,6 @@ const layerSourceControlProviderRegistry = SourceControlProviderRegistry.layer.p |
| 302 | Layer.mergeAll( | 301 | Layer.mergeAll( |
| 303 | BitbucketApi.layer, | 302 | BitbucketApi.layer, |
| 304 | GitHubApi.layerWithDependencies, | 303 | GitHubApi.layerWithDependencies, |
| 305 | GitLabCli.layer, | ||
| 306 | SourceControlBuiltInDrivers.layer, | 304 | SourceControlBuiltInDrivers.layer, |
| 307 | ), | 305 | ), |
| 308 | ), | 306 | ), |
apps/server/src/sourceControl/GitLabCli.test.ts deleted-384| ... | @@ -1,384 +0,0 @@ | ||
| 1 | import { assert, it, afterEach, expect, vi } from "@effect/vitest"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Layer from "effect/Layer"; | ||
| 4 | import { ChildProcessSpawner } from "effect/process"; | ||
| 5 | |||
| 6 | import { VcsProcessExitError } from "@t3tools/contracts"; | ||
| 7 | |||
| 8 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | ||
| 9 | import * as GitLabCli from "./GitLabCli.ts"; | ||
| 10 | |||
| 11 | const mockedRun = vi.fn<VcsProcess.VcsProcess["Service"]["run"]>(); | ||
| 12 | const layer = it.layer( | ||
| 13 | GitLabCli.layer.pipe( | ||
| 14 | Layer.provide( | ||
| 15 | Layer.mock(VcsProcess.VcsProcess)({ | ||
| 16 | run: mockedRun, | ||
| 17 | }), | ||
| 18 | ), | ||
| 19 | ), | ||
| 20 | ); | ||
| 21 | |||
| 22 | function processOutput(stdout: string): VcsProcess.VcsProcessOutput { | ||
| 23 | return { | ||
| 24 | exitCode: ChildProcessSpawner.ExitCode(0), | ||
| 25 | stdout, | ||
| 26 | stderr: "", | ||
| 27 | stdoutTruncated: false, | ||
| 28 | stderrTruncated: false, | ||
| 29 | }; | ||
| 30 | } | ||
| 31 | |||
| 32 | afterEach(() => { | ||
| 33 | mockedRun.mockReset(); | ||
| 34 | }); | ||
| 35 | |||
| 36 | layer("GitLabCli.layer", (it) => { | ||
| 37 | it.effect("parses merge request view output", () => | ||
| 38 | Effect.gen(function* () { | ||
| 39 | mockedRun.mockReturnValueOnce( | ||
| 40 | Effect.succeed( | ||
| 41 | processOutput( | ||
| 42 | JSON.stringify({ | ||
| 43 | iid: 42, | ||
| 44 | title: "Add MR thread creation", | ||
| 45 | web_url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", | ||
| 46 | target_branch: "main", | ||
| 47 | source_branch: "feature/mr-threads", | ||
| 48 | state: "closed", | ||
| 49 | closed_at: "2026-08-23T10:00:00Z", | ||
| 50 | source_project_id: 101, | ||
| 51 | target_project_id: 100, | ||
| 52 | source_project: { | ||
| 53 | path_with_namespace: "octocat/t3code", | ||
| 54 | }, | ||
| 55 | }), | ||
| 56 | ), | ||
| 57 | ), | ||
| 58 | ); | ||
| 59 | |||
| 60 | const result = yield* Effect.gen(function* () { | ||
| 61 | const glab = yield* GitLabCli.GitLabCli; | ||
| 62 | return yield* glab.getMergeRequest({ | ||
| 63 | cwd: "/repo", | ||
| 64 | reference: "42", | ||
| 65 | }); | ||
| 66 | }); | ||
| 67 | |||
| 68 | assert.deepStrictEqual(result, { | ||
| 69 | number: 42, | ||
| 70 | title: "Add MR thread creation", | ||
| 71 | url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", | ||
| 72 | baseRefName: "main", | ||
| 73 | headRefName: "feature/mr-threads", | ||
| 74 | state: "closed", | ||
| 75 | closedAt: "2026-08-23T10:00:00Z", | ||
| 76 | mergedAt: null, | ||
| 77 | isCrossRepository: true, | ||
| 78 | headRepositoryNameWithOwner: "octocat/t3code", | ||
| 79 | headRepositoryOwnerLogin: "octocat", | ||
| 80 | }); | ||
| 81 | expect(mockedRun).toHaveBeenCalledWith( | ||
| 82 | expect.objectContaining({ | ||
| 83 | command: "glab", | ||
| 84 | cwd: "/repo", | ||
| 85 | args: ["mr", "view", "42", "--output", "json"], | ||
| 86 | }), | ||
| 87 | ); | ||
| 88 | }), | ||
| 89 | ); | ||
| 90 | |||
| 91 | it.effect("skips invalid entries when parsing MR lists", () => | ||
| 92 | Effect.gen(function* () { | ||
| 93 | mockedRun.mockReturnValueOnce( | ||
| 94 | Effect.succeed( | ||
| 95 | processOutput( | ||
| 96 | JSON.stringify([ | ||
| 97 | { | ||
| 98 | iid: 0, | ||
| 99 | title: "invalid", | ||
| 100 | web_url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/0", | ||
| 101 | target_branch: "main", | ||
| 102 | source_branch: "feature/invalid", | ||
| 103 | }, | ||
| 104 | { | ||
| 105 | iid: 43, | ||
| 106 | title: " Valid MR ", | ||
| 107 | web_url: " https://gitlab.com/pingdotgg/t3code/-/merge_requests/43 ", | ||
| 108 | target_branch: " main ", | ||
| 109 | source_branch: " feature/mr-list ", | ||
| 110 | state: "merged", | ||
| 111 | merged_at: "2026-08-23T11:00:00Z", | ||
| 112 | }, | ||
| 113 | ]), | ||
| 114 | ), | ||
| 115 | ), | ||
| 116 | ); | ||
| 117 | |||
| 118 | const result = yield* Effect.gen(function* () { | ||
| 119 | const glab = yield* GitLabCli.GitLabCli; | ||
| 120 | return yield* glab.listMergeRequests({ | ||
| 121 | cwd: "/repo", | ||
| 122 | headSelector: "feature/mr-list", | ||
| 123 | state: "all", | ||
| 124 | }); | ||
| 125 | }); | ||
| 126 | |||
| 127 | assert.deepStrictEqual(result, [ | ||
| 128 | { | ||
| 129 | number: 43, | ||
| 130 | title: "Valid MR", | ||
| 131 | url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/43", | ||
| 132 | baseRefName: "main", | ||
| 133 | headRefName: "feature/mr-list", | ||
| 134 | state: "merged", | ||
| 135 | closedAt: null, | ||
| 136 | mergedAt: "2026-08-23T11:00:00Z", | ||
| 137 | }, | ||
| 138 | ]); | ||
| 139 | expect(mockedRun).toHaveBeenCalledWith( | ||
| 140 | expect.objectContaining({ | ||
| 141 | command: "glab", | ||
| 142 | cwd: "/repo", | ||
| 143 | args: [ | ||
| 144 | "mr", | ||
| 145 | "list", | ||
| 146 | "--source-branch", | ||
| 147 | "feature/mr-list", | ||
| 148 | "--all", | ||
| 149 | "--per-page", | ||
| 150 | "20", | ||
| 151 | "--output", | ||
| 152 | "json", | ||
| 153 | ], | ||
| 154 | }), | ||
| 155 | ); | ||
| 156 | }), | ||
| 157 | ); | ||
| 158 | |||
| 159 | it.effect("reads repository clone URLs", () => | ||
| 160 | Effect.gen(function* () { | ||
| 161 | mockedRun.mockReturnValueOnce( | ||
| 162 | Effect.succeed( | ||
| 163 | processOutput( | ||
| 164 | JSON.stringify({ | ||
| 165 | path_with_namespace: "octocat/t3code", | ||
| 166 | web_url: "https://gitlab.com/octocat/t3code", | ||
| 167 | http_url_to_repo: "https://gitlab.com/octocat/t3code.git", | ||
| 168 | ssh_url_to_repo: "git@gitlab.com:octocat/t3code.git", | ||
| 169 | }), | ||
| 170 | ), | ||
| 171 | ), | ||
| 172 | ); | ||
| 173 | |||
| 174 | const result = yield* Effect.gen(function* () { | ||
| 175 | const glab = yield* GitLabCli.GitLabCli; | ||
| 176 | return yield* glab.getRepositoryCloneUrls({ | ||
| 177 | cwd: "/repo", | ||
| 178 | repository: "octocat/t3code", | ||
| 179 | }); | ||
| 180 | }); | ||
| 181 | |||
| 182 | assert.deepStrictEqual(result, { | ||
| 183 | nameWithOwner: "octocat/t3code", | ||
| 184 | url: "https://gitlab.com/octocat/t3code", | ||
| 185 | sshUrl: "git@gitlab.com:octocat/t3code.git", | ||
| 186 | }); | ||
| 187 | }), | ||
| 188 | ); | ||
| 189 | |||
| 190 | it.effect("creates merge requests through the GitLab API without placing the body in argv", () => | ||
| 191 | Effect.gen(function* () { | ||
| 192 | mockedRun.mockReturnValueOnce(Effect.succeed(processOutput("{}"))); | ||
| 193 | |||
| 194 | const glab = yield* GitLabCli.GitLabCli; | ||
| 195 | yield* glab.createMergeRequest({ | ||
| 196 | cwd: "/repo", | ||
| 197 | baseBranch: "main", | ||
| 198 | headSelector: "owner:feature/provider", | ||
| 199 | title: "Provider MR", | ||
| 200 | bodyFile: "/tmp/t3-mr-body.md", | ||
| 201 | }); | ||
| 202 | |||
| 203 | expect(mockedRun).toHaveBeenCalledWith( | ||
| 204 | expect.objectContaining({ | ||
| 205 | command: "glab", | ||
| 206 | cwd: "/repo", | ||
| 207 | args: [ | ||
| 208 | "api", | ||
| 209 | "--method", | ||
| 210 | "POST", | ||
| 211 | "projects/:fullpath/merge_requests", | ||
| 212 | "--raw-field", | ||
| 213 | "source_branch=feature/provider", | ||
| 214 | "--raw-field", | ||
| 215 | "target_branch=main", | ||
| 216 | "--raw-field", | ||
| 217 | "title=Provider MR", | ||
| 218 | "--field", | ||
| 219 | "description=@/tmp/t3-mr-body.md", | ||
| 220 | ], | ||
| 221 | }), | ||
| 222 | ); | ||
| 223 | }), | ||
| 224 | ); | ||
| 225 | |||
| 226 | it.effect("creates repositories under an explicit namespace", () => | ||
| 227 | Effect.gen(function* () { | ||
| 228 | mockedRun | ||
| 229 | |||
| 230 | .mockReturnValueOnce(Effect.succeed(processOutput(JSON.stringify({ id: 1234 })))) | ||
| 231 | .mockReturnValueOnce( | ||
| 232 | Effect.succeed( | ||
| 233 | processOutput( | ||
| 234 | JSON.stringify({ | ||
| 235 | path_with_namespace: "octocat/t3code", | ||
| 236 | web_url: "https://gitlab.com/octocat/t3code", | ||
| 237 | http_url_to_repo: "https://gitlab.com/octocat/t3code.git", | ||
| 238 | ssh_url_to_repo: "git@gitlab.com:octocat/t3code.git", | ||
| 239 | }), | ||
| 240 | ), | ||
| 241 | ), | ||
| 242 | ); | ||
| 243 | |||
| 244 | const glab = yield* GitLabCli.GitLabCli; | ||
| 245 | const result = yield* glab.createRepository({ | ||
| 246 | cwd: "/repo", | ||
| 247 | repository: "octocat/t3code", | ||
| 248 | visibility: "public", | ||
| 249 | }); | ||
| 250 | |||
| 251 | assert.deepStrictEqual(result, { | ||
| 252 | nameWithOwner: "octocat/t3code", | ||
| 253 | url: "https://gitlab.com/octocat/t3code", | ||
| 254 | sshUrl: "git@gitlab.com:octocat/t3code.git", | ||
| 255 | }); | ||
| 256 | expect(mockedRun).toHaveBeenNthCalledWith( | ||
| 257 | 1, | ||
| 258 | expect.objectContaining({ | ||
| 259 | command: "glab", | ||
| 260 | cwd: "/repo", | ||
| 261 | args: ["api", "namespaces/octocat"], | ||
| 262 | }), | ||
| 263 | ); | ||
| 264 | expect(mockedRun).toHaveBeenNthCalledWith( | ||
| 265 | 2, | ||
| 266 | expect.objectContaining({ | ||
| 267 | command: "glab", | ||
| 268 | cwd: "/repo", | ||
| 269 | args: [ | ||
| 270 | "api", | ||
| 271 | "--method", | ||
| 272 | "POST", | ||
| 273 | "projects", | ||
| 274 | "--raw-field", | ||
| 275 | "path=t3code", | ||
| 276 | "--raw-field", | ||
| 277 | "name=t3code", | ||
| 278 | "--raw-field", | ||
| 279 | "visibility=public", | ||
| 280 | "--raw-field", | ||
| 281 | "namespace_id=1234", | ||
| 282 | ], | ||
| 283 | }), | ||
| 284 | ); | ||
| 285 | }), | ||
| 286 | ); | ||
| 287 | |||
| 288 | it.effect("does not pass unsupported force flags when checking out merge requests", () => | ||
| 289 | Effect.gen(function* () { | ||
| 290 | mockedRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); | ||
| 291 | |||
| 292 | const glab = yield* GitLabCli.GitLabCli; | ||
| 293 | yield* glab.checkoutMergeRequest({ | ||
| 294 | cwd: "/repo", | ||
| 295 | reference: "42", | ||
| 296 | force: true, | ||
| 297 | }); | ||
| 298 | |||
| 299 | expect(mockedRun).toHaveBeenCalledWith( | ||
| 300 | expect.objectContaining({ | ||
| 301 | command: "glab", | ||
| 302 | cwd: "/repo", | ||
| 303 | args: ["mr", "checkout", "42"], | ||
| 304 | }), | ||
| 305 | ); | ||
| 306 | }), | ||
| 307 | ); | ||
| 308 | |||
| 309 | it.effect("surfaces a friendly error when the merge request is not found", () => | ||
| 310 | Effect.gen(function* () { | ||
| 311 | const cause = new VcsProcessExitError({ | ||
| 312 | operation: "GitLabCli.execute", | ||
| 313 | command: "glab", | ||
| 314 | cwd: "/repo", | ||
| 315 | exitCode: 1, | ||
| 316 | detail: "GET 404 merge request not found", | ||
| 317 | failureKind: "not-found", | ||
| 318 | }); | ||
| 319 | mockedRun.mockReturnValueOnce(Effect.fail(cause)); | ||
| 320 | |||
| 321 | const error = yield* Effect.gen(function* () { | ||
| 322 | const glab = yield* GitLabCli.GitLabCli; | ||
| 323 | return yield* glab.getMergeRequest({ | ||
| 324 | cwd: "/repo", | ||
| 325 | reference: "4888", | ||
| 326 | }); | ||
| 327 | }).pipe(Effect.flip); | ||
| 328 | |||
| 329 | assert.equal(error.message.includes("Merge request 4888 was not found"), true); | ||
| 330 | assert.strictEqual(error._tag, "GitLabMergeRequestNotFoundError"); | ||
| 331 | assert.strictEqual(error.command, "glab"); | ||
| 332 | assert.strictEqual(error.cwd, "/repo"); | ||
| 333 | assert.strictEqual(error.cause, cause); | ||
| 334 | assert.equal(error.message.includes(cause.detail), false); | ||
| 335 | }), | ||
| 336 | ); | ||
| 337 | |||
| 338 | it.effect("keeps non-merge-request not-found failures generic", () => | ||
| 339 | Effect.gen(function* () { | ||
| 340 | const cause = new VcsProcessExitError({ | ||
| 341 | operation: "GitLabCli.execute", | ||
| 342 | command: "glab", | ||
| 343 | cwd: "/repo", | ||
| 344 | exitCode: 1, | ||
| 345 | detail: "GET 404 project not found", | ||
| 346 | failureKind: "not-found", | ||
| 347 | }); | ||
| 348 | mockedRun.mockReturnValueOnce(Effect.fail(cause)); | ||
| 349 | |||
| 350 | const error = yield* Effect.gen(function* () { | ||
| 351 | const glab = yield* GitLabCli.GitLabCli; | ||
| 352 | return yield* glab.getRepositoryCloneUrls({ | ||
| 353 | cwd: "/repo", | ||
| 354 | repository: "missing/project", | ||
| 355 | }); | ||
| 356 | }).pipe(Effect.flip); | ||
| 357 | |||
| 358 | assert.strictEqual(error._tag, "GitLabCliCommandError"); | ||
| 359 | assert.strictEqual(error.cause, cause); | ||
| 360 | }), | ||
| 361 | ); | ||
| 362 | |||
| 363 | it.effect("preserves rate-limit failures as a distinct error", () => | ||
| 364 | Effect.gen(function* () { | ||
| 365 | const cause = new VcsProcessExitError({ | ||
| 366 | operation: "GitLabCli.execute", | ||
| 367 | command: "glab", | ||
| 368 | cwd: "/repo", | ||
| 369 | exitCode: 1, | ||
| 370 | detail: "API rate limit exceeded.", | ||
| 371 | failureKind: "rate-limited", | ||
| 372 | }); | ||
| 373 | mockedRun.mockReturnValueOnce(Effect.fail(cause)); | ||
| 374 | |||
| 375 | const glab = yield* GitLabCli.GitLabCli; | ||
| 376 | const error = yield* glab | ||
| 377 | .execute({ cwd: "/repo", args: ["api", "projects"] }) | ||
| 378 | .pipe(Effect.flip); | ||
| 379 | |||
| 380 | assert.strictEqual(error._tag, "GitLabCliRateLimitError"); | ||
| 381 | assert.strictEqual(error.cause, cause); | ||
| 382 | }), | ||
| 383 | ); | ||
| 384 | }); | ||
apps/server/src/sourceControl/GitLabCli.ts deleted-660| ... | @@ -1,660 +0,0 @@ | ||
| 1 | import * as Context from "effect/Context"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Layer from "effect/Layer"; | ||
| 4 | import * as Match from "effect/Match"; | ||
| 5 | import * as Option from "effect/Option"; | ||
| 6 | import * as Result from "effect/Result"; | ||
| 7 | import * as Schema from "effect/Schema"; | ||
| 8 | import type * as DateTime from "effect/DateTime"; | ||
| 9 | |||
| 10 | import { | ||
| 11 | TrimmedNonEmptyString, | ||
| 12 | type SourceControlRepositoryVisibility, | ||
| 13 | type VcsError, | ||
| 14 | } from "@t3tools/contracts"; | ||
| 15 | |||
| 16 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | ||
| 17 | import { | ||
| 18 | decodeGitLabMergeRequestJson, | ||
| 19 | decodeGitLabMergeRequestListJson, | ||
| 20 | } from "./gitLabMergeRequests.ts"; | ||
| 21 | import type * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; | ||
| 22 | |||
| 23 | const DEFAULT_TIMEOUT_MS = 30_000; | ||
| 24 | |||
| 25 | const gitLabCliExecutionErrorContext = { | ||
| 26 | operation: Schema.Literal("execute"), | ||
| 27 | command: Schema.Literal("glab"), | ||
| 28 | cwd: Schema.String, | ||
| 29 | cause: Schema.Defect(), | ||
| 30 | }; | ||
| 31 | |||
| 32 | const gitLabCliDecodeErrorContext = { | ||
| 33 | command: Schema.Literal("glab"), | ||
| 34 | cwd: Schema.String, | ||
| 35 | cause: Schema.Defect(), | ||
| 36 | }; | ||
| 37 | |||
| 38 | export class GitLabCliUnavailableError extends Schema.TaggedError<GitLabCliUnavailableError>()( | ||
| 39 | "GitLabCliUnavailableError", | ||
| 40 | gitLabCliExecutionErrorContext, | ||
| 41 | ) { | ||
| 42 | get detail(): string { | ||
| 43 | return "GitLab CLI (`glab`) is required but not available on PATH."; | ||
| 44 | } | ||
| 45 | |||
| 46 | override get message(): string { | ||
| 47 | return `GitLab CLI failed in ${this.operation}: ${this.detail}`; | ||
| 48 | } | ||
| 49 | } | ||
| 50 | |||
| 51 | export class GitLabCliAuthenticationError extends Schema.TaggedError<GitLabCliAuthenticationError>()( | ||
| 52 | "GitLabCliAuthenticationError", | ||
| 53 | gitLabCliExecutionErrorContext, | ||
| 54 | ) { | ||
| 55 | get detail(): string { | ||
| 56 | return "GitLab CLI is not authenticated. Run `glab auth login` and retry."; | ||
| 57 | } | ||
| 58 | |||
| 59 | override get message(): string { | ||
| 60 | return `GitLab CLI failed in ${this.operation}: ${this.detail}`; | ||
| 61 | } | ||
| 62 | } | ||
| 63 | |||
| 64 | export class GitLabCliRateLimitError extends Schema.TaggedError<GitLabCliRateLimitError>()( | ||
| 65 | "GitLabCliRateLimitError", | ||
| 66 | gitLabCliExecutionErrorContext, | ||
| 67 | ) { | ||
| 68 | get detail(): string { | ||
| 69 | return "GitLab API rate limit exceeded."; | ||
| 70 | } | ||
| 71 | |||
| 72 | override get message(): string { | ||
| 73 | return `GitLab CLI failed in ${this.operation}: ${this.detail}`; | ||
| 74 | } | ||
| 75 | } | ||
| 76 | |||
| 77 | export class GitLabMergeRequestNotFoundError extends Schema.TaggedError<GitLabMergeRequestNotFoundError>()( | ||
| 78 | "GitLabMergeRequestNotFoundError", | ||
| 79 | { | ||
| 80 | ...gitLabCliExecutionErrorContext, | ||
| 81 | reference: Schema.String, | ||
| 82 | }, | ||
| 83 | ) { | ||
| 84 | get detail(): string { | ||
| 85 | return `Merge request ${this.reference} was not found. Check the MR number or URL and try again.`; | ||
| 86 | } | ||
| 87 | |||
| 88 | override get message(): string { | ||
| 89 | return `GitLab CLI failed in ${this.operation}: ${this.detail}`; | ||
| 90 | } | ||
| 91 | |||
| 92 | static fromVcsError( | ||
| 93 | context: { | ||
| 94 | readonly operation: "execute"; | ||
| 95 | readonly command: "glab"; | ||
| 96 | readonly cwd: string; | ||
| 97 | readonly reference: string; | ||
| 98 | }, | ||
| 99 | error: VcsError, | ||
| 100 | ): GitLabCliError { | ||
| 101 | if (error._tag === "VcsProcessExitError" && error.failureKind === "not-found") { | ||
| 102 | return new GitLabMergeRequestNotFoundError({ ...context, cause: error }); | ||
| 103 | } | ||
| 104 | |||
| 105 | return GitLabCliCommandError.fromVcsError( | ||
| 106 | { | ||
| 107 | operation: context.operation, | ||
| 108 | command: context.command, | ||
| 109 | cwd: context.cwd, | ||
| 110 | }, | ||
| 111 | error, | ||
| 112 | ); | ||
| 113 | } | ||
| 114 | } | ||
| 115 | |||
| 116 | export class GitLabCliCommandError extends Schema.TaggedError<GitLabCliCommandError>()( | ||
| 117 | "GitLabCliCommandError", | ||
| 118 | gitLabCliExecutionErrorContext, | ||
| 119 | ) { | ||
| 120 | get detail(): string { | ||
| 121 | return "GitLab CLI command failed."; | ||
| 122 | } | ||
| 123 | |||
| 124 | override get message(): string { | ||
| 125 | return `GitLab CLI failed in ${this.operation}: ${this.detail}`; | ||
| 126 | } | ||
| 127 | |||
| 128 | static fromVcsError( | ||
| 129 | context: { | ||
| 130 | readonly operation: "execute"; | ||
| 131 | readonly command: "glab"; | ||
| 132 | readonly cwd: string; | ||
| 133 | }, | ||
| 134 | error: VcsError, | ||
| 135 | ): GitLabCliError { | ||
| 136 | return Match.valueTags(error, { | ||
| 137 | VcsProcessSpawnError: (cause) => new GitLabCliUnavailableError({ ...context, cause }), | ||
| 138 | VcsProcessExitError: (cause) => { | ||
| 139 | switch (cause.failureKind) { | ||
| 140 | case "authentication": | ||
| 141 | return new GitLabCliAuthenticationError({ ...context, cause }); | ||
| 142 | case "rate-limited": | ||
| 143 | return new GitLabCliRateLimitError({ ...context, cause }); | ||
| 144 | case "not-found": | ||
| 145 | case "command-failed": | ||
| 146 | case undefined: | ||
| 147 | return new GitLabCliCommandError({ ...context, cause }); | ||
| 148 | } | ||
| 149 | }, | ||
| 150 | VcsProcessTimeoutError: (cause) => new GitLabCliCommandError({ ...context, cause }), | ||
| 151 | VcsProcessStdinWriteError: (cause) => new GitLabCliCommandError({ ...context, cause }), | ||
| 152 | VcsProcessOutputReadError: (cause) => new GitLabCliCommandError({ ...context, cause }), | ||
| 153 | VcsProcessOutputLimitError: (cause) => new GitLabCliCommandError({ ...context, cause }), | ||
| 154 | VcsProcessMissingExitCodeError: (cause) => new GitLabCliCommandError({ ...context, cause }), | ||
| 155 | VcsRepositoryDetectionError: (cause) => new GitLabCliCommandError({ ...context, cause }), | ||
| 156 | VcsUnsupportedOperationError: (cause) => new GitLabCliCommandError({ ...context, cause }), | ||
| 157 | }); | ||
| 158 | } | ||
| 159 | } | ||
| 160 | |||
| 161 | export class GitLabMergeRequestListDecodeError extends Schema.TaggedError<GitLabMergeRequestListDecodeError>()( | ||
| 162 | "GitLabMergeRequestListDecodeError", | ||
| 163 | { | ||
| 164 | ...gitLabCliDecodeErrorContext, | ||
| 165 | operation: Schema.Literal("listMergeRequests"), | ||
| 166 | }, | ||
| 167 | ) { | ||
| 168 | get detail(): string { | ||
| 169 | return "GitLab CLI returned invalid MR list JSON."; | ||
| 170 | } | ||
| 171 | |||
| 172 | override get message(): string { | ||
| 173 | return `GitLab CLI failed in ${this.operation}: ${this.detail}`; | ||
| 174 | } | ||
| 175 | } | ||
| 176 | |||
| 177 | export class GitLabMergeRequestDecodeError extends Schema.TaggedError<GitLabMergeRequestDecodeError>()( | ||
| 178 | "GitLabMergeRequestDecodeError", | ||
| 179 | { | ||
| 180 | ...gitLabCliDecodeErrorContext, | ||
| 181 | operation: Schema.Literal("getMergeRequest"), | ||
| 182 | reference: Schema.String, | ||
| 183 | }, | ||
| 184 | ) { | ||
| 185 | get detail(): string { | ||
| 186 | return "GitLab CLI returned invalid merge request JSON."; | ||
| 187 | } | ||
| 188 | |||
| 189 | override get message(): string { | ||
| 190 | return `GitLab CLI failed in ${this.operation}: ${this.detail}`; | ||
| 191 | } | ||
| 192 | } | ||
| 193 | |||
| 194 | export class GitLabRepositoryDecodeError extends Schema.TaggedError<GitLabRepositoryDecodeError>()( | ||
| 195 | "GitLabRepositoryDecodeError", | ||
| 196 | { | ||
| 197 | ...gitLabCliDecodeErrorContext, | ||
| 198 | operation: Schema.Literals(["getRepositoryCloneUrls", "createRepository", "getDefaultBranch"]), | ||
| 199 | repository: Schema.optional(Schema.String), | ||
| 200 | }, | ||
| 201 | ) { | ||
| 202 | get detail(): string { | ||
| 203 | return "GitLab CLI returned invalid repository JSON."; | ||
| 204 | } | ||
| 205 | |||
| 206 | override get message(): string { | ||
| 207 | return `GitLab CLI failed in ${this.operation}: ${this.detail}`; | ||
| 208 | } | ||
| 209 | } | ||
| 210 | |||
| 211 | export class GitLabNamespaceDecodeError extends Schema.TaggedError<GitLabNamespaceDecodeError>()( | ||
| 212 | "GitLabNamespaceDecodeError", | ||
| 213 | { | ||
| 214 | ...gitLabCliDecodeErrorContext, | ||
| 215 | operation: Schema.Literal("createRepository"), | ||
| 216 | namespacePath: Schema.String, | ||
| 217 | }, | ||
| 218 | ) { | ||
| 219 | get detail(): string { | ||
| 220 | return "GitLab CLI returned invalid namespace JSON."; | ||
| 221 | } | ||
| 222 | |||
| 223 | override get message(): string { | ||
| 224 | return `GitLab CLI failed in ${this.operation}: ${this.detail}`; | ||
| 225 | } | ||
| 226 | } | ||
| 227 | |||
| 228 | export const GitLabCliError = Schema.Union([ | ||
| 229 | GitLabCliUnavailableError, | ||
| 230 | GitLabCliAuthenticationError, | ||
| 231 | GitLabCliRateLimitError, | ||
| 232 | GitLabMergeRequestNotFoundError, | ||
| 233 | GitLabCliCommandError, | ||
| 234 | GitLabMergeRequestListDecodeError, | ||
| 235 | GitLabMergeRequestDecodeError, | ||
| 236 | GitLabRepositoryDecodeError, | ||
| 237 | GitLabNamespaceDecodeError, | ||
| 238 | ]); | ||
| 239 | export type GitLabCliError = typeof GitLabCliError.Type; | ||
| 240 | |||
| 241 | export interface GitLabMergeRequestSummary { | ||
| 242 | readonly number: number; | ||
| 243 | readonly title: string; | ||
| 244 | readonly url: string; | ||
| 245 | readonly baseRefName: string; | ||
| 246 | readonly headRefName: string; | ||
| 247 | readonly state?: "open" | "closed" | "merged"; | ||
| 248 | readonly isDraft?: boolean; | ||
| 249 | readonly closedAt?: string | null; | ||
| 250 | readonly mergedAt?: string | null; | ||
| 251 | readonly updatedAt?: Option.Option<DateTime.Utc>; | ||
| 252 | readonly isCrossRepository?: boolean; | ||
| 253 | readonly headRepositoryNameWithOwner?: string | null; | ||
| 254 | readonly headRepositoryOwnerLogin?: string | null; | ||
| 255 | } | ||
| 256 | |||
| 257 | export interface GitLabRepositoryCloneUrls { | ||
| 258 | readonly nameWithOwner: string; | ||
| 259 | readonly url: string; | ||
| 260 | readonly sshUrl: string; | ||
| 261 | } | ||
| 262 | |||
| 263 | export class GitLabCli extends Context.Service< | ||
| 264 | GitLabCli, | ||
| 265 | { | ||
| 266 | readonly execute: (input: { | ||
| 267 | readonly cwd: string; | ||
| 268 | readonly args: ReadonlyArray<string>; | ||
| 269 | readonly timeoutMs?: number; | ||
| 270 | /** Piped to the child's stdin, for payloads that must never appear in argv. */ | ||
| 271 | readonly stdin?: string; | ||
| 272 | readonly maxOutputBytes?: number; | ||
| 273 | }) => Effect.Effect<VcsProcess.VcsProcessOutput, GitLabCliError>; | ||
| 274 | |||
| 275 | readonly listMergeRequests: (input: { | ||
| 276 | readonly cwd: string; | ||
| 277 | readonly headSelector: string; | ||
| 278 | readonly source?: SourceControlProvider.SourceControlRefSelector; | ||
| 279 | readonly state: "open" | "closed" | "merged" | "all"; | ||
| 280 | readonly limit?: number; | ||
| 281 | }) => Effect.Effect<ReadonlyArray<GitLabMergeRequestSummary>, GitLabCliError>; | ||
| 282 | |||
| 283 | readonly getMergeRequest: (input: { | ||
| 284 | readonly cwd: string; | ||
| 285 | readonly reference: string; | ||
| 286 | }) => Effect.Effect<GitLabMergeRequestSummary, GitLabCliError>; | ||
| 287 | |||
| 288 | readonly getRepositoryCloneUrls: (input: { | ||
| 289 | readonly cwd: string; | ||
| 290 | readonly repository: string; | ||
| 291 | }) => Effect.Effect<GitLabRepositoryCloneUrls, GitLabCliError>; | ||
| 292 | |||
| 293 | readonly createRepository: (input: { | ||
| 294 | readonly cwd: string; | ||
| 295 | readonly repository: string; | ||
| 296 | readonly visibility: SourceControlRepositoryVisibility; | ||
| 297 | }) => Effect.Effect<GitLabRepositoryCloneUrls, GitLabCliError>; | ||
| 298 | |||
| 299 | readonly createMergeRequest: (input: { | ||
| 300 | readonly cwd: string; | ||
| 301 | readonly baseBranch: string; | ||
| 302 | readonly headSelector: string; | ||
| 303 | readonly source?: SourceControlProvider.SourceControlRefSelector; | ||
| 304 | readonly target?: SourceControlProvider.SourceControlRefSelector; | ||
| 305 | readonly title: string; | ||
| 306 | readonly bodyFile: string; | ||
| 307 | }) => Effect.Effect<void, GitLabCliError>; | ||
| 308 | |||
| 309 | readonly getDefaultBranch: (input: { | ||
| 310 | readonly cwd: string; | ||
| 311 | }) => Effect.Effect<string | null, GitLabCliError>; | ||
| 312 | |||
| 313 | readonly checkoutMergeRequest: (input: { | ||
| 314 | readonly cwd: string; | ||
| 315 | readonly reference: string; | ||
| 316 | readonly force?: boolean; | ||
| 317 | }) => Effect.Effect<void, GitLabCliError>; | ||
| 318 | } | ||
| 319 | >()("t3/sourceControl/GitLabCli") {} | ||
| 320 | |||
| 321 | const RawGitLabRepositoryCloneUrlsSchema = Schema.Struct({ | ||
| 322 | path_with_namespace: TrimmedNonEmptyString, | ||
| 323 | web_url: TrimmedNonEmptyString, | ||
| 324 | http_url_to_repo: TrimmedNonEmptyString, | ||
| 325 | ssh_url_to_repo: TrimmedNonEmptyString, | ||
| 326 | }); | ||
| 327 | |||
| 328 | const RawGitLabDefaultBranchSchema = Schema.Struct({ | ||
| 329 | default_branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), | ||
| 330 | }); | ||
| 331 | |||
| 332 | const RawGitLabNamespaceSchema = Schema.Struct({ | ||
| 333 | id: Schema.Number, | ||
| 334 | }); | ||
| 335 | |||
| 336 | const decodeGitLabRepositoryCloneUrls = Schema.decodeEffect( | ||
| 337 | Schema.fromJsonString(RawGitLabRepositoryCloneUrlsSchema), | ||
| 338 | ); | ||
| 339 | const decodeGitLabDefaultBranch = Schema.decodeEffect( | ||
| 340 | Schema.fromJsonString(RawGitLabDefaultBranchSchema), | ||
| 341 | ); | ||
| 342 | const decodeGitLabNamespace = Schema.decodeEffect(Schema.fromJsonString(RawGitLabNamespaceSchema)); | ||
| 343 | |||
| 344 | function normalizeRepositoryCloneUrls( | ||
| 345 | raw: Schema.Schema.Type<typeof RawGitLabRepositoryCloneUrlsSchema>, | ||
| 346 | ): GitLabRepositoryCloneUrls { | ||
| 347 | return { | ||
| 348 | nameWithOwner: raw.path_with_namespace, | ||
| 349 | url: raw.web_url, | ||
| 350 | sshUrl: raw.ssh_url_to_repo, | ||
| 351 | }; | ||
| 352 | } | ||
| 353 | |||
| 354 | function stateArgs(state: "open" | "closed" | "merged" | "all"): ReadonlyArray<string> { | ||
| 355 | switch (state) { | ||
| 356 | case "open": | ||
| 357 | return []; | ||
| 358 | case "closed": | ||
| 359 | return ["--closed"]; | ||
| 360 | case "merged": | ||
| 361 | return ["--merged"]; | ||
| 362 | case "all": | ||
| 363 | return ["--all"]; | ||
| 364 | } | ||
| 365 | } | ||
| 366 | |||
| 367 | function normalizeHeadSelector(headSelector: string): string { | ||
| 368 | const trimmed = headSelector.trim(); | ||
| 369 | const ownerBranch = /^[^:]+:(.+)$/.exec(trimmed); | ||
| 370 | return ownerBranch?.[1]?.trim() || trimmed; | ||
| 371 | } | ||
| 372 | |||
| 373 | function sourceRefName(input: { | ||
| 374 | readonly headSelector: string; | ||
| 375 | readonly source?: SourceControlProvider.SourceControlRefSelector; | ||
| 376 | }): string { | ||
| 377 | return input.source?.refName ?? normalizeHeadSelector(input.headSelector); | ||
| 378 | } | ||
| 379 | |||
| 380 | function sourceProjectIdentifier( | ||
| 381 | source: SourceControlProvider.SourceControlRefSelector | undefined, | ||
| 382 | ): string | null { | ||
| 383 | return source?.repository ?? source?.owner ?? null; | ||
| 384 | } | ||
| 385 | |||
| 386 | function toSummaryWithOptionalUpdatedAt( | ||
| 387 | record: GitLabMergeRequestSummary & { | ||
| 388 | readonly updatedAt: Option.Option<DateTime.Utc>; | ||
| 389 | }, | ||
| 390 | ): GitLabMergeRequestSummary { | ||
| 391 | const { updatedAt, ...summary } = record; | ||
| 392 | return Option.isSome(updatedAt) ? { ...summary, updatedAt } : summary; | ||
| 393 | } | ||
| 394 | |||
| 395 | function parseRepositoryPath(repository: string): { | ||
| 396 | readonly namespacePath: string | null; | ||
| 397 | readonly projectPath: string; | ||
| 398 | } { | ||
| 399 | const parts: Array<string> = []; | ||
| 400 | for (const part of repository.split("/")) { | ||
| 401 | const trimmed = part.trim(); | ||
| 402 | if (trimmed.length > 0) { | ||
| 403 | parts.push(trimmed); | ||
| 404 | } | ||
| 405 | } | ||
| 406 | const projectPath = parts.at(-1) ?? repository.trim(); | ||
| 407 | const namespacePath = parts.length > 1 ? parts.slice(0, -1).join("/") : null; | ||
| 408 | return { namespacePath, projectPath }; | ||
| 409 | } | ||
| 410 | |||
| 411 | /** @public Service construction is part of the canonical Effect module API. */ | ||
| 412 | export const make = Effect.gen(function* () { | ||
| 413 | const process = yield* VcsProcess.VcsProcess; | ||
| 414 | |||
| 415 | const run = ( | ||
| 416 | input: Parameters<GitLabCli["Service"]["execute"]>[0], | ||
| 417 | mapError: (error: VcsError) => GitLabCliError, | ||
| 418 | ) => | ||
| 419 | process | ||
| 420 | .run({ | ||
| 421 | operation: "GitLabCli.execute", | ||
| 422 | command: "glab", | ||
| 423 | args: input.args, | ||
| 424 | cwd: input.cwd, | ||
| 425 | timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, | ||
| 426 | ...(input.stdin === undefined ? {} : { stdin: input.stdin }), | ||
| 427 | ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), | ||
| 428 | }) | ||
| 429 | .pipe(Effect.mapError(mapError)); | ||
| 430 | |||
| 431 | const execute: GitLabCli["Service"]["execute"] = (input) => | ||
| 432 | run(input, (error) => | ||
| 433 | GitLabCliCommandError.fromVcsError( | ||
| 434 | { operation: "execute", command: "glab", cwd: input.cwd }, | ||
| 435 | error, | ||
| 436 | ), | ||
| 437 | ); | ||
| 438 | |||
| 439 | const executeMergeRequest = (input: { | ||
| 440 | readonly cwd: string; | ||
| 441 | readonly reference: string; | ||
| 442 | readonly args: ReadonlyArray<string>; | ||
| 443 | }) => | ||
| 444 | run(input, (error) => | ||
| 445 | GitLabMergeRequestNotFoundError.fromVcsError( | ||
| 446 | { | ||
| 447 | operation: "execute", | ||
| 448 | command: "glab", | ||
| 449 | cwd: input.cwd, | ||
| 450 | reference: input.reference, | ||
| 451 | }, | ||
| 452 | error, | ||
| 453 | ), | ||
| 454 | ); | ||
| 455 | |||
| 456 | return GitLabCli.of({ | ||
| 457 | execute, | ||
| 458 | listMergeRequests: (input) => | ||
| 459 | execute({ | ||
| 460 | cwd: input.cwd, | ||
| 461 | args: [ | ||
| 462 | "mr", | ||
| 463 | "list", | ||
| 464 | "--source-branch", | ||
| 465 | sourceRefName(input), | ||
| 466 | ...stateArgs(input.state), | ||
| 467 | "--per-page", | ||
| 468 | String(input.limit ?? 20), | ||
| 469 | "--output", | ||
| 470 | "json", | ||
| 471 | ], | ||
| 472 | }).pipe( | ||
| 473 | Effect.map((result) => result.stdout.trim()), | ||
| 474 | Effect.flatMap((raw) => | ||
| 475 | raw.length === 0 | ||
| 476 | ? Effect.succeed([]) | ||
| 477 | : Effect.sync(() => decodeGitLabMergeRequestListJson(raw)).pipe( | ||
| 478 | Effect.flatMap((decoded) => { | ||
| 479 | if (!Result.isSuccess(decoded)) { | ||
| 480 | return Effect.fail( | ||
| 481 | new GitLabMergeRequestListDecodeError({ | ||
| 482 | operation: "listMergeRequests", | ||
| 483 | command: "glab", | ||
| 484 | cwd: input.cwd, | ||
| 485 | cause: decoded.failure, | ||
| 486 | }), | ||
| 487 | ); | ||
| 488 | } | ||
| 489 | |||
| 490 | return Effect.succeed(decoded.success.map(toSummaryWithOptionalUpdatedAt)); | ||
| 491 | }), | ||
| 492 | ), | ||
| 493 | ), | ||
| 494 | ), | ||
| 495 | getMergeRequest: (input) => | ||
| 496 | executeMergeRequest({ | ||
| 497 | cwd: input.cwd, | ||
| 498 | reference: input.reference, | ||
| 499 | args: ["mr", "view", input.reference, "--output", "json"], | ||
| 500 | }).pipe( | ||
| 501 | Effect.map((result) => result.stdout.trim()), | ||
| 502 | Effect.flatMap((raw) => | ||
| 503 | Effect.sync(() => decodeGitLabMergeRequestJson(raw)).pipe( | ||
| 504 | Effect.flatMap((decoded) => { | ||
| 505 | if (!Result.isSuccess(decoded)) { | ||
| 506 | return Effect.fail( | ||
| 507 | new GitLabMergeRequestDecodeError({ | ||
| 508 | operation: "getMergeRequest", | ||
| 509 | command: "glab", | ||
| 510 | cwd: input.cwd, | ||
| 511 | reference: input.reference, | ||
| 512 | cause: decoded.failure, | ||
| 513 | }), | ||
| 514 | ); | ||
| 515 | } | ||
| 516 | |||
| 517 | return Effect.succeed(toSummaryWithOptionalUpdatedAt(decoded.success)); | ||
| 518 | }), | ||
| 519 | ), | ||
| 520 | ), | ||
| 521 | ), | ||
| 522 | getRepositoryCloneUrls: (input) => | ||
| 523 | execute({ | ||
| 524 | cwd: input.cwd, | ||
| 525 | args: ["api", `projects/${encodeURIComponent(input.repository)}`], | ||
| 526 | }).pipe( | ||
| 527 | Effect.map((result) => result.stdout.trim()), | ||
| 528 | Effect.flatMap((raw) => | ||
| 529 | decodeGitLabRepositoryCloneUrls(raw).pipe( | ||
| 530 | Effect.mapError( | ||
| 531 | (cause) => | ||
| 532 | new GitLabRepositoryDecodeError({ | ||
| 533 | operation: "getRepositoryCloneUrls", | ||
| 534 | command: "glab", | ||
| 535 | cwd: input.cwd, | ||
| 536 | repository: input.repository, | ||
| 537 | cause, | ||
| 538 | }), | ||
| 539 | ), | ||
| 540 | ), | ||
| 541 | ), | ||
| 542 | Effect.map(normalizeRepositoryCloneUrls), | ||
| 543 | ), | ||
| 544 | createRepository: (input) => { | ||
| 545 | const { namespacePath, projectPath } = parseRepositoryPath(input.repository); | ||
| 546 | const namespaceId: Effect.Effect<number | null, GitLabCliError> = namespacePath | ||
| 547 | ? execute({ | ||
| 548 | cwd: input.cwd, | ||
| 549 | args: ["api", `namespaces/${encodeURIComponent(namespacePath)}`], | ||
| 550 | }).pipe( | ||
| 551 | Effect.map((result) => result.stdout.trim()), | ||
| 552 | Effect.flatMap((raw) => | ||
| 553 | decodeGitLabNamespace(raw).pipe( | ||
| 554 | Effect.mapError( | ||
| 555 | (cause) => | ||
| 556 | new GitLabNamespaceDecodeError({ | ||
| 557 | operation: "createRepository", | ||
| 558 | command: "glab", | ||
| 559 | cwd: input.cwd, | ||
| 560 | namespacePath, | ||
| 561 | cause, | ||
| 562 | }), | ||
| 563 | ), | ||
| 564 | ), | ||
| 565 | ), | ||
| 566 | Effect.map((namespace) => namespace.id), | ||
| 567 | ) | ||
| 568 | : Effect.succeed(null); | ||
| 569 | |||
| 570 | return namespaceId.pipe( | ||
| 571 | Effect.flatMap((resolvedNamespaceId) => | ||
| 572 | execute({ | ||
| 573 | cwd: input.cwd, | ||
| 574 | args: [ | ||
| 575 | "api", | ||
| 576 | "--method", | ||
| 577 | "POST", | ||
| 578 | "projects", | ||
| 579 | "--raw-field", | ||
| 580 | `path=${projectPath}`, | ||
| 581 | "--raw-field", | ||
| 582 | `name=${projectPath}`, | ||
| 583 | "--raw-field", | ||
| 584 | `visibility=${input.visibility}`, | ||
| 585 | ...(resolvedNamespaceId === null | ||
| 586 | ? [] | ||
| 587 | : ["--raw-field", `namespace_id=${resolvedNamespaceId}`]), | ||
| 588 | ], | ||
| 589 | }), | ||
| 590 | ), | ||
| 591 | Effect.map((result) => result.stdout.trim()), | ||
| 592 | Effect.flatMap((raw) => | ||
| 593 | decodeGitLabRepositoryCloneUrls(raw).pipe( | ||
| 594 | Effect.mapError( | ||
| 595 | (cause) => | ||
| 596 | new GitLabRepositoryDecodeError({ | ||
| 597 | operation: "createRepository", | ||
| 598 | command: "glab", | ||
| 599 | cwd: input.cwd, | ||
| 600 | repository: input.repository, | ||
| 601 | cause, | ||
| 602 | }), | ||
| 603 | ), | ||
| 604 | ), | ||
| 605 | ), | ||
| 606 | Effect.map(normalizeRepositoryCloneUrls), | ||
| 607 | ); | ||
| 608 | }, | ||
| 609 | createMergeRequest: (input) => { | ||
| 610 | const sourceProject = sourceProjectIdentifier(input.source); | ||
| 611 | return execute({ | ||
| 612 | cwd: input.cwd, | ||
| 613 | args: [ | ||
| 614 | "api", | ||
| 615 | "--method", | ||
| 616 | "POST", | ||
| 617 | "projects/:fullpath/merge_requests", | ||
| 618 | "--raw-field", | ||
| 619 | `source_branch=${sourceRefName(input)}`, | ||
| 620 | "--raw-field", | ||
| 621 | `target_branch=${input.target?.refName ?? input.baseBranch}`, | ||
| 622 | ...(sourceProject ? ["--raw-field", `source_project_id=${sourceProject}`] : []), | ||
| 623 | "--raw-field", | ||
| 624 | `title=${input.title}`, | ||
| 625 | "--field", | ||
| 626 | `description=@${input.bodyFile}`, | ||
| 627 | ], | ||
| 628 | }).pipe(Effect.asVoid); | ||
| 629 | }, | ||
| 630 | getDefaultBranch: (input) => | ||
| 631 | execute({ | ||
| 632 | cwd: input.cwd, | ||
| 633 | args: ["api", "projects/:fullpath"], | ||
| 634 | }).pipe( | ||
| 635 | Effect.map((result) => result.stdout.trim()), | ||
| 636 | Effect.flatMap((raw) => | ||
| 637 | decodeGitLabDefaultBranch(raw).pipe( | ||
| 638 | Effect.mapError( | ||
| 639 | (cause) => | ||
| 640 | new GitLabRepositoryDecodeError({ | ||
| 641 | operation: "getDefaultBranch", | ||
| 642 | command: "glab", | ||
| 643 | cwd: input.cwd, | ||
| 644 | cause, | ||
| 645 | }), | ||
| 646 | ), | ||
| 647 | ), | ||
| 648 | ), | ||
| 649 | Effect.map((value) => value.default_branch ?? null), | ||
| 650 | ), | ||
| 651 | checkoutMergeRequest: (input) => | ||
| 652 | executeMergeRequest({ | ||
| 653 | cwd: input.cwd, | ||
| 654 | reference: input.reference, | ||
| 655 | args: ["mr", "checkout", input.reference], | ||
| 656 | }).pipe(Effect.asVoid), | ||
| 657 | }); | ||
| 658 | }); | ||
| 659 | |||
| 660 | export const layer = Layer.effect(GitLabCli, make); | ||
apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts deleted-309| ... | @@ -1,309 +0,0 @@ | ||
| 1 | import { assert, it } from "@effect/vitest"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Layer from "effect/Layer"; | ||
| 4 | import * as Option from "effect/Option"; | ||
| 5 | import { ChildProcessSpawner } from "effect/process"; | ||
| 6 | |||
| 7 | import * as GitLabCli from "./GitLabCli.ts"; | ||
| 8 | import { parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts"; | ||
| 9 | import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; | ||
| 10 | |||
| 11 | function makeProvider(gitlab: Partial<GitLabCli.GitLabCli["Service"]>) { | ||
| 12 | return GitLabSourceControlProvider.make.pipe( | ||
| 13 | Effect.provide(Layer.mock(GitLabCli.GitLabCli)(gitlab)), | ||
| 14 | ); | ||
| 15 | } | ||
| 16 | |||
| 17 | it.effect("maps GitLab MR summaries into provider-neutral change requests", () => | ||
| 18 | Effect.gen(function* () { | ||
| 19 | const provider = yield* makeProvider({ | ||
| 20 | getMergeRequest: () => | ||
| 21 | Effect.succeed({ | ||
| 22 | number: 42, | ||
| 23 | title: "Add GitLab provider", | ||
| 24 | url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", | ||
| 25 | baseRefName: "main", | ||
| 26 | headRefName: "feature/source-control", | ||
| 27 | state: "closed", | ||
| 28 | closedAt: "2026-08-23T10:00:00Z", | ||
| 29 | isCrossRepository: true, | ||
| 30 | headRepositoryNameWithOwner: "fork/t3code", | ||
| 31 | headRepositoryOwnerLogin: "fork", | ||
| 32 | }), | ||
| 33 | }); | ||
| 34 | |||
| 35 | const changeRequest = yield* provider.getChangeRequest({ | ||
| 36 | cwd: "/repo", | ||
| 37 | reference: "42", | ||
| 38 | }); | ||
| 39 | |||
| 40 | assert.deepStrictEqual(changeRequest, { | ||
| 41 | provider: "gitlab", | ||
| 42 | number: 42, | ||
| 43 | title: "Add GitLab provider", | ||
| 44 | url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", | ||
| 45 | baseRefName: "main", | ||
| 46 | headRefName: "feature/source-control", | ||
| 47 | state: "closed", | ||
| 48 | closedAt: "2026-08-23T10:00:00Z", | ||
| 49 | mergedAt: null, | ||
| 50 | updatedAt: Option.none(), | ||
| 51 | isCrossRepository: true, | ||
| 52 | headRepositoryNameWithOwner: "fork/t3code", | ||
| 53 | headRepositoryOwnerLogin: "fork", | ||
| 54 | }); | ||
| 55 | }), | ||
| 56 | ); | ||
| 57 | |||
| 58 | it.effect("adds repository context while retaining GitLab CLI causes", () => | ||
| 59 | Effect.gen(function* () { | ||
| 60 | const cause = new GitLabCli.GitLabCliCommandError({ | ||
| 61 | operation: "execute", | ||
| 62 | command: "glab", | ||
| 63 | cwd: "/repo", | ||
| 64 | cause: new Error("raw upstream detail that should remain in the cause"), | ||
| 65 | }); | ||
| 66 | const provider = yield* makeProvider({ | ||
| 67 | createRepository: () => Effect.fail(cause), | ||
| 68 | }); | ||
| 69 | |||
| 70 | const error = yield* provider | ||
| 71 | .createRepository({ | ||
| 72 | cwd: "/repo", | ||
| 73 | repository: "owner/repo", | ||
| 74 | visibility: "private", | ||
| 75 | }) | ||
| 76 | .pipe(Effect.flip); | ||
| 77 | |||
| 78 | assert.deepStrictEqual( | ||
| 79 | { | ||
| 80 | provider: error.provider, | ||
| 81 | operation: error.operation, | ||
| 82 | command: error.command, | ||
| 83 | cwd: error.cwd, | ||
| 84 | repository: error.repository, | ||
| 85 | detail: error.detail, | ||
| 86 | }, | ||
| 87 | { | ||
| 88 | provider: "gitlab", | ||
| 89 | operation: "createRepository", | ||
| 90 | command: "glab", | ||
| 91 | cwd: "/repo", | ||
| 92 | repository: "owner/repo", | ||
| 93 | detail: "GitLab CLI command failed.", | ||
| 94 | }, | ||
| 95 | ); | ||
| 96 | assert.strictEqual(error.cause, cause); | ||
| 97 | assert.equal(error.message.includes("raw upstream detail"), false); | ||
| 98 | }), | ||
| 99 | ); | ||
| 100 | |||
| 101 | it.effect("lists GitLab MRs through provider-neutral input names", () => | ||
| 102 | Effect.gen(function* () { | ||
| 103 | let listInput: Parameters<GitLabCli.GitLabCli["Service"]["listMergeRequests"]>[0] | null = null; | ||
| 104 | const provider = yield* makeProvider({ | ||
| 105 | listMergeRequests: (input) => { | ||
| 106 | listInput = input; | ||
| 107 | return Effect.succeed([]); | ||
| 108 | }, | ||
| 109 | }); | ||
| 110 | |||
| 111 | yield* provider.listChangeRequests({ | ||
| 112 | cwd: "/repo", | ||
| 113 | headSelector: "feature/provider", | ||
| 114 | state: "all", | ||
| 115 | limit: 10, | ||
| 116 | }); | ||
| 117 | |||
| 118 | assert.deepStrictEqual(listInput, { | ||
| 119 | cwd: "/repo", | ||
| 120 | headSelector: "feature/provider", | ||
| 121 | state: "all", | ||
| 122 | limit: 10, | ||
| 123 | }); | ||
| 124 | }), | ||
| 125 | ); | ||
| 126 | |||
| 127 | it.effect("creates GitLab MRs through provider-neutral input names", () => | ||
| 128 | Effect.gen(function* () { | ||
| 129 | let createInput: Parameters<GitLabCli.GitLabCli["Service"]["createMergeRequest"]>[0] | null = | ||
| 130 | null; | ||
| 131 | const provider = yield* makeProvider({ | ||
| 132 | createMergeRequest: (input) => { | ||
| 133 | createInput = input; | ||
| 134 | return Effect.void; | ||
| 135 | }, | ||
| 136 | }); | ||
| 137 | |||
| 138 | yield* provider.createChangeRequest({ | ||
| 139 | cwd: "/repo", | ||
| 140 | baseRefName: "main", | ||
| 141 | headSelector: "owner:feature/provider", | ||
| 142 | title: "Provider MR", | ||
| 143 | bodyFile: "/tmp/body.md", | ||
| 144 | }); | ||
| 145 | |||
| 146 | assert.deepStrictEqual(createInput, { | ||
| 147 | cwd: "/repo", | ||
| 148 | baseBranch: "main", | ||
| 149 | headSelector: "owner:feature/provider", | ||
| 150 | source: { | ||
| 151 | owner: "owner", | ||
| 152 | refName: "feature/provider", | ||
| 153 | }, | ||
| 154 | title: "Provider MR", | ||
| 155 | bodyFile: "/tmp/body.md", | ||
| 156 | }); | ||
| 157 | }), | ||
| 158 | ); | ||
| 159 | |||
| 160 | it("accepts authenticated GitLab hosts when another configured host fails", () => { | ||
| 161 | const auth = GitLabSourceControlProvider.discovery.parseAuth({ | ||
| 162 | exitCode: ChildProcessSpawner.ExitCode(1), | ||
| 163 | stdout: `gitlab.com | ||
| 164 | x gitlab.com: API call failed: 401 Unauthorized | ||
| 165 | ! No token found | ||
| 166 | self-hosted.example.test | ||
| 167 | ✓ Logged in to self-hosted.example.test as gitlab-user | ||
| 168 | ✓ Token found: ****** | ||
| 169 | `, | ||
| 170 | stderr: "", | ||
| 171 | }); | ||
| 172 | |||
| 173 | assert.deepStrictEqual( | ||
| 174 | { | ||
| 175 | status: auth.status, | ||
| 176 | account: auth.account, | ||
| 177 | host: auth.host, | ||
| 178 | }, | ||
| 179 | { | ||
| 180 | status: "authenticated", | ||
| 181 | account: Option.some("gitlab-user"), | ||
| 182 | host: Option.some("self-hosted.example.test"), | ||
| 183 | }, | ||
| 184 | ); | ||
| 185 | }); | ||
| 186 | |||
| 187 | it("refines unknown GitLab remotes with mixed-case provider hosts", () => { | ||
| 188 | const provider = GitLabSourceControlProvider.discovery.refineUnknownRemote?.({ | ||
| 189 | cwd: "/repo", | ||
| 190 | context: { | ||
| 191 | provider: { | ||
| 192 | kind: "unknown", | ||
| 193 | name: "Self-Hosted.Example.Test", | ||
| 194 | baseUrl: "https://Self-Hosted.Example.Test", | ||
| 195 | }, | ||
| 196 | remoteName: "origin", | ||
| 197 | remoteUrl: "https://Self-Hosted.Example.Test/group/project.git", | ||
| 198 | }, | ||
| 199 | auth: { | ||
| 200 | exitCode: ChildProcessSpawner.ExitCode(0), | ||
| 201 | stdout: `self-hosted.example.test | ||
| 202 | ✓ Logged in to self-hosted.example.test as gitlab-user | ||
| 203 | ✓ Token found: ****** | ||
| 204 | `, | ||
| 205 | stderr: "", | ||
| 206 | }, | ||
| 207 | }); | ||
| 208 | |||
| 209 | assert.deepStrictEqual(provider, { | ||
| 210 | kind: "gitlab", | ||
| 211 | name: "GitLab Self-Hosted", | ||
| 212 | baseUrl: "https://Self-Hosted.Example.Test", | ||
| 213 | }); | ||
| 214 | }); | ||
| 215 | |||
| 216 | it("parses authenticated GitLab auth status hosts with ports and single-label names", () => { | ||
| 217 | assert.deepStrictEqual( | ||
| 218 | parseGitLabAuthStatusHosts(`localhost:8080 | ||
| 219 | ✓ Logged in to localhost:8080 as local-user | ||
| 220 | selfhosted | ||
| 221 | ✓ Logged in to selfhosted as single-label-user | ||
| 222 | `), | ||
| 223 | [ | ||
| 224 | { host: "localhost:8080", account: "local-user" }, | ||
| 225 | { host: "selfhosted", account: "single-label-user" }, | ||
| 226 | ], | ||
| 227 | ); | ||
| 228 | }); | ||
| 229 | |||
| 230 | it.effect.each(["merge_requests", "issues"])( | ||
| 231 | "resolves %s subjects on the linked host without using the checkout", | ||
| 232 | (kind) => | ||
| 233 | Effect.gen(function* () { | ||
| 234 | const provider = yield* makeProvider({ | ||
| 235 | execute: (input) => { | ||
| 236 | assert.deepStrictEqual(input.args, [ | ||
| 237 | "api", | ||
| 238 | "--hostname", | ||
| 239 | "gitlab.com", | ||
| 240 | `projects/group%2Fsubgroup%2Fproject/${kind}/42`, | ||
| 241 | ]); | ||
| 242 | assert.strictEqual(input.maxOutputBytes, 32_000); | ||
| 243 | assert.strictEqual(input.timeoutMs, 3_000); | ||
| 244 | return Effect.succeed({ | ||
| 245 | exitCode: ChildProcessSpawner.ExitCode(0), | ||
| 246 | stdout: JSON.stringify({ | ||
| 247 | title: "Pairing expiry", | ||
| 248 | description: "Preserve remote access", | ||
| 249 | }), | ||
| 250 | stderr: "", | ||
| 251 | stdoutTruncated: false, | ||
| 252 | stderrTruncated: false, | ||
| 253 | }); | ||
| 254 | }, | ||
| 255 | }); | ||
| 256 | const lookup = provider.resolveLink?.({ | ||
| 257 | cwd: "/unrelated", | ||
| 258 | url: new URL(`https://gitlab.com/group/subgroup/project/-/${kind}/42`), | ||
| 259 | }); | ||
| 260 | assert.ok(lookup); | ||
| 261 | assert.deepStrictEqual(yield* lookup, { | ||
| 262 | title: "Pairing expiry", | ||
| 263 | body: "Preserve remote access", | ||
| 264 | }); | ||
| 265 | assert.strictEqual( | ||
| 266 | provider.resolveLink?.({ | ||
| 267 | cwd: "/unrelated", | ||
| 268 | url: new URL("https://gitlab.com/owner/repo"), | ||
| 269 | }), | ||
| 270 | undefined, | ||
| 271 | ); | ||
| 272 | }), | ||
| 273 | ); | ||
| 274 | |||
| 275 | it.effect.each(["read", "decode"] as const)( | ||
| 276 | "retains the %s failure without exposing its raw contents", | ||
| 277 | (stage) => | ||
| 278 | Effect.gen(function* () { | ||
| 279 | const cause = new GitLabCli.GitLabCliCommandError({ | ||
| 280 | command: "glab", | ||
| 281 | cwd: "/repo", | ||
| 282 | operation: "execute", | ||
| 283 | cause: new Error("private response text"), | ||
| 284 | }); | ||
| 285 | const provider = yield* makeProvider({ | ||
| 286 | execute: () => | ||
| 287 | stage === "read" | ||
| 288 | ? Effect.fail(cause) | ||
| 289 | : Effect.succeed({ | ||
| 290 | exitCode: ChildProcessSpawner.ExitCode(0), | ||
| 291 | stdout: "private response text", | ||
| 292 | stderr: "", | ||
| 293 | stdoutTruncated: false, | ||
| 294 | stderrTruncated: false, | ||
| 295 | }), | ||
| 296 | }); | ||
| 297 | const lookup = provider.resolveLink?.({ | ||
| 298 | cwd: "/repo", | ||
| 299 | url: new URL("https://gitlab.com/owner/repo/-/issues/42"), | ||
| 300 | }); | ||
| 301 | assert.ok(lookup); | ||
| 302 | const error = yield* Effect.flip(lookup); | ||
| 303 | assert.strictEqual(error.operation, stage === "read" ? "resolveLink" : "resolveLink.decode"); | ||
| 304 | assert.strictEqual(error.detail, "The linked subject could not be read."); | ||
| 305 | assert.notInclude(error.message, "private response text"); | ||
| 306 | if (stage === "read") assert.strictEqual(error.cause, cause); | ||
| 307 | else assert.propertyVal(error.cause, "_tag", "SchemaError"); | ||
| 308 | }), | ||
| 309 | ); | ||
apps/server/src/sourceControl/GitLabSourceControlProvider.ts deleted-308| ... | @@ -1,308 +0,0 @@ | ||
| 1 | import * as Schema from "effect/Schema"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Option from "effect/Option"; | ||
| 4 | import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; | ||
| 5 | |||
| 6 | import * as GitLabCli from "./GitLabCli.ts"; | ||
| 7 | import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; | ||
| 8 | import { | ||
| 9 | combinedAuthOutput, | ||
| 10 | firstSafeAuthLine, | ||
| 11 | matchFirst, | ||
| 12 | parseCliHost, | ||
| 13 | providerAuth, | ||
| 14 | type SourceControlAuthProbeInput, | ||
| 15 | type SourceControlCliDiscoverySpec, | ||
| 16 | type SourceControlUnknownRemoteRefinementInput, | ||
| 17 | } from "@t3tools/source-control-core/server/discovery"; | ||
| 18 | import { findAuthenticatedGitLabHost, parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts"; | ||
| 19 | |||
| 20 | const decodeLinkSubject = Schema.decodeUnknownEffect( | ||
| 21 | Schema.fromJsonString( | ||
| 22 | Schema.Struct({ title: Schema.String, description: Schema.NullOr(Schema.String) }), | ||
| 23 | ), | ||
| 24 | ); | ||
| 25 | |||
| 26 | function toChangeRequest(summary: GitLabCli.GitLabMergeRequestSummary): ChangeRequest { | ||
| 27 | return { | ||
| 28 | provider: "gitlab", | ||
| 29 | number: summary.number, | ||
| 30 | title: summary.title, | ||
| 31 | url: summary.url, | ||
| 32 | baseRefName: summary.baseRefName, | ||
| 33 | headRefName: summary.headRefName, | ||
| 34 | state: summary.state ?? "open", | ||
| 35 | ...(summary.isDraft === true ? { isDraft: true } : {}), | ||
| 36 | closedAt: summary.closedAt ?? null, | ||
| 37 | mergedAt: summary.mergedAt ?? null, | ||
| 38 | updatedAt: summary.updatedAt ?? Option.none(), | ||
| 39 | ...(summary.isCrossRepository !== undefined | ||
| 40 | ? { isCrossRepository: summary.isCrossRepository } | ||
| 41 | : {}), | ||
| 42 | ...(summary.headRepositoryNameWithOwner !== undefined | ||
| 43 | ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner } | ||
| 44 | : {}), | ||
| 45 | ...(summary.headRepositoryOwnerLogin !== undefined | ||
| 46 | ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin } | ||
| 47 | : {}), | ||
| 48 | }; | ||
| 49 | } | ||
| 50 | |||
| 51 | function parseGitLabAuth(input: SourceControlAuthProbeInput) { | ||
| 52 | const output = combinedAuthOutput(input); | ||
| 53 | const authenticatedHost = findAuthenticatedGitLabHost(parseGitLabAuthStatusHosts(output)); | ||
| 54 | const account = | ||
| 55 | authenticatedHost?.account ?? | ||
| 56 | matchFirst(output, [ | ||
| 57 | /Logged in to .* as\s+([^\s(]+)/iu, | ||
| 58 | /Logged in to .* account\s+([^\s(]+)/iu, | ||
| 59 | /account:\s*([^\s(]+)/iu, | ||
| 60 | ]); | ||
| 61 | const host = authenticatedHost?.host ?? parseCliHost(output); | ||
| 62 | |||
| 63 | if (account) { | ||
| 64 | return providerAuth({ status: "authenticated", account, host }); | ||
| 65 | } | ||
| 66 | |||
| 67 | if (input.exitCode !== 0) { | ||
| 68 | return providerAuth({ | ||
| 69 | status: "unauthenticated", | ||
| 70 | host, | ||
| 71 | detail: firstSafeAuthLine(output) ?? "Run `glab auth login` to authenticate GitLab CLI.", | ||
| 72 | }); | ||
| 73 | } | ||
| 74 | |||
| 75 | return providerAuth({ | ||
| 76 | status: "unknown", | ||
| 77 | host, | ||
| 78 | detail: firstSafeAuthLine(output) ?? "GitLab CLI auth status could not be parsed.", | ||
| 79 | }); | ||
| 80 | } | ||
| 81 | |||
| 82 | function refineUnknownGitLabRemote(input: SourceControlUnknownRemoteRefinementInput) { | ||
| 83 | const host = input.context.provider.name.toLowerCase(); | ||
| 84 | const authenticated = parseGitLabAuthStatusHosts(combinedAuthOutput(input.auth)).some( | ||
| 85 | (entry) => entry.account !== null && entry.host === host, | ||
| 86 | ); | ||
| 87 | |||
| 88 | if (!authenticated) { | ||
| 89 | return null; | ||
| 90 | } | ||
| 91 | |||
| 92 | return { | ||
| 93 | kind: "gitlab", | ||
| 94 | name: "GitLab Self-Hosted", | ||
| 95 | baseUrl: input.context.provider.baseUrl, | ||
| 96 | } as const; | ||
| 97 | } | ||
| 98 | |||
| 99 | export const discovery = { | ||
| 100 | type: "cli", | ||
| 101 | kind: "gitlab", | ||
| 102 | label: "GitLab", | ||
| 103 | executable: "glab", | ||
| 104 | versionArgs: ["--version"], | ||
| 105 | authArgs: ["auth", "status"], | ||
| 106 | parseAuth: parseGitLabAuth, | ||
| 107 | refineUnknownRemote: refineUnknownGitLabRemote, | ||
| 108 | installHint: | ||
| 109 | "Install the GitLab command-line tool (`glab`) from https://gitlab.com/gitlab-org/cli or your package manager (for example `brew install glab`).", | ||
| 110 | } satisfies SourceControlCliDiscoverySpec; | ||
| 111 | |||
| 112 | export const make = Effect.gen(function* () { | ||
| 113 | const gitlab = yield* GitLabCli.GitLabCli; | ||
| 114 | |||
| 115 | const readLinkSubject = Effect.fn("GitLabSourceControlProvider.readLinkSubject")(function* ( | ||
| 116 | input: { readonly cwd: string; readonly url: URL }, | ||
| 117 | endpoint: string, | ||
| 118 | ) { | ||
| 119 | const result = yield* gitlab | ||
| 120 | .execute({ | ||
| 121 | cwd: input.cwd, | ||
| 122 | args: ["api", "--hostname", input.url.host, endpoint], | ||
| 123 | timeoutMs: 3_000, | ||
| 124 | maxOutputBytes: 32_000, | ||
| 125 | }) | ||
| 126 | .pipe( | ||
| 127 | Effect.mapError( | ||
| 128 | (cause) => | ||
| 129 | new SourceControlProviderError({ | ||
| 130 | provider: "gitlab", | ||
| 131 | operation: "resolveLink", | ||
| 132 | cwd: input.cwd, | ||
| 133 | detail: "The linked subject could not be read.", | ||
| 134 | cause, | ||
| 135 | }), | ||
| 136 | ), | ||
| 137 | ); | ||
| 138 | const subject = yield* decodeLinkSubject(result.stdout).pipe( | ||
| 139 | Effect.mapError( | ||
| 140 | (cause) => | ||
| 141 | new SourceControlProviderError({ | ||
| 142 | provider: "gitlab", | ||
| 143 | operation: "resolveLink.decode", | ||
| 144 | cwd: input.cwd, | ||
| 145 | detail: "The linked subject could not be read.", | ||
| 146 | cause, | ||
| 147 | }), | ||
| 148 | ), | ||
| 149 | ); | ||
| 150 | return { title: subject.title, body: subject.description }; | ||
| 151 | }); | ||
| 152 | |||
| 153 | return SourceControlProvider.SourceControlProvider.of({ | ||
| 154 | kind: "gitlab", | ||
| 155 | resolveLink: (input) => { | ||
| 156 | // Automatic enrichment must not send ambient CLI credentials to a host from message text. | ||
| 157 | if (input.url.host !== "gitlab.com") return undefined; | ||
| 158 | const match = /^\/(.+)\/-\/(merge_requests|issues)\/([1-9]\d*)(?:\/.*)?$/.exec( | ||
| 159 | input.url.pathname, | ||
| 160 | ); | ||
| 161 | if (!match) return undefined; | ||
| 162 | return readLinkSubject( | ||
| 163 | input, | ||
| 164 | `projects/${encodeURIComponent(match[1]!)}/${match[2]}/${match[3]}`, | ||
| 165 | ); | ||
| 166 | }, | ||
| 167 | listChangeRequests: (input) => { | ||
| 168 | const source = SourceControlProvider.sourceControlRefFromInput(input); | ||
| 169 | return gitlab | ||
| 170 | .listMergeRequests({ | ||
| 171 | cwd: input.cwd, | ||
| 172 | headSelector: input.headSelector, | ||
| 173 | ...(source ? { source } : {}), | ||
| 174 | state: input.state, | ||
| 175 | ...(input.limit !== undefined ? { limit: input.limit } : {}), | ||
| 176 | }) | ||
| 177 | .pipe( | ||
| 178 | Effect.map((items) => items.map(toChangeRequest)), | ||
| 179 | Effect.mapError( | ||
| 180 | (error) => | ||
| 181 | new SourceControlProviderError({ | ||
| 182 | provider: "gitlab", | ||
| 183 | operation: "listChangeRequests", | ||
| 184 | command: error.command, | ||
| 185 | cwd: input.cwd, | ||
| 186 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | ||
| 187 | input.headSelector, | ||
| 188 | ), | ||
| 189 | detail: error.detail, | ||
| 190 | cause: error, | ||
| 191 | }), | ||
| 192 | ), | ||
| 193 | ); | ||
| 194 | }, | ||
| 195 | getChangeRequest: (input) => | ||
| 196 | gitlab.getMergeRequest(input).pipe( | ||
| 197 | Effect.map(toChangeRequest), | ||
| 198 | Effect.mapError( | ||
| 199 | (error) => | ||
| 200 | new SourceControlProviderError({ | ||
| 201 | provider: "gitlab", | ||
| 202 | operation: "getChangeRequest", | ||
| 203 | command: error.command, | ||
| 204 | cwd: input.cwd, | ||
| 205 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | ||
| 206 | input.reference, | ||
| 207 | ), | ||
| 208 | detail: error.detail, | ||
| 209 | cause: error, | ||
| 210 | }), | ||
| 211 | ), | ||
| 212 | ), | ||
| 213 | createChangeRequest: (input) => { | ||
| 214 | const source = SourceControlProvider.sourceControlRefFromInput(input); | ||
| 215 | return gitlab | ||
| 216 | .createMergeRequest({ | ||
| 217 | cwd: input.cwd, | ||
| 218 | baseBranch: input.baseRefName, | ||
| 219 | headSelector: input.headSelector, | ||
| 220 | ...(source ? { source } : {}), | ||
| 221 | ...(input.target ? { target: input.target } : {}), | ||
| 222 | title: input.title, | ||
| 223 | bodyFile: input.bodyFile, | ||
| 224 | }) | ||
| 225 | .pipe( | ||
| 226 | Effect.mapError( | ||
| 227 | (error) => | ||
| 228 | new SourceControlProviderError({ | ||
| 229 | provider: "gitlab", | ||
| 230 | operation: "createChangeRequest", | ||
| 231 | command: error.command, | ||
| 232 | cwd: input.cwd, | ||
| 233 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | ||
| 234 | input.headSelector, | ||
| 235 | ), | ||
| 236 | detail: error.detail, | ||
| 237 | cause: error, | ||
| 238 | }), | ||
| 239 | ), | ||
| 240 | ); | ||
| 241 | }, | ||
| 242 | getRepositoryCloneUrls: (input) => | ||
| 243 | gitlab.getRepositoryCloneUrls(input).pipe( | ||
| 244 | Effect.mapError( | ||
| 245 | (error) => | ||
| 246 | new SourceControlProviderError({ | ||
| 247 | provider: "gitlab", | ||
| 248 | operation: "getRepositoryCloneUrls", | ||
| 249 | command: error.command, | ||
| 250 | cwd: input.cwd, | ||
| 251 | repository: SourceControlProvider.transportSafeSourceControlErrorValue( | ||
| 252 | input.repository, | ||
| 253 | ), | ||
| 254 | detail: error.detail, | ||
| 255 | cause: error, | ||
| 256 | }), | ||
| 257 | ), | ||
| 258 | ), | ||
| 259 | createRepository: (input) => | ||
| 260 | gitlab.createRepository(input).pipe( | ||
| 261 | Effect.mapError( | ||
| 262 | (error) => | ||
| 263 | new SourceControlProviderError({ | ||
| 264 | provider: "gitlab", | ||
| 265 | operation: "createRepository", | ||
| 266 | command: error.command, | ||
| 267 | cwd: input.cwd, | ||
| 268 | repository: SourceControlProvider.transportSafeSourceControlErrorValue( | ||
| 269 | input.repository, | ||
| 270 | ), | ||
| 271 | detail: error.detail, | ||
| 272 | cause: error, | ||
| 273 | }), | ||
| 274 | ), | ||
| 275 | ), | ||
| 276 | getDefaultBranch: (input) => | ||
| 277 | gitlab.getDefaultBranch(input).pipe( | ||
| 278 | Effect.mapError( | ||
| 279 | (error) => | ||
| 280 | new SourceControlProviderError({ | ||
| 281 | provider: "gitlab", | ||
| 282 | operation: "getDefaultBranch", | ||
| 283 | command: error.command, | ||
| 284 | cwd: input.cwd, | ||
| 285 | detail: error.detail, | ||
| 286 | cause: error, | ||
| 287 | }), | ||
| 288 | ), | ||
| 289 | ), | ||
| 290 | checkoutChangeRequest: (input) => | ||
| 291 | gitlab.checkoutMergeRequest(input).pipe( | ||
| 292 | Effect.mapError( | ||
| 293 | (error) => | ||
| 294 | new SourceControlProviderError({ | ||
| 295 | provider: "gitlab", | ||
| 296 | operation: "checkoutChangeRequest", | ||
| 297 | command: error.command, | ||
| 298 | cwd: input.cwd, | ||
| 299 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | ||
| 300 | input.reference, | ||
| 301 | ), | ||
| 302 | detail: error.detail, | ||
| 303 | cause: error, | ||
| 304 | }), | ||
| 305 | ), | ||
| 306 | ), | ||
| 307 | }); | ||
| 308 | }); | ||
apps/server/src/sourceControl/SourceControlDiscovery.test.ts+3-1| ... | @@ -20,7 +20,8 @@ import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/Azu | ... | @@ -20,7 +20,8 @@ import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/Azu |
| 20 | import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli"; | 20 | import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli"; |
| 21 | import * as BitbucketApi from "./BitbucketApi.ts"; | 21 | import * as BitbucketApi from "./BitbucketApi.ts"; |
| 22 | import * as GitHubApi from "./GitHubApi.ts"; | 22 | import * as GitHubApi from "./GitHubApi.ts"; |
| 23 | import * as GitLabCli from "./GitLabCli.ts"; | 23 | import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; |
| 24 | import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; | ||
| 24 | import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; | 25 | import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; |
| 25 | import * as ForgejoSourceControlProvider from "@t3tools/source-control-forgejo/server/ForgejoSourceControlProvider"; | 26 | import * as ForgejoSourceControlProvider from "@t3tools/source-control-forgejo/server/ForgejoSourceControlProvider"; |
| 26 | import * as ForgejoPullRequestProvider from "@t3tools/source-control-forgejo/server/ForgejoPullRequestProvider"; | 27 | import * as ForgejoPullRequestProvider from "@t3tools/source-control-forgejo/server/ForgejoPullRequestProvider"; |
| ... | @@ -46,6 +47,7 @@ const layerSourceControlProviderRegistryTest = (input: { | ... | @@ -46,6 +47,7 @@ const layerSourceControlProviderRegistryTest = (input: { |
| 46 | Layer.mock(GitHubApi.GitHubApi)({}), | 47 | Layer.mock(GitHubApi.GitHubApi)({}), |
| 47 | Layer.mock(GitVcsDriver.GitVcsDriver)({}), | 48 | Layer.mock(GitVcsDriver.GitVcsDriver)({}), |
| 48 | Layer.mock(GitLabCli.GitLabCli)({}), | 49 | Layer.mock(GitLabCli.GitLabCli)({}), |
| 50 | Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({}), | ||
| 49 | Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), | 51 | Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), |
| 50 | Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}), | 52 | Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}), |
| 51 | Layer.mock(VcsProcess.VcsProcess)(input.process), | 53 | Layer.mock(VcsProcess.VcsProcess)(input.process), |
apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts+3-1| ... | @@ -19,7 +19,8 @@ import * as BitbucketApi from "./BitbucketApi.ts"; | ... | @@ -19,7 +19,8 @@ import * as BitbucketApi from "./BitbucketApi.ts"; |
| 19 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | 19 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; |
| 20 | import * as GitHubApi from "./GitHubApi.ts"; | 20 | import * as GitHubApi from "./GitHubApi.ts"; |
| 21 | import * as GitHubCredentials from "./GitHubCredentials.ts"; | 21 | import * as GitHubCredentials from "./GitHubCredentials.ts"; |
| 22 | import * as GitLabCli from "./GitLabCli.ts"; | 22 | import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; |
| 23 | import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; | ||
| 23 | import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; | 24 | import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; |
| 24 | import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; | 25 | import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; |
| 25 | import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; | 26 | import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; |
| ... | @@ -114,6 +115,7 @@ function makeRegistry(input: { | ... | @@ -114,6 +115,7 @@ function makeRegistry(input: { |
| 114 | }), | 115 | }), |
| 115 | Layer.mock(GitVcsDriver.GitVcsDriver)({}), | 116 | Layer.mock(GitVcsDriver.GitVcsDriver)({}), |
| 116 | Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}), | 117 | Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}), |
| 118 | Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({}), | ||
| 117 | Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), | 119 | Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), |
| 118 | ServerConfig.layerTest(process.cwd(), { | 120 | ServerConfig.layerTest(process.cwd(), { |
| 119 | prefix: "t3-source-control-registry-test-", | 121 | prefix: "t3-source-control-registry-test-", |
apps/server/src/sourceControl/SourceControlProviderRegistry.ts-7| ... | @@ -13,7 +13,6 @@ import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/source | ... | @@ -13,7 +13,6 @@ import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/source |
| 13 | 13 | ||
| 14 | import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; | 14 | import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; |
| 15 | import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; | 15 | import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; |
| 16 | import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; | ||
| 17 | import * as BuiltInDrivers from "./builtInDrivers.ts"; | 16 | import * as BuiltInDrivers from "./builtInDrivers.ts"; |
| 18 | import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; | 17 | import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; |
| 19 | import { | 18 | import { |
| ... | @@ -306,7 +305,6 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit | ... | @@ -306,7 +305,6 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit |
| 306 | export const make = Effect.gen(function* () { | 305 | export const make = Effect.gen(function* () { |
| 307 | const github = yield* GitHubSourceControlProvider.make; | 306 | const github = yield* GitHubSourceControlProvider.make; |
| 308 | const githubDiscovery = yield* GitHubSourceControlProvider.makeDiscovery; | 307 | const githubDiscovery = yield* GitHubSourceControlProvider.makeDiscovery; |
| 309 | const gitlab = yield* GitLabSourceControlProvider.make; | ||
| 310 | const bitbucket = yield* BitbucketSourceControlProvider.make; | 308 | const bitbucket = yield* BitbucketSourceControlProvider.make; |
| 311 | const bitbucketDiscovery = yield* BitbucketSourceControlProvider.makeDiscovery; | 309 | const bitbucketDiscovery = yield* BitbucketSourceControlProvider.makeDiscovery; |
| 312 | const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) => | 310 | const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) => |
| ... | @@ -326,11 +324,6 @@ export const make = Effect.gen(function* () { | ... | @@ -326,11 +324,6 @@ export const make = Effect.gen(function* () { |
| 326 | provider: github, | 324 | provider: github, |
| 327 | discovery: githubDiscovery, | 325 | discovery: githubDiscovery, |
| 328 | }, | 326 | }, |
| 329 | { | ||
| 330 | kind: "gitlab", | ||
| 331 | provider: gitlab, | ||
| 332 | discovery: GitLabSourceControlProvider.discovery, | ||
| 333 | }, | ||
| 334 | { | 327 | { |
| 335 | kind: "bitbucket", | 328 | kind: "bitbucket", |
| 336 | provider: bitbucket, | 329 | provider: bitbucket, |
apps/server/src/sourceControl/builtInDrivers.ts+5-1| ... | @@ -12,12 +12,15 @@ import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops | ... | @@ -12,12 +12,15 @@ import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops |
| 12 | import * as AzureDevOpsDriver from "@t3tools/source-control-azure-devops/server/driver"; | 12 | import * as AzureDevOpsDriver from "@t3tools/source-control-azure-devops/server/driver"; |
| 13 | import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; | 13 | import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; |
| 14 | import * as ForgejoDriver from "@t3tools/source-control-forgejo/server/driver"; | 14 | import * as ForgejoDriver from "@t3tools/source-control-forgejo/server/driver"; |
| 15 | import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; | ||
| 16 | import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; | ||
| 17 | import * as GitLabDriver from "@t3tools/source-control-gitlab/server/driver"; | ||
| 15 | import type { SourceControlDriver } from "@t3tools/source-control-core/server/driver"; | 18 | import type { SourceControlDriver } from "@t3tools/source-control-core/server/driver"; |
| 16 | import * as Layer from "effect/Layer"; | 19 | import * as Layer from "effect/Layer"; |
| 17 | 20 | ||
| 18 | import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; | 21 | import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; |
| 19 | 22 | ||
| 20 | const drivers = [AzureDevOpsDriver.driver, ForgejoDriver.driver]; | 23 | const drivers = [GitLabDriver.driver, AzureDevOpsDriver.driver, ForgejoDriver.driver]; |
| 21 | 24 | ||
| 22 | /** Every service a built-in driver's `make` needs; the server's layers must provide them all. */ | 25 | /** Every service a built-in driver's `make` needs; the server's layers must provide them all. */ |
| 23 | export type BuiltInSourceControlDriversEnv = | 26 | export type BuiltInSourceControlDriversEnv = |
| ... | @@ -31,4 +34,5 @@ export const BUILT_IN_SOURCE_CONTROL_DRIVERS: ReadonlyArray< | ... | @@ -31,4 +34,5 @@ export const BUILT_IN_SOURCE_CONTROL_DRIVERS: ReadonlyArray< |
| 31 | export const layer = Layer.mergeAll( | 34 | export const layer = Layer.mergeAll( |
| 32 | AzureDevOpsPullRequestCli.layer.pipe(Layer.provideMerge(AzureDevOpsCli.layer)), | 35 | AzureDevOpsPullRequestCli.layer.pipe(Layer.provideMerge(AzureDevOpsCli.layer)), |
| 33 | ForgejoCli.layer, | 36 | ForgejoCli.layer, |
| 37 | GitLabPullRequestCli.layer.pipe(Layer.provideMerge(GitLabCli.layer)), | ||
| 34 | ).pipe(Layer.provideMerge(ServerSourceControlHost.layer)); | 38 | ).pipe(Layer.provideMerge(ServerSourceControlHost.layer)); |
apps/server/src/sourceControl/gitLabAuthStatus.ts deleted-48| ... | @@ -1,48 +0,0 @@ | ||
| 1 | const HOST_LINE_PATTERN = /^(?:[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?|\[[a-f0-9:.]+\])(?::\d+)?$/iu; | ||
| 2 | const LOGGED_IN_PATTERN = /Logged in to .+? as\s+([^\s(]+)/iu; | ||
| 3 | |||
| 4 | export interface GitLabAuthStatusHost { | ||
| 5 | readonly host: string; | ||
| 6 | readonly account: string | null; | ||
| 7 | } | ||
| 8 | |||
| 9 | export function parseGitLabAuthStatusHosts(text: string): ReadonlyArray<GitLabAuthStatusHost> { | ||
| 10 | const hosts: GitLabAuthStatusHost[] = []; | ||
| 11 | let currentHost: string | null = null; | ||
| 12 | let currentLines: string[] = []; | ||
| 13 | |||
| 14 | const flush = () => { | ||
| 15 | if (currentHost === null) return; | ||
| 16 | |||
| 17 | const account = LOGGED_IN_PATTERN.exec(currentLines.join("\n"))?.[1]?.trim() || null; | ||
| 18 | hosts.push({ host: currentHost, account }); | ||
| 19 | currentHost = null; | ||
| 20 | currentLines = []; | ||
| 21 | }; | ||
| 22 | |||
| 23 | for (const rawLine of text.split(/\r?\n/)) { | ||
| 24 | const line = rawLine.trim(); | ||
| 25 | if (line.length === 0) continue; | ||
| 26 | |||
| 27 | const isHostLine = | ||
| 28 | rawLine.length === rawLine.trimStart().length && HOST_LINE_PATTERN.test(line); | ||
| 29 | if (isHostLine) { | ||
| 30 | flush(); | ||
| 31 | currentHost = line.toLowerCase(); | ||
| 32 | continue; | ||
| 33 | } | ||
| 34 | |||
| 35 | if (currentHost !== null) { | ||
| 36 | currentLines.push(line); | ||
| 37 | } | ||
| 38 | } | ||
| 39 | |||
| 40 | flush(); | ||
| 41 | return hosts; | ||
| 42 | } | ||
| 43 | |||
| 44 | export function findAuthenticatedGitLabHost( | ||
| 45 | hosts: ReadonlyArray<GitLabAuthStatusHost>, | ||
| 46 | ): GitLabAuthStatusHost | undefined { | ||
| 47 | return hosts.find((host) => host.account !== null); | ||
| 48 | } | ||
apps/server/src/sourceControl/gitLabMergeRequests.ts deleted-161| ... | @@ -1,161 +0,0 @@ | ||
| 1 | import * as Cause from "effect/Cause"; | ||
| 2 | import * as DateTime from "effect/DateTime"; | ||
| 3 | import * as Exit from "effect/Exit"; | ||
| 4 | import * as Option from "effect/Option"; | ||
| 5 | import * as Result from "effect/Result"; | ||
| 6 | import * as Schema from "effect/Schema"; | ||
| 7 | import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; | ||
| 8 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; | ||
| 9 | |||
| 10 | export interface NormalizedGitLabMergeRequestRecord { | ||
| 11 | readonly number: number; | ||
| 12 | readonly title: string; | ||
| 13 | readonly url: string; | ||
| 14 | readonly baseRefName: string; | ||
| 15 | readonly headRefName: string; | ||
| 16 | readonly state: "open" | "closed" | "merged"; | ||
| 17 | readonly isDraft?: boolean; | ||
| 18 | readonly closedAt?: string | null; | ||
| 19 | readonly mergedAt?: string | null; | ||
| 20 | readonly updatedAt: Option.Option<DateTime.Utc>; | ||
| 21 | readonly isCrossRepository?: boolean; | ||
| 22 | readonly headRepositoryNameWithOwner?: string | null; | ||
| 23 | readonly headRepositoryOwnerLogin?: string | null; | ||
| 24 | } | ||
| 25 | |||
| 26 | const GitLabProjectReferenceSchema = Schema.Struct({ | ||
| 27 | path_with_namespace: Schema.optional(Schema.String), | ||
| 28 | pathWithNamespace: Schema.optional(Schema.String), | ||
| 29 | namespace: Schema.optional( | ||
| 30 | Schema.NullOr( | ||
| 31 | Schema.Struct({ | ||
| 32 | path: Schema.optional(Schema.String), | ||
| 33 | full_path: Schema.optional(Schema.String), | ||
| 34 | fullPath: Schema.optional(Schema.String), | ||
| 35 | }), | ||
| 36 | ), | ||
| 37 | ), | ||
| 38 | }); | ||
| 39 | |||
| 40 | const GitLabMergeRequestSchema = Schema.Struct({ | ||
| 41 | iid: PositiveInt, | ||
| 42 | title: TrimmedNonEmptyString, | ||
| 43 | web_url: TrimmedNonEmptyString, | ||
| 44 | source_branch: TrimmedNonEmptyString, | ||
| 45 | target_branch: TrimmedNonEmptyString, | ||
| 46 | state: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 47 | draft: Schema.optional(Schema.Boolean), | ||
| 48 | work_in_progress: Schema.optional(Schema.Boolean), | ||
| 49 | closed_at: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 50 | merged_at: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 51 | updated_at: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), | ||
| 52 | source_project_id: Schema.optional(Schema.NullOr(Schema.Number)), | ||
| 53 | target_project_id: Schema.optional(Schema.NullOr(Schema.Number)), | ||
| 54 | source_project: Schema.optional(Schema.NullOr(GitLabProjectReferenceSchema)), | ||
| 55 | target_project: Schema.optional(Schema.NullOr(GitLabProjectReferenceSchema)), | ||
| 56 | }); | ||
| 57 | |||
| 58 | function trimOptionalString(value: string | null | undefined): string | null { | ||
| 59 | const trimmed = value?.trim() ?? ""; | ||
| 60 | return trimmed.length > 0 ? trimmed : null; | ||
| 61 | } | ||
| 62 | |||
| 63 | function normalizeGitLabMergeRequestState( | ||
| 64 | state: string | null | undefined, | ||
| 65 | ): "open" | "closed" | "merged" { | ||
| 66 | const normalized = state?.trim().toLowerCase(); | ||
| 67 | if (normalized === "merged") { | ||
| 68 | return "merged"; | ||
| 69 | } | ||
| 70 | if (normalized === "closed") { | ||
| 71 | return "closed"; | ||
| 72 | } | ||
| 73 | return "open"; | ||
| 74 | } | ||
| 75 | |||
| 76 | function projectPathWithNamespace( | ||
| 77 | project: Schema.Schema.Type<typeof GitLabProjectReferenceSchema> | null | undefined, | ||
| 78 | ): string | null { | ||
| 79 | const explicit = | ||
| 80 | trimOptionalString(project?.path_with_namespace) ?? | ||
| 81 | trimOptionalString(project?.pathWithNamespace); | ||
| 82 | if (explicit) { | ||
| 83 | return explicit; | ||
| 84 | } | ||
| 85 | |||
| 86 | const namespacePath = | ||
| 87 | trimOptionalString(project?.namespace?.full_path) ?? | ||
| 88 | trimOptionalString(project?.namespace?.fullPath) ?? | ||
| 89 | trimOptionalString(project?.namespace?.path); | ||
| 90 | return namespacePath; | ||
| 91 | } | ||
| 92 | |||
| 93 | function ownerLoginFromPathWithNamespace(pathWithNamespace: string | null): string | null { | ||
| 94 | const [owner] = pathWithNamespace?.split("/") ?? []; | ||
| 95 | return trimOptionalString(owner); | ||
| 96 | } | ||
| 97 | |||
| 98 | function normalizeGitLabMergeRequestRecord( | ||
| 99 | raw: Schema.Schema.Type<typeof GitLabMergeRequestSchema>, | ||
| 100 | ): NormalizedGitLabMergeRequestRecord { | ||
| 101 | const sourceProjectPath = projectPathWithNamespace(raw.source_project); | ||
| 102 | const targetProjectPath = projectPathWithNamespace(raw.target_project); | ||
| 103 | const isCrossRepository = | ||
| 104 | typeof raw.source_project_id === "number" && typeof raw.target_project_id === "number" | ||
| 105 | ? raw.source_project_id !== raw.target_project_id | ||
| 106 | : sourceProjectPath !== null && targetProjectPath !== null | ||
| 107 | ? sourceProjectPath.toLowerCase() !== targetProjectPath.toLowerCase() | ||
| 108 | : undefined; | ||
| 109 | const headRepositoryOwnerLogin = ownerLoginFromPathWithNamespace(sourceProjectPath); | ||
| 110 | |||
| 111 | return { | ||
| 112 | number: raw.iid, | ||
| 113 | title: raw.title, | ||
| 114 | url: raw.web_url, | ||
| 115 | baseRefName: raw.target_branch, | ||
| 116 | headRefName: raw.source_branch, | ||
| 117 | state: normalizeGitLabMergeRequestState(raw.state), | ||
| 118 | ...(raw.draft === true || raw.work_in_progress === true ? { isDraft: true } : {}), | ||
| 119 | closedAt: raw.closed_at ?? null, | ||
| 120 | mergedAt: raw.merged_at ?? null, | ||
| 121 | updatedAt: raw.updated_at ?? Option.none(), | ||
| 122 | ...(typeof isCrossRepository === "boolean" ? { isCrossRepository } : {}), | ||
| 123 | ...(sourceProjectPath ? { headRepositoryNameWithOwner: sourceProjectPath } : {}), | ||
| 124 | ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}), | ||
| 125 | }; | ||
| 126 | } | ||
| 127 | |||
| 128 | const decodeGitLabMergeRequestList = decodeJsonResult(Schema.Array(Schema.Unknown)); | ||
| 129 | const decodeGitLabMergeRequest = decodeJsonResult(GitLabMergeRequestSchema); | ||
| 130 | const decodeGitLabMergeRequestEntry = Schema.decodeUnknownExit(GitLabMergeRequestSchema); | ||
| 131 | |||
| 132 | export function decodeGitLabMergeRequestListJson( | ||
| 133 | raw: string, | ||
| 134 | ): Result.Result< | ||
| 135 | ReadonlyArray<NormalizedGitLabMergeRequestRecord>, | ||
| 136 | Cause.Cause<Schema.SchemaError> | ||
| 137 | > { | ||
| 138 | const result = decodeGitLabMergeRequestList(raw); | ||
| 139 | if (Result.isSuccess(result)) { | ||
| 140 | const mergeRequests: NormalizedGitLabMergeRequestRecord[] = []; | ||
| 141 | for (const entry of result.success) { | ||
| 142 | const decodedEntry = decodeGitLabMergeRequestEntry(entry); | ||
| 143 | if (Exit.isFailure(decodedEntry)) { | ||
| 144 | continue; | ||
| 145 | } | ||
| 146 | mergeRequests.push(normalizeGitLabMergeRequestRecord(decodedEntry.value)); | ||
| 147 | } | ||
| 148 | return Result.succeed(mergeRequests); | ||
| 149 | } | ||
| 150 | return Result.fail(result.failure); | ||
| 151 | } | ||
| 152 | |||
| 153 | export function decodeGitLabMergeRequestJson( | ||
| 154 | raw: string, | ||
| 155 | ): Result.Result<NormalizedGitLabMergeRequestRecord, Cause.Cause<Schema.SchemaError>> { | ||
| 156 | const result = decodeGitLabMergeRequest(raw); | ||
| 157 | if (Result.isSuccess(result)) { | ||
| 158 | return Result.succeed(normalizeGitLabMergeRequestRecord(result.success)); | ||
| 159 | } | ||
| 160 | return Result.fail(result.failure); | ||
| 161 | } | ||
apps/server/src/ws.ts-2| ... | @@ -231,7 +231,6 @@ import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery. | ... | @@ -231,7 +231,6 @@ import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery. |
| 231 | import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; | 231 | import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; |
| 232 | import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; | 232 | import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; |
| 233 | import * as GitHubApi from "./sourceControl/GitHubApi.ts"; | 233 | import * as GitHubApi from "./sourceControl/GitHubApi.ts"; |
| 234 | import * as GitLabCli from "./sourceControl/GitLabCli.ts"; | ||
| 235 | import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts"; | 234 | import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts"; |
| 236 | import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; | 235 | import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; |
| 237 | import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; | 236 | import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; |
| ... | @@ -3205,7 +3204,6 @@ export const layer = Layer.unwrap( | ... | @@ -3205,7 +3204,6 @@ export const layer = Layer.unwrap( |
| 3205 | Layer.mergeAll( | 3204 | Layer.mergeAll( |
| 3206 | BitbucketApi.layer, | 3205 | BitbucketApi.layer, |
| 3207 | GitHubApi.layerWithDependencies, | 3206 | GitHubApi.layerWithDependencies, |
| 3208 | GitLabCli.layer, | ||
| 3209 | SourceControlBuiltInDrivers.layer, | 3207 | SourceControlBuiltInDrivers.layer, |
| 3210 | ), | 3208 | ), |
| 3211 | ), | 3209 | ), |
docs/internals/pull-request-file-revisions.md+2-2| ... | @@ -7,7 +7,7 @@ one. No type distinguishes those meanings and no single file states the whole pa | ... | @@ -7,7 +7,7 @@ one. No type distinguishes those meanings and no single file states the whole pa |
| 7 | this page is for. | 7 | this page is for. |
| 8 | 8 | ||
| 9 | The failure is on record rather than hypothetical. The interface doc above | 9 | The failure is on record rather than hypothetical. The interface doc above |
| 10 | [GitLab's `getFileRevisions`](../../apps/server/src/pullRequest/GitLabPullRequestCli.ts) once said | 10 | [GitLab's `getFileRevisions`](../../packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts) once said |
| 11 | the opposite of the code beneath it, because it described a missing path the way the layer below | 11 | the opposite of the code beneath it, because it described a missing path the way the layer below |
| 12 | means it. Anything written against that reading would treat a version the host declined to give as | 12 | means it. Anything written against that reading would treat a version the host declined to give as |
| 13 | a file the change request deleted, which reports every file a reader has cleared as changed across | 13 | 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 | ... | @@ -30,7 +30,7 @@ What "never got to look at" is belongs to the host, and the contract cannot know |
| 30 | - [Bitbucket](../../apps/server/src/pullRequest/BitbucketPullRequestApi.ts) reads them off the | 30 | - [Bitbucket](../../apps/server/src/pullRequest/BitbucketPullRequestApi.ts) reads them off the |
| 31 | pull request's own patch, the only place it states a file's version, so a patch cut short at the | 31 | pull request's own patch, the only place it states a file's version, so a patch cut short at the |
| 32 | byte ceiling leaves the paths past the cut out. | 32 | byte ceiling leaves the paths past the cut out. |
| 33 | - [GitLab](../../apps/server/src/pullRequest/GitLabPullRequestCli.ts) asks in batches, so a batch | 33 | - [GitLab](../../packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts) asks in batches, so a batch |
| 34 | it could not read leaves that batch's paths out. | 34 | it could not read leaves that batch's paths out. |
| 35 | 35 | ||
| 36 | Those three facts stay with their providers. The contract states only that absence means the read | 36 | Those three facts stay with their providers. The contract states only that absence means the read |
package.json+1-1| ... | @@ -28,7 +28,7 @@ | ... | @@ -28,7 +28,7 @@ |
| 28 | "tc": "vp run -r --concurrency-limit 2 typecheck", | 28 | "tc": "vp run -r --concurrency-limit 2 typecheck", |
| 29 | "lint": "vp lint --report-unused-disable-directives", | 29 | "lint": "vp lint --report-unused-disable-directives", |
| 30 | "knip": "knip --preprocessor ./scripts/knip-schemas.ts", | 30 | "knip": "knip --preprocessor ./scripts/knip-schemas.ts", |
| 31 | "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", | 31 | "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", |
| 32 | "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", | 32 | "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", |
| 33 | "lint:mobile": "node scripts/mobile-native-static-check.ts", | 33 | "lint:mobile": "node scripts/mobile-native-static-check.ts", |
| 34 | "test": "vp run -r test", | 34 | "test": "vp run -r test", |
packages/source-control-gitlab/package.json created+55| ... | @@ -0,0 +1,55 @@ | ||
| 1 | { | ||
| 2 | "name": "@t3tools/source-control-gitlab", | ||
| 3 | "private": true, | ||
| 4 | "type": "module", | ||
| 5 | "exports": { | ||
| 6 | "./server/driver": { | ||
| 7 | "types": "./src/server/driver.ts", | ||
| 8 | "import": "./src/server/driver.ts" | ||
| 9 | }, | ||
| 10 | "./server/gitLabAuthStatus": { | ||
| 11 | "types": "./src/server/gitLabAuthStatus.ts", | ||
| 12 | "import": "./src/server/gitLabAuthStatus.ts" | ||
| 13 | }, | ||
| 14 | "./server/GitLabCli": { | ||
| 15 | "types": "./src/server/GitLabCli.ts", | ||
| 16 | "import": "./src/server/GitLabCli.ts" | ||
| 17 | }, | ||
| 18 | "./server/gitLabMergeRequestJson": { | ||
| 19 | "types": "./src/server/gitLabMergeRequestJson.ts", | ||
| 20 | "import": "./src/server/gitLabMergeRequestJson.ts" | ||
| 21 | }, | ||
| 22 | "./server/gitLabMergeRequests": { | ||
| 23 | "types": "./src/server/gitLabMergeRequests.ts", | ||
| 24 | "import": "./src/server/gitLabMergeRequests.ts" | ||
| 25 | }, | ||
| 26 | "./server/GitLabPullRequestCli": { | ||
| 27 | "types": "./src/server/GitLabPullRequestCli.ts", | ||
| 28 | "import": "./src/server/GitLabPullRequestCli.ts" | ||
| 29 | }, | ||
| 30 | "./server/GitLabPullRequestProvider": { | ||
| 31 | "types": "./src/server/GitLabPullRequestProvider.ts", | ||
| 32 | "import": "./src/server/GitLabPullRequestProvider.ts" | ||
| 33 | }, | ||
| 34 | "./server/GitLabSourceControlProvider": { | ||
| 35 | "types": "./src/server/GitLabSourceControlProvider.ts", | ||
| 36 | "import": "./src/server/GitLabSourceControlProvider.ts" | ||
| 37 | } | ||
| 38 | }, | ||
| 39 | "scripts": { | ||
| 40 | "typecheck": "tsc --noEmit", | ||
| 41 | "test": "vp test run --config ../../vite.config.ts --dir ." | ||
| 42 | }, | ||
| 43 | "dependencies": { | ||
| 44 | "@t3tools/contracts": "workspace:*", | ||
| 45 | "@t3tools/shared": "workspace:*", | ||
| 46 | "@t3tools/source-control-core": "workspace:*", | ||
| 47 | "effect": "catalog:" | ||
| 48 | }, | ||
| 49 | "devDependencies": { | ||
| 50 | "@effect/vitest": "catalog:", | ||
| 51 | "@t3tools/source-control-testing": "workspace:*", | ||
| 52 | "@types/node": "catalog:", | ||
| 53 | "vite-plus": "catalog:" | ||
| 54 | } | ||
| 55 | } | ||
packages/source-control-gitlab/src/server/GitLabCli.test.ts created+379| ... | @@ -0,0 +1,379 @@ | ||
| 1 | import { assert, it, afterEach, expect, vi } from "@effect/vitest"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Layer from "effect/Layer"; | ||
| 4 | import { ChildProcessSpawner } from "effect/process"; | ||
| 5 | |||
| 6 | import { VcsProcessExitError } from "@t3tools/contracts"; | ||
| 7 | |||
| 8 | import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; | ||
| 9 | import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; | ||
| 10 | import * as GitLabCli from "./GitLabCli.ts"; | ||
| 11 | |||
| 12 | const mockedRun = vi.fn<SourceControlHost.SourceControlHost["Service"]["process"]["run"]>(); | ||
| 13 | const layer = it.layer( | ||
| 14 | GitLabCli.layer.pipe(Layer.provide(TestSourceControlHost.layer({ process: { run: mockedRun } }))), | ||
| 15 | ); | ||
| 16 | |||
| 17 | function processOutput(stdout: string): SourceControlHost.SourceControlProcessOutput { | ||
| 18 | return { | ||
| 19 | exitCode: ChildProcessSpawner.ExitCode(0), | ||
| 20 | stdout, | ||
| 21 | stderr: "", | ||
| 22 | stdoutTruncated: false, | ||
| 23 | stderrTruncated: false, | ||
| 24 | }; | ||
| 25 | } | ||
| 26 | |||
| 27 | afterEach(() => { | ||
| 28 | mockedRun.mockReset(); | ||
| 29 | }); | ||
| 30 | |||
| 31 | layer("GitLabCli.layer", (it) => { | ||
| 32 | it.effect("parses merge request view output", () => | ||
| 33 | Effect.gen(function* () { | ||
| 34 | mockedRun.mockReturnValueOnce( | ||
| 35 | Effect.succeed( | ||
| 36 | processOutput( | ||
| 37 | JSON.stringify({ | ||
| 38 | iid: 42, | ||
| 39 | title: "Add MR thread creation", | ||
| 40 | web_url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", | ||
| 41 | target_branch: "main", | ||
| 42 | source_branch: "feature/mr-threads", | ||
| 43 | state: "closed", | ||
| 44 | closed_at: "2026-08-23T10:00:00Z", | ||
| 45 | source_project_id: 101, | ||
| 46 | target_project_id: 100, | ||
| 47 | source_project: { | ||
| 48 | path_with_namespace: "octocat/t3code", | ||
| 49 | }, | ||
| 50 | }), | ||
| 51 | ), | ||
| 52 | ), | ||
| 53 | ); | ||
| 54 | |||
| 55 | const result = yield* Effect.gen(function* () { | ||
| 56 | const glab = yield* GitLabCli.GitLabCli; | ||
| 57 | return yield* glab.getMergeRequest({ | ||
| 58 | cwd: "/repo", | ||
| 59 | reference: "42", | ||
| 60 | }); | ||
| 61 | }); | ||
| 62 | |||
| 63 | assert.deepStrictEqual(result, { | ||
| 64 | number: 42, | ||
| 65 | title: "Add MR thread creation", | ||
| 66 | url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", | ||
| 67 | baseRefName: "main", | ||
| 68 | headRefName: "feature/mr-threads", | ||
| 69 | state: "closed", | ||
| 70 | closedAt: "2026-08-23T10:00:00Z", | ||
| 71 | mergedAt: null, | ||
| 72 | isCrossRepository: true, | ||
| 73 | headRepositoryNameWithOwner: "octocat/t3code", | ||
| 74 | headRepositoryOwnerLogin: "octocat", | ||
| 75 | }); | ||
| 76 | expect(mockedRun).toHaveBeenCalledWith( | ||
| 77 | expect.objectContaining({ | ||
| 78 | command: "glab", | ||
| 79 | cwd: "/repo", | ||
| 80 | args: ["mr", "view", "42", "--output", "json"], | ||
| 81 | }), | ||
| 82 | ); | ||
| 83 | }), | ||
| 84 | ); | ||
| 85 | |||
| 86 | it.effect("skips invalid entries when parsing MR lists", () => | ||
| 87 | Effect.gen(function* () { | ||
| 88 | mockedRun.mockReturnValueOnce( | ||
| 89 | Effect.succeed( | ||
| 90 | processOutput( | ||
| 91 | JSON.stringify([ | ||
| 92 | { | ||
| 93 | iid: 0, | ||
| 94 | title: "invalid", | ||
| 95 | web_url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/0", | ||
| 96 | target_branch: "main", | ||
| 97 | source_branch: "feature/invalid", | ||
| 98 | }, | ||
| 99 | { | ||
| 100 | iid: 43, | ||
| 101 | title: " Valid MR ", | ||
| 102 | web_url: " https://gitlab.com/pingdotgg/t3code/-/merge_requests/43 ", | ||
| 103 | target_branch: " main ", | ||
| 104 | source_branch: " feature/mr-list ", | ||
| 105 | state: "merged", | ||
| 106 | merged_at: "2026-08-23T11:00:00Z", | ||
| 107 | }, | ||
| 108 | ]), | ||
| 109 | ), | ||
| 110 | ), | ||
| 111 | ); | ||
| 112 | |||
| 113 | const result = yield* Effect.gen(function* () { | ||
| 114 | const glab = yield* GitLabCli.GitLabCli; | ||
| 115 | return yield* glab.listMergeRequests({ | ||
| 116 | cwd: "/repo", | ||
| 117 | headSelector: "feature/mr-list", | ||
| 118 | state: "all", | ||
| 119 | }); | ||
| 120 | }); | ||
| 121 | |||
| 122 | assert.deepStrictEqual(result, [ | ||
| 123 | { | ||
| 124 | number: 43, | ||
| 125 | title: "Valid MR", | ||
| 126 | url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/43", | ||
| 127 | baseRefName: "main", | ||
| 128 | headRefName: "feature/mr-list", | ||
| 129 | state: "merged", | ||
| 130 | closedAt: null, | ||
| 131 | mergedAt: "2026-08-23T11:00:00Z", | ||
| 132 | }, | ||
| 133 | ]); | ||
| 134 | expect(mockedRun).toHaveBeenCalledWith( | ||
| 135 | expect.objectContaining({ | ||
| 136 | command: "glab", | ||
| 137 | cwd: "/repo", | ||
| 138 | args: [ | ||
| 139 | "mr", | ||
| 140 | "list", | ||
| 141 | "--source-branch", | ||
| 142 | "feature/mr-list", | ||
| 143 | "--all", | ||
| 144 | "--per-page", | ||
| 145 | "20", | ||
| 146 | "--output", | ||
| 147 | "json", | ||
| 148 | ], | ||
| 149 | }), | ||
| 150 | ); | ||
| 151 | }), | ||
| 152 | ); | ||
| 153 | |||
| 154 | it.effect("reads repository clone URLs", () => | ||
| 155 | Effect.gen(function* () { | ||
| 156 | mockedRun.mockReturnValueOnce( | ||
| 157 | Effect.succeed( | ||
| 158 | processOutput( | ||
| 159 | JSON.stringify({ | ||
| 160 | path_with_namespace: "octocat/t3code", | ||
| 161 | web_url: "https://gitlab.com/octocat/t3code", | ||
| 162 | http_url_to_repo: "https://gitlab.com/octocat/t3code.git", | ||
| 163 | ssh_url_to_repo: "git@gitlab.com:octocat/t3code.git", | ||
| 164 | }), | ||
| 165 | ), | ||
| 166 | ), | ||
| 167 | ); | ||
| 168 | |||
| 169 | const result = yield* Effect.gen(function* () { | ||
| 170 | const glab = yield* GitLabCli.GitLabCli; | ||
| 171 | return yield* glab.getRepositoryCloneUrls({ | ||
| 172 | cwd: "/repo", | ||
| 173 | repository: "octocat/t3code", | ||
| 174 | }); | ||
| 175 | }); | ||
| 176 | |||
| 177 | assert.deepStrictEqual(result, { | ||
| 178 | nameWithOwner: "octocat/t3code", | ||
| 179 | url: "https://gitlab.com/octocat/t3code", | ||
| 180 | sshUrl: "git@gitlab.com:octocat/t3code.git", | ||
| 181 | }); | ||
| 182 | }), | ||
| 183 | ); | ||
| 184 | |||
| 185 | it.effect("creates merge requests through the GitLab API without placing the body in argv", () => | ||
| 186 | Effect.gen(function* () { | ||
| 187 | mockedRun.mockReturnValueOnce(Effect.succeed(processOutput("{}"))); | ||
| 188 | |||
| 189 | const glab = yield* GitLabCli.GitLabCli; | ||
| 190 | yield* glab.createMergeRequest({ | ||
| 191 | cwd: "/repo", | ||
| 192 | baseBranch: "main", | ||
| 193 | headSelector: "owner:feature/provider", | ||
| 194 | title: "Provider MR", | ||
| 195 | bodyFile: "/tmp/t3-mr-body.md", | ||
| 196 | }); | ||
| 197 | |||
| 198 | expect(mockedRun).toHaveBeenCalledWith( | ||
| 199 | expect.objectContaining({ | ||
| 200 | command: "glab", | ||
| 201 | cwd: "/repo", | ||
| 202 | args: [ | ||
| 203 | "api", | ||
| 204 | "--method", | ||
| 205 | "POST", | ||
| 206 | "projects/:fullpath/merge_requests", | ||
| 207 | "--raw-field", | ||
| 208 | "source_branch=feature/provider", | ||
| 209 | "--raw-field", | ||
| 210 | "target_branch=main", | ||
| 211 | "--raw-field", | ||
| 212 | "title=Provider MR", | ||
| 213 | "--field", | ||
| 214 | "description=@/tmp/t3-mr-body.md", | ||
| 215 | ], | ||
| 216 | }), | ||
| 217 | ); | ||
| 218 | }), | ||
| 219 | ); | ||
| 220 | |||
| 221 | it.effect("creates repositories under an explicit namespace", () => | ||
| 222 | Effect.gen(function* () { | ||
| 223 | mockedRun | ||
| 224 | |||
| 225 | .mockReturnValueOnce(Effect.succeed(processOutput(JSON.stringify({ id: 1234 })))) | ||
| 226 | .mockReturnValueOnce( | ||
| 227 | Effect.succeed( | ||
| 228 | processOutput( | ||
| 229 | JSON.stringify({ | ||
| 230 | path_with_namespace: "octocat/t3code", | ||
| 231 | web_url: "https://gitlab.com/octocat/t3code", | ||
| 232 | http_url_to_repo: "https://gitlab.com/octocat/t3code.git", | ||
| 233 | ssh_url_to_repo: "git@gitlab.com:octocat/t3code.git", | ||
| 234 | }), | ||
| 235 | ), | ||
| 236 | ), | ||
| 237 | ); | ||
| 238 | |||
| 239 | const glab = yield* GitLabCli.GitLabCli; | ||
| 240 | const result = yield* glab.createRepository({ | ||
| 241 | cwd: "/repo", | ||
| 242 | repository: "octocat/t3code", | ||
| 243 | visibility: "public", | ||
| 244 | }); | ||
| 245 | |||
| 246 | assert.deepStrictEqual(result, { | ||
| 247 | nameWithOwner: "octocat/t3code", | ||
| 248 | url: "https://gitlab.com/octocat/t3code", | ||
| 249 | sshUrl: "git@gitlab.com:octocat/t3code.git", | ||
| 250 | }); | ||
| 251 | expect(mockedRun).toHaveBeenNthCalledWith( | ||
| 252 | 1, | ||
| 253 | expect.objectContaining({ | ||
| 254 | command: "glab", | ||
| 255 | cwd: "/repo", | ||
| 256 | args: ["api", "namespaces/octocat"], | ||
| 257 | }), | ||
| 258 | ); | ||
| 259 | expect(mockedRun).toHaveBeenNthCalledWith( | ||
| 260 | 2, | ||
| 261 | expect.objectContaining({ | ||
| 262 | command: "glab", | ||
| 263 | cwd: "/repo", | ||
| 264 | args: [ | ||
| 265 | "api", | ||
| 266 | "--method", | ||
| 267 | "POST", | ||
| 268 | "projects", | ||
| 269 | "--raw-field", | ||
| 270 | "path=t3code", | ||
| 271 | "--raw-field", | ||
| 272 | "name=t3code", | ||
| 273 | "--raw-field", | ||
| 274 | "visibility=public", | ||
| 275 | "--raw-field", | ||
| 276 | "namespace_id=1234", | ||
| 277 | ], | ||
| 278 | }), | ||
| 279 | ); | ||
| 280 | }), | ||
| 281 | ); | ||
| 282 | |||
| 283 | it.effect("does not pass unsupported force flags when checking out merge requests", () => | ||
| 284 | Effect.gen(function* () { | ||
| 285 | mockedRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); | ||
| 286 | |||
| 287 | const glab = yield* GitLabCli.GitLabCli; | ||
| 288 | yield* glab.checkoutMergeRequest({ | ||
| 289 | cwd: "/repo", | ||
| 290 | reference: "42", | ||
| 291 | force: true, | ||
| 292 | }); | ||
| 293 | |||
| 294 | expect(mockedRun).toHaveBeenCalledWith( | ||
| 295 | expect.objectContaining({ | ||
| 296 | command: "glab", | ||
| 297 | cwd: "/repo", | ||
| 298 | args: ["mr", "checkout", "42"], | ||
| 299 | }), | ||
| 300 | ); | ||
| 301 | }), | ||
| 302 | ); | ||
| 303 | |||
| 304 | it.effect("surfaces a friendly error when the merge request is not found", () => | ||
| 305 | Effect.gen(function* () { | ||
| 306 | const cause = new VcsProcessExitError({ | ||
| 307 | operation: "GitLabCli.execute", | ||
| 308 | command: "glab", | ||
| 309 | cwd: "/repo", | ||
| 310 | exitCode: 1, | ||
| 311 | detail: "GET 404 merge request not found", | ||
| 312 | failureKind: "not-found", | ||
| 313 | }); | ||
| 314 | mockedRun.mockReturnValueOnce(Effect.fail(cause)); | ||
| 315 | |||
| 316 | const error = yield* Effect.gen(function* () { | ||
| 317 | const glab = yield* GitLabCli.GitLabCli; | ||
| 318 | return yield* glab.getMergeRequest({ | ||
| 319 | cwd: "/repo", | ||
| 320 | reference: "4888", | ||
| 321 | }); | ||
| 322 | }).pipe(Effect.flip); | ||
| 323 | |||
| 324 | assert.equal(error.message.includes("Merge request 4888 was not found"), true); | ||
| 325 | assert.strictEqual(error._tag, "GitLabMergeRequestNotFoundError"); | ||
| 326 | assert.strictEqual(error.command, "glab"); | ||
| 327 | assert.strictEqual(error.cwd, "/repo"); | ||
| 328 | assert.strictEqual(error.cause, cause); | ||
| 329 | assert.equal(error.message.includes(cause.detail), false); | ||
| 330 | }), | ||
| 331 | ); | ||
| 332 | |||
| 333 | it.effect("keeps non-merge-request not-found failures generic", () => | ||
| 334 | Effect.gen(function* () { | ||
| 335 | const cause = new VcsProcessExitError({ | ||
| 336 | operation: "GitLabCli.execute", | ||
| 337 | command: "glab", | ||
| 338 | cwd: "/repo", | ||
| 339 | exitCode: 1, | ||
| 340 | detail: "GET 404 project not found", | ||
| 341 | failureKind: "not-found", | ||
| 342 | }); | ||
| 343 | mockedRun.mockReturnValueOnce(Effect.fail(cause)); | ||
| 344 | |||
| 345 | const error = yield* Effect.gen(function* () { | ||
| 346 | const glab = yield* GitLabCli.GitLabCli; | ||
| 347 | return yield* glab.getRepositoryCloneUrls({ | ||
| 348 | cwd: "/repo", | ||
| 349 | repository: "missing/project", | ||
| 350 | }); | ||
| 351 | }).pipe(Effect.flip); | ||
| 352 | |||
| 353 | assert.strictEqual(error._tag, "GitLabCliCommandError"); | ||
| 354 | assert.strictEqual(error.cause, cause); | ||
| 355 | }), | ||
| 356 | ); | ||
| 357 | |||
| 358 | it.effect("preserves rate-limit failures as a distinct error", () => | ||
| 359 | Effect.gen(function* () { | ||
| 360 | const cause = new VcsProcessExitError({ | ||
| 361 | operation: "GitLabCli.execute", | ||
| 362 | command: "glab", | ||
| 363 | cwd: "/repo", | ||
| 364 | exitCode: 1, | ||
| 365 | detail: "API rate limit exceeded.", | ||
| 366 | failureKind: "rate-limited", | ||
| 367 | }); | ||
| 368 | mockedRun.mockReturnValueOnce(Effect.fail(cause)); | ||
| 369 | |||
| 370 | const glab = yield* GitLabCli.GitLabCli; | ||
| 371 | const error = yield* glab | ||
| 372 | .execute({ cwd: "/repo", args: ["api", "projects"] }) | ||
| 373 | .pipe(Effect.flip); | ||
| 374 | |||
| 375 | assert.strictEqual(error._tag, "GitLabCliRateLimitError"); | ||
| 376 | assert.strictEqual(error.cause, cause); | ||
| 377 | }), | ||
| 378 | ); | ||
| 379 | }); | ||
packages/source-control-gitlab/src/server/GitLabCli.ts created+660| ... | @@ -0,0 +1,660 @@ | ||
| 1 | import * as Context from "effect/Context"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Layer from "effect/Layer"; | ||
| 4 | import * as Match from "effect/Match"; | ||
| 5 | import * as Option from "effect/Option"; | ||
| 6 | import * as Result from "effect/Result"; | ||
| 7 | import * as Schema from "effect/Schema"; | ||
| 8 | import type * as DateTime from "effect/DateTime"; | ||
| 9 | |||
| 10 | import { | ||
| 11 | TrimmedNonEmptyString, | ||
| 12 | type SourceControlRepositoryVisibility, | ||
| 13 | type VcsError, | ||
| 14 | } from "@t3tools/contracts"; | ||
| 15 | |||
| 16 | import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; | ||
| 17 | import { | ||
| 18 | decodeGitLabMergeRequestJson, | ||
| 19 | decodeGitLabMergeRequestListJson, | ||
| 20 | } from "./gitLabMergeRequests.ts"; | ||
| 21 | import type * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; | ||
| 22 | |||
| 23 | const DEFAULT_TIMEOUT_MS = 30_000; | ||
| 24 | |||
| 25 | const gitLabCliExecutionErrorContext = { | ||
| 26 | operation: Schema.Literal("execute"), | ||
| 27 | command: Schema.Literal("glab"), | ||
| 28 | cwd: Schema.String, | ||
| 29 | cause: Schema.Defect(), | ||
| 30 | }; | ||
| 31 | |||
| 32 | const gitLabCliDecodeErrorContext = { | ||
| 33 | command: Schema.Literal("glab"), | ||
| 34 | cwd: Schema.String, | ||
| 35 | cause: Schema.Defect(), | ||
| 36 | }; | ||
| 37 | |||
| 38 | export class GitLabCliUnavailableError extends Schema.TaggedError<GitLabCliUnavailableError>()( | ||
| 39 | "GitLabCliUnavailableError", | ||
| 40 | gitLabCliExecutionErrorContext, | ||
| 41 | ) { | ||
| 42 | get detail(): string { | ||
| 43 | return "GitLab CLI (`glab`) is required but not available on PATH."; | ||
| 44 | } | ||
| 45 | |||
| 46 | override get message(): string { | ||
| 47 | return `GitLab CLI failed in ${this.operation}: ${this.detail}`; | ||
| 48 | } | ||
| 49 | } | ||
| 50 | |||
| 51 | export class GitLabCliAuthenticationError extends Schema.TaggedError<GitLabCliAuthenticationError>()( | ||
| 52 | "GitLabCliAuthenticationError", | ||
| 53 | gitLabCliExecutionErrorContext, | ||
| 54 | ) { | ||
| 55 | get detail(): string { | ||
| 56 | return "GitLab CLI is not authenticated. Run `glab auth login` and retry."; | ||
| 57 | } | ||
| 58 | |||
| 59 | override get message(): string { | ||
| 60 | return `GitLab CLI failed in ${this.operation}: ${this.detail}`; | ||
| 61 | } | ||
| 62 | } | ||
| 63 | |||
| 64 | export class GitLabCliRateLimitError extends Schema.TaggedError<GitLabCliRateLimitError>()( | ||
| 65 | "GitLabCliRateLimitError", | ||
| 66 | gitLabCliExecutionErrorContext, | ||
| 67 | ) { | ||
| 68 | get detail(): string { | ||
| 69 | return "GitLab API rate limit exceeded."; | ||
| 70 | } | ||
| 71 | |||
| 72 | override get message(): string { | ||
| 73 | return `GitLab CLI failed in ${this.operation}: ${this.detail}`; | ||
| 74 | } | ||
| 75 | } | ||
| 76 | |||
| 77 | export class GitLabMergeRequestNotFoundError extends Schema.TaggedError<GitLabMergeRequestNotFoundError>()( | ||
| 78 | "GitLabMergeRequestNotFoundError", | ||
| 79 | { | ||
| 80 | ...gitLabCliExecutionErrorContext, | ||
| 81 | reference: Schema.String, | ||
| 82 | }, | ||
| 83 | ) { | ||
| 84 | get detail(): string { | ||
| 85 | return `Merge request ${this.reference} was not found. Check the MR number or URL and try again.`; | ||
| 86 | } | ||
| 87 | |||
| 88 | override get message(): string { | ||
| 89 | return `GitLab CLI failed in ${this.operation}: ${this.detail}`; | ||
| 90 | } | ||
| 91 | |||
| 92 | static fromVcsError( | ||
| 93 | context: { | ||
| 94 | readonly operation: "execute"; | ||
| 95 | readonly command: "glab"; | ||
| 96 | readonly cwd: string; | ||
| 97 | readonly reference: string; | ||
| 98 | }, | ||
| 99 | error: VcsError, | ||
| 100 | ): GitLabCliError { | ||
| 101 | if (error._tag === "VcsProcessExitError" && error.failureKind === "not-found") { | ||
| 102 | return new GitLabMergeRequestNotFoundError({ ...context, cause: error }); | ||
| 103 | } | ||
| 104 | |||
| 105 | return GitLabCliCommandError.fromVcsError( | ||
| 106 | { | ||
| 107 | operation: context.operation, | ||
| 108 | command: context.command, | ||
| 109 | cwd: context.cwd, | ||
| 110 | }, | ||
| 111 | error, | ||
| 112 | ); | ||
| 113 | } | ||
| 114 | } | ||
| 115 | |||
| 116 | export class GitLabCliCommandError extends Schema.TaggedError<GitLabCliCommandError>()( | ||
| 117 | "GitLabCliCommandError", | ||
| 118 | gitLabCliExecutionErrorContext, | ||
| 119 | ) { | ||
| 120 | get detail(): string { | ||
| 121 | return "GitLab CLI command failed."; | ||
| 122 | } | ||
| 123 | |||
| 124 | override get message(): string { | ||
| 125 | return `GitLab CLI failed in ${this.operation}: ${this.detail}`; | ||
| 126 | } | ||
| 127 | |||
| 128 | static fromVcsError( | ||
| 129 | context: { | ||
| 130 | readonly operation: "execute"; | ||
| 131 | readonly command: "glab"; | ||
| 132 | readonly cwd: string; | ||
| 133 | }, | ||
| 134 | error: VcsError, | ||
| 135 | ): GitLabCliError { | ||
| 136 | return Match.valueTags(error, { | ||
| 137 | VcsProcessSpawnError: (cause) => new GitLabCliUnavailableError({ ...context, cause }), | ||
| 138 | VcsProcessExitError: (cause) => { | ||
| 139 | switch (cause.failureKind) { | ||
| 140 | case "authentication": | ||
| 141 | return new GitLabCliAuthenticationError({ ...context, cause }); | ||
| 142 | case "rate-limited": | ||
| 143 | return new GitLabCliRateLimitError({ ...context, cause }); | ||
| 144 | case "not-found": | ||
| 145 | case "command-failed": | ||
| 146 | case undefined: | ||
| 147 | return new GitLabCliCommandError({ ...context, cause }); | ||
| 148 | } | ||
| 149 | }, | ||
| 150 | VcsProcessTimeoutError: (cause) => new GitLabCliCommandError({ ...context, cause }), | ||
| 151 | VcsProcessStdinWriteError: (cause) => new GitLabCliCommandError({ ...context, cause }), | ||
| 152 | VcsProcessOutputReadError: (cause) => new GitLabCliCommandError({ ...context, cause }), | ||
| 153 | VcsProcessOutputLimitError: (cause) => new GitLabCliCommandError({ ...context, cause }), | ||
| 154 | VcsProcessMissingExitCodeError: (cause) => new GitLabCliCommandError({ ...context, cause }), | ||
| 155 | VcsRepositoryDetectionError: (cause) => new GitLabCliCommandError({ ...context, cause }), | ||
| 156 | VcsUnsupportedOperationError: (cause) => new GitLabCliCommandError({ ...context, cause }), | ||
| 157 | }); | ||
| 158 | } | ||
| 159 | } | ||
| 160 | |||
| 161 | export class GitLabMergeRequestListDecodeError extends Schema.TaggedError<GitLabMergeRequestListDecodeError>()( | ||
| 162 | "GitLabMergeRequestListDecodeError", | ||
| 163 | { | ||
| 164 | ...gitLabCliDecodeErrorContext, | ||
| 165 | operation: Schema.Literal("listMergeRequests"), | ||
| 166 | }, | ||
| 167 | ) { | ||
| 168 | get detail(): string { | ||
| 169 | return "GitLab CLI returned invalid MR list JSON."; | ||
| 170 | } | ||
| 171 | |||
| 172 | override get message(): string { | ||
| 173 | return `GitLab CLI failed in ${this.operation}: ${this.detail}`; | ||
| 174 | } | ||
| 175 | } | ||
| 176 | |||
| 177 | export class GitLabMergeRequestDecodeError extends Schema.TaggedError<GitLabMergeRequestDecodeError>()( | ||
| 178 | "GitLabMergeRequestDecodeError", | ||
| 179 | { | ||
| 180 | ...gitLabCliDecodeErrorContext, | ||
| 181 | operation: Schema.Literal("getMergeRequest"), | ||
| 182 | reference: Schema.String, | ||
| 183 | }, | ||
| 184 | ) { | ||
| 185 | get detail(): string { | ||
| 186 | return "GitLab CLI returned invalid merge request JSON."; | ||
| 187 | } | ||
| 188 | |||
| 189 | override get message(): string { | ||
| 190 | return `GitLab CLI failed in ${this.operation}: ${this.detail}`; | ||
| 191 | } | ||
| 192 | } | ||
| 193 | |||
| 194 | export class GitLabRepositoryDecodeError extends Schema.TaggedError<GitLabRepositoryDecodeError>()( | ||
| 195 | "GitLabRepositoryDecodeError", | ||
| 196 | { | ||
| 197 | ...gitLabCliDecodeErrorContext, | ||
| 198 | operation: Schema.Literals(["getRepositoryCloneUrls", "createRepository", "getDefaultBranch"]), | ||
| 199 | repository: Schema.optional(Schema.String), | ||
| 200 | }, | ||
| 201 | ) { | ||
| 202 | get detail(): string { | ||
| 203 | return "GitLab CLI returned invalid repository JSON."; | ||
| 204 | } | ||
| 205 | |||
| 206 | override get message(): string { | ||
| 207 | return `GitLab CLI failed in ${this.operation}: ${this.detail}`; | ||
| 208 | } | ||
| 209 | } | ||
| 210 | |||
| 211 | export class GitLabNamespaceDecodeError extends Schema.TaggedError<GitLabNamespaceDecodeError>()( | ||
| 212 | "GitLabNamespaceDecodeError", | ||
| 213 | { | ||
| 214 | ...gitLabCliDecodeErrorContext, | ||
| 215 | operation: Schema.Literal("createRepository"), | ||
| 216 | namespacePath: Schema.String, | ||
| 217 | }, | ||
| 218 | ) { | ||
| 219 | get detail(): string { | ||
| 220 | return "GitLab CLI returned invalid namespace JSON."; | ||
| 221 | } | ||
| 222 | |||
| 223 | override get message(): string { | ||
| 224 | return `GitLab CLI failed in ${this.operation}: ${this.detail}`; | ||
| 225 | } | ||
| 226 | } | ||
| 227 | |||
| 228 | export const GitLabCliError = Schema.Union([ | ||
| 229 | GitLabCliUnavailableError, | ||
| 230 | GitLabCliAuthenticationError, | ||
| 231 | GitLabCliRateLimitError, | ||
| 232 | GitLabMergeRequestNotFoundError, | ||
| 233 | GitLabCliCommandError, | ||
| 234 | GitLabMergeRequestListDecodeError, | ||
| 235 | GitLabMergeRequestDecodeError, | ||
| 236 | GitLabRepositoryDecodeError, | ||
| 237 | GitLabNamespaceDecodeError, | ||
| 238 | ]); | ||
| 239 | export type GitLabCliError = typeof GitLabCliError.Type; | ||
| 240 | |||
| 241 | export interface GitLabMergeRequestSummary { | ||
| 242 | readonly number: number; | ||
| 243 | readonly title: string; | ||
| 244 | readonly url: string; | ||
| 245 | readonly baseRefName: string; | ||
| 246 | readonly headRefName: string; | ||
| 247 | readonly state?: "open" | "closed" | "merged"; | ||
| 248 | readonly isDraft?: boolean; | ||
| 249 | readonly closedAt?: string | null; | ||
| 250 | readonly mergedAt?: string | null; | ||
| 251 | readonly updatedAt?: Option.Option<DateTime.Utc>; | ||
| 252 | readonly isCrossRepository?: boolean; | ||
| 253 | readonly headRepositoryNameWithOwner?: string | null; | ||
| 254 | readonly headRepositoryOwnerLogin?: string | null; | ||
| 255 | } | ||
| 256 | |||
| 257 | export interface GitLabRepositoryCloneUrls { | ||
| 258 | readonly nameWithOwner: string; | ||
| 259 | readonly url: string; | ||
| 260 | readonly sshUrl: string; | ||
| 261 | } | ||
| 262 | |||
| 263 | export class GitLabCli extends Context.Service< | ||
| 264 | GitLabCli, | ||
| 265 | { | ||
| 266 | readonly execute: (input: { | ||
| 267 | readonly cwd: string; | ||
| 268 | readonly args: ReadonlyArray<string>; | ||
| 269 | readonly timeoutMs?: number; | ||
| 270 | /** Piped to the child's stdin, for payloads that must never appear in argv. */ | ||
| 271 | readonly stdin?: string; | ||
| 272 | readonly maxOutputBytes?: number; | ||
| 273 | }) => Effect.Effect<SourceControlHost.SourceControlProcessOutput, GitLabCliError>; | ||
| 274 | |||
| 275 | readonly listMergeRequests: (input: { | ||
| 276 | readonly cwd: string; | ||
| 277 | readonly headSelector: string; | ||
| 278 | readonly source?: SourceControlProvider.SourceControlRefSelector; | ||
| 279 | readonly state: "open" | "closed" | "merged" | "all"; | ||
| 280 | readonly limit?: number; | ||
| 281 | }) => Effect.Effect<ReadonlyArray<GitLabMergeRequestSummary>, GitLabCliError>; | ||
| 282 | |||
| 283 | readonly getMergeRequest: (input: { | ||
| 284 | readonly cwd: string; | ||
| 285 | readonly reference: string; | ||
| 286 | }) => Effect.Effect<GitLabMergeRequestSummary, GitLabCliError>; | ||
| 287 | |||
| 288 | readonly getRepositoryCloneUrls: (input: { | ||
| 289 | readonly cwd: string; | ||
| 290 | readonly repository: string; | ||
| 291 | }) => Effect.Effect<GitLabRepositoryCloneUrls, GitLabCliError>; | ||
| 292 | |||
| 293 | readonly createRepository: (input: { | ||
| 294 | readonly cwd: string; | ||
| 295 | readonly repository: string; | ||
| 296 | readonly visibility: SourceControlRepositoryVisibility; | ||
| 297 | }) => Effect.Effect<GitLabRepositoryCloneUrls, GitLabCliError>; | ||
| 298 | |||
| 299 | readonly createMergeRequest: (input: { | ||
| 300 | readonly cwd: string; | ||
| 301 | readonly baseBranch: string; | ||
| 302 | readonly headSelector: string; | ||
| 303 | readonly source?: SourceControlProvider.SourceControlRefSelector; | ||
| 304 | readonly target?: SourceControlProvider.SourceControlRefSelector; | ||
| 305 | readonly title: string; | ||
| 306 | readonly bodyFile: string; | ||
| 307 | }) => Effect.Effect<void, GitLabCliError>; | ||
| 308 | |||
| 309 | readonly getDefaultBranch: (input: { | ||
| 310 | readonly cwd: string; | ||
| 311 | }) => Effect.Effect<string | null, GitLabCliError>; | ||
| 312 | |||
| 313 | readonly checkoutMergeRequest: (input: { | ||
| 314 | readonly cwd: string; | ||
| 315 | readonly reference: string; | ||
| 316 | readonly force?: boolean; | ||
| 317 | }) => Effect.Effect<void, GitLabCliError>; | ||
| 318 | } | ||
| 319 | >()("@t3tools/source-control-gitlab/server/GitLabCli") {} | ||
| 320 | |||
| 321 | const RawGitLabRepositoryCloneUrlsSchema = Schema.Struct({ | ||
| 322 | path_with_namespace: TrimmedNonEmptyString, | ||
| 323 | web_url: TrimmedNonEmptyString, | ||
| 324 | http_url_to_repo: TrimmedNonEmptyString, | ||
| 325 | ssh_url_to_repo: TrimmedNonEmptyString, | ||
| 326 | }); | ||
| 327 | |||
| 328 | const RawGitLabDefaultBranchSchema = Schema.Struct({ | ||
| 329 | default_branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), | ||
| 330 | }); | ||
| 331 | |||
| 332 | const RawGitLabNamespaceSchema = Schema.Struct({ | ||
| 333 | id: Schema.Number, | ||
| 334 | }); | ||
| 335 | |||
| 336 | const decodeGitLabRepositoryCloneUrls = Schema.decodeEffect( | ||
| 337 | Schema.fromJsonString(RawGitLabRepositoryCloneUrlsSchema), | ||
| 338 | ); | ||
| 339 | const decodeGitLabDefaultBranch = Schema.decodeEffect( | ||
| 340 | Schema.fromJsonString(RawGitLabDefaultBranchSchema), | ||
| 341 | ); | ||
| 342 | const decodeGitLabNamespace = Schema.decodeEffect(Schema.fromJsonString(RawGitLabNamespaceSchema)); | ||
| 343 | |||
| 344 | function normalizeRepositoryCloneUrls( | ||
| 345 | raw: Schema.Schema.Type<typeof RawGitLabRepositoryCloneUrlsSchema>, | ||
| 346 | ): GitLabRepositoryCloneUrls { | ||
| 347 | return { | ||
| 348 | nameWithOwner: raw.path_with_namespace, | ||
| 349 | url: raw.web_url, | ||
| 350 | sshUrl: raw.ssh_url_to_repo, | ||
| 351 | }; | ||
| 352 | } | ||
| 353 | |||
| 354 | function stateArgs(state: "open" | "closed" | "merged" | "all"): ReadonlyArray<string> { | ||
| 355 | switch (state) { | ||
| 356 | case "open": | ||
| 357 | return []; | ||
| 358 | case "closed": | ||
| 359 | return ["--closed"]; | ||
| 360 | case "merged": | ||
| 361 | return ["--merged"]; | ||
| 362 | case "all": | ||
| 363 | return ["--all"]; | ||
| 364 | } | ||
| 365 | } | ||
| 366 | |||
| 367 | function normalizeHeadSelector(headSelector: string): string { | ||
| 368 | const trimmed = headSelector.trim(); | ||
| 369 | const ownerBranch = /^[^:]+:(.+)$/.exec(trimmed); | ||
| 370 | return ownerBranch?.[1]?.trim() || trimmed; | ||
| 371 | } | ||
| 372 | |||
| 373 | function sourceRefName(input: { | ||
| 374 | readonly headSelector: string; | ||
| 375 | readonly source?: SourceControlProvider.SourceControlRefSelector; | ||
| 376 | }): string { | ||
| 377 | return input.source?.refName ?? normalizeHeadSelector(input.headSelector); | ||
| 378 | } | ||
| 379 | |||
| 380 | function sourceProjectIdentifier( | ||
| 381 | source: SourceControlProvider.SourceControlRefSelector | undefined, | ||
| 382 | ): string | null { | ||
| 383 | return source?.repository ?? source?.owner ?? null; | ||
| 384 | } | ||
| 385 | |||
| 386 | function toSummaryWithOptionalUpdatedAt( | ||
| 387 | record: GitLabMergeRequestSummary & { | ||
| 388 | readonly updatedAt: Option.Option<DateTime.Utc>; | ||
| 389 | }, | ||
| 390 | ): GitLabMergeRequestSummary { | ||
| 391 | const { updatedAt, ...summary } = record; | ||
| 392 | return Option.isSome(updatedAt) ? { ...summary, updatedAt } : summary; | ||
| 393 | } | ||
| 394 | |||
| 395 | function parseRepositoryPath(repository: string): { | ||
| 396 | readonly namespacePath: string | null; | ||
| 397 | readonly projectPath: string; | ||
| 398 | } { | ||
| 399 | const parts: Array<string> = []; | ||
| 400 | for (const part of repository.split("/")) { | ||
| 401 | const trimmed = part.trim(); | ||
| 402 | if (trimmed.length > 0) { | ||
| 403 | parts.push(trimmed); | ||
| 404 | } | ||
| 405 | } | ||
| 406 | const projectPath = parts.at(-1) ?? repository.trim(); | ||
| 407 | const namespacePath = parts.length > 1 ? parts.slice(0, -1).join("/") : null; | ||
| 408 | return { namespacePath, projectPath }; | ||
| 409 | } | ||
| 410 | |||
| 411 | /** @public Service construction is part of the canonical Effect module API. */ | ||
| 412 | export const make = Effect.gen(function* () { | ||
| 413 | const { process } = yield* SourceControlHost.SourceControlHost; | ||
| 414 | |||
| 415 | const run = ( | ||
| 416 | input: Parameters<GitLabCli["Service"]["execute"]>[0], | ||
| 417 | mapError: (error: VcsError) => GitLabCliError, | ||
| 418 | ) => | ||
| 419 | process | ||
| 420 | .run({ | ||
| 421 | operation: "GitLabCli.execute", | ||
| 422 | command: "glab", | ||
| 423 | args: input.args, | ||
| 424 | cwd: input.cwd, | ||
| 425 | timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, | ||
| 426 | ...(input.stdin === undefined ? {} : { stdin: input.stdin }), | ||
| 427 | ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), | ||
| 428 | }) | ||
| 429 | .pipe(Effect.mapError(mapError)); | ||
| 430 | |||
| 431 | const execute: GitLabCli["Service"]["execute"] = (input) => | ||
| 432 | run(input, (error) => | ||
| 433 | GitLabCliCommandError.fromVcsError( | ||
| 434 | { operation: "execute", command: "glab", cwd: input.cwd }, | ||
| 435 | error, | ||
| 436 | ), | ||
| 437 | ); | ||
| 438 | |||
| 439 | const executeMergeRequest = (input: { | ||
| 440 | readonly cwd: string; | ||
| 441 | readonly reference: string; | ||
| 442 | readonly args: ReadonlyArray<string>; | ||
| 443 | }) => | ||
| 444 | run(input, (error) => | ||
| 445 | GitLabMergeRequestNotFoundError.fromVcsError( | ||
| 446 | { | ||
| 447 | operation: "execute", | ||
| 448 | command: "glab", | ||
| 449 | cwd: input.cwd, | ||
| 450 | reference: input.reference, | ||
| 451 | }, | ||
| 452 | error, | ||
| 453 | ), | ||
| 454 | ); | ||
| 455 | |||
| 456 | return GitLabCli.of({ | ||
| 457 | execute, | ||
| 458 | listMergeRequests: (input) => | ||
| 459 | execute({ | ||
| 460 | cwd: input.cwd, | ||
| 461 | args: [ | ||
| 462 | "mr", | ||
| 463 | "list", | ||
| 464 | "--source-branch", | ||
| 465 | sourceRefName(input), | ||
| 466 | ...stateArgs(input.state), | ||
| 467 | "--per-page", | ||
| 468 | String(input.limit ?? 20), | ||
| 469 | "--output", | ||
| 470 | "json", | ||
| 471 | ], | ||
| 472 | }).pipe( | ||
| 473 | Effect.map((result) => result.stdout.trim()), | ||
| 474 | Effect.flatMap((raw) => | ||
| 475 | raw.length === 0 | ||
| 476 | ? Effect.succeed([]) | ||
| 477 | : Effect.sync(() => decodeGitLabMergeRequestListJson(raw)).pipe( | ||
| 478 | Effect.flatMap((decoded) => { | ||
| 479 | if (!Result.isSuccess(decoded)) { | ||
| 480 | return Effect.fail( | ||
| 481 | new GitLabMergeRequestListDecodeError({ | ||
| 482 | operation: "listMergeRequests", | ||
| 483 | command: "glab", | ||
| 484 | cwd: input.cwd, | ||
| 485 | cause: decoded.failure, | ||
| 486 | }), | ||
| 487 | ); | ||
| 488 | } | ||
| 489 | |||
| 490 | return Effect.succeed(decoded.success.map(toSummaryWithOptionalUpdatedAt)); | ||
| 491 | }), | ||
| 492 | ), | ||
| 493 | ), | ||
| 494 | ), | ||
| 495 | getMergeRequest: (input) => | ||
| 496 | executeMergeRequest({ | ||
| 497 | cwd: input.cwd, | ||
| 498 | reference: input.reference, | ||
| 499 | args: ["mr", "view", input.reference, "--output", "json"], | ||
| 500 | }).pipe( | ||
| 501 | Effect.map((result) => result.stdout.trim()), | ||
| 502 | Effect.flatMap((raw) => | ||
| 503 | Effect.sync(() => decodeGitLabMergeRequestJson(raw)).pipe( | ||
| 504 | Effect.flatMap((decoded) => { | ||
| 505 | if (!Result.isSuccess(decoded)) { | ||
| 506 | return Effect.fail( | ||
| 507 | new GitLabMergeRequestDecodeError({ | ||
| 508 | operation: "getMergeRequest", | ||
| 509 | command: "glab", | ||
| 510 | cwd: input.cwd, | ||
| 511 | reference: input.reference, | ||
| 512 | cause: decoded.failure, | ||
| 513 | }), | ||
| 514 | ); | ||
| 515 | } | ||
| 516 | |||
| 517 | return Effect.succeed(toSummaryWithOptionalUpdatedAt(decoded.success)); | ||
| 518 | }), | ||
| 519 | ), | ||
| 520 | ), | ||
| 521 | ), | ||
| 522 | getRepositoryCloneUrls: (input) => | ||
| 523 | execute({ | ||
| 524 | cwd: input.cwd, | ||
| 525 | args: ["api", `projects/${encodeURIComponent(input.repository)}`], | ||
| 526 | }).pipe( | ||
| 527 | Effect.map((result) => result.stdout.trim()), | ||
| 528 | Effect.flatMap((raw) => | ||
| 529 | decodeGitLabRepositoryCloneUrls(raw).pipe( | ||
| 530 | Effect.mapError( | ||
| 531 | (cause) => | ||
| 532 | new GitLabRepositoryDecodeError({ | ||
| 533 | operation: "getRepositoryCloneUrls", | ||
| 534 | command: "glab", | ||
| 535 | cwd: input.cwd, | ||
| 536 | repository: input.repository, | ||
| 537 | cause, | ||
| 538 | }), | ||
| 539 | ), | ||
| 540 | ), | ||
| 541 | ), | ||
| 542 | Effect.map(normalizeRepositoryCloneUrls), | ||
| 543 | ), | ||
| 544 | createRepository: (input) => { | ||
| 545 | const { namespacePath, projectPath } = parseRepositoryPath(input.repository); | ||
| 546 | const namespaceId: Effect.Effect<number | null, GitLabCliError> = namespacePath | ||
| 547 | ? execute({ | ||
| 548 | cwd: input.cwd, | ||
| 549 | args: ["api", `namespaces/${encodeURIComponent(namespacePath)}`], | ||
| 550 | }).pipe( | ||
| 551 | Effect.map((result) => result.stdout.trim()), | ||
| 552 | Effect.flatMap((raw) => | ||
| 553 | decodeGitLabNamespace(raw).pipe( | ||
| 554 | Effect.mapError( | ||
| 555 | (cause) => | ||
| 556 | new GitLabNamespaceDecodeError({ | ||
| 557 | operation: "createRepository", | ||
| 558 | command: "glab", | ||
| 559 | cwd: input.cwd, | ||
| 560 | namespacePath, | ||
| 561 | cause, | ||
| 562 | }), | ||
| 563 | ), | ||
| 564 | ), | ||
| 565 | ), | ||
| 566 | Effect.map((namespace) => namespace.id), | ||
| 567 | ) | ||
| 568 | : Effect.succeed(null); | ||
| 569 | |||
| 570 | return namespaceId.pipe( | ||
| 571 | Effect.flatMap((resolvedNamespaceId) => | ||
| 572 | execute({ | ||
| 573 | cwd: input.cwd, | ||
| 574 | args: [ | ||
| 575 | "api", | ||
| 576 | "--method", | ||
| 577 | "POST", | ||
| 578 | "projects", | ||
| 579 | "--raw-field", | ||
| 580 | `path=${projectPath}`, | ||
| 581 | "--raw-field", | ||
| 582 | `name=${projectPath}`, | ||
| 583 | "--raw-field", | ||
| 584 | `visibility=${input.visibility}`, | ||
| 585 | ...(resolvedNamespaceId === null | ||
| 586 | ? [] | ||
| 587 | : ["--raw-field", `namespace_id=${resolvedNamespaceId}`]), | ||
| 588 | ], | ||
| 589 | }), | ||
| 590 | ), | ||
| 591 | Effect.map((result) => result.stdout.trim()), | ||
| 592 | Effect.flatMap((raw) => | ||
| 593 | decodeGitLabRepositoryCloneUrls(raw).pipe( | ||
| 594 | Effect.mapError( | ||
| 595 | (cause) => | ||
| 596 | new GitLabRepositoryDecodeError({ | ||
| 597 | operation: "createRepository", | ||
| 598 | command: "glab", | ||
| 599 | cwd: input.cwd, | ||
| 600 | repository: input.repository, | ||
| 601 | cause, | ||
| 602 | }), | ||
| 603 | ), | ||
| 604 | ), | ||
| 605 | ), | ||
| 606 | Effect.map(normalizeRepositoryCloneUrls), | ||
| 607 | ); | ||
| 608 | }, | ||
| 609 | createMergeRequest: (input) => { | ||
| 610 | const sourceProject = sourceProjectIdentifier(input.source); | ||
| 611 | return execute({ | ||
| 612 | cwd: input.cwd, | ||
| 613 | args: [ | ||
| 614 | "api", | ||
| 615 | "--method", | ||
| 616 | "POST", | ||
| 617 | "projects/:fullpath/merge_requests", | ||
| 618 | "--raw-field", | ||
| 619 | `source_branch=${sourceRefName(input)}`, | ||
| 620 | "--raw-field", | ||
| 621 | `target_branch=${input.target?.refName ?? input.baseBranch}`, | ||
| 622 | ...(sourceProject ? ["--raw-field", `source_project_id=${sourceProject}`] : []), | ||
| 623 | "--raw-field", | ||
| 624 | `title=${input.title}`, | ||
| 625 | "--field", | ||
| 626 | `description=@${input.bodyFile}`, | ||
| 627 | ], | ||
| 628 | }).pipe(Effect.asVoid); | ||
| 629 | }, | ||
| 630 | getDefaultBranch: (input) => | ||
| 631 | execute({ | ||
| 632 | cwd: input.cwd, | ||
| 633 | args: ["api", "projects/:fullpath"], | ||
| 634 | }).pipe( | ||
| 635 | Effect.map((result) => result.stdout.trim()), | ||
| 636 | Effect.flatMap((raw) => | ||
| 637 | decodeGitLabDefaultBranch(raw).pipe( | ||
| 638 | Effect.mapError( | ||
| 639 | (cause) => | ||
| 640 | new GitLabRepositoryDecodeError({ | ||
| 641 | operation: "getDefaultBranch", | ||
| 642 | command: "glab", | ||
| 643 | cwd: input.cwd, | ||
| 644 | cause, | ||
| 645 | }), | ||
| 646 | ), | ||
| 647 | ), | ||
| 648 | ), | ||
| 649 | Effect.map((value) => value.default_branch ?? null), | ||
| 650 | ), | ||
| 651 | checkoutMergeRequest: (input) => | ||
| 652 | executeMergeRequest({ | ||
| 653 | cwd: input.cwd, | ||
| 654 | reference: input.reference, | ||
| 655 | args: ["mr", "checkout", input.reference], | ||
| 656 | }).pipe(Effect.asVoid), | ||
| 657 | }); | ||
| 658 | }); | ||
| 659 | |||
| 660 | export const layer = Layer.effect(GitLabCli, make); | ||
packages/source-control-gitlab/src/server/GitLabPullRequestCli.test.ts created+1536| ... | @@ -0,0 +1,1536 @@ | ||
| 1 | import { afterEach, assert, expect, it, vi } from "@effect/vitest"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Layer from "effect/Layer"; | ||
| 4 | import { ChildProcessSpawner } from "effect/process"; | ||
| 5 | |||
| 6 | import * as GitLabCli from "./GitLabCli.ts"; | ||
| 7 | import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts"; | ||
| 8 | |||
| 9 | const mockedExecute = vi.fn<GitLabCli.GitLabCli["Service"]["execute"]>(); | ||
| 10 | |||
| 11 | const layer = it.layer( | ||
| 12 | GitLabPullRequestCli.layer.pipe( | ||
| 13 | Layer.provide( | ||
| 14 | Layer.mock(GitLabCli.GitLabCli)({ | ||
| 15 | execute: mockedExecute, | ||
| 16 | }), | ||
| 17 | ), | ||
| 18 | ), | ||
| 19 | ); | ||
| 20 | |||
| 21 | function output(stdout: string, stdoutTruncated = false, stdoutInvalidUtf8 = false) { | ||
| 22 | return { | ||
| 23 | exitCode: ChildProcessSpawner.ExitCode(0), | ||
| 24 | stdout, | ||
| 25 | stderr: "", | ||
| 26 | stdoutTruncated, | ||
| 27 | stderrTruncated: false, | ||
| 28 | stdoutInvalidUtf8, | ||
| 29 | }; | ||
| 30 | } | ||
| 31 | |||
| 32 | function mergeRequests(count: number, firstNumber: number): string { | ||
| 33 | return JSON.stringify( | ||
| 34 | Array.from({ length: count }, (_, index) => ({ | ||
| 35 | iid: firstNumber + index, | ||
| 36 | title: `Merge request ${firstNumber + index}`, | ||
| 37 | web_url: `https://gitlab.com/acme/web/-/merge_requests/${firstNumber + index}`, | ||
| 38 | source_branch: "feat/page", | ||
| 39 | target_branch: "main", | ||
| 40 | created_at: "2026-07-01T00:00:00Z", | ||
| 41 | updated_at: "2026-07-02T00:00:00Z", | ||
| 42 | })), | ||
| 43 | ); | ||
| 44 | } | ||
| 45 | |||
| 46 | /** A page of `/diffs` as GitLab serves it, a full one unless the count says otherwise. */ | ||
| 47 | function diffPage(firstIndex: number, count = 100): string { | ||
| 48 | return JSON.stringify( | ||
| 49 | Array.from({ length: count }, (_, index) => ({ | ||
| 50 | old_path: `src/${firstIndex + index}.ts`, | ||
| 51 | new_path: `src/${firstIndex + index}.ts`, | ||
| 52 | diff: "@@ -1 +1 @@\n-a\n+b\n", | ||
| 53 | })), | ||
| 54 | ); | ||
| 55 | } | ||
| 56 | |||
| 57 | /** A page of merge request notes, which is what the flat conversation is read from. */ | ||
| 58 | function notes(count: number, firstId: number): string { | ||
| 59 | return JSON.stringify( | ||
| 60 | Array.from({ length: count }, (_, index) => ({ | ||
| 61 | id: firstId + index, | ||
| 62 | body: `note ${firstId + index}`, | ||
| 63 | author: { username: "bilal" }, | ||
| 64 | created_at: "2026-07-01T00:00:00Z", | ||
| 65 | })), | ||
| 66 | ); | ||
| 67 | } | ||
| 68 | |||
| 69 | /** Who opened the merge request, and somebody already reviewing it. */ | ||
| 70 | const author = { id: 1, username: "bilal" }; | ||
| 71 | const reviewer = { id: 5, username: "octocat" }; | ||
| 72 | |||
| 73 | /** One merge request as `/merge_requests/:iid` answers with it. */ | ||
| 74 | function mergeRequestJson(overrides: Record<string, unknown>): string { | ||
| 75 | return JSON.stringify({ | ||
| 76 | iid: 7, | ||
| 77 | title: "Merge request 7", | ||
| 78 | web_url: "https://gitlab.com/acme/web/-/merge_requests/7", | ||
| 79 | source_branch: "feat/page", | ||
| 80 | target_branch: "main", | ||
| 81 | created_at: "2026-07-01T00:00:00Z", | ||
| 82 | updated_at: "2026-07-02T00:00:00Z", | ||
| 83 | author, | ||
| 84 | ...overrides, | ||
| 85 | }); | ||
| 86 | } | ||
| 87 | |||
| 88 | /** The endpoint or subcommand of the nth glab invocation. */ | ||
| 89 | function argsOfCall(index: number): ReadonlyArray<string> { | ||
| 90 | return callAt(index).args; | ||
| 91 | } | ||
| 92 | |||
| 93 | /** The whole nth invocation, so a request body can be asserted alongside its path. */ | ||
| 94 | function callAt(index: number) { | ||
| 95 | const call = mockedExecute.mock.calls[index]; | ||
| 96 | assert.isDefined(call); | ||
| 97 | return call[0]; | ||
| 98 | } | ||
| 99 | |||
| 100 | afterEach(() => { | ||
| 101 | mockedExecute.mockReset(); | ||
| 102 | }); | ||
| 103 | |||
| 104 | layer("GitLabPullRequestCli.layer", (it) => { | ||
| 105 | it.effect("asks GitLab for one row more than the page, to probe for a next page", () => | ||
| 106 | Effect.gen(function* () { | ||
| 107 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(mergeRequests(3, 1)))); | ||
| 108 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 109 | |||
| 110 | const batch = yield* cli.listMergeRequests({ | ||
| 111 | cwd: "/w", | ||
| 112 | repository: "acme/web", | ||
| 113 | state: "open", | ||
| 114 | involvement: "all", | ||
| 115 | viewer: "bilal", | ||
| 116 | limit: 10, | ||
| 117 | }); | ||
| 118 | |||
| 119 | assert.strictEqual(batch.items.length, 3); | ||
| 120 | assert.isFalse(batch.truncated); | ||
| 121 | assert.strictEqual(batch.cursorAdvance, 3); | ||
| 122 | const path = argsOfCall(0)[1] ?? ""; | ||
| 123 | expect(path).toContain("projects/acme%2Fweb/merge_requests"); | ||
| 124 | expect(path).toContain("per_page=11"); | ||
| 125 | expect(path).toContain("state=opened"); | ||
| 126 | }), | ||
| 127 | ); | ||
| 128 | |||
| 129 | it.effect("walks pages at a fixed size, because GitLab pages by offset", () => | ||
| 130 | Effect.gen(function* () { | ||
| 131 | mockedExecute | ||
| 132 | .mockReturnValueOnce(Effect.succeed(output(mergeRequests(100, 1)))) | ||
| 133 | .mockReturnValueOnce(Effect.succeed(output(mergeRequests(100, 101)))); | ||
| 134 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 135 | |||
| 136 | const batch = yield* cli.listMergeRequests({ | ||
| 137 | cwd: "/w", | ||
| 138 | repository: "acme/web", | ||
| 139 | state: "open", | ||
| 140 | involvement: "all", | ||
| 141 | viewer: "bilal", | ||
| 142 | limit: 150, | ||
| 143 | }); | ||
| 144 | |||
| 145 | assert.strictEqual(batch.items.length, 150); | ||
| 146 | assert.isTrue(batch.truncated); | ||
| 147 | for (const index of [0, 1]) { | ||
| 148 | expect(argsOfCall(index)[1]).toContain("per_page=100"); | ||
| 149 | } | ||
| 150 | expect(argsOfCall(0)[1]).toContain("page=1"); | ||
| 151 | expect(argsOfCall(1)[1]).toContain("page=2"); | ||
| 152 | }), | ||
| 153 | ); | ||
| 154 | |||
| 155 | it.effect("hands a search to GitLab's own search parameter", () => | ||
| 156 | Effect.gen(function* () { | ||
| 157 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | ||
| 158 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 159 | |||
| 160 | yield* cli.listMergeRequests({ | ||
| 161 | cwd: "/w", | ||
| 162 | repository: "acme/web", | ||
| 163 | state: "open", | ||
| 164 | involvement: "all", | ||
| 165 | viewer: "bilal", | ||
| 166 | limit: 10, | ||
| 167 | query: "page", | ||
| 168 | }); | ||
| 169 | |||
| 170 | // GitLab matches `search` against title and description, which is more than the row shows. | ||
| 171 | expect(argsOfCall(0)[1]).toContain("search=page"); | ||
| 172 | }), | ||
| 173 | ); | ||
| 174 | |||
| 175 | it.effect("carries on from the number of rows already delivered", () => | ||
| 176 | Effect.gen(function* () { | ||
| 177 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(mergeRequests(3, 1)))); | ||
| 178 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 179 | |||
| 180 | yield* cli.listMergeRequests({ | ||
| 181 | cwd: "/w", | ||
| 182 | repository: "acme/web", | ||
| 183 | state: "open", | ||
| 184 | involvement: "all", | ||
| 185 | viewer: "bilal", | ||
| 186 | limit: 10, | ||
| 187 | cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 10 }, | ||
| 188 | }); | ||
| 189 | |||
| 190 | // GitLab's timestamp filter has no tie-breaker, so an offset is what advances through a | ||
| 191 | // boundary shared by more rows than one page can hold. | ||
| 192 | const path = argsOfCall(0)[1] ?? ""; | ||
| 193 | expect(path).not.toContain("updated_before="); | ||
| 194 | expect(path).toContain("order_by=updated_at"); | ||
| 195 | expect(path).toContain("per_page=11"); | ||
| 196 | expect(path).toContain("page=1"); | ||
| 197 | }), | ||
| 198 | ); | ||
| 199 | |||
| 200 | it.effect("advances beyond several pages sharing the cursor timestamp", () => | ||
| 201 | Effect.gen(function* () { | ||
| 202 | mockedExecute | ||
| 203 | .mockReturnValueOnce(Effect.succeed(output(mergeRequests(11, 144)))) | ||
| 204 | .mockReturnValueOnce(Effect.succeed(output(mergeRequests(11, 155)))); | ||
| 205 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 206 | |||
| 207 | const batch = yield* cli.listMergeRequests({ | ||
| 208 | cwd: "/w", | ||
| 209 | repository: "acme/web", | ||
| 210 | state: "open", | ||
| 211 | involvement: "all", | ||
| 212 | viewer: "bilal", | ||
| 213 | limit: 10, | ||
| 214 | cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 150 }, | ||
| 215 | }); | ||
| 216 | |||
| 217 | expect(argsOfCall(0)[1]).toContain("per_page=11"); | ||
| 218 | expect(argsOfCall(0)[1]).toContain("page=14"); | ||
| 219 | expect(argsOfCall(1)[1]).toContain("page=15"); | ||
| 220 | expect(batch.items.map((item) => item.number)).toEqual([ | ||
| 221 | 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, | ||
| 222 | ]); | ||
| 223 | assert.isTrue(batch.truncated); | ||
| 224 | }), | ||
| 225 | ); | ||
| 226 | |||
| 227 | it.effect("advances the cursor through malformed raw rows", () => | ||
| 228 | Effect.gen(function* () { | ||
| 229 | const rows = JSON.parse(mergeRequests(2, 1)) as ReadonlyArray<unknown>; | ||
| 230 | mockedExecute.mockReturnValueOnce( | ||
| 231 | Effect.succeed(output(JSON.stringify([{ iid: "malformed" }, ...rows]))), | ||
| 232 | ); | ||
| 233 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 234 | |||
| 235 | const batch = yield* cli.listMergeRequests({ | ||
| 236 | cwd: "/w", | ||
| 237 | repository: "acme/web", | ||
| 238 | state: "open", | ||
| 239 | involvement: "all", | ||
| 240 | viewer: "bilal", | ||
| 241 | limit: 2, | ||
| 242 | }); | ||
| 243 | |||
| 244 | expect(batch.items.map((item) => item.number)).toEqual([1, 2]); | ||
| 245 | assert.strictEqual(batch.cursorAdvance, 3); | ||
| 246 | assert.isTrue(batch.truncated); | ||
| 247 | }), | ||
| 248 | ); | ||
| 249 | |||
| 250 | it.effect("URL-encodes a search, so it cannot add a parameter of its own", () => | ||
| 251 | Effect.gen(function* () { | ||
| 252 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | ||
| 253 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 254 | |||
| 255 | yield* cli.listMergeRequests({ | ||
| 256 | cwd: "/w", | ||
| 257 | repository: "acme/web", | ||
| 258 | state: "open", | ||
| 259 | involvement: "all", | ||
| 260 | viewer: "bilal", | ||
| 261 | limit: 10, | ||
| 262 | query: '-a&per_page=1 "b"', | ||
| 263 | }); | ||
| 264 | |||
| 265 | const path = argsOfCall(0)[1] ?? ""; | ||
| 266 | expect(path).toContain("search=-a%26per_page%3D1%20%22b%22"); | ||
| 267 | // The page size the walk fixed is still the only one in the query. | ||
| 268 | assert.strictEqual(path.match(/per_page=/g)?.length, 1); | ||
| 269 | }), | ||
| 270 | ); | ||
| 271 | |||
| 272 | it.effect("asks for no search at all when the reader typed only spaces", () => | ||
| 273 | Effect.gen(function* () { | ||
| 274 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | ||
| 275 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 276 | |||
| 277 | yield* cli.listMergeRequests({ | ||
| 278 | cwd: "/w", | ||
| 279 | repository: "acme/web", | ||
| 280 | state: "open", | ||
| 281 | involvement: "all", | ||
| 282 | viewer: "bilal", | ||
| 283 | limit: 10, | ||
| 284 | query: " ", | ||
| 285 | }); | ||
| 286 | |||
| 287 | expect(argsOfCall(0)[1]).not.toContain("search="); | ||
| 288 | }), | ||
| 289 | ); | ||
| 290 | |||
| 291 | it.effect("stops walking on a short page", () => | ||
| 292 | Effect.gen(function* () { | ||
| 293 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(mergeRequests(40, 1)))); | ||
| 294 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 295 | |||
| 296 | const batch = yield* cli.listMergeRequests({ | ||
| 297 | cwd: "/w", | ||
| 298 | repository: "acme/web", | ||
| 299 | state: "open", | ||
| 300 | involvement: "all", | ||
| 301 | viewer: "bilal", | ||
| 302 | limit: 150, | ||
| 303 | }); | ||
| 304 | |||
| 305 | assert.strictEqual(batch.items.length, 40); | ||
| 306 | assert.isFalse(batch.truncated); | ||
| 307 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | ||
| 308 | }), | ||
| 309 | ); | ||
| 310 | |||
| 311 | it.effect("stops walking when every row on a page fails to decode", () => | ||
| 312 | Effect.gen(function* () { | ||
| 313 | // Full pages of unusable rows: nothing is collected, so the collected-count bound never | ||
| 314 | // trips and only the page bound can end the walk. | ||
| 315 | const unusable = JSON.stringify(Array.from({ length: 100 }, () => ({ iid: "nope" }))); | ||
| 316 | mockedExecute.mockReturnValue(Effect.succeed(output(unusable))); | ||
| 317 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 318 | |||
| 319 | const batch = yield* cli.listMergeRequests({ | ||
| 320 | cwd: "/w", | ||
| 321 | repository: "acme/web", | ||
| 322 | state: "open", | ||
| 323 | involvement: "all", | ||
| 324 | viewer: "bilal", | ||
| 325 | limit: 150, | ||
| 326 | }); | ||
| 327 | |||
| 328 | assert.strictEqual(batch.items.length, 0); | ||
| 329 | // ceil((150 + 1) / 100) pages, not one request per page forever. | ||
| 330 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | ||
| 331 | }), | ||
| 332 | ); | ||
| 333 | |||
| 334 | it.effect("asks GitLab for every state on the All tab", () => | ||
| 335 | Effect.gen(function* () { | ||
| 336 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | ||
| 337 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 338 | |||
| 339 | yield* cli.listMergeRequests({ | ||
| 340 | cwd: "/w", | ||
| 341 | repository: "acme/web", | ||
| 342 | state: "all", | ||
| 343 | involvement: "all", | ||
| 344 | viewer: "bilal", | ||
| 345 | limit: 10, | ||
| 346 | }); | ||
| 347 | |||
| 348 | expect(argsOfCall(0)[1]).toContain("state=all"); | ||
| 349 | }), | ||
| 350 | ); | ||
| 351 | |||
| 352 | it.effect("filters by the reviewer when the viewer is reviewing", () => | ||
| 353 | Effect.gen(function* () { | ||
| 354 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | ||
| 355 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 356 | |||
| 357 | yield* cli.listMergeRequests({ | ||
| 358 | cwd: "/w", | ||
| 359 | repository: "acme/web", | ||
| 360 | state: "open", | ||
| 361 | involvement: "reviewing", | ||
| 362 | viewer: "bilal", | ||
| 363 | limit: 10, | ||
| 364 | }); | ||
| 365 | |||
| 366 | expect(argsOfCall(0)[1]).toContain("reviewer_username=bilal"); | ||
| 367 | }), | ||
| 368 | ); | ||
| 369 | |||
| 370 | it.effect("addresses a nested group project by its encoded full path", () => | ||
| 371 | Effect.gen(function* () { | ||
| 372 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | ||
| 373 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 374 | |||
| 375 | yield* cli.listMergeRequests({ | ||
| 376 | cwd: "/w", | ||
| 377 | repository: "acme/platform/web", | ||
| 378 | state: "open", | ||
| 379 | involvement: "all", | ||
| 380 | viewer: "bilal", | ||
| 381 | limit: 10, | ||
| 382 | }); | ||
| 383 | |||
| 384 | expect(argsOfCall(0)[1]).toContain("projects/acme%2Fplatform%2Fweb/merge_requests"); | ||
| 385 | }), | ||
| 386 | ); | ||
| 387 | |||
| 388 | it.effect("merges immediately rather than leaving auto-merge armed", () => | ||
| 389 | Effect.gen(function* () { | ||
| 390 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(""))); | ||
| 391 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 392 | |||
| 393 | yield* cli.runMergeRequestAction({ | ||
| 394 | cwd: "/w", | ||
| 395 | repository: "acme/web", | ||
| 396 | number: 7, | ||
| 397 | action: "merge", | ||
| 398 | mergeMethod: "squash", | ||
| 399 | }); | ||
| 400 | |||
| 401 | expect(argsOfCall(0)).toEqual([ | ||
| 402 | "mr", | ||
| 403 | "merge", | ||
| 404 | "7", | ||
| 405 | "--repo", | ||
| 406 | "acme/web", | ||
| 407 | "--auto-merge=false", | ||
| 408 | "--yes", | ||
| 409 | "--squash", | ||
| 410 | ]); | ||
| 411 | }), | ||
| 412 | ); | ||
| 413 | |||
| 414 | it.effect("arms auto-merge with the same strategy a merge would have used", () => | ||
| 415 | Effect.gen(function* () { | ||
| 416 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(""))); | ||
| 417 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 418 | |||
| 419 | yield* cli.runMergeRequestAction({ | ||
| 420 | cwd: "/w", | ||
| 421 | repository: "acme/web", | ||
| 422 | number: 7, | ||
| 423 | action: "enable-auto-merge", | ||
| 424 | mergeMethod: "squash", | ||
| 425 | }); | ||
| 426 | |||
| 427 | expect(argsOfCall(0)).toEqual([ | ||
| 428 | "mr", | ||
| 429 | "merge", | ||
| 430 | "7", | ||
| 431 | "--repo", | ||
| 432 | "acme/web", | ||
| 433 | "--auto-merge=true", | ||
| 434 | "--yes", | ||
| 435 | "--squash", | ||
| 436 | ]); | ||
| 437 | }), | ||
| 438 | ); | ||
| 439 | |||
| 440 | it.effect("cancels an armed auto-merge through the API glab has no flag for", () => | ||
| 441 | Effect.gen(function* () { | ||
| 442 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("{}"))); | ||
| 443 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 444 | |||
| 445 | yield* cli.runMergeRequestAction({ | ||
| 446 | cwd: "/w", | ||
| 447 | repository: "acme/platform/web", | ||
| 448 | number: 7, | ||
| 449 | action: "disable-auto-merge", | ||
| 450 | }); | ||
| 451 | |||
| 452 | expect(argsOfCall(0)).toEqual([ | ||
| 453 | "api", | ||
| 454 | "projects/acme%2Fplatform%2Fweb/merge_requests/7/cancel_merge_when_pipeline_succeeds", | ||
| 455 | "--method", | ||
| 456 | "POST", | ||
| 457 | ]); | ||
| 458 | }), | ||
| 459 | ); | ||
| 460 | |||
| 461 | it.effect("brings a stale branch up to date by rebasing it, the only way GitLab has", () => | ||
| 462 | Effect.gen(function* () { | ||
| 463 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(""))); | ||
| 464 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 465 | |||
| 466 | yield* cli.runMergeRequestAction({ | ||
| 467 | cwd: "/w", | ||
| 468 | repository: "acme/web", | ||
| 469 | number: 7, | ||
| 470 | action: "update-branch", | ||
| 471 | }); | ||
| 472 | |||
| 473 | expect(argsOfCall(0)).toEqual(["mr", "rebase", "7", "--repo", "acme/web"]); | ||
| 474 | }), | ||
| 475 | ); | ||
| 476 | |||
| 477 | it.effect("moves a merge request back to draft through glab", () => | ||
| 478 | Effect.gen(function* () { | ||
| 479 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(""))); | ||
| 480 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 481 | |||
| 482 | yield* cli.runMergeRequestAction({ | ||
| 483 | cwd: "/w", | ||
| 484 | repository: "acme/web", | ||
| 485 | number: 7, | ||
| 486 | action: "draft", | ||
| 487 | }); | ||
| 488 | |||
| 489 | expect(argsOfCall(0)).toEqual(["mr", "update", "7", "--repo", "acme/web", "--draft"]); | ||
| 490 | }), | ||
| 491 | ); | ||
| 492 | |||
| 493 | it.effect("sends a comment body over stdin, never in argv", () => | ||
| 494 | Effect.gen(function* () { | ||
| 495 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(""))); | ||
| 496 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 497 | |||
| 498 | yield* cli.commentOnMergeRequest({ | ||
| 499 | cwd: "/w", | ||
| 500 | repository: "acme/web", | ||
| 501 | number: 7, | ||
| 502 | body: "true", | ||
| 503 | }); | ||
| 504 | |||
| 505 | const call = mockedExecute.mock.calls[0]; | ||
| 506 | assert.isDefined(call); | ||
| 507 | expect(call[0].args).toEqual([ | ||
| 508 | "api", | ||
| 509 | "projects/acme%2Fweb/merge_requests/7/notes", | ||
| 510 | "--method", | ||
| 511 | "POST", | ||
| 512 | "--input", | ||
| 513 | "-", | ||
| 514 | "--header", | ||
| 515 | "Content-Type: application/json", | ||
| 516 | ]); | ||
| 517 | // A JSON body, so a comment reading as a literal `true` stays text. | ||
| 518 | expect(call[0].stdin).toBe('{"body":"true"}'); | ||
| 519 | }), | ||
| 520 | ); | ||
| 521 | |||
| 522 | it.effect("reads one diff page and hands back the cursor for the next", () => | ||
| 523 | Effect.gen(function* () { | ||
| 524 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(diffPage(0)))); | ||
| 525 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 526 | |||
| 527 | const diff = yield* cli.getMergeRequestDiff({ | ||
| 528 | cwd: "/w", | ||
| 529 | repository: "acme/web", | ||
| 530 | number: 7, | ||
| 531 | }); | ||
| 532 | |||
| 533 | // One page per call: the reader asks for the rest, the walk does not run on by itself. | ||
| 534 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | ||
| 535 | assert.isNotNull(diff.nextCursor); | ||
| 536 | // A full page means more files, not a slice with something missing from it. | ||
| 537 | assert.isFalse(diff.truncated); | ||
| 538 | expect(argsOfCall(0)[1]).toContain("merge_requests/7/diffs?per_page=100&page=1"); | ||
| 539 | }), | ||
| 540 | ); | ||
| 541 | |||
| 542 | it.effect("carries on from a cursor at the page it names", () => | ||
| 543 | Effect.gen(function* () { | ||
| 544 | mockedExecute | ||
| 545 | .mockReturnValueOnce(Effect.succeed(output(diffPage(0)))) | ||
| 546 | .mockReturnValueOnce(Effect.succeed(output(diffPage(100, 3)))); | ||
| 547 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 548 | const target = { cwd: "/w", repository: "acme/web", number: 7 }; | ||
| 549 | |||
| 550 | const first = yield* cli.getMergeRequestDiff(target); | ||
| 551 | assert.isNotNull(first.nextCursor); | ||
| 552 | const second = yield* cli.getMergeRequestDiff({ ...target, cursor: first.nextCursor }); | ||
| 553 | |||
| 554 | expect(argsOfCall(1)[1]).toContain("page=2"); | ||
| 555 | // A short page is the end of the change set, so there is nothing to carry on from. | ||
| 556 | assert.isNull(second.nextCursor); | ||
| 557 | expect(second.patch).toContain("diff --git a/src/100.ts b/src/100.ts"); | ||
| 558 | }), | ||
| 559 | ); | ||
| 560 | |||
| 561 | it.effect("refuses a cursor it never handed out rather than reading it into a query", () => | ||
| 562 | Effect.gen(function* () { | ||
| 563 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 564 | |||
| 565 | const error = yield* Effect.flip( | ||
| 566 | cli.getMergeRequestDiff({ | ||
| 567 | cwd: "/w", | ||
| 568 | repository: "acme/web", | ||
| 569 | number: 7, | ||
| 570 | cursor: "1&per_page=1", | ||
| 571 | }), | ||
| 572 | ); | ||
| 573 | |||
| 574 | assert.strictEqual(error._tag, "GitLabDiffCursorError"); | ||
| 575 | assert.strictEqual(mockedExecute.mock.calls.length, 0); | ||
| 576 | }), | ||
| 577 | ); | ||
| 578 | |||
| 579 | it.effect("reads a named commit from its own diff, and pages inside it", () => | ||
| 580 | Effect.gen(function* () { | ||
| 581 | mockedExecute | ||
| 582 | .mockReturnValueOnce(Effect.succeed(output(diffPage(0)))) | ||
| 583 | .mockReturnValueOnce(Effect.succeed(output(diffPage(100, 3)))); | ||
| 584 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 585 | const target = { | ||
| 586 | cwd: "/w", | ||
| 587 | repository: "acme/web", | ||
| 588 | number: 7, | ||
| 589 | commit: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", | ||
| 590 | }; | ||
| 591 | |||
| 592 | const first = yield* cli.getMergeRequestDiff(target); | ||
| 593 | assert.isNotNull(first.nextCursor); | ||
| 594 | const second = yield* cli.getMergeRequestDiff({ ...target, cursor: first.nextCursor }); | ||
| 595 | |||
| 596 | const commitPath = | ||
| 597 | "projects/acme%2Fweb/repository/commits/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0/diff"; | ||
| 598 | expect(argsOfCall(0)[1]).toBe(`${commitPath}?per_page=100&page=1`); | ||
| 599 | // The whole path, not just the page: a cursor branch that dropped the commit would still | ||
| 600 | // ask for page 2, of the merge request's own diff. | ||
| 601 | expect(argsOfCall(1)[1]).toBe(`${commitPath}?per_page=100&page=2`); | ||
| 602 | assert.isNull(second.nextCursor); | ||
| 603 | }), | ||
| 604 | ); | ||
| 605 | |||
| 606 | it.effect("refuses a commit that is not a sha rather than reading it into a path", () => | ||
| 607 | Effect.gen(function* () { | ||
| 608 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 609 | |||
| 610 | const error = yield* Effect.flip( | ||
| 611 | cli.getMergeRequestDiff({ | ||
| 612 | cwd: "/w", | ||
| 613 | repository: "acme/web", | ||
| 614 | number: 7, | ||
| 615 | commit: "../../merge_requests/8/diffs", | ||
| 616 | }), | ||
| 617 | ); | ||
| 618 | |||
| 619 | assert.strictEqual(error._tag, "GitLabDiffCommitError"); | ||
| 620 | assert.strictEqual(mockedExecute.mock.calls.length, 0); | ||
| 621 | }), | ||
| 622 | ); | ||
| 623 | |||
| 624 | it.effect("reports a commit with no parent as a structured error", () => | ||
| 625 | Effect.gen(function* () { | ||
| 626 | mockedExecute.mockReturnValueOnce( | ||
| 627 | Effect.succeed(output(JSON.stringify({ id: "a1b2c3d", parent_ids: [] }))), | ||
| 628 | ); | ||
| 629 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 630 | |||
| 631 | const error = yield* Effect.flip( | ||
| 632 | cli.getMergeRequestDiffFileContents({ | ||
| 633 | cwd: "/w", | ||
| 634 | repository: "acme/web", | ||
| 635 | number: 7, | ||
| 636 | commit: "a1b2c3d", | ||
| 637 | changeType: "change", | ||
| 638 | oldPath: "src/a.ts", | ||
| 639 | newPath: "src/a.ts", | ||
| 640 | }), | ||
| 641 | ); | ||
| 642 | |||
| 643 | assert.strictEqual(error._tag, "GitLabDiffCommitParentUnavailableError"); | ||
| 644 | if (error._tag === "GitLabDiffCommitParentUnavailableError") { | ||
| 645 | assert.strictEqual(error.commit, "a1b2c3d"); | ||
| 646 | } | ||
| 647 | }), | ||
| 648 | ); | ||
| 649 | |||
| 650 | it.effect("expands a new file from a root commit without requiring a parent", () => | ||
| 651 | Effect.gen(function* () { | ||
| 652 | mockedExecute.mockReturnValueOnce( | ||
| 653 | Effect.succeed(output(JSON.stringify({ id: "a1b2c3d", parent_ids: [] }))), | ||
| 654 | ); | ||
| 655 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("first contents\n"))); | ||
| 656 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 657 | |||
| 658 | const contents = yield* cli.getMergeRequestDiffFileContents({ | ||
| 659 | cwd: "/w", | ||
| 660 | repository: "acme/web", | ||
| 661 | number: 7, | ||
| 662 | commit: "a1b2c3d", | ||
| 663 | changeType: "new", | ||
| 664 | oldPath: "src/first.ts", | ||
| 665 | newPath: "src/first.ts", | ||
| 666 | }); | ||
| 667 | |||
| 668 | expect(contents).toEqual({ oldContents: "", newContents: "first contents\n" }); | ||
| 669 | expect(argsOfCall(1)[1]).toContain("raw?ref=a1b2c3d"); | ||
| 670 | }), | ||
| 671 | ); | ||
| 672 | |||
| 673 | it.effect("reports an oversized diff file with its path and reason", () => | ||
| 674 | Effect.gen(function* () { | ||
| 675 | mockedExecute.mockReturnValueOnce( | ||
| 676 | Effect.succeed( | ||
| 677 | output( | ||
| 678 | JSON.stringify({ | ||
| 679 | diff_refs: { | ||
| 680 | base_sha: "a1b2c3d", | ||
| 681 | head_sha: "b1c2d3e", | ||
| 682 | start_sha: "a1b2c3d", | ||
| 683 | }, | ||
| 684 | }), | ||
| 685 | ), | ||
| 686 | ), | ||
| 687 | ); | ||
| 688 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("partial", true))); | ||
| 689 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 690 | |||
| 691 | const error = yield* Effect.flip( | ||
| 692 | cli.getMergeRequestDiffFileContents({ | ||
| 693 | cwd: "/w", | ||
| 694 | repository: "acme/web", | ||
| 695 | number: 7, | ||
| 696 | changeType: "deleted", | ||
| 697 | oldPath: "src/large.ts", | ||
| 698 | newPath: "src/large.ts", | ||
| 699 | }), | ||
| 700 | ); | ||
| 701 | |||
| 702 | assert.strictEqual(error._tag, "GitLabDiffFileContentsUnavailableError"); | ||
| 703 | if (error._tag === "GitLabDiffFileContentsUnavailableError") { | ||
| 704 | assert.strictEqual(error.path, "src/large.ts"); | ||
| 705 | assert.strictEqual(error.reason, "oversized"); | ||
| 706 | } | ||
| 707 | }), | ||
| 708 | ); | ||
| 709 | |||
| 710 | it.effect("reports undecodable diff file contents as binary", () => | ||
| 711 | Effect.gen(function* () { | ||
| 712 | mockedExecute.mockReturnValueOnce( | ||
| 713 | Effect.succeed( | ||
| 714 | output( | ||
| 715 | JSON.stringify({ | ||
| 716 | diff_refs: { | ||
| 717 | base_sha: "a1b2c3d", | ||
| 718 | head_sha: "b1c2d3e", | ||
| 719 | start_sha: "a1b2c3d", | ||
| 720 | }, | ||
| 721 | }), | ||
| 722 | ), | ||
| 723 | ), | ||
| 724 | ); | ||
| 725 | mockedExecute.mockReturnValueOnce( | ||
| 726 | Effect.succeed(output("binary\uFFFDcontents", false, true)), | ||
| 727 | ); | ||
| 728 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 729 | |||
| 730 | const error = yield* Effect.flip( | ||
| 731 | cli.getMergeRequestDiffFileContents({ | ||
| 732 | cwd: "/w", | ||
| 733 | repository: "acme/web", | ||
| 734 | number: 7, | ||
| 735 | changeType: "deleted", | ||
| 736 | oldPath: "assets/logo.png", | ||
| 737 | newPath: "assets/logo.png", | ||
| 738 | }), | ||
| 739 | ); | ||
| 740 | |||
| 741 | assert.strictEqual(error._tag, "GitLabDiffFileContentsUnavailableError"); | ||
| 742 | if (error._tag === "GitLabDiffFileContentsUnavailableError") { | ||
| 743 | assert.strictEqual(error.path, "assets/logo.png"); | ||
| 744 | assert.strictEqual(error.reason, "binary"); | ||
| 745 | } | ||
| 746 | }), | ||
| 747 | ); | ||
| 748 | |||
| 749 | it.effect("returns valid text containing a literal replacement character", () => | ||
| 750 | Effect.gen(function* () { | ||
| 751 | mockedExecute.mockReturnValueOnce( | ||
| 752 | Effect.succeed( | ||
| 753 | output( | ||
| 754 | JSON.stringify({ | ||
| 755 | diff_refs: { | ||
| 756 | base_sha: "a1b2c3d", | ||
| 757 | head_sha: "b1c2d3e", | ||
| 758 | start_sha: "a1b2c3d", | ||
| 759 | }, | ||
| 760 | }), | ||
| 761 | ), | ||
| 762 | ), | ||
| 763 | ); | ||
| 764 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("before\uFFFDafter"))); | ||
| 765 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 766 | |||
| 767 | const contents = yield* cli.getMergeRequestDiffFileContents({ | ||
| 768 | cwd: "/w", | ||
| 769 | repository: "acme/web", | ||
| 770 | number: 7, | ||
| 771 | changeType: "deleted", | ||
| 772 | oldPath: "docs/encoding.md", | ||
| 773 | newPath: "docs/encoding.md", | ||
| 774 | }); | ||
| 775 | |||
| 776 | assert.strictEqual(contents.oldContents, "before\uFFFDafter"); | ||
| 777 | }), | ||
| 778 | ); | ||
| 779 | |||
| 780 | it.effect("ends the diff on a page with no files rather than asking for it again", () => | ||
| 781 | Effect.gen(function* () { | ||
| 782 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | ||
| 783 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 784 | |||
| 785 | const diff = yield* cli.getMergeRequestDiff({ | ||
| 786 | cwd: "/w", | ||
| 787 | repository: "acme/web", | ||
| 788 | number: 7, | ||
| 789 | cursor: "4", | ||
| 790 | }); | ||
| 791 | |||
| 792 | assert.strictEqual(diff.patch, ""); | ||
| 793 | assert.isNull(diff.nextCursor); | ||
| 794 | }), | ||
| 795 | ); | ||
| 796 | |||
| 797 | it.effect("fails a diff page cut off mid-JSON rather than calling the diff whole", () => | ||
| 798 | Effect.gen(function* () { | ||
| 799 | mockedExecute.mockReturnValueOnce( | ||
| 800 | // A byte-truncated prefix: valid JSON never survives the cut. | ||
| 801 | Effect.succeed({ ...output('[{"old_path":"src/x.ts","new_p'), stdoutTruncated: true }), | ||
| 802 | ); | ||
| 803 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 804 | |||
| 805 | const error = yield* Effect.flip( | ||
| 806 | cli.getMergeRequestDiff({ cwd: "/w", repository: "acme/web", number: 7 }), | ||
| 807 | ); | ||
| 808 | |||
| 809 | // An empty slice with no cursor would report every file from this page on as already | ||
| 810 | // read, which is the one answer that loses a change without saying so. | ||
| 811 | assert.strictEqual(error._tag, "GitLabMergeRequestReadError"); | ||
| 812 | }), | ||
| 813 | ); | ||
| 814 | |||
| 815 | it.effect("offers no squash when the project does not say it allows one", () => | ||
| 816 | Effect.gen(function* () { | ||
| 817 | mockedExecute.mockReturnValueOnce( | ||
| 818 | Effect.succeed(output(JSON.stringify({ merge_method: "merge" }))), | ||
| 819 | ); | ||
| 820 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 821 | |||
| 822 | const capabilities = yield* cli.getProjectMergeCapabilities({ | ||
| 823 | cwd: "/w", | ||
| 824 | repository: "acme/web", | ||
| 825 | }); | ||
| 826 | |||
| 827 | assert.deepStrictEqual(capabilities, { merge: true, squash: false, rebase: false }); | ||
| 828 | }), | ||
| 829 | ); | ||
| 830 | |||
| 831 | it.effect("reads the project's merge settings as its merge capabilities", () => | ||
| 832 | Effect.gen(function* () { | ||
| 833 | mockedExecute.mockReturnValueOnce( | ||
| 834 | Effect.succeed(output(JSON.stringify({ merge_method: "ff", squash_option: "never" }))), | ||
| 835 | ); | ||
| 836 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 837 | |||
| 838 | const capabilities = yield* cli.getProjectMergeCapabilities({ | ||
| 839 | cwd: "/w", | ||
| 840 | repository: "acme/web", | ||
| 841 | }); | ||
| 842 | |||
| 843 | assert.deepStrictEqual(capabilities, { merge: false, squash: false, rebase: true }); | ||
| 844 | }), | ||
| 845 | ); | ||
| 846 | |||
| 847 | it.effect("asks the detail read for the divergence GitLab withholds by default", () => | ||
| 848 | Effect.gen(function* () { | ||
| 849 | mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"message":"404 Not Found"}'))); | ||
| 850 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 851 | |||
| 852 | yield* Effect.ignore( | ||
| 853 | cli.getMergeRequestDetail({ cwd: "/w", repository: "acme/web", number: 7 }), | ||
| 854 | ); | ||
| 855 | |||
| 856 | expect(argsOfCall(0)[1]).toBe( | ||
| 857 | "projects/acme%2Fweb/merge_requests/7?include_diverged_commits_count=true", | ||
| 858 | ); | ||
| 859 | }), | ||
| 860 | ); | ||
| 861 | |||
| 862 | it.effect("fails the read when GitLab returns something unreadable", () => | ||
| 863 | Effect.gen(function* () { | ||
| 864 | mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"message":"404 Not Found"}'))); | ||
| 865 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 866 | |||
| 867 | const error = yield* Effect.flip( | ||
| 868 | cli.getMergeRequestDetail({ cwd: "/w", repository: "acme/web", number: 7 }), | ||
| 869 | ); | ||
| 870 | |||
| 871 | assert.strictEqual(error._tag, "GitLabMergeRequestReadError"); | ||
| 872 | }), | ||
| 873 | ); | ||
| 874 | |||
| 875 | it.effect("fails when the authenticated account has no username", () => | ||
| 876 | Effect.gen(function* () { | ||
| 877 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(JSON.stringify({ username: "" })))); | ||
| 878 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 879 | |||
| 880 | const error = yield* Effect.flip(cli.getViewerUsername({ cwd: "/w" })); | ||
| 881 | |||
| 882 | assert.strictEqual(error._tag, "GitLabViewerUnavailableError"); | ||
| 883 | }), | ||
| 884 | ); | ||
| 885 | |||
| 886 | it.effect("walks the notes until GitLab answers with a short page", () => | ||
| 887 | Effect.gen(function* () { | ||
| 888 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(notes(100, 1)))); | ||
| 889 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(notes(2, 101)))); | ||
| 890 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 891 | |||
| 892 | const { comments, truncated } = yield* cli.listNotes({ | ||
| 893 | cwd: "/w", | ||
| 894 | repository: "acme/web", | ||
| 895 | number: 7, | ||
| 896 | }); | ||
| 897 | |||
| 898 | expect(argsOfCall(0).join(" ")).toContain("page=1"); | ||
| 899 | expect(argsOfCall(1).join(" ")).toContain("page=2"); | ||
| 900 | assert.strictEqual(comments.length, 102); | ||
| 901 | assert.isFalse(truncated); | ||
| 902 | }), | ||
| 903 | ); | ||
| 904 | |||
| 905 | it.effect("stops the note walk at its bound and says the conversation was cut short", () => | ||
| 906 | Effect.gen(function* () { | ||
| 907 | // GitLab that never answers short: the walk has to end itself. | ||
| 908 | mockedExecute.mockReturnValue(Effect.succeed(output(notes(100, 1)))); | ||
| 909 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 910 | |||
| 911 | const { truncated } = yield* cli.listNotes({ | ||
| 912 | cwd: "/w", | ||
| 913 | repository: "acme/web", | ||
| 914 | number: 7, | ||
| 915 | }); | ||
| 916 | |||
| 917 | assert.strictEqual(mockedExecute.mock.calls.length, 10); | ||
| 918 | assert.isTrue(truncated); | ||
| 919 | }), | ||
| 920 | ); | ||
| 921 | |||
| 922 | it.effect("reads a positioned discussion as a thread anchored to its line", () => | ||
| 923 | Effect.gen(function* () { | ||
| 924 | mockedExecute.mockReturnValueOnce( | ||
| 925 | Effect.succeed( | ||
| 926 | output( | ||
| 927 | JSON.stringify([ | ||
| 928 | { | ||
| 929 | id: "abc123", | ||
| 930 | notes: [ | ||
| 931 | { | ||
| 932 | id: 1, | ||
| 933 | body: "rename this", | ||
| 934 | author: { username: "bilal", avatar_url: "https://avatars/b.png" }, | ||
| 935 | created_at: "2026-07-01T00:00:00Z", | ||
| 936 | resolvable: true, | ||
| 937 | resolved: true, | ||
| 938 | position: { | ||
| 939 | position_type: "text", | ||
| 940 | new_path: "src/a.ts", | ||
| 941 | old_path: "src/a.ts", | ||
| 942 | new_line: 12, | ||
| 943 | old_line: null, | ||
| 944 | }, | ||
| 945 | }, | ||
| 946 | { | ||
| 947 | id: 2, | ||
| 948 | body: "done", | ||
| 949 | author: { username: "julius" }, | ||
| 950 | created_at: "2026-07-01T01:00:00Z", | ||
| 951 | }, | ||
| 952 | ], | ||
| 953 | }, | ||
| 954 | // A plain note is the timeline's business, not the diff's. | ||
| 955 | { id: "def456", notes: [{ id: 3, body: "ship it", created_at: "2026-07-01Z" }] }, | ||
| 956 | ]), | ||
| 957 | ), | ||
| 958 | ), | ||
| 959 | ); | ||
| 960 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 961 | |||
| 962 | const { threads } = yield* cli.listDiscussions({ | ||
| 963 | cwd: "/w", | ||
| 964 | repository: "acme/web", | ||
| 965 | number: 7, | ||
| 966 | }); | ||
| 967 | |||
| 968 | assert.strictEqual(threads.length, 1); | ||
| 969 | expect(threads[0]).toMatchObject({ | ||
| 970 | id: "abc123", | ||
| 971 | path: "src/a.ts", | ||
| 972 | line: 12, | ||
| 973 | side: "right", | ||
| 974 | isResolved: true, | ||
| 975 | }); | ||
| 976 | assert.strictEqual(threads[0]?.comments.length, 2); | ||
| 977 | }), | ||
| 978 | ); | ||
| 979 | |||
| 980 | it.effect("sends a review as its comments, then its summary, then the verdict", () => | ||
| 981 | Effect.gen(function* () { | ||
| 982 | mockedExecute.mockReturnValueOnce( | ||
| 983 | Effect.succeed( | ||
| 984 | output( | ||
| 985 | JSON.stringify({ | ||
| 986 | iid: 7, | ||
| 987 | title: "t", | ||
| 988 | web_url: "https://gitlab.com/acme/web/-/merge_requests/7", | ||
| 989 | source_branch: "feat", | ||
| 990 | target_branch: "main", | ||
| 991 | created_at: "2026-07-01T00:00:00Z", | ||
| 992 | updated_at: "2026-07-01T00:00:00Z", | ||
| 993 | diff_refs: { base_sha: "base", head_sha: "head", start_sha: "start" }, | ||
| 994 | }), | ||
| 995 | ), | ||
| 996 | ), | ||
| 997 | ); | ||
| 998 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | ||
| 999 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 1000 | |||
| 1001 | yield* cli.submitReview({ | ||
| 1002 | cwd: "/w", | ||
| 1003 | repository: "acme/web", | ||
| 1004 | number: 7, | ||
| 1005 | verdict: "approve", | ||
| 1006 | body: "Looks right.", | ||
| 1007 | comments: [ | ||
| 1008 | { | ||
| 1009 | path: "src/b.ts", | ||
| 1010 | oldPath: "src/a.ts", | ||
| 1011 | position: { kind: "deleted", oldLine: 4 }, | ||
| 1012 | body: "why remove?", | ||
| 1013 | }, | ||
| 1014 | ], | ||
| 1015 | }); | ||
| 1016 | |||
| 1017 | // The diff revisions first, because a positioned comment cannot be placed without them. | ||
| 1018 | expect(argsOfCall(0)[1]).toContain("merge_requests/7"); | ||
| 1019 | expect(argsOfCall(1)[1]).toContain("/discussions"); | ||
| 1020 | expect(JSON.parse(callAt(1).stdin ?? "")).toEqual({ | ||
| 1021 | body: "why remove?", | ||
| 1022 | position: { | ||
| 1023 | base_sha: "base", | ||
| 1024 | head_sha: "head", | ||
| 1025 | start_sha: "start", | ||
| 1026 | position_type: "text", | ||
| 1027 | // A renamed file is the only case the two differ, and GitLab cannot place a | ||
| 1028 | // position that names the same path on both sides of the rename. | ||
| 1029 | old_path: "src/a.ts", | ||
| 1030 | new_path: "src/b.ts", | ||
| 1031 | old_line: 4, | ||
| 1032 | }, | ||
| 1033 | }); | ||
| 1034 | expect(argsOfCall(2)[1]).toContain("/notes"); | ||
| 1035 | // The verdict goes last, so a review that failed part-way is never an approval. | ||
| 1036 | expect(argsOfCall(3)[1]).toContain("/approve"); | ||
| 1037 | }), | ||
| 1038 | ); | ||
| 1039 | |||
| 1040 | it.effect("does not ask for diff revisions when a review carries no line comments", () => | ||
| 1041 | Effect.gen(function* () { | ||
| 1042 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | ||
| 1043 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 1044 | |||
| 1045 | yield* cli.submitReview({ | ||
| 1046 | cwd: "/w", | ||
| 1047 | repository: "acme/web", | ||
| 1048 | number: 7, | ||
| 1049 | verdict: "comment", | ||
| 1050 | body: "One thought.", | ||
| 1051 | comments: [], | ||
| 1052 | }); | ||
| 1053 | |||
| 1054 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | ||
| 1055 | expect(argsOfCall(0)[1]).toContain("/notes"); | ||
| 1056 | }), | ||
| 1057 | ); | ||
| 1058 | |||
| 1059 | it.effect("resolves a discussion in place rather than posting to it", () => | ||
| 1060 | Effect.gen(function* () { | ||
| 1061 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | ||
| 1062 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 1063 | |||
| 1064 | yield* cli.setDiscussionResolution({ | ||
| 1065 | cwd: "/w", | ||
| 1066 | repository: "acme/web", | ||
| 1067 | number: 7, | ||
| 1068 | discussionId: "abc123", | ||
| 1069 | resolved: true, | ||
| 1070 | }); | ||
| 1071 | |||
| 1072 | expect(argsOfCall(0)).toContain("--method"); | ||
| 1073 | expect(argsOfCall(0)).toContain("PUT"); | ||
| 1074 | expect(argsOfCall(0)[1]).toContain("/discussions/abc123"); | ||
| 1075 | expect(JSON.parse(callAt(0).stdin ?? "")).toEqual({ resolved: true }); | ||
| 1076 | }), | ||
| 1077 | ); | ||
| 1078 | |||
| 1079 | it.effect("awards an emoji through a POST naming it, not a body", () => | ||
| 1080 | Effect.gen(function* () { | ||
| 1081 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | ||
| 1082 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 1083 | |||
| 1084 | yield* cli.setReaction({ | ||
| 1085 | cwd: "/w", | ||
| 1086 | repository: "acme/web", | ||
| 1087 | number: 7, | ||
| 1088 | content: "thumbs-up", | ||
| 1089 | reacted: true, | ||
| 1090 | }); | ||
| 1091 | |||
| 1092 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | ||
| 1093 | expect(argsOfCall(0)).toEqual([ | ||
| 1094 | "api", | ||
| 1095 | "projects/acme%2Fweb/merge_requests/7/award_emoji?name=thumbsup", | ||
| 1096 | "--method", | ||
| 1097 | "POST", | ||
| 1098 | ]); | ||
| 1099 | }), | ||
| 1100 | ); | ||
| 1101 | |||
| 1102 | it.effect("removes an award by listing them and deleting the reader's own id", () => | ||
| 1103 | Effect.gen(function* () { | ||
| 1104 | mockedExecute.mockReturnValueOnce( | ||
| 1105 | Effect.succeed(output(JSON.stringify({ username: "bilal" }))), | ||
| 1106 | ); | ||
| 1107 | mockedExecute.mockReturnValueOnce( | ||
| 1108 | Effect.succeed( | ||
| 1109 | output( | ||
| 1110 | JSON.stringify([ | ||
| 1111 | { id: 5, name: "thumbsup", user: { username: "bilal" } }, | ||
| 1112 | { id: 6, name: "thumbsup", user: { username: "julius" } }, | ||
| 1113 | ]), | ||
| 1114 | ), | ||
| 1115 | ), | ||
| 1116 | ); | ||
| 1117 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("{}"))); | ||
| 1118 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 1119 | |||
| 1120 | yield* cli.setReaction({ | ||
| 1121 | cwd: "/w", | ||
| 1122 | repository: "acme/web", | ||
| 1123 | number: 7, | ||
| 1124 | content: "thumbs-up", | ||
| 1125 | reacted: false, | ||
| 1126 | }); | ||
| 1127 | |||
| 1128 | assert.strictEqual(mockedExecute.mock.calls.length, 3); | ||
| 1129 | expect(argsOfCall(2)).toEqual([ | ||
| 1130 | "api", | ||
| 1131 | "projects/acme%2Fweb/merge_requests/7/award_emoji/5", | ||
| 1132 | "--method", | ||
| 1133 | "DELETE", | ||
| 1134 | ]); | ||
| 1135 | }), | ||
| 1136 | ); | ||
| 1137 | |||
| 1138 | it.effect("does nothing when the reader has no award of that name to take back", () => | ||
| 1139 | Effect.gen(function* () { | ||
| 1140 | mockedExecute.mockReturnValueOnce( | ||
| 1141 | Effect.succeed(output(JSON.stringify({ username: "bilal" }))), | ||
| 1142 | ); | ||
| 1143 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | ||
| 1144 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 1145 | |||
| 1146 | yield* cli.setReaction({ | ||
| 1147 | cwd: "/w", | ||
| 1148 | repository: "acme/web", | ||
| 1149 | number: 7, | ||
| 1150 | content: "thumbs-up", | ||
| 1151 | reacted: false, | ||
| 1152 | }); | ||
| 1153 | |||
| 1154 | // Nothing to delete: the reaction the caller asked to take back is already gone. | ||
| 1155 | assert.strictEqual(mockedExecute.mock.calls.length, 2); | ||
| 1156 | }), | ||
| 1157 | ); | ||
| 1158 | |||
| 1159 | it.effect("names a merge request with no diff revisions rather than calling it unreadable", () => | ||
| 1160 | Effect.gen(function* () { | ||
| 1161 | mockedExecute.mockReturnValueOnce( | ||
| 1162 | Effect.succeed( | ||
| 1163 | output( | ||
| 1164 | JSON.stringify({ | ||
| 1165 | iid: 7, | ||
| 1166 | title: "t", | ||
| 1167 | web_url: "https://gitlab.com/acme/web/-/merge_requests/7", | ||
| 1168 | source_branch: "feat", | ||
| 1169 | target_branch: "main", | ||
| 1170 | created_at: "2026-07-01T00:00:00Z", | ||
| 1171 | updated_at: "2026-07-01T00:00:00Z", | ||
| 1172 | diff_refs: null, | ||
| 1173 | }), | ||
| 1174 | ), | ||
| 1175 | ), | ||
| 1176 | ); | ||
| 1177 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 1178 | |||
| 1179 | const error = yield* Effect.flip( | ||
| 1180 | cli.submitReview({ | ||
| 1181 | cwd: "/w", | ||
| 1182 | repository: "acme/web", | ||
| 1183 | number: 7, | ||
| 1184 | verdict: "comment", | ||
| 1185 | body: "", | ||
| 1186 | comments: [{ path: "src/a.ts", position: { kind: "added", newLine: 4 }, body: "nit" }], | ||
| 1187 | }), | ||
| 1188 | ); | ||
| 1189 | |||
| 1190 | // Nothing failed to decode: GitLab answered, and the answer has nowhere to put a | ||
| 1191 | // positioned comment. | ||
| 1192 | assert.strictEqual(error._tag, "GitLabDiffRefsUnavailableError"); | ||
| 1193 | }), | ||
| 1194 | ); | ||
| 1195 | |||
| 1196 | it.effect("reads who has access to the project and who is already on the merge request", () => | ||
| 1197 | Effect.gen(function* () { | ||
| 1198 | mockedExecute | ||
| 1199 | .mockReturnValueOnce(Effect.succeed(output(mergeRequestJson({ reviewers: [reviewer] })))) | ||
| 1200 | .mockReturnValueOnce( | ||
| 1201 | Effect.succeed(output(JSON.stringify([author, reviewer, { id: 9, username: "hubot" }]))), | ||
| 1202 | ); | ||
| 1203 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 1204 | |||
| 1205 | const list = yield* cli.listReviewerCandidates({ | ||
| 1206 | cwd: "/w", | ||
| 1207 | repository: "acme/web", | ||
| 1208 | number: 7, | ||
| 1209 | }); | ||
| 1210 | |||
| 1211 | expect(argsOfCall(1)[1]).toBe("projects/acme%2Fweb/users?per_page=100"); | ||
| 1212 | // The author is left out, and whoever GitLab already has as a reviewer is marked. | ||
| 1213 | expect(list.candidates.map((candidate) => [candidate.id, candidate.isRequested])).toEqual([ | ||
| 1214 | ["5", true], | ||
| 1215 | ["9", false], | ||
| 1216 | ]); | ||
| 1217 | assert.isFalse(list.truncated); | ||
| 1218 | }), | ||
| 1219 | ); | ||
| 1220 | |||
| 1221 | it.effect("writes the reviewer set back with the one being asked added to it", () => | ||
| 1222 | Effect.gen(function* () { | ||
| 1223 | mockedExecute | ||
| 1224 | .mockReturnValueOnce(Effect.succeed(output(mergeRequestJson({ reviewers: [reviewer] })))) | ||
| 1225 | .mockReturnValueOnce(Effect.succeed(output("{}"))); | ||
| 1226 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 1227 | |||
| 1228 | yield* cli.setReviewerRequest({ | ||
| 1229 | cwd: "/w", | ||
| 1230 | repository: "acme/web", | ||
| 1231 | number: 7, | ||
| 1232 | reviewers: [{ id: "9" }], | ||
| 1233 | requested: true, | ||
| 1234 | }); | ||
| 1235 | |||
| 1236 | // GitLab replaces the whole set, so the reviewer already on the merge request has to be | ||
| 1237 | // sent back with the new one or the request would take them off it. | ||
| 1238 | expect(argsOfCall(1)).toContain("PUT"); | ||
| 1239 | expect(JSON.parse(callAt(1).stdin ?? "")).toEqual({ reviewer_ids: [5, 9] }); | ||
| 1240 | }), | ||
| 1241 | ); | ||
| 1242 | |||
| 1243 | it.effect("takes a reviewer out of the set rather than clearing it", () => | ||
| 1244 | Effect.gen(function* () { | ||
| 1245 | mockedExecute | ||
| 1246 | .mockReturnValueOnce( | ||
| 1247 | Effect.succeed( | ||
| 1248 | output(mergeRequestJson({ reviewers: [reviewer, { id: 9, username: "hubot" }] })), | ||
| 1249 | ), | ||
| 1250 | ) | ||
| 1251 | .mockReturnValueOnce(Effect.succeed(output("{}"))); | ||
| 1252 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 1253 | |||
| 1254 | yield* cli.setReviewerRequest({ | ||
| 1255 | cwd: "/w", | ||
| 1256 | repository: "acme/web", | ||
| 1257 | number: 7, | ||
| 1258 | reviewers: [{ id: "9" }], | ||
| 1259 | requested: false, | ||
| 1260 | }); | ||
| 1261 | |||
| 1262 | expect(JSON.parse(callAt(1).stdin ?? "")).toEqual({ reviewer_ids: [5] }); | ||
| 1263 | }), | ||
| 1264 | ); | ||
| 1265 | |||
| 1266 | it.effect("ignores an id GitLab could not have handed out, which names nobody", () => | ||
| 1267 | Effect.gen(function* () { | ||
| 1268 | mockedExecute | ||
| 1269 | .mockReturnValueOnce(Effect.succeed(output(mergeRequestJson({ reviewers: [reviewer] })))) | ||
| 1270 | .mockReturnValueOnce(Effect.succeed(output("{}"))); | ||
| 1271 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 1272 | |||
| 1273 | yield* cli.setReviewerRequest({ | ||
| 1274 | cwd: "/w", | ||
| 1275 | repository: "acme/web", | ||
| 1276 | number: 7, | ||
| 1277 | reviewers: [{ id: "octocat" }], | ||
| 1278 | requested: true, | ||
| 1279 | }); | ||
| 1280 | |||
| 1281 | // Sending it as a number would rewrite the reviewer set around something nobody chose. | ||
| 1282 | expect(JSON.parse(callAt(1).stdin ?? "")).toEqual({ reviewer_ids: [5] }); | ||
| 1283 | }), | ||
| 1284 | ); | ||
| 1285 | |||
| 1286 | it.effect("rewrites a title without touching the description", () => | ||
| 1287 | Effect.gen(function* () { | ||
| 1288 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | ||
| 1289 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 1290 | |||
| 1291 | yield* cli.updateMergeRequest({ | ||
| 1292 | cwd: "/w", | ||
| 1293 | repository: "acme/web", | ||
| 1294 | number: 7, | ||
| 1295 | title: "A better title", | ||
| 1296 | }); | ||
| 1297 | |||
| 1298 | expect(argsOfCall(0)).toEqual([ | ||
| 1299 | "api", | ||
| 1300 | "projects/acme%2Fweb/merge_requests/7", | ||
| 1301 | "--method", | ||
| 1302 | "PUT", | ||
| 1303 | "--input", | ||
| 1304 | "-", | ||
| 1305 | "--header", | ||
| 1306 | "Content-Type: application/json", | ||
| 1307 | ]); | ||
| 1308 | expect(JSON.parse(callAt(0).stdin ?? "")).toEqual({ title: "A better title" }); | ||
| 1309 | }), | ||
| 1310 | ); | ||
| 1311 | |||
| 1312 | it.effect("sends a rewritten body as GitLab's description, and nothing else", () => | ||
| 1313 | Effect.gen(function* () { | ||
| 1314 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | ||
| 1315 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 1316 | |||
| 1317 | yield* cli.updateMergeRequest({ | ||
| 1318 | cwd: "/w", | ||
| 1319 | repository: "acme/web", | ||
| 1320 | number: 7, | ||
| 1321 | description: "What this changes.", | ||
| 1322 | }); | ||
| 1323 | |||
| 1324 | // A title sent as an empty string would wipe the one the merge request already has. | ||
| 1325 | expect(JSON.parse(callAt(0).stdin ?? "")).toEqual({ description: "What this changes." }); | ||
| 1326 | }), | ||
| 1327 | ); | ||
| 1328 | |||
| 1329 | it.effect("rewrites title and description together in one request", () => | ||
| 1330 | Effect.gen(function* () { | ||
| 1331 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | ||
| 1332 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 1333 | |||
| 1334 | yield* cli.updateMergeRequest({ | ||
| 1335 | cwd: "/w", | ||
| 1336 | repository: "acme/web", | ||
| 1337 | number: 7, | ||
| 1338 | title: "A better title", | ||
| 1339 | description: "What this changes.", | ||
| 1340 | }); | ||
| 1341 | |||
| 1342 | assert.strictEqual(mockedExecute.mock.calls.length, 1); | ||
| 1343 | expect(JSON.parse(callAt(0).stdin ?? "")).toEqual({ | ||
| 1344 | title: "A better title", | ||
| 1345 | description: "What this changes.", | ||
| 1346 | }); | ||
| 1347 | }), | ||
| 1348 | ); | ||
| 1349 | |||
| 1350 | it.effect("rewrites a note in place through the note it names", () => | ||
| 1351 | Effect.gen(function* () { | ||
| 1352 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); | ||
| 1353 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 1354 | |||
| 1355 | yield* cli.updateNote({ | ||
| 1356 | cwd: "/w", | ||
| 1357 | repository: "acme/web", | ||
| 1358 | number: 7, | ||
| 1359 | noteId: "42", | ||
| 1360 | body: "true", | ||
| 1361 | }); | ||
| 1362 | |||
| 1363 | expect(argsOfCall(0)).toEqual([ | ||
| 1364 | "api", | ||
| 1365 | "projects/acme%2Fweb/merge_requests/7/notes/42", | ||
| 1366 | "--method", | ||
| 1367 | "PUT", | ||
| 1368 | "--input", | ||
| 1369 | "-", | ||
| 1370 | "--header", | ||
| 1371 | "Content-Type: application/json", | ||
| 1372 | ]); | ||
| 1373 | // A JSON body, so a note rewritten to a literal `true` stays text. | ||
| 1374 | expect(callAt(0).stdin).toBe('{"body":"true"}'); | ||
| 1375 | }), | ||
| 1376 | ); | ||
| 1377 | it.effect("reads blob ids for the marked paths at the merge request's head", () => | ||
| 1378 | Effect.gen(function* () { | ||
| 1379 | mockedExecute.mockReturnValueOnce( | ||
| 1380 | Effect.succeed( | ||
| 1381 | output( | ||
| 1382 | JSON.stringify({ | ||
| 1383 | iid: 7, | ||
| 1384 | title: "t", | ||
| 1385 | web_url: "https://gitlab.com/acme/web/-/merge_requests/7", | ||
| 1386 | source_branch: "feat", | ||
| 1387 | target_branch: "main", | ||
| 1388 | created_at: "2026-07-01T00:00:00Z", | ||
| 1389 | updated_at: "2026-07-01T00:00:00Z", | ||
| 1390 | diff_refs: { base_sha: "base", head_sha: "head", start_sha: "start" }, | ||
| 1391 | }), | ||
| 1392 | ), | ||
| 1393 | ), | ||
| 1394 | ); | ||
| 1395 | mockedExecute.mockReturnValueOnce( | ||
| 1396 | Effect.succeed( | ||
| 1397 | output( | ||
| 1398 | JSON.stringify({ | ||
| 1399 | data: { | ||
| 1400 | project: { repository: { blobs: { nodes: [{ path: "src/a.ts", oid: "aaa" }] } } }, | ||
| 1401 | }, | ||
| 1402 | }), | ||
| 1403 | ), | ||
| 1404 | ), | ||
| 1405 | ); | ||
| 1406 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 1407 | |||
| 1408 | const revisions = yield* cli.getFileRevisions({ | ||
| 1409 | cwd: "/w", | ||
| 1410 | repository: "acme/web", | ||
| 1411 | number: 7, | ||
| 1412 | paths: ["src/a.ts", "src/gone.ts"], | ||
| 1413 | }); | ||
| 1414 | |||
| 1415 | // Every path was looked for at the head, so one the head does not have is one the merge | ||
| 1416 | // request removed: said as the empty version, which a mark on it was stamped with too. | ||
| 1417 | expect([...revisions]).toEqual([ | ||
| 1418 | ["src/a.ts", "aaa"], | ||
| 1419 | ["src/gone.ts", ""], | ||
| 1420 | ]); | ||
| 1421 | // The head the reader is looking at, not whatever the source branch has moved on to. | ||
| 1422 | const body: unknown = JSON.parse(callAt(1).stdin ?? "{}"); | ||
| 1423 | expect(body).toMatchObject({ | ||
| 1424 | variables: { fullPath: "acme/web", ref: "head", paths: ["src/a.ts", "src/gone.ts"] }, | ||
| 1425 | }); | ||
| 1426 | }), | ||
| 1427 | ); | ||
| 1428 | |||
| 1429 | it.effect("leaves the paths out when GitLab did not answer the blobs query", () => | ||
| 1430 | Effect.gen(function* () { | ||
| 1431 | mockedExecute.mockReturnValueOnce( | ||
| 1432 | Effect.succeed( | ||
| 1433 | output( | ||
| 1434 | JSON.stringify({ | ||
| 1435 | iid: 7, | ||
| 1436 | title: "t", | ||
| 1437 | web_url: "https://gitlab.com/acme/web/-/merge_requests/7", | ||
| 1438 | source_branch: "feat", | ||
| 1439 | target_branch: "main", | ||
| 1440 | created_at: "2026-07-01T00:00:00Z", | ||
| 1441 | updated_at: "2026-07-01T00:00:00Z", | ||
| 1442 | diff_refs: { base_sha: "base", head_sha: "head", start_sha: "start" }, | ||
| 1443 | }), | ||
| 1444 | ), | ||
| 1445 | ), | ||
| 1446 | ); | ||
| 1447 | // What GitLab says of a project the token cannot see. It is not the head having none of | ||
| 1448 | // these files, and reading it that way would report every file the reader has cleared as | ||
| 1449 | // changed on nothing worse than a permission. | ||
| 1450 | mockedExecute.mockReturnValueOnce( | ||
| 1451 | Effect.succeed(output(JSON.stringify({ data: { project: null } }))), | ||
| 1452 | ); | ||
| 1453 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 1454 | |||
| 1455 | const revisions = yield* cli.getFileRevisions({ | ||
| 1456 | cwd: "/w", | ||
| 1457 | repository: "acme/web", | ||
| 1458 | number: 7, | ||
| 1459 | paths: ["src/a.ts", "src/b.ts"], | ||
| 1460 | }); | ||
| 1461 | |||
| 1462 | expect([...revisions]).toEqual([]); | ||
| 1463 | }), | ||
| 1464 | ); | ||
| 1465 | |||
| 1466 | it.effect("asks GitLab nothing when no file is marked", () => | ||
| 1467 | Effect.gen(function* () { | ||
| 1468 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 1469 | |||
| 1470 | const revisions = yield* cli.getFileRevisions({ | ||
| 1471 | cwd: "/w", | ||
| 1472 | repository: "acme/web", | ||
| 1473 | number: 7, | ||
| 1474 | paths: [], | ||
| 1475 | }); | ||
| 1476 | |||
| 1477 | expect([...revisions]).toEqual([]); | ||
| 1478 | expect(mockedExecute).not.toHaveBeenCalled(); | ||
| 1479 | }), | ||
| 1480 | ); | ||
| 1481 | |||
| 1482 | it.effect("splits the paths across requests, because GitLab charges the query by how many", () => | ||
| 1483 | Effect.gen(function* () { | ||
| 1484 | const paths = Array.from({ length: 150 }, (_, index) => `src/${index}.ts`); | ||
| 1485 | mockedExecute.mockReturnValueOnce( | ||
| 1486 | Effect.succeed( | ||
| 1487 | output( | ||
| 1488 | JSON.stringify({ | ||
| 1489 | iid: 7, | ||
| 1490 | title: "t", | ||
| 1491 | web_url: "https://gitlab.com/acme/web/-/merge_requests/7", | ||
| 1492 | source_branch: "feat", | ||
| 1493 | target_branch: "main", | ||
| 1494 | created_at: "2026-07-01T00:00:00Z", | ||
| 1495 | updated_at: "2026-07-01T00:00:00Z", | ||
| 1496 | diff_refs: { base_sha: "base", head_sha: "head", start_sha: "start" }, | ||
| 1497 | }), | ||
| 1498 | ), | ||
| 1499 | ), | ||
| 1500 | ); | ||
| 1501 | mockedExecute.mockImplementation((request) => { | ||
| 1502 | const body = JSON.parse(request.stdin ?? "{}") as { | ||
| 1503 | readonly variables: { readonly paths: ReadonlyArray<string> }; | ||
| 1504 | }; | ||
| 1505 | return Effect.succeed( | ||
| 1506 | output( | ||
| 1507 | JSON.stringify({ | ||
| 1508 | data: { | ||
| 1509 | project: { | ||
| 1510 | repository: { | ||
| 1511 | blobs: { | ||
| 1512 | nodes: body.variables.paths.map((path) => ({ path, oid: `oid-${path}` })), | ||
| 1513 | }, | ||
| 1514 | }, | ||
| 1515 | }, | ||
| 1516 | }, | ||
| 1517 | }), | ||
| 1518 | ), | ||
| 1519 | ); | ||
| 1520 | }); | ||
| 1521 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 1522 | |||
| 1523 | const revisions = yield* cli.getFileRevisions({ | ||
| 1524 | cwd: "/w", | ||
| 1525 | repository: "acme/web", | ||
| 1526 | number: 7, | ||
| 1527 | paths, | ||
| 1528 | }); | ||
| 1529 | |||
| 1530 | assert.strictEqual(revisions.size, 150); | ||
| 1531 | assert.strictEqual(revisions.get("src/149.ts"), "oid-src/149.ts"); | ||
| 1532 | // The diff refs, then two batches: a hundred paths and the fifty left over. | ||
| 1533 | assert.strictEqual(mockedExecute.mock.calls.length, 3); | ||
| 1534 | }), | ||
| 1535 | ); | ||
| 1536 | }); | ||
packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts created+1495| ... | @@ -0,0 +1,1495 @@ | ||
| 1 | import * as Context from "effect/Context"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Layer from "effect/Layer"; | ||
| 4 | import * as Result from "effect/Result"; | ||
| 5 | import * as Schema from "effect/Schema"; | ||
| 6 | import type { | ||
| 7 | PullRequestAction, | ||
| 8 | PullRequestComment, | ||
| 9 | PullRequestCommit, | ||
| 10 | PullRequestInvolvement, | ||
| 11 | PullRequestListState, | ||
| 12 | PullRequestMergeCapabilities, | ||
| 13 | PullRequestMergeMethod, | ||
| 14 | PullRequestReaction, | ||
| 15 | PullRequestReactionContent, | ||
| 16 | PullRequestReviewCommentDraft, | ||
| 17 | PullRequestReviewPosition, | ||
| 18 | PullRequestReviewThread, | ||
| 19 | PullRequestReviewVerdict, | ||
| 20 | PullRequestReviewerCandidateList, | ||
| 21 | } from "@t3tools/contracts"; | ||
| 22 | |||
| 23 | import * as GitLabCli from "./GitLabCli.ts"; | ||
| 24 | import { | ||
| 25 | AWARD_EMOJI_GRAPHQL_QUERY, | ||
| 26 | decodeAwardEmojiJson, | ||
| 27 | decodeCommitDiffRefsJson, | ||
| 28 | decodeCommitsJson, | ||
| 29 | decodeDiffRefsJson, | ||
| 30 | decodeDiscussionsJson, | ||
| 31 | decodeMergeRequestDetailJson, | ||
| 32 | decodeMergeRequestDiffsJson, | ||
| 33 | decodeMergeRequestListJson, | ||
| 34 | decodeNotesJson, | ||
| 35 | decodeOwnAwardIdJson, | ||
| 36 | decodeProjectMergeCapabilitiesJson, | ||
| 37 | decodeProjectUsersJson, | ||
| 38 | decodeRepositoryBlobsJson, | ||
| 39 | decodeViewerJson, | ||
| 40 | gitLabAwardName, | ||
| 41 | REPOSITORY_BLOBS_GRAPHQL_QUERY, | ||
| 42 | type GitLabDiffRefs, | ||
| 43 | type GitLabMergeRequestDetail, | ||
| 44 | type GitLabMergeRequestListItem, | ||
| 45 | type GitLabProjectUsers, | ||
| 46 | } from "./gitLabMergeRequestJson.ts"; | ||
| 47 | import type { ProviderListCursor } from "@t3tools/source-control-core/server/PullRequestProvider"; | ||
| 48 | |||
| 49 | /** | ||
| 50 | * Names the read that produced unusable output, so a failure reports the call it came from | ||
| 51 | * rather than borrowing another operation's message. | ||
| 52 | */ | ||
| 53 | export class GitLabMergeRequestReadError extends Schema.TaggedError<GitLabMergeRequestReadError>()( | ||
| 54 | "GitLabMergeRequestReadError", | ||
| 55 | { | ||
| 56 | command: Schema.Literal("glab"), | ||
| 57 | cwd: Schema.String, | ||
| 58 | operation: Schema.String, | ||
| 59 | cause: Schema.Defect(), | ||
| 60 | }, | ||
| 61 | ) { | ||
| 62 | get detail(): string { | ||
| 63 | return `GitLab CLI returned an unreadable ${this.operation} response.`; | ||
| 64 | } | ||
| 65 | |||
| 66 | override get message(): string { | ||
| 67 | return `GitLab CLI failed in ${this.operation}: ${this.detail}`; | ||
| 68 | } | ||
| 69 | } | ||
| 70 | |||
| 71 | /** Not a decode failure: glab answered, the account it answered for just has no username. */ | ||
| 72 | export class GitLabViewerUnavailableError extends Schema.TaggedError<GitLabViewerUnavailableError>()( | ||
| 73 | "GitLabViewerUnavailableError", | ||
| 74 | { | ||
| 75 | command: Schema.Literal("glab"), | ||
| 76 | cwd: Schema.String, | ||
| 77 | }, | ||
| 78 | ) { | ||
| 79 | get detail(): string { | ||
| 80 | return "GitLab CLI returned no username for the authenticated account."; | ||
| 81 | } | ||
| 82 | |||
| 83 | override get message(): string { | ||
| 84 | return `GitLab CLI failed in getViewerUsername: ${this.detail}`; | ||
| 85 | } | ||
| 86 | } | ||
| 87 | |||
| 88 | /** Not a decode failure: GitLab answered, the merge request just has no revisions to place a | ||
| 89 | * comment against. */ | ||
| 90 | export class GitLabDiffRefsUnavailableError extends Schema.TaggedError<GitLabDiffRefsUnavailableError>()( | ||
| 91 | "GitLabDiffRefsUnavailableError", | ||
| 92 | { | ||
| 93 | command: Schema.Literal("glab"), | ||
| 94 | cwd: Schema.String, | ||
| 95 | number: Schema.Int, | ||
| 96 | }, | ||
| 97 | ) { | ||
| 98 | get detail(): string { | ||
| 99 | return "The merge request reported no diff revisions."; | ||
| 100 | } | ||
| 101 | |||
| 102 | override get message(): string { | ||
| 103 | return `GitLab CLI failed in getDiffRefs: ${this.detail}`; | ||
| 104 | } | ||
| 105 | } | ||
| 106 | |||
| 107 | /** Not a decode failure: the reader asked to carry on from a cursor this walk never handed out. */ | ||
| 108 | export class GitLabDiffCursorError extends Schema.TaggedError<GitLabDiffCursorError>()( | ||
| 109 | "GitLabDiffCursorError", | ||
| 110 | { | ||
| 111 | command: Schema.Literal("glab"), | ||
| 112 | cwd: Schema.String, | ||
| 113 | }, | ||
| 114 | ) { | ||
| 115 | get detail(): string { | ||
| 116 | return "The diff cursor was not one this merge request handed out."; | ||
| 117 | } | ||
| 118 | |||
| 119 | override get message(): string { | ||
| 120 | return `GitLab CLI failed in getMergeRequestDiff: ${this.detail}`; | ||
| 121 | } | ||
| 122 | } | ||
| 123 | |||
| 124 | /** Not a decode failure: the reader named a commit that is not a sha this project could hold. */ | ||
| 125 | export class GitLabDiffCommitError extends Schema.TaggedError<GitLabDiffCommitError>()( | ||
| 126 | "GitLabDiffCommitError", | ||
| 127 | { | ||
| 128 | command: Schema.Literal("glab"), | ||
| 129 | cwd: Schema.String, | ||
| 130 | }, | ||
| 131 | ) { | ||
| 132 | get detail(): string { | ||
| 133 | return "The named commit was not a commit sha."; | ||
| 134 | } | ||
| 135 | |||
| 136 | override get message(): string { | ||
| 137 | return `GitLab CLI failed in getMergeRequestDiff: ${this.detail}`; | ||
| 138 | } | ||
| 139 | } | ||
| 140 | |||
| 141 | /** The commit exists and decoded, but it has no parent to use as the old revision. */ | ||
| 142 | export class GitLabDiffCommitParentUnavailableError extends Schema.TaggedError<GitLabDiffCommitParentUnavailableError>()( | ||
| 143 | "GitLabDiffCommitParentUnavailableError", | ||
| 144 | { | ||
| 145 | command: Schema.Literal("glab"), | ||
| 146 | cwd: Schema.String, | ||
| 147 | commit: Schema.String, | ||
| 148 | }, | ||
| 149 | ) { | ||
| 150 | get detail(): string { | ||
| 151 | return `Commit ${this.commit} reported no parent revision.`; | ||
| 152 | } | ||
| 153 | |||
| 154 | override get message(): string { | ||
| 155 | return `GitLab CLI failed in getMergeRequestDiffFileContents: ${this.detail}`; | ||
| 156 | } | ||
| 157 | } | ||
| 158 | |||
| 159 | /** A blob exists, but expanding it would be unsafe or would not produce text. */ | ||
| 160 | export class GitLabDiffFileContentsUnavailableError extends Schema.TaggedError<GitLabDiffFileContentsUnavailableError>()( | ||
| 161 | "GitLabDiffFileContentsUnavailableError", | ||
| 162 | { | ||
| 163 | command: Schema.Literal("glab"), | ||
| 164 | cwd: Schema.String, | ||
| 165 | path: Schema.String, | ||
| 166 | reason: Schema.Literals(["oversized", "binary"]), | ||
| 167 | }, | ||
| 168 | ) { | ||
| 169 | get detail(): string { | ||
| 170 | return this.reason === "oversized" | ||
| 171 | ? `The diff file '${this.path}' exceeds the 1 MB expansion limit.` | ||
| 172 | : `The diff file '${this.path}' is binary.`; | ||
| 173 | } | ||
| 174 | |||
| 175 | override get message(): string { | ||
| 176 | return `GitLab CLI failed in getMergeRequestDiffFileContents: ${this.detail}`; | ||
| 177 | } | ||
| 178 | } | ||
| 179 | |||
| 180 | export type GitLabPullRequestCliError = | ||
| 181 | | GitLabCli.GitLabCliError | ||
| 182 | | GitLabMergeRequestReadError | ||
| 183 | | GitLabDiffCursorError | ||
| 184 | | GitLabDiffCommitError | ||
| 185 | | GitLabDiffCommitParentUnavailableError | ||
| 186 | | GitLabDiffFileContentsUnavailableError | ||
| 187 | | GitLabDiffRefsUnavailableError | ||
| 188 | | GitLabViewerUnavailableError; | ||
| 189 | |||
| 190 | /** GitLab's own ceiling on `per_page`, so a larger page has to be walked. */ | ||
| 191 | const MAX_PAGE_SIZE = 100; | ||
| 192 | /** Commit history is read one page deep; the rest of a long history stays on GitLab. */ | ||
| 193 | const COMMIT_PAGE_SIZE = 100; | ||
| 194 | /** | ||
| 195 | * Pages of the conversation to follow before it is reported as truncated. GitLab caps a page at | ||
| 196 | * a hundred, so this is a thousand notes and a thousand discussions — more than any merge | ||
| 197 | * request a person is reading holds, and a walk that ends whatever the host has. | ||
| 198 | */ | ||
| 199 | const CONVERSATION_PAGES = 10; | ||
| 200 | const DIFF_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; | ||
| 201 | const DIFF_TIMEOUT_MS = 60_000; | ||
| 202 | const DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024; | ||
| 203 | |||
| 204 | export interface GitLabMergeRequestListBatch { | ||
| 205 | readonly items: ReadonlyArray<GitLabMergeRequestListItem>; | ||
| 206 | readonly truncated: boolean; | ||
| 207 | /** Raw GitLab rows consumed to produce this page, including malformed rows. */ | ||
| 208 | readonly cursorAdvance: number; | ||
| 209 | } | ||
| 210 | |||
| 211 | export interface GitLabMergeRequestDiffSlice { | ||
| 212 | readonly patch: string; | ||
| 213 | /** Files in this slice had their hunks withheld, as opposed to there being more slices. */ | ||
| 214 | readonly truncated: boolean; | ||
| 215 | /** Where the next slice starts, or null once the patch is whole. */ | ||
| 216 | readonly nextCursor: string | null; | ||
| 217 | } | ||
| 218 | |||
| 219 | export class GitLabPullRequestCli extends Context.Service< | ||
| 220 | GitLabPullRequestCli, | ||
| 221 | { | ||
| 222 | readonly getViewerUsername: (input: { | ||
| 223 | readonly cwd: string; | ||
| 224 | }) => Effect.Effect<string, GitLabPullRequestCliError>; | ||
| 225 | |||
| 226 | readonly listMergeRequests: (input: { | ||
| 227 | readonly cwd: string; | ||
| 228 | readonly repository: string; | ||
| 229 | readonly state: PullRequestListState; | ||
| 230 | readonly involvement: PullRequestInvolvement; | ||
| 231 | readonly viewer: string; | ||
| 232 | readonly limit: number; | ||
| 233 | /** Free text for GitLab's own `search`, which matches title and description. */ | ||
| 234 | readonly query?: string | undefined; | ||
| 235 | /** Where to carry on from in GitLab's stable update-ordered row set. */ | ||
| 236 | readonly cursor?: ProviderListCursor | undefined; | ||
| 237 | }) => Effect.Effect<GitLabMergeRequestListBatch, GitLabPullRequestCliError>; | ||
| 238 | |||
| 239 | readonly getMergeRequestDetail: (input: { | ||
| 240 | readonly cwd: string; | ||
| 241 | readonly repository: string; | ||
| 242 | readonly number: number; | ||
| 243 | }) => Effect.Effect<GitLabMergeRequestDetail, GitLabPullRequestCliError>; | ||
| 244 | |||
| 245 | readonly listNotes: (input: { | ||
| 246 | readonly cwd: string; | ||
| 247 | readonly repository: string; | ||
| 248 | readonly number: number; | ||
| 249 | }) => Effect.Effect< | ||
| 250 | { readonly comments: ReadonlyArray<PullRequestComment>; readonly truncated: boolean }, | ||
| 251 | GitLabPullRequestCliError | ||
| 252 | >; | ||
| 253 | |||
| 254 | readonly listCommits: (input: { | ||
| 255 | readonly cwd: string; | ||
| 256 | readonly repository: string; | ||
| 257 | readonly number: number; | ||
| 258 | }) => Effect.Effect<ReadonlyArray<PullRequestCommit>, GitLabPullRequestCliError>; | ||
| 259 | |||
| 260 | readonly getMergeRequestDiff: (input: { | ||
| 261 | readonly cwd: string; | ||
| 262 | readonly repository: string; | ||
| 263 | readonly number: number; | ||
| 264 | /** Absent asks for the first slice; anything else is a cursor a slice handed back. */ | ||
| 265 | readonly cursor?: string | undefined; | ||
| 266 | /** One commit's own changes, rather than everything the merge request carries. */ | ||
| 267 | readonly commit?: string | undefined; | ||
| 268 | }) => Effect.Effect<GitLabMergeRequestDiffSlice, GitLabPullRequestCliError>; | ||
| 269 | |||
| 270 | readonly getMergeRequestDiffFileContents: (input: { | ||
| 271 | readonly cwd: string; | ||
| 272 | readonly repository: string; | ||
| 273 | readonly number: number; | ||
| 274 | readonly commit?: string | undefined; | ||
| 275 | readonly changeType: "change" | "rename-pure" | "rename-changed" | "new" | "deleted"; | ||
| 276 | readonly oldPath: string; | ||
| 277 | readonly newPath: string; | ||
| 278 | }) => Effect.Effect< | ||
| 279 | { readonly oldContents: string; readonly newContents: string }, | ||
| 280 | GitLabPullRequestCliError | ||
| 281 | >; | ||
| 282 | |||
| 283 | readonly getProjectMergeCapabilities: (input: { | ||
| 284 | readonly cwd: string; | ||
| 285 | readonly repository: string; | ||
| 286 | }) => Effect.Effect<PullRequestMergeCapabilities, GitLabPullRequestCliError>; | ||
| 287 | |||
| 288 | /** | ||
| 289 | * What the merge request's head has of each of these paths, as blob ids. | ||
| 290 | * | ||
| 291 | * The head sha comes from the merge request's own diff refs, so the answer is the version a | ||
| 292 | * reader is looking at rather than whatever the source branch has moved on to. A path the | ||
| 293 | * head does not have is answered as the empty revision, since the batch it was asked in was | ||
| 294 | * looked at; a batch GitLab did not answer for is left out instead. | ||
| 295 | */ | ||
| 296 | readonly getFileRevisions: (input: { | ||
| 297 | readonly cwd: string; | ||
| 298 | readonly repository: string; | ||
| 299 | readonly number: number; | ||
| 300 | readonly paths: ReadonlyArray<string>; | ||
| 301 | }) => Effect.Effect<ReadonlyMap<string, string>, GitLabPullRequestCliError>; | ||
| 302 | |||
| 303 | /** | ||
| 304 | * Who this merge request may be sent to, and who it has already been sent to. Two reads at | ||
| 305 | * once, because GitLab keeps the people with access on the project and the reviewers on the | ||
| 306 | * merge request, and neither answers for the other. | ||
| 307 | */ | ||
| 308 | readonly listReviewerCandidates: (input: { | ||
| 309 | readonly cwd: string; | ||
| 310 | readonly repository: string; | ||
| 311 | readonly number: number; | ||
| 312 | }) => Effect.Effect<PullRequestReviewerCandidateList, GitLabPullRequestCliError>; | ||
| 313 | |||
| 314 | readonly setReviewerRequest: (input: { | ||
| 315 | readonly cwd: string; | ||
| 316 | readonly repository: string; | ||
| 317 | readonly number: number; | ||
| 318 | readonly reviewers: ReadonlyArray<{ readonly id: string }>; | ||
| 319 | readonly requested: boolean; | ||
| 320 | }) => Effect.Effect<void, GitLabPullRequestCliError>; | ||
| 321 | |||
| 322 | readonly runMergeRequestAction: (input: { | ||
| 323 | readonly cwd: string; | ||
| 324 | readonly repository: string; | ||
| 325 | readonly number: number; | ||
| 326 | readonly action: PullRequestAction; | ||
| 327 | readonly mergeMethod?: PullRequestMergeMethod; | ||
| 328 | }) => Effect.Effect<void, GitLabPullRequestCliError>; | ||
| 329 | |||
| 330 | /** Whichever of the two is given is sent. GitLab calls a merge request's body its description. */ | ||
| 331 | readonly updateMergeRequest: (input: { | ||
| 332 | readonly cwd: string; | ||
| 333 | readonly repository: string; | ||
| 334 | readonly number: number; | ||
| 335 | readonly title?: string | undefined; | ||
| 336 | readonly description?: string | undefined; | ||
| 337 | }) => Effect.Effect<void, GitLabPullRequestCliError>; | ||
| 338 | |||
| 339 | readonly commentOnMergeRequest: (input: { | ||
| 340 | readonly cwd: string; | ||
| 341 | readonly repository: string; | ||
| 342 | readonly number: number; | ||
| 343 | readonly body: string; | ||
| 344 | }) => Effect.Effect<void, GitLabPullRequestCliError>; | ||
| 345 | |||
| 346 | readonly updateNote: (input: { | ||
| 347 | readonly cwd: string; | ||
| 348 | readonly repository: string; | ||
| 349 | readonly number: number; | ||
| 350 | readonly noteId: string; | ||
| 351 | readonly body: string; | ||
| 352 | }) => Effect.Effect<void, GitLabPullRequestCliError>; | ||
| 353 | |||
| 354 | readonly listDiscussions: (input: { | ||
| 355 | readonly cwd: string; | ||
| 356 | readonly repository: string; | ||
| 357 | readonly number: number; | ||
| 358 | }) => Effect.Effect< | ||
| 359 | { readonly threads: ReadonlyArray<PullRequestReviewThread>; readonly truncated: boolean }, | ||
| 360 | GitLabPullRequestCliError | ||
| 361 | >; | ||
| 362 | |||
| 363 | readonly submitReview: (input: { | ||
| 364 | readonly cwd: string; | ||
| 365 | readonly repository: string; | ||
| 366 | readonly number: number; | ||
| 367 | readonly verdict: PullRequestReviewVerdict; | ||
| 368 | readonly body: string; | ||
| 369 | readonly comments: ReadonlyArray<PullRequestReviewCommentDraft>; | ||
| 370 | }) => Effect.Effect<void, GitLabPullRequestCliError>; | ||
| 371 | |||
| 372 | readonly replyToDiscussion: (input: { | ||
| 373 | readonly cwd: string; | ||
| 374 | readonly repository: string; | ||
| 375 | readonly number: number; | ||
| 376 | readonly discussionId: string; | ||
| 377 | readonly body: string; | ||
| 378 | }) => Effect.Effect<void, GitLabPullRequestCliError>; | ||
| 379 | |||
| 380 | readonly setDiscussionResolution: (input: { | ||
| 381 | readonly cwd: string; | ||
| 382 | readonly repository: string; | ||
| 383 | readonly number: number; | ||
| 384 | readonly discussionId: string; | ||
| 385 | readonly resolved: boolean; | ||
| 386 | }) => Effect.Effect<void, GitLabPullRequestCliError>; | ||
| 387 | |||
| 388 | /** The awards on the merge request and on every note of it, keyed by the note's REST id. */ | ||
| 389 | readonly listReactions: (input: { | ||
| 390 | readonly cwd: string; | ||
| 391 | readonly repository: string; | ||
| 392 | readonly number: number; | ||
| 393 | }) => Effect.Effect< | ||
| 394 | { | ||
| 395 | readonly reactions: ReadonlyArray<PullRequestReaction>; | ||
| 396 | readonly reactionsByNoteId: ReadonlyMap<string, ReadonlyArray<PullRequestReaction>>; | ||
| 397 | }, | ||
| 398 | GitLabPullRequestCliError | ||
| 399 | >; | ||
| 400 | |||
| 401 | /** | ||
| 402 | * Awards an emoji, or takes the award back. `noteId` is a note of the merge request; absent | ||
| 403 | * awards the merge request itself, which is where its description's reactions live. | ||
| 404 | */ | ||
| 405 | readonly setReaction: (input: { | ||
| 406 | readonly cwd: string; | ||
| 407 | readonly repository: string; | ||
| 408 | readonly number: number; | ||
| 409 | readonly noteId?: string | undefined; | ||
| 410 | readonly content: PullRequestReactionContent; | ||
| 411 | readonly reacted: boolean; | ||
| 412 | }) => Effect.Effect<void, GitLabPullRequestCliError>; | ||
| 413 | } | ||
| 414 | >()("@t3tools/source-control-gitlab/server/GitLabPullRequestCli") {} | ||
| 415 | |||
| 416 | /** The REST API addresses a project by its URL-encoded full path. */ | ||
| 417 | function projectPath(repository: string): string { | ||
| 418 | return encodeURIComponent(repository.trim()); | ||
| 419 | } | ||
| 420 | |||
| 421 | function gitLabReviewPositionLines( | ||
| 422 | position: PullRequestReviewPosition, | ||
| 423 | ): | ||
| 424 | | { readonly new_line: number } | ||
| 425 | | { readonly old_line: number } | ||
| 426 | | { readonly old_line: number; readonly new_line: number } { | ||
| 427 | switch (position.kind) { | ||
| 428 | case "added": | ||
| 429 | return { new_line: position.newLine }; | ||
| 430 | case "deleted": | ||
| 431 | return { old_line: position.oldLine }; | ||
| 432 | case "context": | ||
| 433 | return { old_line: position.oldLine, new_line: position.newLine }; | ||
| 434 | } | ||
| 435 | } | ||
| 436 | |||
| 437 | function stateParam(state: PullRequestListState): string { | ||
| 438 | // GitLab's `closed` already excludes merged merge requests, so no extra filter is needed, | ||
| 439 | // and it spans every state under `all`. | ||
| 440 | return state === "open" ? "opened" : state; | ||
| 441 | } | ||
| 442 | |||
| 443 | function involvementParams(input: { | ||
| 444 | readonly involvement: PullRequestInvolvement; | ||
| 445 | readonly viewer: string; | ||
| 446 | }): ReadonlyArray<readonly [string, string]> { | ||
| 447 | switch (input.involvement) { | ||
| 448 | case "authored": | ||
| 449 | return [["author_username", input.viewer]]; | ||
| 450 | case "reviewing": | ||
| 451 | return [["reviewer_username", input.viewer]]; | ||
| 452 | case "all": | ||
| 453 | return []; | ||
| 454 | } | ||
| 455 | } | ||
| 456 | |||
| 457 | /** | ||
| 458 | * The page a diff cursor names, or null for anything this walk cannot have issued. The cursor | ||
| 459 | * arrives from the reader as a string and goes straight into a query, so it is parsed rather | ||
| 460 | * than trusted; the length bound keeps a page number out of exponential notation. | ||
| 461 | */ | ||
| 462 | function diffCursorPage(cursor: string): number | null { | ||
| 463 | return /^[1-9][0-9]{0,6}$/.test(cursor) ? Number(cursor) : null; | ||
| 464 | } | ||
| 465 | |||
| 466 | /** | ||
| 467 | * A commit sha arrives from the reader and goes straight into a request path, so it is checked | ||
| 468 | * rather than trusted: hexadecimal only, from the shortest abbreviation a host prints up to a | ||
| 469 | * whole sha. | ||
| 470 | */ | ||
| 471 | function isCommitSha(value: string): boolean { | ||
| 472 | return /^[0-9a-f]{7,64}$/i.test(value); | ||
| 473 | } | ||
| 474 | |||
| 475 | function searchParams(search: string | undefined): ReadonlyArray<readonly [string, string]> { | ||
| 476 | const trimmed = search?.trim() ?? ""; | ||
| 477 | return trimmed.length === 0 ? [] : [["search", trimmed]]; | ||
| 478 | } | ||
| 479 | |||
| 480 | function query(params: ReadonlyArray<readonly [string, string]>): string { | ||
| 481 | return params.map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join("&"); | ||
| 482 | } | ||
| 483 | |||
| 484 | function actionArgs( | ||
| 485 | action: PullRequestAction, | ||
| 486 | mergeMethod: PullRequestMergeMethod | undefined, | ||
| 487 | ): ReadonlyArray<string> { | ||
| 488 | switch (action) { | ||
| 489 | case "merge": | ||
| 490 | return [ | ||
| 491 | "merge", | ||
| 492 | // glab turns on auto-merge whenever a pipeline is running. The button means merge now. | ||
| 493 | "--auto-merge=false", | ||
| 494 | "--yes", | ||
| 495 | ...(mergeMethod === "squash" ? ["--squash"] : []), | ||
| 496 | ...(mergeMethod === "rebase" ? ["--rebase"] : []), | ||
| 497 | ]; | ||
| 498 | // The same command with the flag the other way up: here the wait is the whole point, so | ||
| 499 | // glab is told to arm the merge rather than talked out of it. | ||
| 500 | case "enable-auto-merge": | ||
| 501 | return [ | ||
| 502 | "merge", | ||
| 503 | "--auto-merge=true", | ||
| 504 | "--yes", | ||
| 505 | ...(mergeMethod === "squash" ? ["--squash"] : []), | ||
| 506 | ...(mergeMethod === "rebase" ? ["--rebase"] : []), | ||
| 507 | ]; | ||
| 508 | // Never reached: taking the arming back has no `glab mr` command, so it goes to the API. | ||
| 509 | case "disable-auto-merge": | ||
| 510 | return []; | ||
| 511 | case "ready": | ||
| 512 | return ["update", "--ready"]; | ||
| 513 | case "draft": | ||
| 514 | return ["update", "--draft"]; | ||
| 515 | case "close": | ||
| 516 | return ["close"]; | ||
| 517 | // A rebase, because GitLab has no other way to move a branch onto its target: there is no | ||
| 518 | // merge-the-target-in equivalent of GitHub's update button, which is why this host declares | ||
| 519 | // `rebase` alone and never has to read the method it was handed. | ||
| 520 | case "update-branch": | ||
| 521 | return ["rebase"]; | ||
| 522 | case "reopen": | ||
| 523 | return ["reopen"]; | ||
| 524 | // Never reached: this host does not declare the action, so the service refuses it first. | ||
| 525 | case "revert": | ||
| 526 | case "approve-workflows": | ||
| 527 | throw new Error(`GitLab merge request action ${action} is unsupported`); | ||
| 528 | } | ||
| 529 | } | ||
| 530 | |||
| 531 | /** @public Service construction is part of the canonical Effect module API. */ | ||
| 532 | export const make = Effect.gen(function* () { | ||
| 533 | const gitlab = yield* GitLabCli.GitLabCli; | ||
| 534 | |||
| 535 | const api = (input: { | ||
| 536 | readonly cwd: string; | ||
| 537 | readonly path: string; | ||
| 538 | readonly method?: string; | ||
| 539 | readonly stdin?: string; | ||
| 540 | readonly maxOutputBytes?: number; | ||
| 541 | readonly timeoutMs?: number; | ||
| 542 | }) => | ||
| 543 | gitlab.execute({ | ||
| 544 | cwd: input.cwd, | ||
| 545 | args: [ | ||
| 546 | "api", | ||
| 547 | input.path, | ||
| 548 | ...(input.method === undefined ? [] : ["--method", input.method]), | ||
| 549 | // A raw body from stdin: argv is visible in process listings and is echoed back | ||
| 550 | // inside process-runner failure messages. Unlike `gh`, `glab api --input` sends no | ||
| 551 | // Content-Type at all, and GitLab answers a bodyless content type with HTTP 415. | ||
| 552 | ...(input.stdin === undefined | ||
| 553 | ? [] | ||
| 554 | : ["--input", "-", "--header", "Content-Type: application/json"]), | ||
| 555 | ], | ||
| 556 | ...(input.stdin === undefined ? {} : { stdin: input.stdin }), | ||
| 557 | ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), | ||
| 558 | ...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }), | ||
| 559 | }); | ||
| 560 | |||
| 561 | /** | ||
| 562 | * `per_page` stops at 100, so a larger page is walked one request at a time. The walk is | ||
| 563 | * bounded twice over: it stops on a short page or once the extra row that reveals a next | ||
| 564 | * page has been read, and it never asks for more pages than the caller's page needs. The | ||
| 565 | * second bound is what makes it terminate when every row on a page fails to decode, which | ||
| 566 | * leaves nothing collected but does not mean GitLab has run out of rows. | ||
| 567 | */ | ||
| 568 | const listPage = (input: { | ||
| 569 | readonly cwd: string; | ||
| 570 | readonly repository: string; | ||
| 571 | readonly state: PullRequestListState; | ||
| 572 | readonly involvement: PullRequestInvolvement; | ||
| 573 | readonly viewer: string; | ||
| 574 | readonly limit: number; | ||
| 575 | readonly query?: string | undefined; | ||
| 576 | readonly cursor?: ProviderListCursor | undefined; | ||
| 577 | readonly page: number; | ||
| 578 | readonly collected: ReadonlyArray<GitLabMergeRequestListItem>; | ||
| 579 | readonly cursorAdvance: number; | ||
| 580 | }): Effect.Effect<GitLabMergeRequestListBatch, GitLabPullRequestCliError> => { | ||
| 581 | // A continuation uses GitLab's offset pagination. Its timestamp filter is inclusive and has | ||
| 582 | // no tie-breaker, so a page where many rows share the boundary would otherwise return the | ||
| 583 | // same prefix forever. `delivered` is the stable offset the service has already handed over. | ||
| 584 | const delivered = input.cursor?.delivered ?? 0; | ||
| 585 | const perPage = Math.min(input.limit + 1, MAX_PAGE_SIZE); | ||
| 586 | const firstPage = Math.floor(delivered / perPage) + 1; | ||
| 587 | const skipOnFirstPage = input.page === firstPage ? delivered % perPage : 0; | ||
| 588 | // A page made entirely of malformed rows has no item from which the service can build a | ||
| 589 | // continuation. Bound the walk to the raw span this request asked for rather than recursing | ||
| 590 | // forever on a host that keeps returning full unusable pages. | ||
| 591 | const lastPage = Math.floor((delivered + input.limit) / perPage) + 1; | ||
| 592 | return api({ | ||
| 593 | cwd: input.cwd, | ||
| 594 | path: `projects/${projectPath(input.repository)}/merge_requests?${query([ | ||
| 595 | ["state", stateParam(input.state)], | ||
| 596 | ...involvementParams(input), | ||
| 597 | // The listing is read through `glab api` rather than `glab mr list`, so the search is | ||
| 598 | // the REST API's own `search` parameter — the one `mr list --search` passes on. It | ||
| 599 | // matches title and description, and travels URL-encoded like every other value here, | ||
| 600 | // so no text in it can become a parameter of its own. | ||
| 601 | ...searchParams(input.query), | ||
| 602 | ["order_by", "updated_at"], | ||
| 603 | ["sort", "desc"], | ||
| 604 | ["per_page", String(perPage)], | ||
| 605 | ["page", String(input.page)], | ||
| 606 | ])}`, | ||
| 607 | }).pipe( | ||
| 608 | Effect.flatMap((result) => { | ||
| 609 | const raw = result.stdout.trim(); | ||
| 610 | if (raw.length === 0) { | ||
| 611 | return Effect.succeed({ | ||
| 612 | items: input.collected, | ||
| 613 | truncated: false, | ||
| 614 | cursorAdvance: input.cursorAdvance, | ||
| 615 | }); | ||
| 616 | } | ||
| 617 | const decoded = decodeMergeRequestListJson(raw); | ||
| 618 | if (!Result.isSuccess(decoded)) { | ||
| 619 | return Effect.fail( | ||
| 620 | new GitLabMergeRequestReadError({ | ||
| 621 | command: "glab", | ||
| 622 | cwd: input.cwd, | ||
| 623 | operation: "listMergeRequests", | ||
| 624 | cause: decoded.failure, | ||
| 625 | }), | ||
| 626 | ); | ||
| 627 | } | ||
| 628 | const pageItems: GitLabMergeRequestListItem[] = []; | ||
| 629 | const pageRawIndexes: number[] = []; | ||
| 630 | for (const [index, item] of decoded.success.items.entries()) { | ||
| 631 | const rawIndex = decoded.success.rawIndexes[index]!; | ||
| 632 | if (rawIndex < skipOnFirstPage) continue; | ||
| 633 | pageItems.push(item); | ||
| 634 | pageRawIndexes.push(rawIndex); | ||
| 635 | } | ||
| 636 | const remaining = input.limit - input.collected.length; | ||
| 637 | const lastItemRawIndex = pageRawIndexes[remaining - 1]; | ||
| 638 | if (lastItemRawIndex !== undefined) { | ||
| 639 | const consumed = lastItemRawIndex + 1 - skipOnFirstPage; | ||
| 640 | return Effect.succeed({ | ||
| 641 | items: [...input.collected, ...pageItems.slice(0, remaining)], | ||
| 642 | truncated: | ||
| 643 | lastItemRawIndex + 1 < decoded.success.rawCount || | ||
| 644 | decoded.success.rawCount === perPage, | ||
| 645 | cursorAdvance: input.cursorAdvance + consumed, | ||
| 646 | }); | ||
| 647 | } | ||
| 648 | const collected = [...input.collected, ...pageItems]; | ||
| 649 | const consumed = Math.max(0, decoded.success.rawCount - skipOnFirstPage); | ||
| 650 | // Counted before decoding, so a skipped malformed row cannot end paging early. | ||
| 651 | const exhausted = decoded.success.rawCount < perPage; | ||
| 652 | if (exhausted) { | ||
| 653 | return Effect.succeed({ | ||
| 654 | items: collected, | ||
| 655 | truncated: false, | ||
| 656 | cursorAdvance: input.cursorAdvance + consumed, | ||
| 657 | }); | ||
| 658 | } | ||
| 659 | if (input.page >= lastPage) { | ||
| 660 | return Effect.succeed({ | ||
| 661 | items: collected, | ||
| 662 | truncated: true, | ||
| 663 | cursorAdvance: input.cursorAdvance + consumed, | ||
| 664 | }); | ||
| 665 | } | ||
| 666 | return listPage({ | ||
| 667 | ...input, | ||
| 668 | page: input.page + 1, | ||
| 669 | collected, | ||
| 670 | cursorAdvance: input.cursorAdvance + consumed, | ||
| 671 | }); | ||
| 672 | }), | ||
| 673 | ); | ||
| 674 | }; | ||
| 675 | |||
| 676 | /** | ||
| 677 | * One page of a merge request's files, as a patch that stands on its own. GitLab pages | ||
| 678 | * `/diffs` by offset and has no cursor of its own, so the page number is the cursor; the | ||
| 679 | * caller carries on from it for as long as GitLab keeps handing full pages back. | ||
| 680 | * | ||
| 681 | * A named commit is read from the commit's own diff, which answers in the same shape and pages | ||
| 682 | * the same way. | ||
| 683 | */ | ||
| 684 | const diffPage = (input: { | ||
| 685 | readonly cwd: string; | ||
| 686 | readonly repository: string; | ||
| 687 | readonly number: number; | ||
| 688 | readonly page: number; | ||
| 689 | readonly commit?: string | undefined; | ||
| 690 | }): Effect.Effect<GitLabMergeRequestDiffSlice, GitLabPullRequestCliError> => | ||
| 691 | api({ | ||
| 692 | cwd: input.cwd, | ||
| 693 | path: `projects/${projectPath(input.repository)}/${ | ||
| 694 | input.commit === undefined | ||
| 695 | ? `merge_requests/${input.number}/diffs` | ||
| 696 | : `repository/commits/${input.commit}/diff` | ||
| 697 | }?${query([ | ||
| 698 | ["per_page", String(MAX_PAGE_SIZE)], | ||
| 699 | ["page", String(input.page)], | ||
| 700 | ])}`, | ||
| 701 | maxOutputBytes: DIFF_MAX_OUTPUT_BYTES, | ||
| 702 | timeoutMs: DIFF_TIMEOUT_MS, | ||
| 703 | }).pipe( | ||
| 704 | Effect.flatMap((result) => { | ||
| 705 | // A byte-truncated response is a JSON prefix, so this page cannot be read at all. | ||
| 706 | // Answering with no cursor would call the diff whole while silently dropping this page | ||
| 707 | // and every one after it, so the read fails and says which page could not be had. | ||
| 708 | if (result.stdoutTruncated) { | ||
| 709 | return Effect.fail( | ||
| 710 | new GitLabMergeRequestReadError({ | ||
| 711 | command: "glab", | ||
| 712 | cwd: input.cwd, | ||
| 713 | operation: "getMergeRequestDiff", | ||
| 714 | cause: new Error( | ||
| 715 | `Page ${input.page} of the merge request diff was too large to read.`, | ||
| 716 | ), | ||
| 717 | }), | ||
| 718 | ); | ||
| 719 | } | ||
| 720 | const decoded = decodeMergeRequestDiffsJson(result.stdout.trim()); | ||
| 721 | if (!Result.isSuccess(decoded)) { | ||
| 722 | return Effect.fail( | ||
| 723 | new GitLabMergeRequestReadError({ | ||
| 724 | command: "glab", | ||
| 725 | cwd: input.cwd, | ||
| 726 | operation: "getMergeRequestDiff", | ||
| 727 | cause: decoded.failure, | ||
| 728 | }), | ||
| 729 | ); | ||
| 730 | } | ||
| 731 | const patch = decoded.success.patch; | ||
| 732 | // Counted before decoding, so a page whose files all failed to decode still moves on | ||
| 733 | // rather than pointing the reader back at the page it just read. | ||
| 734 | const morePages = decoded.success.rawCount >= MAX_PAGE_SIZE; | ||
| 735 | return Effect.succeed({ | ||
| 736 | // The slice ends on a newline, so a file GitLab gave a header and no hunks for does | ||
| 737 | // not run into the first line of the next slice. | ||
| 738 | patch: patch.length === 0 ? patch : patch.replace(/\n?$/, "\n"), | ||
| 739 | truncated: decoded.success.truncated, | ||
| 740 | nextCursor: morePages ? String(input.page + 1) : null, | ||
| 741 | }); | ||
| 742 | }), | ||
| 743 | ); | ||
| 744 | |||
| 745 | /** | ||
| 746 | * The conversation, a page at a time. GitLab pages by offset and reports no total, so a short | ||
| 747 | * page is the only thing that says it is done — and the raw count decides, not the kept one: | ||
| 748 | * the notes GitLab wrote itself are dropped, and a whole page of them still means there is | ||
| 749 | * more to read. | ||
| 750 | */ | ||
| 751 | const notesPage = (input: { | ||
| 752 | readonly cwd: string; | ||
| 753 | readonly repository: string; | ||
| 754 | readonly number: number; | ||
| 755 | readonly page: number; | ||
| 756 | readonly collected: ReadonlyArray<PullRequestComment>; | ||
| 757 | }): Effect.Effect< | ||
| 758 | { readonly comments: ReadonlyArray<PullRequestComment>; readonly truncated: boolean }, | ||
| 759 | GitLabPullRequestCliError | ||
| 760 | > => | ||
| 761 | api({ | ||
| 762 | cwd: input.cwd, | ||
| 763 | path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/notes?${query( | ||
| 764 | [ | ||
| 765 | ["per_page", String(MAX_PAGE_SIZE)], | ||
| 766 | ["page", String(input.page)], | ||
| 767 | ["order_by", "created_at"], | ||
| 768 | ["sort", "asc"], | ||
| 769 | ], | ||
| 770 | )}`, | ||
| 771 | }).pipe( | ||
| 772 | Effect.flatMap((result) => { | ||
| 773 | const decoded = decodeNotesJson(result.stdout.trim()); | ||
| 774 | if (!Result.isSuccess(decoded)) { | ||
| 775 | return Effect.fail( | ||
| 776 | new GitLabMergeRequestReadError({ | ||
| 777 | command: "glab", | ||
| 778 | cwd: input.cwd, | ||
| 779 | operation: "listNotes", | ||
| 780 | cause: decoded.failure, | ||
| 781 | }), | ||
| 782 | ); | ||
| 783 | } | ||
| 784 | const collected = [...input.collected, ...decoded.success.comments]; | ||
| 785 | if (decoded.success.rawCount < MAX_PAGE_SIZE) { | ||
| 786 | return Effect.succeed({ comments: collected, truncated: false }); | ||
| 787 | } | ||
| 788 | return input.page >= CONVERSATION_PAGES | ||
| 789 | ? Effect.succeed({ comments: collected, truncated: true }) | ||
| 790 | : notesPage({ ...input, page: input.page + 1, collected }); | ||
| 791 | }), | ||
| 792 | ); | ||
| 793 | |||
| 794 | /** The positioned discussions, walked the same way and stopped by the same bound. */ | ||
| 795 | const discussionsPage = (input: { | ||
| 796 | readonly cwd: string; | ||
| 797 | readonly repository: string; | ||
| 798 | readonly number: number; | ||
| 799 | readonly page: number; | ||
| 800 | readonly collected: ReadonlyArray<PullRequestReviewThread>; | ||
| 801 | }): Effect.Effect< | ||
| 802 | { readonly threads: ReadonlyArray<PullRequestReviewThread>; readonly truncated: boolean }, | ||
| 803 | GitLabPullRequestCliError | ||
| 804 | > => | ||
| 805 | api({ | ||
| 806 | cwd: input.cwd, | ||
| 807 | path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/discussions?${query( | ||
| 808 | [ | ||
| 809 | ["per_page", String(MAX_PAGE_SIZE)], | ||
| 810 | ["page", String(input.page)], | ||
| 811 | ], | ||
| 812 | )}`, | ||
| 813 | }).pipe( | ||
| 814 | Effect.flatMap((result) => { | ||
| 815 | const decoded = decodeDiscussionsJson(result.stdout.trim()); | ||
| 816 | if (!Result.isSuccess(decoded)) { | ||
| 817 | return Effect.fail( | ||
| 818 | new GitLabMergeRequestReadError({ | ||
| 819 | command: "glab", | ||
| 820 | cwd: input.cwd, | ||
| 821 | operation: "listDiscussions", | ||
| 822 | cause: decoded.failure, | ||
| 823 | }), | ||
| 824 | ); | ||
| 825 | } | ||
| 826 | const collected = [...input.collected, ...decoded.success.threads]; | ||
| 827 | // The raw count again: this endpoint returns the plain notes too, so a full page of | ||
| 828 | // those is not the end of the positioned ones. | ||
| 829 | if (decoded.success.rawCount < MAX_PAGE_SIZE) { | ||
| 830 | return Effect.succeed({ threads: collected, truncated: false }); | ||
| 831 | } | ||
| 832 | return input.page >= CONVERSATION_PAGES | ||
| 833 | ? Effect.succeed({ threads: collected, truncated: true }) | ||
| 834 | : discussionsPage({ ...input, page: input.page + 1, collected }); | ||
| 835 | }), | ||
| 836 | ); | ||
| 837 | |||
| 838 | /** | ||
| 839 | * The revisions a positioned comment is written against. GitLab resolves a comment's line | ||
| 840 | * against these three shas, so a review with line comments cannot be sent without them. | ||
| 841 | */ | ||
| 842 | const getDiffRefs = (input: { | ||
| 843 | readonly cwd: string; | ||
| 844 | readonly repository: string; | ||
| 845 | readonly number: number; | ||
| 846 | }): Effect.Effect<GitLabDiffRefs, GitLabPullRequestCliError> => | ||
| 847 | api({ | ||
| 848 | cwd: input.cwd, | ||
| 849 | path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}`, | ||
| 850 | }).pipe( | ||
| 851 | Effect.flatMap((result): Effect.Effect<GitLabDiffRefs, GitLabPullRequestCliError> => { | ||
| 852 | const decoded = decodeDiffRefsJson(result.stdout.trim()); | ||
| 853 | if (!Result.isSuccess(decoded)) { | ||
| 854 | return Effect.fail( | ||
| 855 | new GitLabMergeRequestReadError({ | ||
| 856 | command: "glab", | ||
| 857 | cwd: input.cwd, | ||
| 858 | operation: "getDiffRefs", | ||
| 859 | cause: decoded.failure, | ||
| 860 | }), | ||
| 861 | ); | ||
| 862 | } | ||
| 863 | // A merge request with no diff refs is a well-formed answer that cannot carry a | ||
| 864 | // positioned comment — a dead end, but not something that failed to be read. | ||
| 865 | return decoded.success === null | ||
| 866 | ? Effect.fail( | ||
| 867 | new GitLabDiffRefsUnavailableError({ | ||
| 868 | command: "glab", | ||
| 869 | cwd: input.cwd, | ||
| 870 | number: input.number, | ||
| 871 | }), | ||
| 872 | ) | ||
| 873 | : Effect.succeed(decoded.success); | ||
| 874 | }), | ||
| 875 | ); | ||
| 876 | |||
| 877 | const getCommitDiffRefs = (input: { | ||
| 878 | readonly cwd: string; | ||
| 879 | readonly repository: string; | ||
| 880 | readonly commit: string; | ||
| 881 | readonly allowRoot: boolean; | ||
| 882 | }): Effect.Effect<GitLabDiffRefs, GitLabPullRequestCliError> => | ||
| 883 | api({ | ||
| 884 | cwd: input.cwd, | ||
| 885 | path: `projects/${projectPath(input.repository)}/repository/commits/${input.commit}`, | ||
| 886 | }).pipe( | ||
| 887 | Effect.flatMap((result): Effect.Effect<GitLabDiffRefs, GitLabPullRequestCliError> => { | ||
| 888 | const decoded = decodeCommitDiffRefsJson(result.stdout.trim()); | ||
| 889 | if (!Result.isSuccess(decoded)) { | ||
| 890 | return Effect.fail( | ||
| 891 | new GitLabMergeRequestReadError({ | ||
| 892 | command: "glab", | ||
| 893 | cwd: input.cwd, | ||
| 894 | operation: "getMergeRequestDiffFileContents", | ||
| 895 | cause: decoded.failure, | ||
| 896 | }), | ||
| 897 | ); | ||
| 898 | } | ||
| 899 | return decoded.success === null | ||
| 900 | ? input.allowRoot | ||
| 901 | ? Effect.succeed({ | ||
| 902 | baseSha: "", | ||
| 903 | headSha: input.commit, | ||
| 904 | startSha: "", | ||
| 905 | }) | ||
| 906 | : Effect.fail( | ||
| 907 | new GitLabDiffCommitParentUnavailableError({ | ||
| 908 | command: "glab", | ||
| 909 | cwd: input.cwd, | ||
| 910 | commit: input.commit, | ||
| 911 | }), | ||
| 912 | ) | ||
| 913 | : Effect.succeed(decoded.success); | ||
| 914 | }), | ||
| 915 | ); | ||
| 916 | |||
| 917 | /** | ||
| 918 | * The merge request itself, which several calls need for different parts of it: the detail for | ||
| 919 | * everything, and the reviewer paths for the ids GitLab writes a reviewer set with. | ||
| 920 | */ | ||
| 921 | const mergeRequestDetail = (input: { | ||
| 922 | readonly cwd: string; | ||
| 923 | readonly repository: string; | ||
| 924 | readonly number: number; | ||
| 925 | }): Effect.Effect<GitLabMergeRequestDetail, GitLabPullRequestCliError> => | ||
| 926 | api({ | ||
| 927 | cwd: input.cwd, | ||
| 928 | // How far behind the target branch this one is comes only when asked for by name, and it | ||
| 929 | // is asked for here rather than on a second read because it is the same merge request. | ||
| 930 | path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}?${query([ | ||
| 931 | ["include_diverged_commits_count", "true"], | ||
| 932 | ])}`, | ||
| 933 | }).pipe( | ||
| 934 | Effect.flatMap((result) => { | ||
| 935 | const decoded = decodeMergeRequestDetailJson(result.stdout.trim()); | ||
| 936 | return Result.isSuccess(decoded) | ||
| 937 | ? Effect.succeed(decoded.success) | ||
| 938 | : Effect.fail( | ||
| 939 | new GitLabMergeRequestReadError({ | ||
| 940 | command: "glab", | ||
| 941 | cwd: input.cwd, | ||
| 942 | operation: "getMergeRequestDetail", | ||
| 943 | cause: decoded.failure, | ||
| 944 | }), | ||
| 945 | ); | ||
| 946 | }), | ||
| 947 | ); | ||
| 948 | |||
| 949 | /** The people with access to the project, one page deep. */ | ||
| 950 | const projectUsers = (input: { | ||
| 951 | readonly cwd: string; | ||
| 952 | readonly repository: string; | ||
| 953 | }): Effect.Effect<GitLabProjectUsers, GitLabPullRequestCliError> => | ||
| 954 | api({ | ||
| 955 | cwd: input.cwd, | ||
| 956 | path: `projects/${projectPath(input.repository)}/users?${query([ | ||
| 957 | ["per_page", String(MAX_PAGE_SIZE)], | ||
| 958 | ])}`, | ||
| 959 | }).pipe( | ||
| 960 | Effect.flatMap((result) => { | ||
| 961 | const decoded = decodeProjectUsersJson(result.stdout.trim()); | ||
| 962 | return Result.isSuccess(decoded) | ||
| 963 | ? Effect.succeed(decoded.success) | ||
| 964 | : Effect.fail( | ||
| 965 | new GitLabMergeRequestReadError({ | ||
| 966 | command: "glab", | ||
| 967 | cwd: input.cwd, | ||
| 968 | operation: "listReviewerCandidates", | ||
| 969 | cause: decoded.failure, | ||
| 970 | }), | ||
| 971 | ); | ||
| 972 | }), | ||
| 973 | ); | ||
| 974 | |||
| 975 | /** Where an award is written: a note of the merge request, or the merge request itself. */ | ||
| 976 | const awardSubjectPath = (input: { | ||
| 977 | readonly repository: string; | ||
| 978 | readonly number: number; | ||
| 979 | readonly noteId?: string | undefined; | ||
| 980 | }) => { | ||
| 981 | const mergeRequest = `projects/${projectPath(input.repository)}/merge_requests/${input.number}`; | ||
| 982 | return input.noteId === undefined | ||
| 983 | ? `${mergeRequest}/award_emoji` | ||
| 984 | : `${mergeRequest}/notes/${encodeURIComponent(input.noteId)}/award_emoji`; | ||
| 985 | }; | ||
| 986 | |||
| 987 | /** | ||
| 988 | * The awards on the merge request and its notes, a page of notes at a time. Bounded by the same | ||
| 989 | * count as the conversation itself: awards past the notes that were read belong to notes the | ||
| 990 | * page is not showing. | ||
| 991 | */ | ||
| 992 | const awardsPage = (input: { | ||
| 993 | readonly cwd: string; | ||
| 994 | readonly repository: string; | ||
| 995 | readonly number: number; | ||
| 996 | readonly cursor: string | null; | ||
| 997 | readonly page: number; | ||
| 998 | readonly collected: { | ||
| 999 | readonly reactions: ReadonlyArray<PullRequestReaction>; | ||
| 1000 | readonly reactionsByNoteId: Map<string, ReadonlyArray<PullRequestReaction>>; | ||
| 1001 | } | null; | ||
| 1002 | }): Effect.Effect< | ||
| 1003 | { | ||
| 1004 | readonly reactions: ReadonlyArray<PullRequestReaction>; | ||
| 1005 | readonly reactionsByNoteId: ReadonlyMap<string, ReadonlyArray<PullRequestReaction>>; | ||
| 1006 | }, | ||
| 1007 | GitLabPullRequestCliError | ||
| 1008 | > => | ||
| 1009 | api({ | ||
| 1010 | cwd: input.cwd, | ||
| 1011 | path: "graphql", | ||
| 1012 | method: "POST", | ||
| 1013 | stdin: JSON.stringify({ | ||
| 1014 | query: AWARD_EMOJI_GRAPHQL_QUERY, | ||
| 1015 | variables: { | ||
| 1016 | fullPath: input.repository, | ||
| 1017 | iid: String(input.number), | ||
| 1018 | cursor: input.cursor, | ||
| 1019 | }, | ||
| 1020 | }), | ||
| 1021 | }).pipe( | ||
| 1022 | Effect.flatMap((result) => { | ||
| 1023 | const decoded = decodeAwardEmojiJson(result.stdout.trim()); | ||
| 1024 | if (!Result.isSuccess(decoded)) { | ||
| 1025 | return Effect.fail( | ||
| 1026 | new GitLabMergeRequestReadError({ | ||
| 1027 | command: "glab", | ||
| 1028 | cwd: input.cwd, | ||
| 1029 | operation: "listReactions", | ||
| 1030 | cause: decoded.failure, | ||
| 1031 | }), | ||
| 1032 | ); | ||
| 1033 | } | ||
| 1034 | const collected = input.collected ?? { | ||
| 1035 | reactions: decoded.success.reactions, | ||
| 1036 | reactionsByNoteId: new Map<string, ReadonlyArray<PullRequestReaction>>(), | ||
| 1037 | }; | ||
| 1038 | for (const [id, reactions] of decoded.success.reactionsByNoteId) | ||
| 1039 | collected.reactionsByNoteId.set(id, reactions); | ||
| 1040 | return decoded.success.nextCursor === null || input.page >= CONVERSATION_PAGES | ||
| 1041 | ? Effect.succeed(collected) | ||
| 1042 | : awardsPage({ | ||
| 1043 | ...input, | ||
| 1044 | cursor: decoded.success.nextCursor, | ||
| 1045 | page: input.page + 1, | ||
| 1046 | collected, | ||
| 1047 | }); | ||
| 1048 | }), | ||
| 1049 | ); | ||
| 1050 | |||
| 1051 | /** | ||
| 1052 | * GitLab charges the blobs query by how many paths it is handed, and its connection hands back | ||
| 1053 | * one page. A hundred at a time keeps each request inside both. | ||
| 1054 | */ | ||
| 1055 | const BLOB_PATHS_PER_REQUEST = 100; | ||
| 1056 | |||
| 1057 | const blobsAt = (input: { | ||
| 1058 | readonly cwd: string; | ||
| 1059 | readonly repository: string; | ||
| 1060 | readonly ref: string; | ||
| 1061 | readonly paths: ReadonlyArray<string>; | ||
| 1062 | }): Effect.Effect<ReadonlyMap<string, string> | null, GitLabPullRequestCliError> => | ||
| 1063 | api({ | ||
| 1064 | cwd: input.cwd, | ||
| 1065 | path: "graphql", | ||
| 1066 | method: "POST", | ||
| 1067 | stdin: JSON.stringify({ | ||
| 1068 | query: REPOSITORY_BLOBS_GRAPHQL_QUERY, | ||
| 1069 | variables: { fullPath: input.repository, ref: input.ref, paths: input.paths }, | ||
| 1070 | }), | ||
| 1071 | }).pipe( | ||
| 1072 | Effect.flatMap( | ||
| 1073 | (result): Effect.Effect<ReadonlyMap<string, string> | null, GitLabPullRequestCliError> => { | ||
| 1074 | const decoded = decodeRepositoryBlobsJson(result.stdout.trim()); | ||
| 1075 | return Result.isSuccess(decoded) | ||
| 1076 | ? Effect.succeed(decoded.success) | ||
| 1077 | : Effect.fail( | ||
| 1078 | new GitLabMergeRequestReadError({ | ||
| 1079 | command: "glab", | ||
| 1080 | cwd: input.cwd, | ||
| 1081 | operation: "getFileRevisions", | ||
| 1082 | cause: decoded.failure, | ||
| 1083 | }), | ||
| 1084 | ); | ||
| 1085 | }, | ||
| 1086 | ), | ||
| 1087 | ); | ||
| 1088 | |||
| 1089 | const fileRevisions = (input: { | ||
| 1090 | readonly cwd: string; | ||
| 1091 | readonly repository: string; | ||
| 1092 | readonly number: number; | ||
| 1093 | readonly paths: ReadonlyArray<string>; | ||
| 1094 | }): Effect.Effect<ReadonlyMap<string, string>, GitLabPullRequestCliError> => | ||
| 1095 | input.paths.length === 0 | ||
| 1096 | ? Effect.succeed(new Map()) | ||
| 1097 | : getDiffRefs(input).pipe( | ||
| 1098 | Effect.flatMap((refs) => { | ||
| 1099 | const batches: Array<ReadonlyArray<string>> = []; | ||
| 1100 | for (let at = 0; at < input.paths.length; at += BLOB_PATHS_PER_REQUEST) { | ||
| 1101 | batches.push(input.paths.slice(at, at + BLOB_PATHS_PER_REQUEST)); | ||
| 1102 | } | ||
| 1103 | return Effect.forEach( | ||
| 1104 | batches, | ||
| 1105 | (paths) => | ||
| 1106 | blobsAt({ ...input, ref: refs.headSha, paths }).pipe( | ||
| 1107 | Effect.map((page) => ({ paths, page })), | ||
| 1108 | ), | ||
| 1109 | { concurrency: 2 }, | ||
| 1110 | ).pipe( | ||
| 1111 | Effect.map((pages) => { | ||
| 1112 | const revisions = new Map<string, string>(); | ||
| 1113 | for (const { paths, page } of pages) { | ||
| 1114 | // A batch GitLab did not answer says nothing about its paths, so they are left | ||
| 1115 | // out and the caller reads them as versions it could not learn, which leaves | ||
| 1116 | // the marks on them alone. Filling them in as removed would report every file | ||
| 1117 | // a reader has cleared as changed over a project the token cannot see. | ||
| 1118 | if (page === null) continue; | ||
| 1119 | for (const [path, oid] of page) revisions.set(path, oid); | ||
| 1120 | // Within a batch that was answered, every path was looked for at the head, so | ||
| 1121 | // one that is not there is one the merge request removed. Said as the empty | ||
| 1122 | // revision, which is an answer the caller can compare against and keep. | ||
| 1123 | for (const path of paths) { | ||
| 1124 | if (!revisions.has(path)) revisions.set(path, ""); | ||
| 1125 | } | ||
| 1126 | } | ||
| 1127 | return revisions as ReadonlyMap<string, string>; | ||
| 1128 | }), | ||
| 1129 | ); | ||
| 1130 | }), | ||
| 1131 | ); | ||
| 1132 | |||
| 1133 | const viewerUsername = (input: { readonly cwd: string }) => | ||
| 1134 | api({ cwd: input.cwd, path: "user" }).pipe( | ||
| 1135 | Effect.flatMap((result): Effect.Effect<string, GitLabPullRequestCliError> => { | ||
| 1136 | const decoded = decodeViewerJson(result.stdout.trim()); | ||
| 1137 | if (!Result.isSuccess(decoded)) { | ||
| 1138 | return Effect.fail( | ||
| 1139 | new GitLabMergeRequestReadError({ | ||
| 1140 | command: "glab", | ||
| 1141 | cwd: input.cwd, | ||
| 1142 | operation: "getViewerUsername", | ||
| 1143 | cause: decoded.failure, | ||
| 1144 | }), | ||
| 1145 | ); | ||
| 1146 | } | ||
| 1147 | return decoded.success === null | ||
| 1148 | ? Effect.fail(new GitLabViewerUnavailableError({ command: "glab", cwd: input.cwd })) | ||
| 1149 | : Effect.succeed(decoded.success); | ||
| 1150 | }), | ||
| 1151 | ); | ||
| 1152 | |||
| 1153 | return GitLabPullRequestCli.of({ | ||
| 1154 | getViewerUsername: viewerUsername, | ||
| 1155 | |||
| 1156 | listMergeRequests: (input) => { | ||
| 1157 | const perPage = Math.min(input.limit + 1, MAX_PAGE_SIZE); | ||
| 1158 | const page = Math.floor((input.cursor?.delivered ?? 0) / perPage) + 1; | ||
| 1159 | return listPage({ ...input, page, collected: [], cursorAdvance: 0 }); | ||
| 1160 | }, | ||
| 1161 | |||
| 1162 | getMergeRequestDetail: mergeRequestDetail, | ||
| 1163 | |||
| 1164 | listNotes: (input) => notesPage({ ...input, page: 1, collected: [] }), | ||
| 1165 | |||
| 1166 | listReactions: (input) => awardsPage({ ...input, cursor: null, page: 1, collected: null }), | ||
| 1167 | |||
| 1168 | getFileRevisions: fileRevisions, | ||
| 1169 | |||
| 1170 | setReaction: (input) => | ||
| 1171 | Effect.gen(function* () { | ||
| 1172 | const subject = awardSubjectPath(input); | ||
| 1173 | if (input.reacted) { | ||
| 1174 | yield* api({ | ||
| 1175 | cwd: input.cwd, | ||
| 1176 | path: `${subject}?${query([["name", gitLabAwardName(input.content)]])}`, | ||
| 1177 | method: "POST", | ||
| 1178 | }); | ||
| 1179 | return; | ||
| 1180 | } | ||
| 1181 | // GitLab deletes an award by its id and takes no emoji name there, so the reader's own | ||
| 1182 | // award of that name is looked up first. Nothing to delete is success: the reaction the | ||
| 1183 | // caller asked to take back is already gone. | ||
| 1184 | const viewer = yield* viewerUsername({ cwd: input.cwd }); | ||
| 1185 | const listed = yield* api({ cwd: input.cwd, path: subject }); | ||
| 1186 | const own = decodeOwnAwardIdJson(listed.stdout.trim(), { | ||
| 1187 | content: input.content, | ||
| 1188 | viewer, | ||
| 1189 | }); | ||
| 1190 | if (!Result.isSuccess(own)) { | ||
| 1191 | return yield* new GitLabMergeRequestReadError({ | ||
| 1192 | command: "glab", | ||
| 1193 | cwd: input.cwd, | ||
| 1194 | operation: "setReaction", | ||
| 1195 | cause: own.failure, | ||
| 1196 | }); | ||
| 1197 | } | ||
| 1198 | if (own.success === null) return; | ||
| 1199 | yield* api({ | ||
| 1200 | cwd: input.cwd, | ||
| 1201 | path: `${subject}/${own.success}`, | ||
| 1202 | method: "DELETE", | ||
| 1203 | }); | ||
| 1204 | }), | ||
| 1205 | |||
| 1206 | listCommits: (input) => | ||
| 1207 | api({ | ||
| 1208 | cwd: input.cwd, | ||
| 1209 | path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/commits?${query( | ||
| 1210 | [ | ||
| 1211 | ["per_page", String(COMMIT_PAGE_SIZE)], | ||
| 1212 | ["with_stats", "true"], | ||
| 1213 | ], | ||
| 1214 | )}`, | ||
| 1215 | }).pipe( | ||
| 1216 | Effect.flatMap((result) => { | ||
| 1217 | const decoded = decodeCommitsJson(result.stdout.trim()); | ||
| 1218 | return Result.isSuccess(decoded) | ||
| 1219 | ? Effect.succeed(decoded.success) | ||
| 1220 | : Effect.fail( | ||
| 1221 | new GitLabMergeRequestReadError({ | ||
| 1222 | command: "glab", | ||
| 1223 | cwd: input.cwd, | ||
| 1224 | operation: "listCommits", | ||
| 1225 | cause: decoded.failure, | ||
| 1226 | }), | ||
| 1227 | ); | ||
| 1228 | }), | ||
| 1229 | ), | ||
| 1230 | |||
| 1231 | getMergeRequestDiff: (input) => { | ||
| 1232 | if (input.commit !== undefined && !isCommitSha(input.commit)) { | ||
| 1233 | return Effect.fail(new GitLabDiffCommitError({ command: "glab", cwd: input.cwd })); | ||
| 1234 | } | ||
| 1235 | const target = { | ||
| 1236 | cwd: input.cwd, | ||
| 1237 | repository: input.repository, | ||
| 1238 | number: input.number, | ||
| 1239 | ...(input.commit === undefined ? {} : { commit: input.commit }), | ||
| 1240 | }; | ||
| 1241 | if (input.cursor === undefined) { | ||
| 1242 | return diffPage({ ...target, page: 1 }); | ||
| 1243 | } | ||
| 1244 | const page = diffCursorPage(input.cursor); | ||
| 1245 | return page === null | ||
| 1246 | ? Effect.fail(new GitLabDiffCursorError({ command: "glab", cwd: input.cwd })) | ||
| 1247 | : diffPage({ ...target, page }); | ||
| 1248 | }, | ||
| 1249 | |||
| 1250 | getMergeRequestDiffFileContents: (input) => | ||
| 1251 | Effect.gen(function* () { | ||
| 1252 | if (input.commit !== undefined && !isCommitSha(input.commit)) { | ||
| 1253 | return yield* new GitLabDiffCommitError({ command: "glab", cwd: input.cwd }); | ||
| 1254 | } | ||
| 1255 | const refs = yield* input.commit === undefined | ||
| 1256 | ? getDiffRefs(input) | ||
| 1257 | : getCommitDiffRefs({ | ||
| 1258 | cwd: input.cwd, | ||
| 1259 | repository: input.repository, | ||
| 1260 | commit: input.commit, | ||
| 1261 | allowRoot: input.changeType === "new", | ||
| 1262 | }); | ||
| 1263 | |||
| 1264 | const readFile = (revision: string, filePath: string) => | ||
| 1265 | api({ | ||
| 1266 | cwd: input.cwd, | ||
| 1267 | path: `projects/${projectPath(input.repository)}/repository/files/${encodeURIComponent( | ||
| 1268 | filePath, | ||
| 1269 | )}/raw?ref=${encodeURIComponent(revision)}`, | ||
| 1270 | maxOutputBytes: DIFF_FILE_MAX_OUTPUT_BYTES, | ||
| 1271 | timeoutMs: DIFF_TIMEOUT_MS, | ||
| 1272 | }).pipe( | ||
| 1273 | Effect.flatMap((result) => | ||
| 1274 | result.stdoutTruncated || | ||
| 1275 | result.stdout.includes("\0") || | ||
| 1276 | result.stdoutInvalidUtf8 === true | ||
| 1277 | ? Effect.fail( | ||
| 1278 | new GitLabDiffFileContentsUnavailableError({ | ||
| 1279 | command: "glab", | ||
| 1280 | cwd: input.cwd, | ||
| 1281 | path: filePath, | ||
| 1282 | reason: result.stdoutTruncated ? "oversized" : "binary", | ||
| 1283 | }), | ||
| 1284 | ) | ||
| 1285 | : Effect.succeed(result.stdout), | ||
| 1286 | ), | ||
| 1287 | ); | ||
| 1288 | |||
| 1289 | const [oldContents, newContents] = yield* Effect.all( | ||
| 1290 | [ | ||
| 1291 | input.changeType === "new" ? Effect.succeed("") : readFile(refs.baseSha, input.oldPath), | ||
| 1292 | input.changeType === "deleted" | ||
| 1293 | ? Effect.succeed("") | ||
| 1294 | : readFile(refs.headSha, input.newPath), | ||
| 1295 | ], | ||
| 1296 | { concurrency: 2 }, | ||
| 1297 | ); | ||
| 1298 | return { oldContents, newContents }; | ||
| 1299 | }), | ||
| 1300 | |||
| 1301 | getProjectMergeCapabilities: (input) => | ||
| 1302 | api({ | ||
| 1303 | cwd: input.cwd, | ||
| 1304 | path: `projects/${projectPath(input.repository)}?license=false`, | ||
| 1305 | }).pipe( | ||
| 1306 | Effect.flatMap((result) => { | ||
| 1307 | const decoded = decodeProjectMergeCapabilitiesJson(result.stdout.trim()); | ||
| 1308 | return Result.isSuccess(decoded) | ||
| 1309 | ? Effect.succeed(decoded.success) | ||
| 1310 | : Effect.fail( | ||
| 1311 | new GitLabMergeRequestReadError({ | ||
| 1312 | command: "glab", | ||
| 1313 | cwd: input.cwd, | ||
| 1314 | operation: "getProjectMergeCapabilities", | ||
| 1315 | cause: decoded.failure, | ||
| 1316 | }), | ||
| 1317 | ); | ||
| 1318 | }), | ||
| 1319 | ), | ||
| 1320 | |||
| 1321 | listReviewerCandidates: (input) => | ||
| 1322 | Effect.all([mergeRequestDetail(input), projectUsers(input)], { concurrency: 2 }).pipe( | ||
| 1323 | Effect.map(([mergeRequest, users]) => { | ||
| 1324 | const author = mergeRequest.author?.login; | ||
| 1325 | const requested = new Set(mergeRequest.reviewRequestLogins); | ||
| 1326 | return { | ||
| 1327 | // The author is dropped rather than shown unusable: GitLab refuses to make the person | ||
| 1328 | // who opened a merge request its reviewer. | ||
| 1329 | candidates: users.candidates.flatMap((candidate) => | ||
| 1330 | candidate.login === author | ||
| 1331 | ? [] | ||
| 1332 | : [{ ...candidate, isRequested: requested.has(candidate.login) }], | ||
| 1333 | ), | ||
| 1334 | truncated: users.rawCount >= MAX_PAGE_SIZE, | ||
| 1335 | }; | ||
| 1336 | }), | ||
| 1337 | ), | ||
| 1338 | |||
| 1339 | setReviewerRequest: (input) => | ||
| 1340 | mergeRequestDetail(input).pipe( | ||
| 1341 | Effect.flatMap((mergeRequest) => { | ||
| 1342 | // GitLab has no endpoint that adds or removes one reviewer: `reviewer_ids` replaces the | ||
| 1343 | // whole set, so the set that is already there is read first and the change applied to | ||
| 1344 | // it. Asking again for somebody already on it writes the same set back, which is how | ||
| 1345 | // GitLab re-requests a review. | ||
| 1346 | const ids = new Set(mergeRequest.reviewerIds); | ||
| 1347 | for (const reviewer of input.reviewers) { | ||
| 1348 | const id = Number(reviewer.id); | ||
| 1349 | // A candidate GitLab did not name is not an id it would accept, and sending it would | ||
| 1350 | // rewrite the reviewer set around a number nobody chose. | ||
| 1351 | if (!Number.isSafeInteger(id) || id <= 0) continue; | ||
| 1352 | if (input.requested) ids.add(id); | ||
| 1353 | else ids.delete(id); | ||
| 1354 | } | ||
| 1355 | return api({ | ||
| 1356 | cwd: input.cwd, | ||
| 1357 | path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}`, | ||
| 1358 | method: "PUT", | ||
| 1359 | stdin: JSON.stringify({ reviewer_ids: [...ids] }), | ||
| 1360 | }); | ||
| 1361 | }), | ||
| 1362 | Effect.asVoid, | ||
| 1363 | ), | ||
| 1364 | |||
| 1365 | runMergeRequestAction: (input) => { | ||
| 1366 | // `glab mr merge` arms auto-merge and never disarms it, so the one direction the CLI has | ||
| 1367 | // no flag for is asked of GitLab directly through the same `api` passthrough the rest of | ||
| 1368 | // this module writes with. | ||
| 1369 | if (input.action === "disable-auto-merge") { | ||
| 1370 | return api({ | ||
| 1371 | cwd: input.cwd, | ||
| 1372 | path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/cancel_merge_when_pipeline_succeeds`, | ||
| 1373 | method: "POST", | ||
| 1374 | }).pipe(Effect.asVoid); | ||
| 1375 | } | ||
| 1376 | const [subcommand, ...flags] = actionArgs(input.action, input.mergeMethod); | ||
| 1377 | return gitlab | ||
| 1378 | .execute({ | ||
| 1379 | cwd: input.cwd, | ||
| 1380 | args: ["mr", subcommand!, String(input.number), "--repo", input.repository, ...flags], | ||
| 1381 | }) | ||
| 1382 | .pipe(Effect.asVoid); | ||
| 1383 | }, | ||
| 1384 | |||
| 1385 | updateMergeRequest: (input) => | ||
| 1386 | api({ | ||
| 1387 | cwd: input.cwd, | ||
| 1388 | path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}`, | ||
| 1389 | method: "PUT", | ||
| 1390 | // Only the fields the caller asked to change: GitLab leaves out what it is not sent, and | ||
| 1391 | // clears what it is sent empty — so a title corrected on its own must carry no | ||
| 1392 | // description at all. | ||
| 1393 | stdin: JSON.stringify({ | ||
| 1394 | ...(input.title === undefined ? {} : { title: input.title }), | ||
| 1395 | ...(input.description === undefined ? {} : { description: input.description }), | ||
| 1396 | }), | ||
| 1397 | }).pipe(Effect.asVoid), | ||
| 1398 | |||
| 1399 | commentOnMergeRequest: (input) => | ||
| 1400 | api({ | ||
| 1401 | cwd: input.cwd, | ||
| 1402 | path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/notes`, | ||
| 1403 | method: "POST", | ||
| 1404 | // A JSON body rather than a `--raw-field`: glab coerces a field that reads as a | ||
| 1405 | // literal `true` or a number, and a comment body is text either way. | ||
| 1406 | stdin: JSON.stringify({ body: input.body }), | ||
| 1407 | }).pipe(Effect.asVoid), | ||
| 1408 | |||
| 1409 | updateNote: (input) => | ||
| 1410 | api({ | ||
| 1411 | cwd: input.cwd, | ||
| 1412 | path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/notes/${encodeURIComponent( | ||
| 1413 | input.noteId, | ||
| 1414 | )}`, | ||
| 1415 | method: "PUT", | ||
| 1416 | stdin: JSON.stringify({ body: input.body }), | ||
| 1417 | }).pipe(Effect.asVoid), | ||
| 1418 | |||
| 1419 | listDiscussions: (input) => discussionsPage({ ...input, page: 1, collected: [] }), | ||
| 1420 | |||
| 1421 | submitReview: (input) => | ||
| 1422 | Effect.gen(function* () { | ||
| 1423 | const project = projectPath(input.repository); | ||
| 1424 | const mergeRequest = `projects/${project}/merge_requests/${input.number}`; | ||
| 1425 | // GitLab has no pending review to attach comments to, so a review is replayed as the | ||
| 1426 | // requests it is made of: the line comments, then the summary, then the verdict. A | ||
| 1427 | // failure part-way therefore leaves what was already posted in place, which is why | ||
| 1428 | // the verdict goes last — a half-sent review is never an approval. | ||
| 1429 | if (input.comments.length > 0) { | ||
| 1430 | const refs = yield* getDiffRefs(input); | ||
| 1431 | yield* Effect.forEach( | ||
| 1432 | input.comments, | ||
| 1433 | (comment) => | ||
| 1434 | api({ | ||
| 1435 | cwd: input.cwd, | ||
| 1436 | path: `${mergeRequest}/discussions`, | ||
| 1437 | method: "POST", | ||
| 1438 | stdin: JSON.stringify({ | ||
| 1439 | body: comment.body, | ||
| 1440 | position: { | ||
| 1441 | base_sha: refs.baseSha, | ||
| 1442 | head_sha: refs.headSha, | ||
| 1443 | start_sha: refs.startSha, | ||
| 1444 | position_type: "text", | ||
| 1445 | // Both paths are sent because GitLab resolves a position against both | ||
| 1446 | // sides of the diff. They differ only for a renamed file, which is why the | ||
| 1447 | // draft carries the name the file had before the change. | ||
| 1448 | old_path: comment.oldPath ?? comment.path, | ||
| 1449 | new_path: comment.path, | ||
| 1450 | ...gitLabReviewPositionLines(comment.position), | ||
| 1451 | }, | ||
| 1452 | }), | ||
| 1453 | }), | ||
| 1454 | { discard: true }, | ||
| 1455 | ); | ||
| 1456 | } | ||
| 1457 | if (input.body.trim().length > 0) { | ||
| 1458 | yield* api({ | ||
| 1459 | cwd: input.cwd, | ||
| 1460 | path: `${mergeRequest}/notes`, | ||
| 1461 | method: "POST", | ||
| 1462 | // A JSON body rather than a `--raw-field`, for the reason the plain comment gives: | ||
| 1463 | // glab coerces a field that reads as a literal `true` or a number. | ||
| 1464 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 1465 | stdin: JSON.stringify({ body: input.body }), | ||
| 1466 | }); | ||
| 1467 | } | ||
| 1468 | if (input.verdict === "approve") { | ||
| 1469 | yield* api({ cwd: input.cwd, path: `${mergeRequest}/approve`, method: "POST" }); | ||
| 1470 | } | ||
| 1471 | }), | ||
| 1472 | |||
| 1473 | replyToDiscussion: (input) => | ||
| 1474 | api({ | ||
| 1475 | cwd: input.cwd, | ||
| 1476 | path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/discussions/${encodeURIComponent( | ||
| 1477 | input.discussionId, | ||
| 1478 | )}/notes`, | ||
| 1479 | method: "POST", | ||
| 1480 | stdin: JSON.stringify({ body: input.body }), | ||
| 1481 | }).pipe(Effect.asVoid), | ||
| 1482 | |||
| 1483 | setDiscussionResolution: (input) => | ||
| 1484 | api({ | ||
| 1485 | cwd: input.cwd, | ||
| 1486 | path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/discussions/${encodeURIComponent( | ||
| 1487 | input.discussionId, | ||
| 1488 | )}`, | ||
| 1489 | method: "PUT", | ||
| 1490 | stdin: JSON.stringify({ resolved: input.resolved }), | ||
| 1491 | }).pipe(Effect.asVoid), | ||
| 1492 | }); | ||
| 1493 | }); | ||
| 1494 | |||
| 1495 | export const layer = Layer.effect(GitLabPullRequestCli, make); | ||
packages/source-control-gitlab/src/server/GitLabPullRequestProvider.test.ts created+214| ... | @@ -0,0 +1,214 @@ | ||
| 1 | import { assert, describe, expect, it, vi } from "@effect/vitest"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Layer from "effect/Layer"; | ||
| 4 | |||
| 5 | import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts"; | ||
| 6 | import { gitLabViewerPermissions, make } from "./GitLabPullRequestProvider.ts"; | ||
| 7 | |||
| 8 | describe("gitLabViewerPermissions", () => { | ||
| 9 | it("offers everything to a viewer GitLab says can merge", () => { | ||
| 10 | expect(gitLabViewerPermissions({ viewerCanMerge: true })).toEqual({ | ||
| 11 | // Arming a merge for later and taking the arming back answer to the same `can_merge`. | ||
| 12 | actions: [ | ||
| 13 | "merge", | ||
| 14 | "ready", | ||
| 15 | "draft", | ||
| 16 | "close", | ||
| 17 | "reopen", | ||
| 18 | "update-branch", | ||
| 19 | "enable-auto-merge", | ||
| 20 | "disable-auto-merge", | ||
| 21 | ], | ||
| 22 | comment: true, | ||
| 23 | resolve: true, | ||
| 24 | verdicts: ["comment", "approve"], | ||
| 25 | // GitLab says nothing about who may set a reviewer, and an unreported permission is granted. | ||
| 26 | requestReviewers: true, | ||
| 27 | // Rebase and nothing else: GitLab cannot merge a target branch into a source branch, so | ||
| 28 | // offering the choice would be offering something no request could carry out. | ||
| 29 | updateMethods: ["rebase"], | ||
| 30 | }); | ||
| 31 | }); | ||
| 32 | |||
| 33 | it("keeps merge, now and later, from a viewer GitLab says cannot", () => { | ||
| 34 | // `user.can_merge` already accounts for the role, the approval rules and a protected target | ||
| 35 | // branch, so it is the one answer here that does not have to be inferred. | ||
| 36 | expect(gitLabViewerPermissions({ viewerCanMerge: false })).toEqual({ | ||
| 37 | actions: ["ready", "draft", "close", "reopen"], | ||
| 38 | comment: true, | ||
| 39 | resolve: true, | ||
| 40 | verdicts: ["comment", "approve"], | ||
| 41 | requestReviewers: true, | ||
| 42 | }); | ||
| 43 | }); | ||
| 44 | |||
| 45 | it("names no way of updating a branch it will not let this viewer update", () => { | ||
| 46 | // The action and the strategy behind it go together: a button offered with nothing to press | ||
| 47 | // it with, or a strategy left standing next to a withheld button, is a half-refusal. | ||
| 48 | expect(gitLabViewerPermissions({ viewerCanMerge: false }).updateMethods).toBeUndefined(); | ||
| 49 | }); | ||
| 50 | |||
| 51 | it("treats an author with read access as any other reader, which is all GitLab says", () => { | ||
| 52 | // Its REST API names no relationship between the viewer and the merge request beyond | ||
| 53 | // `can_merge`, so the four an author keeps stay offered to everyone rather than being taken | ||
| 54 | // from the one person entitled to them. | ||
| 55 | expect(gitLabViewerPermissions({ viewerCanMerge: false }).actions).toEqual([ | ||
| 56 | "ready", | ||
| 57 | "draft", | ||
| 58 | "close", | ||
| 59 | "reopen", | ||
| 60 | ]); | ||
| 61 | }); | ||
| 62 | }); | ||
| 63 | |||
| 64 | describe("getChangeRequest base freshness", () => { | ||
| 65 | const detail = { | ||
| 66 | number: 7, | ||
| 67 | title: "Merge request 7", | ||
| 68 | url: "https://gitlab.com/acme/web/-/merge_requests/7", | ||
| 69 | author: null, | ||
| 70 | headBranch: "feat/page", | ||
| 71 | baseBranch: "main", | ||
| 72 | state: "open" as const, | ||
| 73 | isDraft: false, | ||
| 74 | mergeability: "mergeable" as const, | ||
| 75 | additions: 0, | ||
| 76 | deletions: 0, | ||
| 77 | createdAt: "2026-07-01T00:00:00Z", | ||
| 78 | updatedAt: "2026-07-02T00:00:00Z", | ||
| 79 | reviewRequestLogins: [], | ||
| 80 | labels: [], | ||
| 81 | body: "", | ||
| 82 | changedFiles: 1, | ||
| 83 | mergedAt: null, | ||
| 84 | closedAt: null, | ||
| 85 | reviewers: [], | ||
| 86 | checks: [], | ||
| 87 | viewerCanMerge: true, | ||
| 88 | reviewerIds: [], | ||
| 89 | }; | ||
| 90 | |||
| 91 | it.effect("reads checks without fetching project settings", () => | ||
| 92 | Effect.gen(function* () { | ||
| 93 | const provider = yield* make.pipe( | ||
| 94 | Effect.provide( | ||
| 95 | Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({ | ||
| 96 | getMergeRequestDetail: () => Effect.succeed(detail), | ||
| 97 | }), | ||
| 98 | ), | ||
| 99 | ); | ||
| 100 | const read = provider.getChangeRequestChecks; | ||
| 101 | if (read === undefined) return yield* Effect.die("checks read missing"); | ||
| 102 | expect( | ||
| 103 | yield* read({ cwd: "/w", repository: "group/subgroup/web", host: "gitlab.com", number: 7 }), | ||
| 104 | ).toEqual({ state: "open", checks: [] }); | ||
| 105 | }), | ||
| 106 | ); | ||
| 107 | |||
| 108 | const readWith = (divergence: { readonly divergedCommits?: number }) => | ||
| 109 | Effect.gen(function* () { | ||
| 110 | const provider = yield* make; | ||
| 111 | return yield* provider.getChangeRequest({ | ||
| 112 | cwd: "/w", | ||
| 113 | repository: "acme/web", | ||
| 114 | host: "gitlab.com", | ||
| 115 | number: 7, | ||
| 116 | }); | ||
| 117 | }).pipe( | ||
| 118 | Effect.provide( | ||
| 119 | Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({ | ||
| 120 | getMergeRequestDetail: () => Effect.succeed({ ...detail, ...divergence }), | ||
| 121 | getProjectMergeCapabilities: () => | ||
| 122 | Effect.succeed({ merge: true, squash: true, rebase: true }), | ||
| 123 | }), | ||
| 124 | ), | ||
| 125 | ); | ||
| 126 | |||
| 127 | it.effect("reads a counted divergence as a branch that has fallen behind", () => | ||
| 128 | Effect.gen(function* () { | ||
| 129 | const changeRequest = yield* readWith({ divergedCommits: 3 }); | ||
| 130 | |||
| 131 | expect(changeRequest.baseComparison).toBe("behind"); | ||
| 132 | expect(changeRequest.behindBy).toBe(3); | ||
| 133 | }), | ||
| 134 | ); | ||
| 135 | |||
| 136 | it.effect("reads a divergence of none as a branch that is current", () => | ||
| 137 | Effect.gen(function* () { | ||
| 138 | const changeRequest = yield* readWith({ divergedCommits: 0 }); | ||
| 139 | |||
| 140 | expect(changeRequest.baseComparison).toBe("up-to-date"); | ||
| 141 | expect(changeRequest.behindBy).toBe(0); | ||
| 142 | }), | ||
| 143 | ); | ||
| 144 | |||
| 145 | it.effect("says nothing at all where GitLab counted nothing", () => | ||
| 146 | Effect.gen(function* () { | ||
| 147 | // An install too old to answer has to leave the page silent rather than let it claim the | ||
| 148 | // branch is current, which is the one wrong thing this banner could say. | ||
| 149 | const changeRequest = yield* readWith({}); | ||
| 150 | |||
| 151 | expect(changeRequest.baseComparison).toBe("unknown"); | ||
| 152 | expect(changeRequest.behindBy).toBeUndefined(); | ||
| 153 | }), | ||
| 154 | ); | ||
| 155 | }); | ||
| 156 | |||
| 157 | describe("rewriting what has already been said", () => { | ||
| 158 | const updateMergeRequest = vi.fn(() => Effect.void); | ||
| 159 | const updateNote = vi.fn(() => Effect.void); | ||
| 160 | |||
| 161 | const providerWith = make.pipe( | ||
| 162 | Effect.provide( | ||
| 163 | Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({ updateMergeRequest, updateNote }), | ||
| 164 | ), | ||
| 165 | ); | ||
| 166 | |||
| 167 | it.effect("sends only the half of the merge request the reader rewrote", () => | ||
| 168 | Effect.gen(function* () { | ||
| 169 | const provider = yield* providerWith; | ||
| 170 | assert.isDefined(provider.updateChangeRequest); | ||
| 171 | |||
| 172 | yield* provider.updateChangeRequest({ | ||
| 173 | cwd: "/w", | ||
| 174 | repository: "acme/web", | ||
| 175 | host: "gitlab.com", | ||
| 176 | number: 7, | ||
| 177 | body: "What this changes.", | ||
| 178 | }); | ||
| 179 | |||
| 180 | // GitLab calls it the description, and the title stays out of the request entirely. | ||
| 181 | expect(updateMergeRequest).toHaveBeenCalledWith({ | ||
| 182 | cwd: "/w", | ||
| 183 | repository: "acme/web", | ||
| 184 | number: 7, | ||
| 185 | description: "What this changes.", | ||
| 186 | }); | ||
| 187 | }), | ||
| 188 | ); | ||
| 189 | |||
| 190 | it.effect("rewrites a positioned comment through the same note as any other", () => | ||
| 191 | Effect.gen(function* () { | ||
| 192 | const provider = yield* providerWith; | ||
| 193 | assert.isDefined(provider.updateComment); | ||
| 194 | |||
| 195 | yield* provider.updateComment({ | ||
| 196 | cwd: "/w", | ||
| 197 | repository: "acme/web", | ||
| 198 | host: "gitlab.com", | ||
| 199 | number: 7, | ||
| 200 | commentId: "42", | ||
| 201 | kind: "review-comment", | ||
| 202 | body: "Reworded.", | ||
| 203 | }); | ||
| 204 | |||
| 205 | expect(updateNote).toHaveBeenCalledWith({ | ||
| 206 | cwd: "/w", | ||
| 207 | repository: "acme/web", | ||
| 208 | number: 7, | ||
| 209 | noteId: "42", | ||
| 210 | body: "Reworded.", | ||
| 211 | }); | ||
| 212 | }), | ||
| 213 | ); | ||
| 214 | }); | ||
packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts created+339| ... | @@ -0,0 +1,339 @@ | ||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import type { | ||
| 3 | PullRequestCapabilities, | ||
| 4 | PullRequestReaction, | ||
| 5 | PullRequestViewerPermissions, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | |||
| 8 | import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts"; | ||
| 9 | import { | ||
| 10 | PullRequestProviderError, | ||
| 11 | type PullRequestProviderFailure, | ||
| 12 | type ProviderChangeRequestActivity, | ||
| 13 | type ProviderChangeRequestDetail, | ||
| 14 | type PullRequestProviderApi, | ||
| 15 | } from "@t3tools/source-control-core/server/PullRequestProvider"; | ||
| 16 | |||
| 17 | const CAPABILITIES: PullRequestCapabilities = { | ||
| 18 | diff: true, | ||
| 19 | comment: true, | ||
| 20 | actions: [ | ||
| 21 | "merge", | ||
| 22 | "ready", | ||
| 23 | "draft", | ||
| 24 | "close", | ||
| 25 | "reopen", | ||
| 26 | "update-branch", | ||
| 27 | "enable-auto-merge", | ||
| 28 | "disable-auto-merge", | ||
| 29 | ], | ||
| 30 | // GitLab offers all three, though a project settles on one; `mergeCapabilities` narrows it. | ||
| 31 | mergeMethods: ["merge", "squash", "rebase"], | ||
| 32 | // Rebase alone: GitLab moves a stale branch onto its target by replaying it, and has nothing | ||
| 33 | // that merges the target back in the way GitHub's update button can. Declaring only what it | ||
| 34 | // does is what lets a request to merge the target in be refused instead of quietly rebasing. | ||
| 35 | updateMethods: ["rebase"], | ||
| 36 | search: true, | ||
| 37 | reactions: true, | ||
| 38 | // GitLab keeps a reader's viewed files in one browser's local storage, where nothing outside | ||
| 39 | // that browser can read or write them. So the marks made here are this environment's own: they | ||
| 40 | // follow the reader between the clients connected to it, but they are not the ones gitlab.com | ||
| 41 | // shows, and the surface says so rather than implying a review can be carried on from there. | ||
| 42 | viewedFiles: "environment", | ||
| 43 | review: { | ||
| 44 | inlineComment: true, | ||
| 45 | reply: true, | ||
| 46 | resolve: true, | ||
| 47 | // No "changes requested": GitLab has approval and unresolved discussions, and nothing that | ||
| 48 | // says a merge request has been reviewed and rejected. | ||
| 49 | verdicts: ["comment", "approve"], | ||
| 50 | }, | ||
| 51 | reviewers: { request: true, listCandidates: true }, | ||
| 52 | edit: { changeRequest: true, comment: true }, | ||
| 53 | }; | ||
| 54 | |||
| 55 | /** | ||
| 56 | * The actions `user.can_merge` answers for. Rebasing writes to the source branch rather than to | ||
| 57 | * the target, so it is not literally the same permission — but GitLab reports nothing narrower, | ||
| 58 | * and someone it will not let land this change has no business rewriting its branch either. | ||
| 59 | */ | ||
| 60 | const MERGE_ACTIONS: ReadonlySet<string> = new Set([ | ||
| 61 | "merge", | ||
| 62 | "update-branch", | ||
| 63 | "enable-auto-merge", | ||
| 64 | "disable-auto-merge", | ||
| 65 | ]); | ||
| 66 | |||
| 67 | /** | ||
| 68 | * What the signed-in account may do here. GitLab answers exactly one of these questions per | ||
| 69 | * viewer, on the merge request itself: `user.can_merge`, which is why merging is the only thing | ||
| 70 | * narrowed. | ||
| 71 | * | ||
| 72 | * The rest stay granted. GitLab's REST API reports the viewer's role on the project but never | ||
| 73 | * whether they opened this merge request — and its author may close it, reopen it and move it in | ||
| 74 | * and out of draft whatever their role, just as the author of a note may resolve the discussion | ||
| 75 | * it started. Withholding those controls from the one person entitled to them is the worse of the | ||
| 76 | * two mistakes, so they are offered and GitLab explains any refusal itself. | ||
| 77 | * | ||
| 78 | * Asking for a review is granted for the same reason: GitLab takes a reviewer set from the author | ||
| 79 | * and from anyone with the Developer role, and states neither of those two facts here. | ||
| 80 | */ | ||
| 81 | export function gitLabViewerPermissions(input: { | ||
| 82 | readonly viewerCanMerge: boolean; | ||
| 83 | }): PullRequestViewerPermissions { | ||
| 84 | return { | ||
| 85 | // Arming the merge and taking the arming back are the merge, deferred, so they answer to | ||
| 86 | // the same `can_merge` the merge itself does. | ||
| 87 | actions: CAPABILITIES.actions.filter( | ||
| 88 | (action) => !MERGE_ACTIONS.has(action) || input.viewerCanMerge, | ||
| 89 | ), | ||
| 90 | comment: true, | ||
| 91 | resolve: true, | ||
| 92 | verdicts: CAPABILITIES.review.verdicts, | ||
| 93 | requestReviewers: true, | ||
| 94 | ...(input.viewerCanMerge ? { updateMethods: CAPABILITIES.updateMethods } : {}), | ||
| 95 | }; | ||
| 96 | } | ||
| 97 | |||
| 98 | /** The CLI tags that mean the tool itself is unusable, rather than one request failing. */ | ||
| 99 | export function gitLabProviderFailure( | ||
| 100 | error: GitLabPullRequestCli.GitLabPullRequestCliError, | ||
| 101 | ): PullRequestProviderFailure { | ||
| 102 | if (error._tag === "GitLabCliUnavailableError") return { reason: "missing-tool" }; | ||
| 103 | if (error._tag === "GitLabCliAuthenticationError") return { reason: "unauthenticated" }; | ||
| 104 | if (error._tag === "GitLabCliRateLimitError") return { reason: "rate-limited" }; | ||
| 105 | if (error._tag === "GitLabMergeRequestNotFoundError") return { reason: "not-found" }; | ||
| 106 | return { reason: "failed" }; | ||
| 107 | } | ||
| 108 | |||
| 109 | export const make = Effect.gen(function* () { | ||
| 110 | const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; | ||
| 111 | |||
| 112 | const fail = (operation: string) => (error: GitLabPullRequestCli.GitLabPullRequestCliError) => | ||
| 113 | new PullRequestProviderError({ | ||
| 114 | provider: "gitlab", | ||
| 115 | operation, | ||
| 116 | ...gitLabProviderFailure(error), | ||
| 117 | detail: error.detail, | ||
| 118 | cause: error, | ||
| 119 | }); | ||
| 120 | |||
| 121 | const provider: PullRequestProviderApi = { | ||
| 122 | kind: "gitlab", | ||
| 123 | capabilities: CAPABILITIES, | ||
| 124 | |||
| 125 | getViewer: (input) => | ||
| 126 | cli.getViewerUsername({ cwd: input.cwd }).pipe(Effect.mapError(fail("getViewer"))), | ||
| 127 | |||
| 128 | listChangeRequests: (input) => | ||
| 129 | cli | ||
| 130 | .listMergeRequests({ | ||
| 131 | cwd: input.cwd, | ||
| 132 | repository: input.repository, | ||
| 133 | state: input.state, | ||
| 134 | involvement: input.involvement, | ||
| 135 | viewer: input.viewer, | ||
| 136 | limit: input.limit, | ||
| 137 | query: input.query, | ||
| 138 | cursor: input.cursor, | ||
| 139 | }) | ||
| 140 | .pipe( | ||
| 141 | Effect.mapError(fail("listChangeRequests")), | ||
| 142 | // GitLab is asked for its merge requests by update, newest first, whether or not it is | ||
| 143 | // being carried on from — so every page it answers is one a cursor can continue. | ||
| 144 | Effect.map((batch) => ({ ...batch, continues: true })), | ||
| 145 | ), | ||
| 146 | |||
| 147 | getChangeRequestChecks: (input) => | ||
| 148 | cli.getMergeRequestDetail(input).pipe( | ||
| 149 | Effect.map(({ state, checks }) => ({ state, checks })), | ||
| 150 | Effect.mapError(fail("getChangeRequestChecks")), | ||
| 151 | ), | ||
| 152 | |||
| 153 | getChangeRequest: (input) => | ||
| 154 | Effect.all( | ||
| 155 | [ | ||
| 156 | cli.getMergeRequestDetail(input), | ||
| 157 | cli.getProjectMergeCapabilities({ cwd: input.cwd, repository: input.repository }), | ||
| 158 | ], | ||
| 159 | { concurrency: 2 }, | ||
| 160 | ).pipe( | ||
| 161 | Effect.mapError(fail("getChangeRequest")), | ||
| 162 | Effect.map(([mergeRequest, mergeCapabilities]): ProviderChangeRequestDetail => ({ | ||
| 163 | ...mergeRequest, | ||
| 164 | mergeCapabilities, | ||
| 165 | viewerPermissions: gitLabViewerPermissions(mergeRequest), | ||
| 166 | // A GitLab too old to count the divergence says nothing here rather than "up to | ||
| 167 | // date": the banner is worth missing, and a wrong all-clear is not worth showing. | ||
| 168 | baseComparison: | ||
| 169 | mergeRequest.divergedCommits === undefined | ||
| 170 | ? "unknown" | ||
| 171 | : mergeRequest.divergedCommits > 0 | ||
| 172 | ? "behind" | ||
| 173 | : "up-to-date", | ||
| 174 | ...(mergeRequest.divergedCommits === undefined | ||
| 175 | ? {} | ||
| 176 | : { behindBy: mergeRequest.divergedCommits }), | ||
| 177 | })), | ||
| 178 | ), | ||
| 179 | |||
| 180 | getChangeRequestActivity: (input) => | ||
| 181 | Effect.all( | ||
| 182 | [ | ||
| 183 | cli | ||
| 184 | .listNotes(input) | ||
| 185 | .pipe(Effect.orElseSucceed(() => ({ comments: [], truncated: true }))), | ||
| 186 | cli.listCommits(input).pipe(Effect.orElseSucceed(() => [])), | ||
| 187 | cli | ||
| 188 | .listDiscussions(input) | ||
| 189 | .pipe(Effect.orElseSucceed(() => ({ threads: [], truncated: true }))), | ||
| 190 | // The notes endpoint carries no award of any kind, so they are read alongside it. A | ||
| 191 | // failed read costs the conversation its reactions rather than its words. | ||
| 192 | cli.listReactions(input).pipe( | ||
| 193 | Effect.orElseSucceed(() => ({ | ||
| 194 | reactions: [] as ReadonlyArray<PullRequestReaction>, | ||
| 195 | reactionsByNoteId: new Map<string, ReadonlyArray<PullRequestReaction>>(), | ||
| 196 | })), | ||
| 197 | ), | ||
| 198 | ], | ||
| 199 | { concurrency: 4 }, | ||
| 200 | ).pipe( | ||
| 201 | Effect.mapError(fail("getChangeRequestActivity")), | ||
| 202 | Effect.map(([notes, commits, discussions, awards]): ProviderChangeRequestActivity => ({ | ||
| 203 | reactions: awards.reactions, | ||
| 204 | comments: notes.comments.map((comment) => ({ | ||
| 205 | ...comment, | ||
| 206 | reactions: awards.reactionsByNoteId.get(comment.id) ?? [], | ||
| 207 | })), | ||
| 208 | // GitLab reports no count of its own, so the walk's own total is the host's: the | ||
| 209 | // notes endpoint carries every comment on the merge request, including the ones | ||
| 210 | // written under a discussion, and it is read until GitLab runs out. | ||
| 211 | commentCount: notes.comments.length, | ||
| 212 | commentsTruncated: notes.truncated || discussions.truncated, | ||
| 213 | reviewThreads: discussions.threads.map((thread) => ({ | ||
| 214 | ...thread, | ||
| 215 | comments: thread.comments.map((comment) => ({ | ||
| 216 | ...comment, | ||
| 217 | reactions: awards.reactionsByNoteId.get(comment.id) ?? [], | ||
| 218 | })), | ||
| 219 | })), | ||
| 220 | commits, | ||
| 221 | })), | ||
| 222 | ), | ||
| 223 | |||
| 224 | // The same read the detail takes it from, on its own: `user.can_merge` lives on the merge | ||
| 225 | // request, so there is no cheaper thing to ask GitLab. | ||
| 226 | getViewerPermissions: (input) => | ||
| 227 | cli | ||
| 228 | .getMergeRequestDetail(input) | ||
| 229 | .pipe(Effect.mapError(fail("getViewerPermissions")), Effect.map(gitLabViewerPermissions)), | ||
| 230 | |||
| 231 | getDiff: (input) => cli.getMergeRequestDiff(input).pipe(Effect.mapError(fail("getDiff"))), | ||
| 232 | |||
| 233 | // What each marked file is at the head, which is what tells a mark that still stands from one | ||
| 234 | // the branch has moved past. GitLab's own local-storage marks are keyed on the blob id too, | ||
| 235 | // so this stales at the same moment its web UI would. | ||
| 236 | getFileRevisions: (input) => | ||
| 237 | cli.getFileRevisions(input).pipe( | ||
| 238 | Effect.mapError(fail("getFileRevisions")), | ||
| 239 | Effect.map((revisions) => ({ revisions })), | ||
| 240 | ), | ||
| 241 | |||
| 242 | // Users only: GitLab requests a review of a person, and the groups that can stand in for one | ||
| 243 | // appear in approval rules rather than in a merge request's reviewers. | ||
| 244 | listReviewerCandidates: (input) => | ||
| 245 | cli | ||
| 246 | .listReviewerCandidates({ | ||
| 247 | cwd: input.cwd, | ||
| 248 | repository: input.repository, | ||
| 249 | number: input.number, | ||
| 250 | }) | ||
| 251 | .pipe(Effect.mapError(fail("listReviewerCandidates"))), | ||
| 252 | |||
| 253 | setReviewerRequest: (input) => | ||
| 254 | cli | ||
| 255 | .setReviewerRequest({ | ||
| 256 | cwd: input.cwd, | ||
| 257 | repository: input.repository, | ||
| 258 | number: input.number, | ||
| 259 | reviewers: input.reviewers, | ||
| 260 | requested: input.requested, | ||
| 261 | }) | ||
| 262 | .pipe(Effect.mapError(fail("setReviewerRequest"))), | ||
| 263 | |||
| 264 | runAction: (input) => | ||
| 265 | cli | ||
| 266 | .runMergeRequestAction({ | ||
| 267 | cwd: input.cwd, | ||
| 268 | repository: input.repository, | ||
| 269 | number: input.number, | ||
| 270 | action: input.action, | ||
| 271 | ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), | ||
| 272 | }) | ||
| 273 | .pipe(Effect.mapError(fail("runAction"))), | ||
| 274 | |||
| 275 | updateChangeRequest: (input) => | ||
| 276 | cli | ||
| 277 | .updateMergeRequest({ | ||
| 278 | cwd: input.cwd, | ||
| 279 | repository: input.repository, | ||
| 280 | number: input.number, | ||
| 281 | ...(input.title === undefined ? {} : { title: input.title }), | ||
| 282 | ...(input.body === undefined ? {} : { description: input.body }), | ||
| 283 | }) | ||
| 284 | .pipe(Effect.mapError(fail("updateChangeRequest"))), | ||
| 285 | |||
| 286 | comment: (input) => cli.commentOnMergeRequest(input).pipe(Effect.mapError(fail("comment"))), | ||
| 287 | |||
| 288 | // The kind is not read: every comment this provider hands out, positioned or not, carries a | ||
| 289 | // plain REST note id, and one endpoint rewrites both. | ||
| 290 | updateComment: (input) => | ||
| 291 | cli | ||
| 292 | .updateNote({ | ||
| 293 | cwd: input.cwd, | ||
| 294 | repository: input.repository, | ||
| 295 | number: input.number, | ||
| 296 | noteId: input.commentId, | ||
| 297 | body: input.body, | ||
| 298 | }) | ||
| 299 | .pipe(Effect.mapError(fail("updateComment"))), | ||
| 300 | |||
| 301 | submitReview: (input) => cli.submitReview(input).pipe(Effect.mapError(fail("submitReview"))), | ||
| 302 | |||
| 303 | replyToThread: (input) => | ||
| 304 | cli | ||
| 305 | .replyToDiscussion({ | ||
| 306 | cwd: input.cwd, | ||
| 307 | repository: input.repository, | ||
| 308 | number: input.number, | ||
| 309 | discussionId: input.threadId, | ||
| 310 | body: input.body, | ||
| 311 | }) | ||
| 312 | .pipe(Effect.mapError(fail("replyToThread"))), | ||
| 313 | |||
| 314 | setReaction: (input) => | ||
| 315 | cli | ||
| 316 | .setReaction({ | ||
| 317 | cwd: input.cwd, | ||
| 318 | repository: input.repository, | ||
| 319 | number: input.number, | ||
| 320 | ...(input.subjectId === undefined ? {} : { noteId: input.subjectId }), | ||
| 321 | content: input.content, | ||
| 322 | reacted: input.reacted, | ||
| 323 | }) | ||
| 324 | .pipe(Effect.mapError(fail("setReaction"))), | ||
| 325 | |||
| 326 | setThreadResolution: (input) => | ||
| 327 | cli | ||
| 328 | .setDiscussionResolution({ | ||
| 329 | cwd: input.cwd, | ||
| 330 | repository: input.repository, | ||
| 331 | number: input.number, | ||
| 332 | discussionId: input.threadId, | ||
| 333 | resolved: input.resolved, | ||
| 334 | }) | ||
| 335 | .pipe(Effect.mapError(fail("setThreadResolution"))), | ||
| 336 | }; | ||
| 337 | |||
| 338 | return provider; | ||
| 339 | }); | ||
packages/source-control-gitlab/src/server/GitLabSourceControlProvider.test.ts created+309| ... | @@ -0,0 +1,309 @@ | ||
| 1 | import { assert, it } from "@effect/vitest"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Layer from "effect/Layer"; | ||
| 4 | import * as Option from "effect/Option"; | ||
| 5 | import { ChildProcessSpawner } from "effect/process"; | ||
| 6 | |||
| 7 | import * as GitLabCli from "./GitLabCli.ts"; | ||
| 8 | import { parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts"; | ||
| 9 | import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; | ||
| 10 | |||
| 11 | function makeProvider(gitlab: Partial<GitLabCli.GitLabCli["Service"]>) { | ||
| 12 | return GitLabSourceControlProvider.make.pipe( | ||
| 13 | Effect.provide(Layer.mock(GitLabCli.GitLabCli)(gitlab)), | ||
| 14 | ); | ||
| 15 | } | ||
| 16 | |||
| 17 | it.effect("maps GitLab MR summaries into provider-neutral change requests", () => | ||
| 18 | Effect.gen(function* () { | ||
| 19 | const provider = yield* makeProvider({ | ||
| 20 | getMergeRequest: () => | ||
| 21 | Effect.succeed({ | ||
| 22 | number: 42, | ||
| 23 | title: "Add GitLab provider", | ||
| 24 | url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", | ||
| 25 | baseRefName: "main", | ||
| 26 | headRefName: "feature/source-control", | ||
| 27 | state: "closed", | ||
| 28 | closedAt: "2026-08-23T10:00:00Z", | ||
| 29 | isCrossRepository: true, | ||
| 30 | headRepositoryNameWithOwner: "fork/t3code", | ||
| 31 | headRepositoryOwnerLogin: "fork", | ||
| 32 | }), | ||
| 33 | }); | ||
| 34 | |||
| 35 | const changeRequest = yield* provider.getChangeRequest({ | ||
| 36 | cwd: "/repo", | ||
| 37 | reference: "42", | ||
| 38 | }); | ||
| 39 | |||
| 40 | assert.deepStrictEqual(changeRequest, { | ||
| 41 | provider: "gitlab", | ||
| 42 | number: 42, | ||
| 43 | title: "Add GitLab provider", | ||
| 44 | url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", | ||
| 45 | baseRefName: "main", | ||
| 46 | headRefName: "feature/source-control", | ||
| 47 | state: "closed", | ||
| 48 | closedAt: "2026-08-23T10:00:00Z", | ||
| 49 | mergedAt: null, | ||
| 50 | updatedAt: Option.none(), | ||
| 51 | isCrossRepository: true, | ||
| 52 | headRepositoryNameWithOwner: "fork/t3code", | ||
| 53 | headRepositoryOwnerLogin: "fork", | ||
| 54 | }); | ||
| 55 | }), | ||
| 56 | ); | ||
| 57 | |||
| 58 | it.effect("adds repository context while retaining GitLab CLI causes", () => | ||
| 59 | Effect.gen(function* () { | ||
| 60 | const cause = new GitLabCli.GitLabCliCommandError({ | ||
| 61 | operation: "execute", | ||
| 62 | command: "glab", | ||
| 63 | cwd: "/repo", | ||
| 64 | cause: new Error("raw upstream detail that should remain in the cause"), | ||
| 65 | }); | ||
| 66 | const provider = yield* makeProvider({ | ||
| 67 | createRepository: () => Effect.fail(cause), | ||
| 68 | }); | ||
| 69 | |||
| 70 | const error = yield* provider | ||
| 71 | .createRepository({ | ||
| 72 | cwd: "/repo", | ||
| 73 | repository: "owner/repo", | ||
| 74 | visibility: "private", | ||
| 75 | }) | ||
| 76 | .pipe(Effect.flip); | ||
| 77 | |||
| 78 | assert.deepStrictEqual( | ||
| 79 | { | ||
| 80 | provider: error.provider, | ||
| 81 | operation: error.operation, | ||
| 82 | command: error.command, | ||
| 83 | cwd: error.cwd, | ||
| 84 | repository: error.repository, | ||
| 85 | detail: error.detail, | ||
| 86 | }, | ||
| 87 | { | ||
| 88 | provider: "gitlab", | ||
| 89 | operation: "createRepository", | ||
| 90 | command: "glab", | ||
| 91 | cwd: "/repo", | ||
| 92 | repository: "owner/repo", | ||
| 93 | detail: "GitLab CLI command failed.", | ||
| 94 | }, | ||
| 95 | ); | ||
| 96 | assert.strictEqual(error.cause, cause); | ||
| 97 | assert.equal(error.message.includes("raw upstream detail"), false); | ||
| 98 | }), | ||
| 99 | ); | ||
| 100 | |||
| 101 | it.effect("lists GitLab MRs through provider-neutral input names", () => | ||
| 102 | Effect.gen(function* () { | ||
| 103 | let listInput: Parameters<GitLabCli.GitLabCli["Service"]["listMergeRequests"]>[0] | null = null; | ||
| 104 | const provider = yield* makeProvider({ | ||
| 105 | listMergeRequests: (input) => { | ||
| 106 | listInput = input; | ||
| 107 | return Effect.succeed([]); | ||
| 108 | }, | ||
| 109 | }); | ||
| 110 | |||
| 111 | yield* provider.listChangeRequests({ | ||
| 112 | cwd: "/repo", | ||
| 113 | headSelector: "feature/provider", | ||
| 114 | state: "all", | ||
| 115 | limit: 10, | ||
| 116 | }); | ||
| 117 | |||
| 118 | assert.deepStrictEqual(listInput, { | ||
| 119 | cwd: "/repo", | ||
| 120 | headSelector: "feature/provider", | ||
| 121 | state: "all", | ||
| 122 | limit: 10, | ||
| 123 | }); | ||
| 124 | }), | ||
| 125 | ); | ||
| 126 | |||
| 127 | it.effect("creates GitLab MRs through provider-neutral input names", () => | ||
| 128 | Effect.gen(function* () { | ||
| 129 | let createInput: Parameters<GitLabCli.GitLabCli["Service"]["createMergeRequest"]>[0] | null = | ||
| 130 | null; | ||
| 131 | const provider = yield* makeProvider({ | ||
| 132 | createMergeRequest: (input) => { | ||
| 133 | createInput = input; | ||
| 134 | return Effect.void; | ||
| 135 | }, | ||
| 136 | }); | ||
| 137 | |||
| 138 | yield* provider.createChangeRequest({ | ||
| 139 | cwd: "/repo", | ||
| 140 | baseRefName: "main", | ||
| 141 | headSelector: "owner:feature/provider", | ||
| 142 | title: "Provider MR", | ||
| 143 | bodyFile: "/tmp/body.md", | ||
| 144 | }); | ||
| 145 | |||
| 146 | assert.deepStrictEqual(createInput, { | ||
| 147 | cwd: "/repo", | ||
| 148 | baseBranch: "main", | ||
| 149 | headSelector: "owner:feature/provider", | ||
| 150 | source: { | ||
| 151 | owner: "owner", | ||
| 152 | refName: "feature/provider", | ||
| 153 | }, | ||
| 154 | title: "Provider MR", | ||
| 155 | bodyFile: "/tmp/body.md", | ||
| 156 | }); | ||
| 157 | }), | ||
| 158 | ); | ||
| 159 | |||
| 160 | it("accepts authenticated GitLab hosts when another configured host fails", () => { | ||
| 161 | const auth = GitLabSourceControlProvider.discovery.parseAuth({ | ||
| 162 | exitCode: ChildProcessSpawner.ExitCode(1), | ||
| 163 | stdout: `gitlab.com | ||
| 164 | x gitlab.com: API call failed: 401 Unauthorized | ||
| 165 | ! No token found | ||
| 166 | self-hosted.example.test | ||
| 167 | ✓ Logged in to self-hosted.example.test as gitlab-user | ||
| 168 | ✓ Token found: ****** | ||
| 169 | `, | ||
| 170 | stderr: "", | ||
| 171 | }); | ||
| 172 | |||
| 173 | assert.deepStrictEqual( | ||
| 174 | { | ||
| 175 | status: auth.status, | ||
| 176 | account: auth.account, | ||
| 177 | host: auth.host, | ||
| 178 | }, | ||
| 179 | { | ||
| 180 | status: "authenticated", | ||
| 181 | account: Option.some("gitlab-user"), | ||
| 182 | host: Option.some("self-hosted.example.test"), | ||
| 183 | }, | ||
| 184 | ); | ||
| 185 | }); | ||
| 186 | |||
| 187 | it("refines unknown GitLab remotes with mixed-case provider hosts", () => { | ||
| 188 | const provider = GitLabSourceControlProvider.discovery.refineUnknownRemote?.({ | ||
| 189 | cwd: "/repo", | ||
| 190 | context: { | ||
| 191 | provider: { | ||
| 192 | kind: "unknown", | ||
| 193 | name: "Self-Hosted.Example.Test", | ||
| 194 | baseUrl: "https://Self-Hosted.Example.Test", | ||
| 195 | }, | ||
| 196 | remoteName: "origin", | ||
| 197 | remoteUrl: "https://Self-Hosted.Example.Test/group/project.git", | ||
| 198 | }, | ||
| 199 | auth: { | ||
| 200 | exitCode: ChildProcessSpawner.ExitCode(0), | ||
| 201 | stdout: `self-hosted.example.test | ||
| 202 | ✓ Logged in to self-hosted.example.test as gitlab-user | ||
| 203 | ✓ Token found: ****** | ||
| 204 | `, | ||
| 205 | stderr: "", | ||
| 206 | }, | ||
| 207 | }); | ||
| 208 | |||
| 209 | assert.deepStrictEqual(provider, { | ||
| 210 | kind: "gitlab", | ||
| 211 | name: "GitLab Self-Hosted", | ||
| 212 | baseUrl: "https://Self-Hosted.Example.Test", | ||
| 213 | }); | ||
| 214 | }); | ||
| 215 | |||
| 216 | it("parses authenticated GitLab auth status hosts with ports and single-label names", () => { | ||
| 217 | assert.deepStrictEqual( | ||
| 218 | parseGitLabAuthStatusHosts(`localhost:8080 | ||
| 219 | ✓ Logged in to localhost:8080 as local-user | ||
| 220 | selfhosted | ||
| 221 | ✓ Logged in to selfhosted as single-label-user | ||
| 222 | `), | ||
| 223 | [ | ||
| 224 | { host: "localhost:8080", account: "local-user" }, | ||
| 225 | { host: "selfhosted", account: "single-label-user" }, | ||
| 226 | ], | ||
| 227 | ); | ||
| 228 | }); | ||
| 229 | |||
| 230 | it.effect.each(["merge_requests", "issues"])( | ||
| 231 | "resolves %s subjects on the linked host without using the checkout", | ||
| 232 | (kind) => | ||
| 233 | Effect.gen(function* () { | ||
| 234 | const provider = yield* makeProvider({ | ||
| 235 | execute: (input) => { | ||
| 236 | assert.deepStrictEqual(input.args, [ | ||
| 237 | "api", | ||
| 238 | "--hostname", | ||
| 239 | "gitlab.com", | ||
| 240 | `projects/group%2Fsubgroup%2Fproject/${kind}/42`, | ||
| 241 | ]); | ||
| 242 | assert.strictEqual(input.maxOutputBytes, 32_000); | ||
| 243 | assert.strictEqual(input.timeoutMs, 3_000); | ||
| 244 | return Effect.succeed({ | ||
| 245 | exitCode: ChildProcessSpawner.ExitCode(0), | ||
| 246 | stdout: JSON.stringify({ | ||
| 247 | title: "Pairing expiry", | ||
| 248 | description: "Preserve remote access", | ||
| 249 | }), | ||
| 250 | stderr: "", | ||
| 251 | stdoutTruncated: false, | ||
| 252 | stderrTruncated: false, | ||
| 253 | }); | ||
| 254 | }, | ||
| 255 | }); | ||
| 256 | const lookup = provider.resolveLink?.({ | ||
| 257 | cwd: "/unrelated", | ||
| 258 | url: new URL(`https://gitlab.com/group/subgroup/project/-/${kind}/42`), | ||
| 259 | }); | ||
| 260 | assert.ok(lookup); | ||
| 261 | assert.deepStrictEqual(yield* lookup, { | ||
| 262 | title: "Pairing expiry", | ||
| 263 | body: "Preserve remote access", | ||
| 264 | }); | ||
| 265 | assert.strictEqual( | ||
| 266 | provider.resolveLink?.({ | ||
| 267 | cwd: "/unrelated", | ||
| 268 | url: new URL("https://gitlab.com/owner/repo"), | ||
| 269 | }), | ||
| 270 | undefined, | ||
| 271 | ); | ||
| 272 | }), | ||
| 273 | ); | ||
| 274 | |||
| 275 | it.effect.each(["read", "decode"] as const)( | ||
| 276 | "retains the %s failure without exposing its raw contents", | ||
| 277 | (stage) => | ||
| 278 | Effect.gen(function* () { | ||
| 279 | const cause = new GitLabCli.GitLabCliCommandError({ | ||
| 280 | command: "glab", | ||
| 281 | cwd: "/repo", | ||
| 282 | operation: "execute", | ||
| 283 | cause: new Error("private response text"), | ||
| 284 | }); | ||
| 285 | const provider = yield* makeProvider({ | ||
| 286 | execute: () => | ||
| 287 | stage === "read" | ||
| 288 | ? Effect.fail(cause) | ||
| 289 | : Effect.succeed({ | ||
| 290 | exitCode: ChildProcessSpawner.ExitCode(0), | ||
| 291 | stdout: "private response text", | ||
| 292 | stderr: "", | ||
| 293 | stdoutTruncated: false, | ||
| 294 | stderrTruncated: false, | ||
| 295 | }), | ||
| 296 | }); | ||
| 297 | const lookup = provider.resolveLink?.({ | ||
| 298 | cwd: "/repo", | ||
| 299 | url: new URL("https://gitlab.com/owner/repo/-/issues/42"), | ||
| 300 | }); | ||
| 301 | assert.ok(lookup); | ||
| 302 | const error = yield* Effect.flip(lookup); | ||
| 303 | assert.strictEqual(error.operation, stage === "read" ? "resolveLink" : "resolveLink.decode"); | ||
| 304 | assert.strictEqual(error.detail, "The linked subject could not be read."); | ||
| 305 | assert.notInclude(error.message, "private response text"); | ||
| 306 | if (stage === "read") assert.strictEqual(error.cause, cause); | ||
| 307 | else assert.propertyVal(error.cause, "_tag", "SchemaError"); | ||
| 308 | }), | ||
| 309 | ); | ||
packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts created+308| ... | @@ -0,0 +1,308 @@ | ||
| 1 | import * as Schema from "effect/Schema"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Option from "effect/Option"; | ||
| 4 | import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; | ||
| 5 | |||
| 6 | import * as GitLabCli from "./GitLabCli.ts"; | ||
| 7 | import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; | ||
| 8 | import { | ||
| 9 | combinedAuthOutput, | ||
| 10 | firstSafeAuthLine, | ||
| 11 | matchFirst, | ||
| 12 | parseCliHost, | ||
| 13 | providerAuth, | ||
| 14 | type SourceControlAuthProbeInput, | ||
| 15 | type SourceControlCliDiscoverySpec, | ||
| 16 | type SourceControlUnknownRemoteRefinementInput, | ||
| 17 | } from "@t3tools/source-control-core/server/discovery"; | ||
| 18 | import { findAuthenticatedGitLabHost, parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts"; | ||
| 19 | |||
| 20 | const decodeLinkSubject = Schema.decodeUnknownEffect( | ||
| 21 | Schema.fromJsonString( | ||
| 22 | Schema.Struct({ title: Schema.String, description: Schema.NullOr(Schema.String) }), | ||
| 23 | ), | ||
| 24 | ); | ||
| 25 | |||
| 26 | function toChangeRequest(summary: GitLabCli.GitLabMergeRequestSummary): ChangeRequest { | ||
| 27 | return { | ||
| 28 | provider: "gitlab", | ||
| 29 | number: summary.number, | ||
| 30 | title: summary.title, | ||
| 31 | url: summary.url, | ||
| 32 | baseRefName: summary.baseRefName, | ||
| 33 | headRefName: summary.headRefName, | ||
| 34 | state: summary.state ?? "open", | ||
| 35 | ...(summary.isDraft === true ? { isDraft: true } : {}), | ||
| 36 | closedAt: summary.closedAt ?? null, | ||
| 37 | mergedAt: summary.mergedAt ?? null, | ||
| 38 | updatedAt: summary.updatedAt ?? Option.none(), | ||
| 39 | ...(summary.isCrossRepository !== undefined | ||
| 40 | ? { isCrossRepository: summary.isCrossRepository } | ||
| 41 | : {}), | ||
| 42 | ...(summary.headRepositoryNameWithOwner !== undefined | ||
| 43 | ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner } | ||
| 44 | : {}), | ||
| 45 | ...(summary.headRepositoryOwnerLogin !== undefined | ||
| 46 | ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin } | ||
| 47 | : {}), | ||
| 48 | }; | ||
| 49 | } | ||
| 50 | |||
| 51 | function parseGitLabAuth(input: SourceControlAuthProbeInput) { | ||
| 52 | const output = combinedAuthOutput(input); | ||
| 53 | const authenticatedHost = findAuthenticatedGitLabHost(parseGitLabAuthStatusHosts(output)); | ||
| 54 | const account = | ||
| 55 | authenticatedHost?.account ?? | ||
| 56 | matchFirst(output, [ | ||
| 57 | /Logged in to .* as\s+([^\s(]+)/iu, | ||
| 58 | /Logged in to .* account\s+([^\s(]+)/iu, | ||
| 59 | /account:\s*([^\s(]+)/iu, | ||
| 60 | ]); | ||
| 61 | const host = authenticatedHost?.host ?? parseCliHost(output); | ||
| 62 | |||
| 63 | if (account) { | ||
| 64 | return providerAuth({ status: "authenticated", account, host }); | ||
| 65 | } | ||
| 66 | |||
| 67 | if (input.exitCode !== 0) { | ||
| 68 | return providerAuth({ | ||
| 69 | status: "unauthenticated", | ||
| 70 | host, | ||
| 71 | detail: firstSafeAuthLine(output) ?? "Run `glab auth login` to authenticate GitLab CLI.", | ||
| 72 | }); | ||
| 73 | } | ||
| 74 | |||
| 75 | return providerAuth({ | ||
| 76 | status: "unknown", | ||
| 77 | host, | ||
| 78 | detail: firstSafeAuthLine(output) ?? "GitLab CLI auth status could not be parsed.", | ||
| 79 | }); | ||
| 80 | } | ||
| 81 | |||
| 82 | function refineUnknownGitLabRemote(input: SourceControlUnknownRemoteRefinementInput) { | ||
| 83 | const host = input.context.provider.name.toLowerCase(); | ||
| 84 | const authenticated = parseGitLabAuthStatusHosts(combinedAuthOutput(input.auth)).some( | ||
| 85 | (entry) => entry.account !== null && entry.host === host, | ||
| 86 | ); | ||
| 87 | |||
| 88 | if (!authenticated) { | ||
| 89 | return null; | ||
| 90 | } | ||
| 91 | |||
| 92 | return { | ||
| 93 | kind: "gitlab", | ||
| 94 | name: "GitLab Self-Hosted", | ||
| 95 | baseUrl: input.context.provider.baseUrl, | ||
| 96 | } as const; | ||
| 97 | } | ||
| 98 | |||
| 99 | export const discovery = { | ||
| 100 | type: "cli", | ||
| 101 | kind: "gitlab", | ||
| 102 | label: "GitLab", | ||
| 103 | executable: "glab", | ||
| 104 | versionArgs: ["--version"], | ||
| 105 | authArgs: ["auth", "status"], | ||
| 106 | parseAuth: parseGitLabAuth, | ||
| 107 | refineUnknownRemote: refineUnknownGitLabRemote, | ||
| 108 | installHint: | ||
| 109 | "Install the GitLab command-line tool (`glab`) from https://gitlab.com/gitlab-org/cli or your package manager (for example `brew install glab`).", | ||
| 110 | } satisfies SourceControlCliDiscoverySpec; | ||
| 111 | |||
| 112 | export const make = Effect.gen(function* () { | ||
| 113 | const gitlab = yield* GitLabCli.GitLabCli; | ||
| 114 | |||
| 115 | const readLinkSubject = Effect.fn("GitLabSourceControlProvider.readLinkSubject")(function* ( | ||
| 116 | input: { readonly cwd: string; readonly url: URL }, | ||
| 117 | endpoint: string, | ||
| 118 | ) { | ||
| 119 | const result = yield* gitlab | ||
| 120 | .execute({ | ||
| 121 | cwd: input.cwd, | ||
| 122 | args: ["api", "--hostname", input.url.host, endpoint], | ||
| 123 | timeoutMs: 3_000, | ||
| 124 | maxOutputBytes: 32_000, | ||
| 125 | }) | ||
| 126 | .pipe( | ||
| 127 | Effect.mapError( | ||
| 128 | (cause) => | ||
| 129 | new SourceControlProviderError({ | ||
| 130 | provider: "gitlab", | ||
| 131 | operation: "resolveLink", | ||
| 132 | cwd: input.cwd, | ||
| 133 | detail: "The linked subject could not be read.", | ||
| 134 | cause, | ||
| 135 | }), | ||
| 136 | ), | ||
| 137 | ); | ||
| 138 | const subject = yield* decodeLinkSubject(result.stdout).pipe( | ||
| 139 | Effect.mapError( | ||
| 140 | (cause) => | ||
| 141 | new SourceControlProviderError({ | ||
| 142 | provider: "gitlab", | ||
| 143 | operation: "resolveLink.decode", | ||
| 144 | cwd: input.cwd, | ||
| 145 | detail: "The linked subject could not be read.", | ||
| 146 | cause, | ||
| 147 | }), | ||
| 148 | ), | ||
| 149 | ); | ||
| 150 | return { title: subject.title, body: subject.description }; | ||
| 151 | }); | ||
| 152 | |||
| 153 | return SourceControlProvider.SourceControlProvider.of({ | ||
| 154 | kind: "gitlab", | ||
| 155 | resolveLink: (input) => { | ||
| 156 | // Automatic enrichment must not send ambient CLI credentials to a host from message text. | ||
| 157 | if (input.url.host !== "gitlab.com") return undefined; | ||
| 158 | const match = /^\/(.+)\/-\/(merge_requests|issues)\/([1-9]\d*)(?:\/.*)?$/.exec( | ||
| 159 | input.url.pathname, | ||
| 160 | ); | ||
| 161 | if (!match) return undefined; | ||
| 162 | return readLinkSubject( | ||
| 163 | input, | ||
| 164 | `projects/${encodeURIComponent(match[1]!)}/${match[2]}/${match[3]}`, | ||
| 165 | ); | ||
| 166 | }, | ||
| 167 | listChangeRequests: (input) => { | ||
| 168 | const source = SourceControlProvider.sourceControlRefFromInput(input); | ||
| 169 | return gitlab | ||
| 170 | .listMergeRequests({ | ||
| 171 | cwd: input.cwd, | ||
| 172 | headSelector: input.headSelector, | ||
| 173 | ...(source ? { source } : {}), | ||
| 174 | state: input.state, | ||
| 175 | ...(input.limit !== undefined ? { limit: input.limit } : {}), | ||
| 176 | }) | ||
| 177 | .pipe( | ||
| 178 | Effect.map((items) => items.map(toChangeRequest)), | ||
| 179 | Effect.mapError( | ||
| 180 | (error) => | ||
| 181 | new SourceControlProviderError({ | ||
| 182 | provider: "gitlab", | ||
| 183 | operation: "listChangeRequests", | ||
| 184 | command: error.command, | ||
| 185 | cwd: input.cwd, | ||
| 186 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | ||
| 187 | input.headSelector, | ||
| 188 | ), | ||
| 189 | detail: error.detail, | ||
| 190 | cause: error, | ||
| 191 | }), | ||
| 192 | ), | ||
| 193 | ); | ||
| 194 | }, | ||
| 195 | getChangeRequest: (input) => | ||
| 196 | gitlab.getMergeRequest(input).pipe( | ||
| 197 | Effect.map(toChangeRequest), | ||
| 198 | Effect.mapError( | ||
| 199 | (error) => | ||
| 200 | new SourceControlProviderError({ | ||
| 201 | provider: "gitlab", | ||
| 202 | operation: "getChangeRequest", | ||
| 203 | command: error.command, | ||
| 204 | cwd: input.cwd, | ||
| 205 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | ||
| 206 | input.reference, | ||
| 207 | ), | ||
| 208 | detail: error.detail, | ||
| 209 | cause: error, | ||
| 210 | }), | ||
| 211 | ), | ||
| 212 | ), | ||
| 213 | createChangeRequest: (input) => { | ||
| 214 | const source = SourceControlProvider.sourceControlRefFromInput(input); | ||
| 215 | return gitlab | ||
| 216 | .createMergeRequest({ | ||
| 217 | cwd: input.cwd, | ||
| 218 | baseBranch: input.baseRefName, | ||
| 219 | headSelector: input.headSelector, | ||
| 220 | ...(source ? { source } : {}), | ||
| 221 | ...(input.target ? { target: input.target } : {}), | ||
| 222 | title: input.title, | ||
| 223 | bodyFile: input.bodyFile, | ||
| 224 | }) | ||
| 225 | .pipe( | ||
| 226 | Effect.mapError( | ||
| 227 | (error) => | ||
| 228 | new SourceControlProviderError({ | ||
| 229 | provider: "gitlab", | ||
| 230 | operation: "createChangeRequest", | ||
| 231 | command: error.command, | ||
| 232 | cwd: input.cwd, | ||
| 233 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | ||
| 234 | input.headSelector, | ||
| 235 | ), | ||
| 236 | detail: error.detail, | ||
| 237 | cause: error, | ||
| 238 | }), | ||
| 239 | ), | ||
| 240 | ); | ||
| 241 | }, | ||
| 242 | getRepositoryCloneUrls: (input) => | ||
| 243 | gitlab.getRepositoryCloneUrls(input).pipe( | ||
| 244 | Effect.mapError( | ||
| 245 | (error) => | ||
| 246 | new SourceControlProviderError({ | ||
| 247 | provider: "gitlab", | ||
| 248 | operation: "getRepositoryCloneUrls", | ||
| 249 | command: error.command, | ||
| 250 | cwd: input.cwd, | ||
| 251 | repository: SourceControlProvider.transportSafeSourceControlErrorValue( | ||
| 252 | input.repository, | ||
| 253 | ), | ||
| 254 | detail: error.detail, | ||
| 255 | cause: error, | ||
| 256 | }), | ||
| 257 | ), | ||
| 258 | ), | ||
| 259 | createRepository: (input) => | ||
| 260 | gitlab.createRepository(input).pipe( | ||
| 261 | Effect.mapError( | ||
| 262 | (error) => | ||
| 263 | new SourceControlProviderError({ | ||
| 264 | provider: "gitlab", | ||
| 265 | operation: "createRepository", | ||
| 266 | command: error.command, | ||
| 267 | cwd: input.cwd, | ||
| 268 | repository: SourceControlProvider.transportSafeSourceControlErrorValue( | ||
| 269 | input.repository, | ||
| 270 | ), | ||
| 271 | detail: error.detail, | ||
| 272 | cause: error, | ||
| 273 | }), | ||
| 274 | ), | ||
| 275 | ), | ||
| 276 | getDefaultBranch: (input) => | ||
| 277 | gitlab.getDefaultBranch(input).pipe( | ||
| 278 | Effect.mapError( | ||
| 279 | (error) => | ||
| 280 | new SourceControlProviderError({ | ||
| 281 | provider: "gitlab", | ||
| 282 | operation: "getDefaultBranch", | ||
| 283 | command: error.command, | ||
| 284 | cwd: input.cwd, | ||
| 285 | detail: error.detail, | ||
| 286 | cause: error, | ||
| 287 | }), | ||
| 288 | ), | ||
| 289 | ), | ||
| 290 | checkoutChangeRequest: (input) => | ||
| 291 | gitlab.checkoutMergeRequest(input).pipe( | ||
| 292 | Effect.mapError( | ||
| 293 | (error) => | ||
| 294 | new SourceControlProviderError({ | ||
| 295 | provider: "gitlab", | ||
| 296 | operation: "checkoutChangeRequest", | ||
| 297 | command: error.command, | ||
| 298 | cwd: input.cwd, | ||
| 299 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | ||
| 300 | input.reference, | ||
| 301 | ), | ||
| 302 | detail: error.detail, | ||
| 303 | cause: error, | ||
| 304 | }), | ||
| 305 | ), | ||
| 306 | ), | ||
| 307 | }); | ||
| 308 | }); | ||
packages/source-control-gitlab/src/server/driver.ts created+19| ... | @@ -0,0 +1,19 @@ | ||
| 1 | /** | ||
| 2 | * The GitLab driver: repository operations, discovery, and merge requests through `glab`. | ||
| 3 | * | ||
| 4 | * @module source-control-gitlab/server/driver | ||
| 5 | */ | ||
| 6 | import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; | ||
| 7 | import * as Effect from "effect/Effect"; | ||
| 8 | |||
| 9 | import * as GitLabPullRequestProvider from "./GitLabPullRequestProvider.ts"; | ||
| 10 | import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; | ||
| 11 | |||
| 12 | export const driver = defineSourceControlDriver({ | ||
| 13 | kind: "gitlab", | ||
| 14 | make: Effect.all({ | ||
| 15 | sourceControl: GitLabSourceControlProvider.make, | ||
| 16 | discovery: Effect.succeed(GitLabSourceControlProvider.discovery), | ||
| 17 | pullRequests: GitLabPullRequestProvider.make, | ||
| 18 | }), | ||
| 19 | }); | ||
packages/source-control-gitlab/src/server/gitLabAuthStatus.ts created+48| ... | @@ -0,0 +1,48 @@ | ||
| 1 | const HOST_LINE_PATTERN = /^(?:[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?|\[[a-f0-9:.]+\])(?::\d+)?$/iu; | ||
| 2 | const LOGGED_IN_PATTERN = /Logged in to .+? as\s+([^\s(]+)/iu; | ||
| 3 | |||
| 4 | export interface GitLabAuthStatusHost { | ||
| 5 | readonly host: string; | ||
| 6 | readonly account: string | null; | ||
| 7 | } | ||
| 8 | |||
| 9 | export function parseGitLabAuthStatusHosts(text: string): ReadonlyArray<GitLabAuthStatusHost> { | ||
| 10 | const hosts: GitLabAuthStatusHost[] = []; | ||
| 11 | let currentHost: string | null = null; | ||
| 12 | let currentLines: string[] = []; | ||
| 13 | |||
| 14 | const flush = () => { | ||
| 15 | if (currentHost === null) return; | ||
| 16 | |||
| 17 | const account = LOGGED_IN_PATTERN.exec(currentLines.join("\n"))?.[1]?.trim() || null; | ||
| 18 | hosts.push({ host: currentHost, account }); | ||
| 19 | currentHost = null; | ||
| 20 | currentLines = []; | ||
| 21 | }; | ||
| 22 | |||
| 23 | for (const rawLine of text.split(/\r?\n/)) { | ||
| 24 | const line = rawLine.trim(); | ||
| 25 | if (line.length === 0) continue; | ||
| 26 | |||
| 27 | const isHostLine = | ||
| 28 | rawLine.length === rawLine.trimStart().length && HOST_LINE_PATTERN.test(line); | ||
| 29 | if (isHostLine) { | ||
| 30 | flush(); | ||
| 31 | currentHost = line.toLowerCase(); | ||
| 32 | continue; | ||
| 33 | } | ||
| 34 | |||
| 35 | if (currentHost !== null) { | ||
| 36 | currentLines.push(line); | ||
| 37 | } | ||
| 38 | } | ||
| 39 | |||
| 40 | flush(); | ||
| 41 | return hosts; | ||
| 42 | } | ||
| 43 | |||
| 44 | export function findAuthenticatedGitLabHost( | ||
| 45 | hosts: ReadonlyArray<GitLabAuthStatusHost>, | ||
| 46 | ): GitLabAuthStatusHost | undefined { | ||
| 47 | return hosts.find((host) => host.account !== null); | ||
| 48 | } | ||
packages/source-control-gitlab/src/server/gitLabMergeRequestJson.test.ts created+803| ... | @@ -0,0 +1,803 @@ | ||
| 1 | import * as Result from "effect/Result"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | decodeAwardEmojiJson, | ||
| 6 | decodeRepositoryBlobsJson, | ||
| 7 | decodeCommitsJson, | ||
| 8 | decodeMergeRequestDetailJson, | ||
| 9 | decodeMergeRequestDiffsJson, | ||
| 10 | decodeMergeRequestListJson, | ||
| 11 | decodeNotesJson, | ||
| 12 | decodeOwnAwardIdJson, | ||
| 13 | decodeViewerJson, | ||
| 14 | gitLabAwardName, | ||
| 15 | } from "./gitLabMergeRequestJson.ts"; | ||
| 16 | |||
| 17 | function listJson(entries: ReadonlyArray<Record<string, unknown>>): string { | ||
| 18 | return JSON.stringify( | ||
| 19 | entries.map((entry) => ({ | ||
| 20 | iid: 1, | ||
| 21 | title: "Add the merge requests page", | ||
| 22 | web_url: "https://gitlab.com/acme/web/-/merge_requests/1", | ||
| 23 | source_branch: "feat/page", | ||
| 24 | target_branch: "main", | ||
| 25 | created_at: "2026-07-01T00:00:00Z", | ||
| 26 | updated_at: "2026-07-02T00:00:00Z", | ||
| 27 | ...entry, | ||
| 28 | })), | ||
| 29 | ); | ||
| 30 | } | ||
| 31 | |||
| 32 | function detailJson(entry: Record<string, unknown>): string { | ||
| 33 | return JSON.stringify({ | ||
| 34 | iid: 1, | ||
| 35 | title: "Add the merge requests page", | ||
| 36 | web_url: "https://gitlab.com/acme/web/-/merge_requests/1", | ||
| 37 | source_branch: "feat/page", | ||
| 38 | target_branch: "main", | ||
| 39 | created_at: "2026-07-01T00:00:00Z", | ||
| 40 | updated_at: "2026-07-02T00:00:00Z", | ||
| 41 | ...entry, | ||
| 42 | }); | ||
| 43 | } | ||
| 44 | |||
| 45 | function expectSuccess<A>(result: Result.Result<A, unknown>): A { | ||
| 46 | expect(Result.isSuccess(result)).toBe(true); | ||
| 47 | if (!Result.isSuccess(result)) throw new Error("expected a successful decode"); | ||
| 48 | return result.success; | ||
| 49 | } | ||
| 50 | |||
| 51 | describe("decodeMergeRequestListJson", () => { | ||
| 52 | it("reads a merge request as a change request", () => { | ||
| 53 | const batch = expectSuccess( | ||
| 54 | decodeMergeRequestListJson( | ||
| 55 | listJson([ | ||
| 56 | { | ||
| 57 | iid: 42, | ||
| 58 | author: { username: "bilal", name: "Bilal" }, | ||
| 59 | state: "opened", | ||
| 60 | merge_status: "can_be_merged", | ||
| 61 | draft: false, | ||
| 62 | reviewers: [{ username: "julius" }], | ||
| 63 | labels: ["backend", " "], | ||
| 64 | }, | ||
| 65 | ]), | ||
| 66 | ), | ||
| 67 | ); | ||
| 68 | |||
| 69 | expect(batch.items).toHaveLength(1); | ||
| 70 | expect(batch.items[0]).toMatchObject({ | ||
| 71 | number: 42, | ||
| 72 | author: { login: "bilal", name: "Bilal" }, | ||
| 73 | headBranch: "feat/page", | ||
| 74 | baseBranch: "main", | ||
| 75 | state: "open", | ||
| 76 | isDraft: false, | ||
| 77 | mergeability: "mergeable", | ||
| 78 | reviewRequestLogins: ["julius"], | ||
| 79 | labels: [{ name: "backend", color: null }], | ||
| 80 | }); | ||
| 81 | }); | ||
| 82 | |||
| 83 | it("reports no line counts, which GitLab does not expose", () => { | ||
| 84 | const batch = expectSuccess(decodeMergeRequestListJson(listJson([{}]))); | ||
| 85 | |||
| 86 | expect(batch.items[0]).toMatchObject({ additions: 0, deletions: 0 }); | ||
| 87 | }); | ||
| 88 | |||
| 89 | it("treats a merged timestamp as merged whatever the state says", () => { | ||
| 90 | const batch = expectSuccess( | ||
| 91 | decodeMergeRequestListJson( | ||
| 92 | listJson([{ state: "opened", merged_at: "2026-07-03T00:00:00Z" }]), | ||
| 93 | ), | ||
| 94 | ); | ||
| 95 | |||
| 96 | expect(batch.items[0]?.state).toBe("merged"); | ||
| 97 | }); | ||
| 98 | |||
| 99 | it("keeps a locked merge request open", () => { | ||
| 100 | const batch = expectSuccess(decodeMergeRequestListJson(listJson([{ state: "locked" }]))); | ||
| 101 | |||
| 102 | expect(batch.items[0]?.state).toBe("open"); | ||
| 103 | }); | ||
| 104 | |||
| 105 | it("reads the legacy draft flag", () => { | ||
| 106 | const batch = expectSuccess(decodeMergeRequestListJson(listJson([{ work_in_progress: true }]))); | ||
| 107 | |||
| 108 | expect(batch.items[0]?.isDraft).toBe(true); | ||
| 109 | }); | ||
| 110 | |||
| 111 | it("calls a conflicted merge request conflicting even while the merge check is pending", () => { | ||
| 112 | const batch = expectSuccess( | ||
| 113 | decodeMergeRequestListJson(listJson([{ merge_status: "checking", has_conflicts: true }])), | ||
| 114 | ); | ||
| 115 | |||
| 116 | expect(batch.items[0]?.mergeability).toBe("conflicting"); | ||
| 117 | }); | ||
| 118 | |||
| 119 | it("leaves an unfinished merge check unknown", () => { | ||
| 120 | const batch = expectSuccess( | ||
| 121 | decodeMergeRequestListJson(listJson([{ merge_status: "checking" }])), | ||
| 122 | ); | ||
| 123 | |||
| 124 | expect(batch.items[0]?.mergeability).toBe("unknown"); | ||
| 125 | }); | ||
| 126 | |||
| 127 | it("skips a malformed row but still counts it, so paging does not stop early", () => { | ||
| 128 | const batch = expectSuccess( | ||
| 129 | decodeMergeRequestListJson( | ||
| 130 | JSON.stringify([{ iid: "not a number" }, ...JSON.parse(listJson([{}]))]), | ||
| 131 | ), | ||
| 132 | ); | ||
| 133 | |||
| 134 | expect(batch.items).toHaveLength(1); | ||
| 135 | expect(batch.rawIndexes).toEqual([1]); | ||
| 136 | expect(batch.rawCount).toBe(2); | ||
| 137 | }); | ||
| 138 | }); | ||
| 139 | |||
| 140 | describe("decodeMergeRequestDetailJson", () => { | ||
| 141 | it("reads the description, file count and pipeline", () => { | ||
| 142 | const detail = expectSuccess( | ||
| 143 | decodeMergeRequestDetailJson( | ||
| 144 | detailJson({ | ||
| 145 | description: "Ships the page.", | ||
| 146 | changes_count: "3", | ||
| 147 | reviewers: [{ username: "julius", name: "Julius" }], | ||
| 148 | head_pipeline: { | ||
| 149 | status: "success", | ||
| 150 | web_url: "https://gitlab.com/acme/web/-/pipelines/9", | ||
| 151 | source: "merge_request_event", | ||
| 152 | }, | ||
| 153 | }), | ||
| 154 | ), | ||
| 155 | ); | ||
| 156 | |||
| 157 | expect(detail.body).toBe("Ships the page."); | ||
| 158 | expect(detail.changedFiles).toBe(3); | ||
| 159 | expect(detail.reviewers).toEqual([{ login: "julius", name: "Julius", avatarUrl: null }]); | ||
| 160 | expect(detail.checks).toEqual([ | ||
| 161 | { | ||
| 162 | name: "Pipeline", | ||
| 163 | status: "success", | ||
| 164 | description: "merge_request_event", | ||
| 165 | url: "https://gitlab.com/acme/web/-/pipelines/9", | ||
| 166 | }, | ||
| 167 | ]); | ||
| 168 | }); | ||
| 169 | |||
| 170 | it("reads an uncounted change set as its floor", () => { | ||
| 171 | const detail = expectSuccess( | ||
| 172 | decodeMergeRequestDetailJson(detailJson({ changes_count: "1000+" })), | ||
| 173 | ); | ||
| 174 | |||
| 175 | expect(detail.changedFiles).toBe(1000); | ||
| 176 | }); | ||
| 177 | |||
| 178 | it("falls back to no file count when GitLab omits one", () => { | ||
| 179 | const detail = expectSuccess(decodeMergeRequestDetailJson(detailJson({}))); | ||
| 180 | |||
| 181 | expect(detail.changedFiles).toBe(0); | ||
| 182 | }); | ||
| 183 | |||
| 184 | it("reads either auto-merge field, and says nothing where GitLab named neither", () => { | ||
| 185 | const armed = (entry: Record<string, unknown>) => | ||
| 186 | expectSuccess(decodeMergeRequestDetailJson(detailJson(entry))).autoMergeEnabled; | ||
| 187 | |||
| 188 | expect(armed({ merge_when_pipeline_succeeds: true })).toBe(true); | ||
| 189 | // The newer name for the same fact, which older GitLab installs do not send. | ||
| 190 | expect(armed({ auto_merge_enabled: true })).toBe(true); | ||
| 191 | expect(armed({ merge_when_pipeline_succeeds: false })).toBe(false); | ||
| 192 | // Absent is GitLab not saying, which the page must not read as "not armed". | ||
| 193 | expect(armed({})).toBeUndefined(); | ||
| 194 | }); | ||
| 195 | |||
| 196 | it("keeps the squash choice stored with auto-merge", () => { | ||
| 197 | expect( | ||
| 198 | expectSuccess( | ||
| 199 | decodeMergeRequestDetailJson( | ||
| 200 | detailJson({ auto_merge_enabled: true, squash_on_merge: true }), | ||
| 201 | ), | ||
| 202 | ), | ||
| 203 | ).toMatchObject({ autoMergeEnabled: true, autoMergeMethod: "squash" }); | ||
| 204 | |||
| 205 | expect( | ||
| 206 | expectSuccess( | ||
| 207 | decodeMergeRequestDetailJson( | ||
| 208 | detailJson({ | ||
| 209 | auto_merge_enabled: true, | ||
| 210 | squash: true, | ||
| 211 | squash_on_merge: false, | ||
| 212 | }), | ||
| 213 | ), | ||
| 214 | ).autoMergeMethod, | ||
| 215 | ).toBeUndefined(); | ||
| 216 | }); | ||
| 217 | |||
| 218 | it("keeps a divergence GitLab did not count apart from a divergence of none", () => { | ||
| 219 | const behind = (entry: Record<string, unknown>) => | ||
| 220 | expectSuccess(decodeMergeRequestDetailJson(detailJson(entry))).divergedCommits; | ||
| 221 | |||
| 222 | expect(behind({ diverged_commits_count: 3 })).toBe(3); | ||
| 223 | // Counted and found level, which is the one answer that entitles the page to say so. | ||
| 224 | expect(behind({ diverged_commits_count: 0 })).toBe(0); | ||
| 225 | // An install that does not answer, and a null where the answer would have gone, are both | ||
| 226 | // silence: reading either as zero would tell a stale branch it is current. | ||
| 227 | expect(behind({})).toBeUndefined(); | ||
| 228 | expect(behind({ diverged_commits_count: null })).toBeUndefined(); | ||
| 229 | }); | ||
| 230 | |||
| 231 | it("maps a pipeline waiting on a person to neutral, not failure", () => { | ||
| 232 | const detail = expectSuccess( | ||
| 233 | decodeMergeRequestDetailJson(detailJson({ head_pipeline: { status: "manual" } })), | ||
| 234 | ); | ||
| 235 | |||
| 236 | expect(detail.checks[0]?.status).toBe("neutral"); | ||
| 237 | }); | ||
| 238 | }); | ||
| 239 | |||
| 240 | describe("decodeViewerJson", () => { | ||
| 241 | it("reads the signed-in username", () => { | ||
| 242 | expect(expectSuccess(decodeViewerJson(JSON.stringify({ username: "bilal" })))).toBe("bilal"); | ||
| 243 | }); | ||
| 244 | |||
| 245 | it("returns nothing when the account has no username", () => { | ||
| 246 | expect(expectSuccess(decodeViewerJson(JSON.stringify({ username: " " })))).toBeNull(); | ||
| 247 | }); | ||
| 248 | }); | ||
| 249 | |||
| 250 | describe("decodeNotesJson", () => { | ||
| 251 | it("keeps comments and drops GitLab's own activity notes", () => { | ||
| 252 | const notes = expectSuccess( | ||
| 253 | decodeNotesJson( | ||
| 254 | JSON.stringify([ | ||
| 255 | { | ||
| 256 | id: 1, | ||
| 257 | body: "assigned to @bilal", | ||
| 258 | system: true, | ||
| 259 | created_at: "2026-07-01T00:00:00Z", | ||
| 260 | }, | ||
| 261 | { | ||
| 262 | id: 2, | ||
| 263 | body: "Looks good.", | ||
| 264 | author: { username: "julius" }, | ||
| 265 | created_at: "2026-07-02T00:00:00Z", | ||
| 266 | }, | ||
| 267 | { id: 3, body: " ", created_at: "2026-07-03T00:00:00Z" }, | ||
| 268 | ]), | ||
| 269 | ), | ||
| 270 | ); | ||
| 271 | |||
| 272 | expect(notes.comments).toHaveLength(1); | ||
| 273 | expect(notes.comments[0]).toMatchObject({ | ||
| 274 | id: "2", | ||
| 275 | kind: "issue-comment", | ||
| 276 | body: "Looks good.", | ||
| 277 | }); | ||
| 278 | // The raw count keeps the dropped notes visible to the caller, which needs them to page. | ||
| 279 | expect(notes.rawCount).toBe(3); | ||
| 280 | }); | ||
| 281 | |||
| 282 | it("reads a line note as a review comment on its file", () => { | ||
| 283 | const notes = expectSuccess( | ||
| 284 | decodeNotesJson( | ||
| 285 | JSON.stringify([ | ||
| 286 | { | ||
| 287 | id: 7, | ||
| 288 | type: "DiffNote", | ||
| 289 | body: "Rename this.", | ||
| 290 | created_at: "2026-07-02T00:00:00Z", | ||
| 291 | position: { new_path: "src/app.ts", old_path: "src/old.ts" }, | ||
| 292 | }, | ||
| 293 | ]), | ||
| 294 | ), | ||
| 295 | ); | ||
| 296 | |||
| 297 | expect(notes.comments[0]).toMatchObject({ kind: "review-comment", path: "src/app.ts" }); | ||
| 298 | }); | ||
| 299 | |||
| 300 | it("falls back to the old path for a note on a deleted line", () => { | ||
| 301 | const notes = expectSuccess( | ||
| 302 | decodeNotesJson( | ||
| 303 | JSON.stringify([ | ||
| 304 | { | ||
| 305 | id: 8, | ||
| 306 | type: "DiffNote", | ||
| 307 | body: "Gone.", | ||
| 308 | created_at: "2026-07-02T00:00:00Z", | ||
| 309 | position: { new_path: null, old_path: "src/old.ts" }, | ||
| 310 | }, | ||
| 311 | ]), | ||
| 312 | ), | ||
| 313 | ); | ||
| 314 | |||
| 315 | expect(notes.comments[0]?.path).toBe("src/old.ts"); | ||
| 316 | }); | ||
| 317 | }); | ||
| 318 | |||
| 319 | describe("decodeCommitsJson", () => { | ||
| 320 | it("returns commits oldest first", () => { | ||
| 321 | const commits = expectSuccess( | ||
| 322 | decodeCommitsJson( | ||
| 323 | JSON.stringify([ | ||
| 324 | { id: "bbb", title: "second", committed_date: "2026-07-02T00:00:00Z" }, | ||
| 325 | { id: "aaa", title: "first", committed_date: "2026-07-01T00:00:00Z" }, | ||
| 326 | ]), | ||
| 327 | ), | ||
| 328 | ); | ||
| 329 | |||
| 330 | expect(commits.map((commit) => commit.oid)).toEqual(["aaa", "bbb"]); | ||
| 331 | }); | ||
| 332 | |||
| 333 | it("skips commits whose id is empty", () => { | ||
| 334 | const commits = expectSuccess( | ||
| 335 | decodeCommitsJson( | ||
| 336 | JSON.stringify([ | ||
| 337 | { id: " ", title: "invalid", committed_date: "2026-07-02T00:00:00Z" }, | ||
| 338 | { id: "aaa", committed_date: "2026-07-01T00:00:00Z" }, | ||
| 339 | ]), | ||
| 340 | ), | ||
| 341 | ); | ||
| 342 | |||
| 343 | expect(commits.map((commit) => commit.oid)).toEqual(["aaa"]); | ||
| 344 | }); | ||
| 345 | |||
| 346 | it("falls back to the creation timestamp when there is no commit date", () => { | ||
| 347 | const commits = expectSuccess( | ||
| 348 | decodeCommitsJson( | ||
| 349 | JSON.stringify([ | ||
| 350 | { | ||
| 351 | id: "aaa", | ||
| 352 | created_at: "2026-07-01T00:00:00+08:00", | ||
| 353 | author_name: "Ada Lovelace", | ||
| 354 | author_email: "ada@example.com", | ||
| 355 | }, | ||
| 356 | ]), | ||
| 357 | ), | ||
| 358 | ); | ||
| 359 | |||
| 360 | expect(commits[0]).toMatchObject({ | ||
| 361 | oid: "aaa", | ||
| 362 | committedDate: "2026-07-01T00:00:00+08:00", | ||
| 363 | authors: [{ login: "Ada Lovelace", name: "Ada Lovelace", avatarUrl: null }], | ||
| 364 | }); | ||
| 365 | }); | ||
| 366 | |||
| 367 | it("carries commit additions and deletions when GitLab returns stats", () => { | ||
| 368 | const commits = expectSuccess( | ||
| 369 | decodeCommitsJson( | ||
| 370 | JSON.stringify([ | ||
| 371 | { | ||
| 372 | id: "aaa", | ||
| 373 | committed_date: "2026-07-01T00:00:00Z", | ||
| 374 | stats: { additions: 21, deletions: 8, total: 29 }, | ||
| 375 | }, | ||
| 376 | ]), | ||
| 377 | ), | ||
| 378 | ); | ||
| 379 | |||
| 380 | expect(commits[0]).toMatchObject({ additions: 21, deletions: 8 }); | ||
| 381 | }); | ||
| 382 | }); | ||
| 383 | |||
| 384 | describe("decodeMergeRequestDiffsJson", () => { | ||
| 385 | it("quotes literal backslashes without interpreting them as escapes", () => { | ||
| 386 | const result = expectSuccess( | ||
| 387 | decodeMergeRequestDiffsJson( | ||
| 388 | JSON.stringify([ | ||
| 389 | { | ||
| 390 | old_path: String.raw`src\notes.ts`, | ||
| 391 | new_path: String.raw`src\notes.ts`, | ||
| 392 | diff: "@@ -1 +1 @@\n-old\n+new", | ||
| 393 | }, | ||
| 394 | ]), | ||
| 395 | ), | ||
| 396 | ); | ||
| 397 | |||
| 398 | expect(result.patch).toBe( | ||
| 399 | [ | ||
| 400 | String.raw`diff --git "a/src\\notes.ts" "b/src\\notes.ts"`, | ||
| 401 | String.raw`--- "a/src\\notes.ts"`, | ||
| 402 | String.raw`+++ "b/src\\notes.ts"`, | ||
| 403 | "@@ -1 +1 @@", | ||
| 404 | "-old", | ||
| 405 | "+new", | ||
| 406 | "", | ||
| 407 | ].join("\n"), | ||
| 408 | ); | ||
| 409 | }); | ||
| 410 | |||
| 411 | it("preserves spaces and literal backslashes in both rename paths", () => { | ||
| 412 | const result = expectSuccess( | ||
| 413 | decodeMergeRequestDiffsJson( | ||
| 414 | JSON.stringify([ | ||
| 415 | { | ||
| 416 | old_path: String.raw` old\name.ts `, | ||
| 417 | new_path: String.raw` new\name.ts `, | ||
| 418 | renamed_file: true, | ||
| 419 | diff: "", | ||
| 420 | }, | ||
| 421 | ]), | ||
| 422 | ), | ||
| 423 | ); | ||
| 424 | |||
| 425 | expect(result.patch).toBe( | ||
| 426 | [ | ||
| 427 | String.raw`diff --git "a/ old\\name.ts " "b/ new\\name.ts "`, | ||
| 428 | String.raw`rename from " old\\name.ts "`, | ||
| 429 | String.raw`rename to " new\\name.ts "`, | ||
| 430 | String.raw`--- "a/ old\\name.ts "`, | ||
| 431 | String.raw`+++ "b/ new\\name.ts "`, | ||
| 432 | ].join("\n"), | ||
| 433 | ); | ||
| 434 | }); | ||
| 435 | |||
| 436 | it("assembles a unified patch GitLab does not return", () => { | ||
| 437 | const result = expectSuccess( | ||
| 438 | decodeMergeRequestDiffsJson( | ||
| 439 | JSON.stringify([ | ||
| 440 | { | ||
| 441 | old_path: "src/app.ts", | ||
| 442 | new_path: "src/app.ts", | ||
| 443 | diff: "@@ -1 +1 @@\n-old\n+new\n", | ||
| 444 | }, | ||
| 445 | ]), | ||
| 446 | ), | ||
| 447 | ); | ||
| 448 | |||
| 449 | expect(result.patch).toBe( | ||
| 450 | [ | ||
| 451 | "diff --git a/src/app.ts b/src/app.ts", | ||
| 452 | "--- a/src/app.ts", | ||
| 453 | "+++ b/src/app.ts", | ||
| 454 | "@@ -1 +1 @@", | ||
| 455 | "-old", | ||
| 456 | "+new", | ||
| 457 | "", | ||
| 458 | ].join("\n"), | ||
| 459 | ); | ||
| 460 | expect(result.truncated).toBe(false); | ||
| 461 | }); | ||
| 462 | |||
| 463 | it("points a new file at /dev/null on the left and a deleted file on the right", () => { | ||
| 464 | const result = expectSuccess( | ||
| 465 | decodeMergeRequestDiffsJson( | ||
| 466 | JSON.stringify([ | ||
| 467 | { | ||
| 468 | old_path: "src/new.ts", | ||
| 469 | new_path: "src/new.ts", | ||
| 470 | new_file: true, | ||
| 471 | b_mode: "100755", | ||
| 472 | diff: "@@ -0,0 +1 @@\n+hello\n", | ||
| 473 | }, | ||
| 474 | { | ||
| 475 | old_path: "src/gone.ts", | ||
| 476 | new_path: "src/gone.ts", | ||
| 477 | deleted_file: true, | ||
| 478 | diff: "@@ -1 +0,0 @@\n-bye\n", | ||
| 479 | }, | ||
| 480 | ]), | ||
| 481 | ), | ||
| 482 | ); | ||
| 483 | |||
| 484 | expect(result.patch).toContain("new file mode 100755"); | ||
| 485 | expect(result.patch).toContain("--- /dev/null"); | ||
| 486 | expect(result.patch).toContain("deleted file mode 100644"); | ||
| 487 | expect(result.patch).toContain("+++ /dev/null"); | ||
| 488 | }); | ||
| 489 | |||
| 490 | it("records a rename so the patch names both paths", () => { | ||
| 491 | const result = expectSuccess( | ||
| 492 | decodeMergeRequestDiffsJson( | ||
| 493 | JSON.stringify([ | ||
| 494 | { old_path: "src/old.ts", new_path: "src/new.ts", renamed_file: true, diff: "" }, | ||
| 495 | ]), | ||
| 496 | ), | ||
| 497 | ); | ||
| 498 | |||
| 499 | expect(result.patch).toContain("rename from src/old.ts"); | ||
| 500 | expect(result.patch).toContain("rename to src/new.ts"); | ||
| 501 | }); | ||
| 502 | |||
| 503 | it("reports truncation for a file GitLab refused to inline", () => { | ||
| 504 | const result = expectSuccess( | ||
| 505 | decodeMergeRequestDiffsJson( | ||
| 506 | JSON.stringify([{ old_path: "big.bin", new_path: "big.bin", diff: "", too_large: true }]), | ||
| 507 | ), | ||
| 508 | ); | ||
| 509 | |||
| 510 | expect(result.truncated).toBe(true); | ||
| 511 | expect(result.patch).toContain("diff --git a/big.bin b/big.bin"); | ||
| 512 | }); | ||
| 513 | |||
| 514 | it("reports how many files GitLab returned, so the caller can page", () => { | ||
| 515 | const result = expectSuccess( | ||
| 516 | decodeMergeRequestDiffsJson( | ||
| 517 | JSON.stringify( | ||
| 518 | Array.from({ length: 3 }, (_, index) => ({ | ||
| 519 | old_path: `src/${index}.ts`, | ||
| 520 | new_path: `src/${index}.ts`, | ||
| 521 | diff: "@@ -1 +1 @@\n-a\n+b\n", | ||
| 522 | })), | ||
| 523 | ), | ||
| 524 | ), | ||
| 525 | ); | ||
| 526 | |||
| 527 | expect(result.rawCount).toBe(3); | ||
| 528 | expect(result.truncated).toBe(false); | ||
| 529 | expect(result.patch).toContain("src/2.ts"); | ||
| 530 | }); | ||
| 531 | |||
| 532 | it("fails when GitLab did not return a list", () => { | ||
| 533 | expect(Result.isFailure(decodeMergeRequestDiffsJson('{"message":"404"}'))).toBe(true); | ||
| 534 | }); | ||
| 535 | }); | ||
| 536 | |||
| 537 | describe("merge request viewer fields", () => { | ||
| 538 | it("carries GitLab's own answer for whether this viewer can merge", () => { | ||
| 539 | expect( | ||
| 540 | expectSuccess(decodeMergeRequestDetailJson(detailJson({ user: { can_merge: false } }))) | ||
| 541 | .viewerCanMerge, | ||
| 542 | ).toBe(false); | ||
| 543 | expect( | ||
| 544 | expectSuccess(decodeMergeRequestDetailJson(detailJson({ user: { can_merge: true } }))) | ||
| 545 | .viewerCanMerge, | ||
| 546 | ).toBe(true); | ||
| 547 | }); | ||
| 548 | |||
| 549 | it("leaves merging permitted where GitLab answered without the field", () => { | ||
| 550 | // Only the single-merge-request endpoint carries `user`, and an install that answers without | ||
| 551 | // it has said nothing about the viewer rather than said no. | ||
| 552 | expect(expectSuccess(decodeMergeRequestDetailJson(detailJson({}))).viewerCanMerge).toBe(true); | ||
| 553 | expect( | ||
| 554 | expectSuccess(decodeMergeRequestDetailJson(detailJson({ user: null }))).viewerCanMerge, | ||
| 555 | ).toBe(true); | ||
| 556 | }); | ||
| 557 | }); | ||
| 558 | |||
| 559 | describe("decodeAwardEmojiJson", () => { | ||
| 560 | it("reads MR-level awards, keys per-note awards by the REST id inside their gid, and ignores an award outside the eight", () => { | ||
| 561 | const result = expectSuccess( | ||
| 562 | decodeAwardEmojiJson( | ||
| 563 | JSON.stringify({ | ||
| 564 | data: { | ||
| 565 | currentUser: { username: "bilal" }, | ||
| 566 | project: { | ||
| 567 | mergeRequest: { | ||
| 568 | awardEmoji: { | ||
| 569 | nodes: [ | ||
| 570 | { name: "thumbsup", user: { username: "bilal" } }, | ||
| 571 | { name: "thumbsup", user: { username: "julius" } }, | ||
| 572 | ], | ||
| 573 | }, | ||
| 574 | notes: { | ||
| 575 | pageInfo: { hasNextPage: false, endCursor: null }, | ||
| 576 | nodes: [ | ||
| 577 | { | ||
| 578 | id: "gid://gitlab/DiffNote/42", | ||
| 579 | awardEmoji: { nodes: [{ name: "heart", user: { username: "julius" } }] }, | ||
| 580 | }, | ||
| 581 | { | ||
| 582 | id: "gid://gitlab/Note/7", | ||
| 583 | // Not one of the eight the contract carries. | ||
| 584 | awardEmoji: { | ||
| 585 | nodes: [{ name: "partyparrot", user: { username: "bilal" } }], | ||
| 586 | }, | ||
| 587 | }, | ||
| 588 | ], | ||
| 589 | }, | ||
| 590 | }, | ||
| 591 | }, | ||
| 592 | }, | ||
| 593 | }), | ||
| 594 | ), | ||
| 595 | ); | ||
| 596 | |||
| 597 | // `bilal` is `currentUser`, so the group they are in reads back as reacted, but their own | ||
| 598 | // username is left out of `actors` — the page names them "You" instead — while `count` still | ||
| 599 | // counts them; `julius` alone does not turn a group's own `viewerHasReacted` on. | ||
| 600 | expect(result.reactions).toEqual([ | ||
| 601 | { content: "thumbs-up", count: 2, actors: ["julius"], viewerHasReacted: true }, | ||
| 602 | ]); | ||
| 603 | // Note 7's only award named nobody the eight recognise, so it carries no reactions and is | ||
| 604 | // left out of the map rather than kept empty. | ||
| 605 | expect([...result.reactionsByNoteId]).toEqual([ | ||
| 606 | ["42", [{ content: "heart", count: 1, actors: ["julius"], viewerHasReacted: false }]], | ||
| 607 | ]); | ||
| 608 | expect(result.nextCursor).toBeNull(); | ||
| 609 | }); | ||
| 610 | |||
| 611 | it("hands back a cursor when GitLab has more notes to page", () => { | ||
| 612 | const result = expectSuccess( | ||
| 613 | decodeAwardEmojiJson( | ||
| 614 | JSON.stringify({ | ||
| 615 | data: { | ||
| 616 | currentUser: null, | ||
| 617 | project: { | ||
| 618 | mergeRequest: { | ||
| 619 | awardEmoji: { nodes: [] }, | ||
| 620 | notes: { pageInfo: { hasNextPage: true, endCursor: "Y3Vyc29yOjE" }, nodes: [] }, | ||
| 621 | }, | ||
| 622 | }, | ||
| 623 | }, | ||
| 624 | }), | ||
| 625 | ), | ||
| 626 | ); | ||
| 627 | |||
| 628 | expect(result.nextCursor).toBe("Y3Vyc29yOjE"); | ||
| 629 | }); | ||
| 630 | |||
| 631 | it("matches the viewer's username case-insensitively, since GitLab is not consistent about case", () => { | ||
| 632 | const result = expectSuccess( | ||
| 633 | decodeAwardEmojiJson( | ||
| 634 | JSON.stringify({ | ||
| 635 | data: { | ||
| 636 | currentUser: { username: "Bilal" }, | ||
| 637 | project: { | ||
| 638 | mergeRequest: { | ||
| 639 | awardEmoji: { | ||
| 640 | nodes: [ | ||
| 641 | { name: "heart", user: { username: "bilal" } }, | ||
| 642 | { name: "heart", user: { username: "julius" } }, | ||
| 643 | ], | ||
| 644 | }, | ||
| 645 | notes: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, | ||
| 646 | }, | ||
| 647 | }, | ||
| 648 | }, | ||
| 649 | }), | ||
| 650 | ), | ||
| 651 | ); | ||
| 652 | |||
| 653 | expect(result.reactions).toEqual([ | ||
| 654 | { content: "heart", count: 2, actors: ["julius"], viewerHasReacted: true }, | ||
| 655 | ]); | ||
| 656 | }); | ||
| 657 | }); | ||
| 658 | |||
| 659 | describe("decodeOwnAwardIdJson", () => { | ||
| 660 | const awards = JSON.stringify([ | ||
| 661 | { id: 101, name: "thumbsup", user: { username: "julius" } }, | ||
| 662 | { id: 102, name: "thumbsup", user: { username: "bilal" } }, | ||
| 663 | ]); | ||
| 664 | |||
| 665 | it("finds the reader's own award of that name, which is the one a removal deletes", () => { | ||
| 666 | expect( | ||
| 667 | expectSuccess(decodeOwnAwardIdJson(awards, { content: "thumbs-up", viewer: "bilal" })), | ||
| 668 | ).toBe(102); | ||
| 669 | }); | ||
| 670 | |||
| 671 | it("returns nothing where the reader has no award of that name", () => { | ||
| 672 | expect( | ||
| 673 | expectSuccess(decodeOwnAwardIdJson(awards, { content: "heart", viewer: "bilal" })), | ||
| 674 | ).toBeNull(); | ||
| 675 | }); | ||
| 676 | }); | ||
| 677 | |||
| 678 | describe("gitLabAwardName", () => { | ||
| 679 | it("spells the contents whose GitLab award name is not their own kebab-case", () => { | ||
| 680 | expect(gitLabAwardName("thumbs-up")).toBe("thumbsup"); | ||
| 681 | expect(gitLabAwardName("laugh")).toBe("laughing"); | ||
| 682 | expect(gitLabAwardName("hooray")).toBe("tada"); | ||
| 683 | }); | ||
| 684 | }); | ||
| 685 | |||
| 686 | describe("decodeRepositoryBlobsJson", () => { | ||
| 687 | /** Null is the query going unanswered, which these cases are not about. */ | ||
| 688 | function expectBlobs(result: Result.Result<ReadonlyMap<string, string> | null, unknown>) { | ||
| 689 | const blobs = expectSuccess(result); | ||
| 690 | expect(blobs).not.toBe(null); | ||
| 691 | if (blobs === null) throw new Error("expected an answered blobs query"); | ||
| 692 | return blobs; | ||
| 693 | } | ||
| 694 | |||
| 695 | it("reads a blob id per path", () => { | ||
| 696 | const blobs = expectBlobs( | ||
| 697 | decodeRepositoryBlobsJson( | ||
| 698 | JSON.stringify({ | ||
| 699 | data: { | ||
| 700 | project: { | ||
| 701 | repository: { | ||
| 702 | blobs: { | ||
| 703 | nodes: [ | ||
| 704 | { path: "src/a.ts", oid: "aaa111" }, | ||
| 705 | { path: "src/b.ts", oid: "bbb222" }, | ||
| 706 | ], | ||
| 707 | }, | ||
| 708 | }, | ||
| 709 | }, | ||
| 710 | }, | ||
| 711 | }), | ||
| 712 | ), | ||
| 713 | ); | ||
| 714 | |||
| 715 | expect([...blobs]).toEqual([ | ||
| 716 | ["src/a.ts", "aaa111"], | ||
| 717 | ["src/b.ts", "bbb222"], | ||
| 718 | ]); | ||
| 719 | }); | ||
| 720 | |||
| 721 | it("leaves out a node missing either half, which names no version", () => { | ||
| 722 | const blobs = expectBlobs( | ||
| 723 | decodeRepositoryBlobsJson( | ||
| 724 | JSON.stringify({ | ||
| 725 | data: { | ||
| 726 | project: { | ||
| 727 | repository: { | ||
| 728 | blobs: { | ||
| 729 | nodes: [ | ||
| 730 | { path: "src/a.ts", oid: null }, | ||
| 731 | { path: null, oid: "bbb222" }, | ||
| 732 | null, | ||
| 733 | { path: "src/c.ts", oid: "ccc333" }, | ||
| 734 | ], | ||
| 735 | }, | ||
| 736 | }, | ||
| 737 | }, | ||
| 738 | }, | ||
| 739 | }), | ||
| 740 | ), | ||
| 741 | ); | ||
| 742 | |||
| 743 | expect([...blobs]).toEqual([["src/c.ts", "ccc333"]]); | ||
| 744 | }); | ||
| 745 | |||
| 746 | it("keys a blob by the path the host spelled, spaces and all", () => { | ||
| 747 | // A leading or trailing space is a legal part of a file's name. Trimmed here, the id lands | ||
| 748 | // under a key the asked-for path is not spelled with, and the caller fills that path in as | ||
| 749 | // the empty revision: a mark on the file then never compares against the real head blob. | ||
| 750 | const blobs = expectBlobs( | ||
| 751 | decodeRepositoryBlobsJson( | ||
| 752 | JSON.stringify({ | ||
| 753 | data: { | ||
| 754 | project: { | ||
| 755 | repository: { | ||
| 756 | blobs: { | ||
| 757 | nodes: [ | ||
| 758 | { path: " leading.ts", oid: "aaa111" }, | ||
| 759 | { path: "trailing.ts ", oid: "bbb222" }, | ||
| 760 | { path: " ", oid: "ccc333" }, | ||
| 761 | { path: "", oid: "ddd444" }, | ||
| 762 | ], | ||
| 763 | }, | ||
| 764 | }, | ||
| 765 | }, | ||
| 766 | }, | ||
| 767 | }), | ||
| 768 | ), | ||
| 769 | ); | ||
| 770 | |||
| 771 | expect([...blobs]).toEqual([ | ||
| 772 | [" leading.ts", "aaa111"], | ||
| 773 | ["trailing.ts ", "bbb222"], | ||
| 774 | // A name that is only spaces is one Git carries too, so it is a path like any other. | ||
| 775 | [" ", "ccc333"], | ||
| 776 | ]); | ||
| 777 | }); | ||
| 778 | |||
| 779 | it("tells a project the reader cannot see from a revision with none of the files", () => { | ||
| 780 | // Null is the query going unanswered. Read as an empty answer it would say the head has none | ||
| 781 | // of the asked-for files, which reports every file a reader has cleared as changed. | ||
| 782 | expect( | ||
| 783 | expectSuccess(decodeRepositoryBlobsJson(JSON.stringify({ data: { project: null } }))), | ||
| 784 | ).toBe(null); | ||
| 785 | expect( | ||
| 786 | expectSuccess( | ||
| 787 | decodeRepositoryBlobsJson(JSON.stringify({ data: { project: { repository: null } } })), | ||
| 788 | ), | ||
| 789 | ).toBe(null); | ||
| 790 | expect( | ||
| 791 | expectSuccess( | ||
| 792 | decodeRepositoryBlobsJson( | ||
| 793 | JSON.stringify({ data: { project: { repository: { blobs: { nodes: [] } } } } }), | ||
| 794 | ), | ||
| 795 | ), | ||
| 796 | ).toEqual(new Map()); | ||
| 797 | }); | ||
| 798 | |||
| 799 | it("fails on output that is not the query's shape", () => { | ||
| 800 | expect(Result.isSuccess(decodeRepositoryBlobsJson("not json"))).toBe(false); | ||
| 801 | expect(Result.isSuccess(decodeRepositoryBlobsJson(JSON.stringify({ errors: [] })))).toBe(false); | ||
| 802 | }); | ||
| 803 | }); | ||
packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts created+1032| ... | @@ -0,0 +1,1032 @@ | ||
| 1 | import * as Cause from "effect/Cause"; | ||
| 2 | import * as Exit from "effect/Exit"; | ||
| 3 | import * as Result from "effect/Result"; | ||
| 4 | import * as Schema from "effect/Schema"; | ||
| 5 | import type { | ||
| 6 | PullRequestActor, | ||
| 7 | PullRequestCheck, | ||
| 8 | PullRequestCheckStatus, | ||
| 9 | PullRequestComment, | ||
| 10 | PullRequestCommit, | ||
| 11 | PullRequestLabel, | ||
| 12 | PullRequestMergeability, | ||
| 13 | PullRequestMergeCapabilities, | ||
| 14 | PullRequestMergeMethod, | ||
| 15 | PullRequestReaction, | ||
| 16 | PullRequestReactionContent, | ||
| 17 | PullRequestReviewThread, | ||
| 18 | PullRequestReviewerCandidate, | ||
| 19 | PullRequestState, | ||
| 20 | } from "@t3tools/contracts"; | ||
| 21 | import { TrimmedNonEmptyString } from "@t3tools/contracts"; | ||
| 22 | import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; | ||
| 23 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; | ||
| 24 | |||
| 25 | /** | ||
| 26 | * GitLab's REST enums are decoded as plain strings and normalized here: a GitLab release that | ||
| 27 | * adds a pipeline status or a merge status must not fail the whole payload. | ||
| 28 | */ | ||
| 29 | const RawUserSchema = Schema.Struct({ | ||
| 30 | /** | ||
| 31 | * GitLab writes a merge request's reviewers as numeric ids and takes no usernames there, so the | ||
| 32 | * id is carried alongside the handle rather than looked up again when a review is asked for. | ||
| 33 | */ | ||
| 34 | id: Schema.optional(Schema.Int), | ||
| 35 | username: Schema.String, | ||
| 36 | name: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 37 | avatar_url: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 38 | }); | ||
| 39 | |||
| 40 | const RawPipelineSchema = Schema.Struct({ | ||
| 41 | status: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 42 | web_url: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 43 | source: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 44 | }); | ||
| 45 | |||
| 46 | const RawMergeRequestSchema = Schema.Struct({ | ||
| 47 | iid: Schema.Int, | ||
| 48 | title: Schema.String, | ||
| 49 | web_url: Schema.String, | ||
| 50 | description: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 51 | author: Schema.optional(Schema.NullOr(RawUserSchema)), | ||
| 52 | source_branch: Schema.String, | ||
| 53 | target_branch: Schema.String, | ||
| 54 | state: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 55 | draft: Schema.optional(Schema.Boolean), | ||
| 56 | work_in_progress: Schema.optional(Schema.Boolean), | ||
| 57 | merge_status: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 58 | has_conflicts: Schema.optional(Schema.NullOr(Schema.Boolean)), | ||
| 59 | created_at: Schema.String, | ||
| 60 | updated_at: Schema.String, | ||
| 61 | merged_at: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 62 | closed_at: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 63 | reviewers: Schema.optional(Schema.NullOr(Schema.Array(RawUserSchema))), | ||
| 64 | labels: Schema.optional(Schema.NullOr(Schema.Array(Schema.String))), | ||
| 65 | // A string, and "1000+" past GitLab's counting limit, so it is parsed rather than decoded. | ||
| 66 | changes_count: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 67 | head_pipeline: Schema.optional(Schema.NullOr(RawPipelineSchema)), | ||
| 68 | /** | ||
| 69 | * What the requesting account may do, which only the single-merge-request endpoint carries. | ||
| 70 | * GitLab answers `can_merge` for this viewer against this merge request, so it already accounts | ||
| 71 | * for the role, the approval rules and a protected target branch — none of which a project's | ||
| 72 | * access level on its own would tell apart. | ||
| 73 | */ | ||
| 74 | user: Schema.optional( | ||
| 75 | Schema.NullOr(Schema.Struct({ can_merge: Schema.optional(Schema.Boolean) })), | ||
| 76 | ), | ||
| 77 | /** | ||
| 78 | * Whether GitLab is holding this merge request to merge it once its pipeline goes green. | ||
| 79 | * `merge_when_pipeline_succeeds` is the field every version answers with; newer ones also | ||
| 80 | * carry `auto_merge_enabled`, which is the same fact under the name GitLab settled on, so | ||
| 81 | * either one saying yes is a yes. | ||
| 82 | */ | ||
| 83 | merge_when_pipeline_succeeds: Schema.optional(Schema.NullOr(Schema.Boolean)), | ||
| 84 | auto_merge_enabled: Schema.optional(Schema.NullOr(Schema.Boolean)), | ||
| 85 | /** The merge request's stored squash choice, including project-policy overrides. */ | ||
| 86 | squash_on_merge: Schema.optional(Schema.NullOr(Schema.Boolean)), | ||
| 87 | squash: Schema.optional(Schema.NullOr(Schema.Boolean)), | ||
| 88 | /** | ||
| 89 | * How far the target branch has moved on since this one left it, which is the same number | ||
| 90 | * GitLab's own "out of date" wording counts. It costs a walk of the two branches, so GitLab | ||
| 91 | * withholds it unless `include_diverged_commits_count` asks for it, and answers it only for a | ||
| 92 | * single merge request — a list never carries it, however it is asked for. | ||
| 93 | */ | ||
| 94 | diverged_commits_count: Schema.optional(Schema.NullOr(Schema.Int)), | ||
| 95 | }); | ||
| 96 | |||
| 97 | const RawNoteSchema = Schema.Struct({ | ||
| 98 | id: Schema.Int, | ||
| 99 | body: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 100 | author: Schema.optional(Schema.NullOr(RawUserSchema)), | ||
| 101 | created_at: Schema.String, | ||
| 102 | /** True for notes GitLab writes itself ("assigned to…"), which are events, not comments. */ | ||
| 103 | system: Schema.optional(Schema.Boolean), | ||
| 104 | type: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 105 | position: Schema.optional( | ||
| 106 | Schema.NullOr( | ||
| 107 | Schema.Struct({ | ||
| 108 | new_path: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 109 | old_path: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 110 | }), | ||
| 111 | ), | ||
| 112 | ), | ||
| 113 | }); | ||
| 114 | |||
| 115 | /** | ||
| 116 | * A discussion note carrying its place in the diff, which is the shape the whole thread view | ||
| 117 | * is built from. `resolved` lives on the note rather than on the discussion: GitLab calls a | ||
| 118 | * discussion resolved once every resolvable note in it is. | ||
| 119 | */ | ||
| 120 | const RawDiscussionNoteSchema = Schema.Struct({ | ||
| 121 | id: Schema.Int, | ||
| 122 | body: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 123 | author: Schema.optional(Schema.NullOr(RawUserSchema)), | ||
| 124 | created_at: Schema.String, | ||
| 125 | system: Schema.optional(Schema.Boolean), | ||
| 126 | resolvable: Schema.optional(Schema.Boolean), | ||
| 127 | resolved: Schema.optional(Schema.NullOr(Schema.Boolean)), | ||
| 128 | position: Schema.optional( | ||
| 129 | Schema.NullOr( | ||
| 130 | Schema.Struct({ | ||
| 131 | position_type: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 132 | new_path: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 133 | old_path: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 134 | new_line: Schema.optional(Schema.NullOr(Schema.Int)), | ||
| 135 | old_line: Schema.optional(Schema.NullOr(Schema.Int)), | ||
| 136 | }), | ||
| 137 | ), | ||
| 138 | ), | ||
| 139 | }); | ||
| 140 | |||
| 141 | const RawDiscussionSchema = Schema.Struct({ | ||
| 142 | id: Schema.String, | ||
| 143 | notes: Schema.optional(Schema.NullOr(Schema.Array(RawDiscussionNoteSchema))), | ||
| 144 | }); | ||
| 145 | |||
| 146 | const RawDiffRefsSchema = Schema.Struct({ | ||
| 147 | diff_refs: Schema.optional( | ||
| 148 | Schema.NullOr( | ||
| 149 | Schema.Struct({ | ||
| 150 | base_sha: Schema.String, | ||
| 151 | head_sha: Schema.String, | ||
| 152 | start_sha: Schema.String, | ||
| 153 | }), | ||
| 154 | ), | ||
| 155 | ), | ||
| 156 | }); | ||
| 157 | |||
| 158 | const RawCommitSchema = Schema.Struct({ | ||
| 159 | id: TrimmedNonEmptyString, | ||
| 160 | title: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 161 | committed_date: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 162 | created_at: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 163 | parent_ids: Schema.optional(Schema.Array(Schema.String)), | ||
| 164 | author_name: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 165 | author_email: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 166 | stats: Schema.optional( | ||
| 167 | Schema.NullOr( | ||
| 168 | Schema.Struct({ | ||
| 169 | additions: Schema.optional(Schema.Int), | ||
| 170 | deletions: Schema.optional(Schema.Int), | ||
| 171 | }), | ||
| 172 | ), | ||
| 173 | ), | ||
| 174 | }); | ||
| 175 | |||
| 176 | const RawDiffSchema = Schema.Struct({ | ||
| 177 | old_path: Schema.String, | ||
| 178 | new_path: Schema.String, | ||
| 179 | a_mode: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 180 | b_mode: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 181 | new_file: Schema.optional(Schema.Boolean), | ||
| 182 | renamed_file: Schema.optional(Schema.Boolean), | ||
| 183 | deleted_file: Schema.optional(Schema.Boolean), | ||
| 184 | diff: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 185 | /** GitLab omits the hunks for a file it considers too large to inline. */ | ||
| 186 | too_large: Schema.optional(Schema.NullOr(Schema.Boolean)), | ||
| 187 | /** And for one it collapsed, which withholds them the same way. */ | ||
| 188 | collapsed: Schema.optional(Schema.NullOr(Schema.Boolean)), | ||
| 189 | }); | ||
| 190 | |||
| 191 | const RawViewerSchema = Schema.Struct({ | ||
| 192 | username: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 193 | }); | ||
| 194 | |||
| 195 | /** A GitLab project settles on one merge strategy plus an optional squash. */ | ||
| 196 | const RawProjectMergeSettingsSchema = Schema.Struct({ | ||
| 197 | merge_method: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 198 | squash_option: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 199 | }); | ||
| 200 | |||
| 201 | export interface GitLabMergeRequestListItem { | ||
| 202 | readonly number: number; | ||
| 203 | readonly title: string; | ||
| 204 | readonly url: string; | ||
| 205 | readonly author: PullRequestActor | null; | ||
| 206 | readonly headBranch: string; | ||
| 207 | readonly baseBranch: string; | ||
| 208 | readonly state: PullRequestState; | ||
| 209 | readonly isDraft: boolean; | ||
| 210 | readonly mergeability: PullRequestMergeability; | ||
| 211 | /** | ||
| 212 | * GitLab reports neither added nor removed lines on a merge request, so both stay zero and | ||
| 213 | * the surface omits the stat. The Code tab counts them from the patch it already fetched. | ||
| 214 | */ | ||
| 215 | readonly additions: number; | ||
| 216 | readonly deletions: number; | ||
| 217 | readonly createdAt: string; | ||
| 218 | readonly updatedAt: string; | ||
| 219 | readonly reviewRequestLogins: ReadonlyArray<string>; | ||
| 220 | readonly labels: ReadonlyArray<PullRequestLabel>; | ||
| 221 | } | ||
| 222 | |||
| 223 | export interface GitLabMergeRequestDetail extends GitLabMergeRequestListItem { | ||
| 224 | readonly body: string; | ||
| 225 | readonly changedFiles: number; | ||
| 226 | readonly mergedAt: string | null; | ||
| 227 | readonly closedAt: string | null; | ||
| 228 | readonly reviewers: ReadonlyArray<PullRequestActor>; | ||
| 229 | readonly checks: ReadonlyArray<PullRequestCheck>; | ||
| 230 | /** False only where GitLab said so; an answer without the field leaves merging permitted. */ | ||
| 231 | readonly viewerCanMerge: boolean; | ||
| 232 | /** The reviewers as GitLab addresses them, which is what writing the set back takes. */ | ||
| 233 | readonly reviewerIds: ReadonlyArray<number>; | ||
| 234 | /** Absent where GitLab named neither auto-merge field, which is not the same as off. */ | ||
| 235 | readonly autoMergeEnabled?: boolean; | ||
| 236 | /** GitLab only exposes the stored strategy separately when that strategy is squash. */ | ||
| 237 | readonly autoMergeMethod?: PullRequestMergeMethod; | ||
| 238 | /** | ||
| 239 | * Absent where GitLab did not count, which is not the same as a branch that has nothing behind | ||
| 240 | * it: an install too old to answer must not be read as saying the branch is current. | ||
| 241 | */ | ||
| 242 | readonly divergedCommits?: number; | ||
| 243 | } | ||
| 244 | |||
| 245 | function trimmed(value: string | null | undefined): string | null { | ||
| 246 | const text = value?.trim() ?? ""; | ||
| 247 | return text.length > 0 ? text : null; | ||
| 248 | } | ||
| 249 | |||
| 250 | function toActor(raw: Schema.Schema.Type<typeof RawUserSchema> | null | undefined) { | ||
| 251 | const login = trimmed(raw?.username); | ||
| 252 | return login === null | ||
| 253 | ? null | ||
| 254 | : { login, name: trimmed(raw?.name), avatarUrl: trimmed(raw?.avatar_url) }; | ||
| 255 | } | ||
| 256 | |||
| 257 | function toState(raw: Schema.Schema.Type<typeof RawMergeRequestSchema>): PullRequestState { | ||
| 258 | if (trimmed(raw.merged_at) !== null) return "merged"; | ||
| 259 | switch (raw.state?.trim().toLowerCase()) { | ||
| 260 | case "merged": | ||
| 261 | return "merged"; | ||
| 262 | case "closed": | ||
| 263 | return "closed"; | ||
| 264 | default: | ||
| 265 | // `locked` is an open merge request whose discussion is locked. | ||
| 266 | return "open"; | ||
| 267 | } | ||
| 268 | } | ||
| 269 | |||
| 270 | function toMergeability( | ||
| 271 | raw: Schema.Schema.Type<typeof RawMergeRequestSchema>, | ||
| 272 | ): PullRequestMergeability { | ||
| 273 | if (raw.has_conflicts === true) return "conflicting"; | ||
| 274 | switch (raw.merge_status?.trim().toLowerCase()) { | ||
| 275 | case "can_be_merged": | ||
| 276 | return "mergeable"; | ||
| 277 | case "cannot_be_merged": | ||
| 278 | return "conflicting"; | ||
| 279 | default: | ||
| 280 | // `unchecked` and `checking` mean GitLab has not finished the merge check yet. | ||
| 281 | return "unknown"; | ||
| 282 | } | ||
| 283 | } | ||
| 284 | |||
| 285 | function toLabels(raw: ReadonlyArray<string> | null | undefined): ReadonlyArray<PullRequestLabel> { | ||
| 286 | // GitLab returns label names only, so there is no colour to carry. | ||
| 287 | return (raw ?? []).flatMap((label) => { | ||
| 288 | const name = trimmed(label); | ||
| 289 | return name === null ? [] : [{ name, color: null }]; | ||
| 290 | }); | ||
| 291 | } | ||
| 292 | |||
| 293 | /** | ||
| 294 | * "3" for a counted change set, "1000+" once GitLab gives up counting. The leading number is | ||
| 295 | * the floor either way, which reads better than dropping an uncounted change set to nothing. | ||
| 296 | */ | ||
| 297 | function toChangedFiles(value: string | null | undefined): number { | ||
| 298 | const parsed = Number.parseInt(value?.trim() ?? "", 10); | ||
| 299 | return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; | ||
| 300 | } | ||
| 301 | |||
| 302 | function toPipelineStatus(value: string | null | undefined): PullRequestCheckStatus { | ||
| 303 | switch (value?.trim().toLowerCase()) { | ||
| 304 | case "success": | ||
| 305 | return "success"; | ||
| 306 | case "failed": | ||
| 307 | return "failure"; | ||
| 308 | case "canceled": | ||
| 309 | case "cancelling": | ||
| 310 | return "cancelled"; | ||
| 311 | case "skipped": | ||
| 312 | return "skipped"; | ||
| 313 | // A pipeline waiting on a person is not progress, and it is not a failure either. | ||
| 314 | case "manual": | ||
| 315 | case "scheduled": | ||
| 316 | return "neutral"; | ||
| 317 | default: | ||
| 318 | return "pending"; | ||
| 319 | } | ||
| 320 | } | ||
| 321 | |||
| 322 | /** | ||
| 323 | * GitLab has no per-job check list on a merge request, so its pipeline is reported as the one | ||
| 324 | * check. The jobs behind it stay one click away through the pipeline URL. | ||
| 325 | */ | ||
| 326 | function toChecks( | ||
| 327 | raw: Schema.Schema.Type<typeof RawMergeRequestSchema>, | ||
| 328 | ): ReadonlyArray<PullRequestCheck> { | ||
| 329 | const pipeline = raw.head_pipeline; | ||
| 330 | if (!pipeline) return []; | ||
| 331 | return [ | ||
| 332 | { | ||
| 333 | name: "Pipeline", | ||
| 334 | status: toPipelineStatus(pipeline.status), | ||
| 335 | description: trimmed(pipeline.source), | ||
| 336 | url: trimmed(pipeline.web_url), | ||
| 337 | }, | ||
| 338 | ]; | ||
| 339 | } | ||
| 340 | |||
| 341 | function toListItem( | ||
| 342 | raw: Schema.Schema.Type<typeof RawMergeRequestSchema>, | ||
| 343 | ): GitLabMergeRequestListItem { | ||
| 344 | return { | ||
| 345 | number: raw.iid, | ||
| 346 | title: raw.title, | ||
| 347 | url: raw.web_url, | ||
| 348 | author: toActor(raw.author), | ||
| 349 | headBranch: raw.source_branch, | ||
| 350 | baseBranch: raw.target_branch, | ||
| 351 | state: toState(raw), | ||
| 352 | isDraft: raw.draft ?? raw.work_in_progress ?? false, | ||
| 353 | mergeability: toMergeability(raw), | ||
| 354 | additions: 0, | ||
| 355 | deletions: 0, | ||
| 356 | createdAt: raw.created_at, | ||
| 357 | updatedAt: raw.updated_at, | ||
| 358 | reviewRequestLogins: (raw.reviewers ?? []).flatMap((reviewer) => { | ||
| 359 | const login = trimmed(reviewer.username); | ||
| 360 | return login === null ? [] : [login]; | ||
| 361 | }), | ||
| 362 | labels: toLabels(raw.labels), | ||
| 363 | }; | ||
| 364 | } | ||
| 365 | |||
| 366 | function toDetail(raw: Schema.Schema.Type<typeof RawMergeRequestSchema>): GitLabMergeRequestDetail { | ||
| 367 | const listItem = toListItem(raw); | ||
| 368 | const autoMerge = | ||
| 369 | raw.merge_when_pipeline_succeeds == null && raw.auto_merge_enabled == null | ||
| 370 | ? undefined | ||
| 371 | : raw.merge_when_pipeline_succeeds === true || raw.auto_merge_enabled === true; | ||
| 372 | return { | ||
| 373 | ...listItem, | ||
| 374 | body: raw.description ?? "", | ||
| 375 | changedFiles: toChangedFiles(raw.changes_count), | ||
| 376 | mergedAt: trimmed(raw.merged_at), | ||
| 377 | closedAt: trimmed(raw.closed_at), | ||
| 378 | // Built from the reviewers themselves rather than from their logins, so the avatars survive. | ||
| 379 | reviewers: (raw.reviewers ?? []).flatMap((reviewer) => { | ||
| 380 | const actor = toActor(reviewer); | ||
| 381 | return actor === null ? [] : [actor]; | ||
| 382 | }), | ||
| 383 | checks: toChecks(raw), | ||
| 384 | viewerCanMerge: raw.user?.can_merge !== false, | ||
| 385 | reviewerIds: (raw.reviewers ?? []).flatMap((reviewer) => | ||
| 386 | reviewer.id === undefined ? [] : [reviewer.id], | ||
| 387 | ), | ||
| 388 | ...(autoMerge === undefined ? {} : { autoMergeEnabled: autoMerge }), | ||
| 389 | ...(autoMerge === true && raw.squash_on_merge === true | ||
| 390 | ? { autoMergeMethod: "squash" as const } | ||
| 391 | : {}), | ||
| 392 | ...(raw.diverged_commits_count == null ? {} : { divergedCommits: raw.diverged_commits_count }), | ||
| 393 | }; | ||
| 394 | } | ||
| 395 | |||
| 396 | const decodeUnknownList = decodeJsonResult(Schema.Array(Schema.Unknown)); | ||
| 397 | const decodeMergeRequestEntry = Schema.decodeUnknownExit(RawMergeRequestSchema); | ||
| 398 | const decodeMergeRequest = decodeJsonResult(RawMergeRequestSchema); | ||
| 399 | const decodeNoteEntry = Schema.decodeUnknownExit(RawNoteSchema); | ||
| 400 | const decodeUserEntry = Schema.decodeUnknownExit(RawUserSchema); | ||
| 401 | const decodeCommitEntry = Schema.decodeUnknownExit(RawCommitSchema); | ||
| 402 | const decodeCommit = decodeJsonResult(RawCommitSchema); | ||
| 403 | const decodeDiffEntry = Schema.decodeUnknownExit(RawDiffSchema); | ||
| 404 | const decodeDiscussionEntry = Schema.decodeUnknownExit(RawDiscussionSchema); | ||
| 405 | const decodeDiffRefs = decodeJsonResult(RawDiffRefsSchema); | ||
| 406 | const decodeViewer = decodeJsonResult(RawViewerSchema); | ||
| 407 | const decodeProjectMergeSettings = decodeJsonResult(RawProjectMergeSettingsSchema); | ||
| 408 | |||
| 409 | type DecodeFailure = Cause.Cause<Schema.SchemaError>; | ||
| 410 | |||
| 411 | export interface GitLabProjectUsers { | ||
| 412 | readonly candidates: ReadonlyArray<PullRequestReviewerCandidate>; | ||
| 413 | /** Rows GitLab returned, counted before decoding, so a skipped row cannot hide a next page. */ | ||
| 414 | readonly rawCount: number; | ||
| 415 | } | ||
| 416 | |||
| 417 | export interface GitLabMergeRequestListBatch { | ||
| 418 | readonly items: ReadonlyArray<GitLabMergeRequestListItem>; | ||
| 419 | /** Zero-based positions of the decoded items in GitLab's raw page. */ | ||
| 420 | readonly rawIndexes: ReadonlyArray<number>; | ||
| 421 | /** Rows GitLab returned, counted before decoding, so a skipped row cannot hide a next page. */ | ||
| 422 | readonly rawCount: number; | ||
| 423 | } | ||
| 424 | |||
| 425 | /** Malformed entries are skipped rather than failing the batch: one unexpected merge request | ||
| 426 | * must not blank the whole list. */ | ||
| 427 | export function decodeMergeRequestListJson( | ||
| 428 | raw: string, | ||
| 429 | ): Result.Result<GitLabMergeRequestListBatch, DecodeFailure> { | ||
| 430 | const decoded = decodeUnknownList(raw); | ||
| 431 | if (!Result.isSuccess(decoded)) { | ||
| 432 | return Result.fail(decoded.failure); | ||
| 433 | } | ||
| 434 | const items: GitLabMergeRequestListItem[] = []; | ||
| 435 | const rawIndexes: number[] = []; | ||
| 436 | for (const [rawIndex, entry] of decoded.success.entries()) { | ||
| 437 | const item = decodeMergeRequestEntry(entry); | ||
| 438 | if (Exit.isSuccess(item)) { | ||
| 439 | items.push(toListItem(item.value)); | ||
| 440 | rawIndexes.push(rawIndex); | ||
| 441 | } | ||
| 442 | } | ||
| 443 | return Result.succeed({ items, rawIndexes, rawCount: decoded.success.length }); | ||
| 444 | } | ||
| 445 | |||
| 446 | export function decodeMergeRequestDetailJson( | ||
| 447 | raw: string, | ||
| 448 | ): Result.Result<GitLabMergeRequestDetail, DecodeFailure> { | ||
| 449 | const decoded = decodeMergeRequest(raw); | ||
| 450 | return Result.isSuccess(decoded) | ||
| 451 | ? Result.succeed(toDetail(decoded.success)) | ||
| 452 | : Result.fail(decoded.failure); | ||
| 453 | } | ||
| 454 | |||
| 455 | export function decodeViewerJson(raw: string): Result.Result<string | null, DecodeFailure> { | ||
| 456 | const decoded = decodeViewer(raw); | ||
| 457 | return Result.isSuccess(decoded) | ||
| 458 | ? Result.succeed(trimmed(decoded.success.username)) | ||
| 459 | : Result.fail(decoded.failure); | ||
| 460 | } | ||
| 461 | |||
| 462 | /** | ||
| 463 | * The people with access to the project, which `GET /projects/:id/users` answers with — the same | ||
| 464 | * list GitLab's own reviewer field is filled from, including the members a group above the project | ||
| 465 | * lends it. A malformed row is skipped rather than failing the menu it belongs to. | ||
| 466 | * | ||
| 467 | * Nobody is marked requested here: who has been asked lives on the merge request, and only the | ||
| 468 | * caller holds both. | ||
| 469 | */ | ||
| 470 | export function decodeProjectUsersJson( | ||
| 471 | raw: string, | ||
| 472 | ): Result.Result<GitLabProjectUsers, DecodeFailure> { | ||
| 473 | const decoded = decodeUnknownList(raw); | ||
| 474 | if (!Result.isSuccess(decoded)) { | ||
| 475 | return Result.fail(decoded.failure); | ||
| 476 | } | ||
| 477 | const candidates: PullRequestReviewerCandidate[] = []; | ||
| 478 | for (const entry of decoded.success) { | ||
| 479 | const user = decodeUserEntry(entry); | ||
| 480 | if (Exit.isFailure(user) || user.value.id === undefined) continue; | ||
| 481 | const actor = toActor(user.value); | ||
| 482 | if (actor === null) continue; | ||
| 483 | candidates.push({ | ||
| 484 | ...actor, | ||
| 485 | id: String(user.value.id), | ||
| 486 | kind: "user", | ||
| 487 | isRequested: false, | ||
| 488 | }); | ||
| 489 | } | ||
| 490 | return Result.succeed({ candidates, rawCount: decoded.success.length }); | ||
| 491 | } | ||
| 492 | |||
| 493 | /** | ||
| 494 | * GitLab settles the strategy per project rather than offering all three per merge request: | ||
| 495 | * `merge_method` picks one of merge commit, semi-linear or fast-forward, and squashing is a | ||
| 496 | * separate switch. An unrecognized setting offers nothing rather than offering a strategy the | ||
| 497 | * project forbids. | ||
| 498 | */ | ||
| 499 | export function decodeProjectMergeCapabilitiesJson( | ||
| 500 | raw: string, | ||
| 501 | ): Result.Result<PullRequestMergeCapabilities, DecodeFailure> { | ||
| 502 | const decoded = decodeProjectMergeSettings(raw); | ||
| 503 | if (!Result.isSuccess(decoded)) { | ||
| 504 | return Result.fail(decoded.failure); | ||
| 505 | } | ||
| 506 | const mergeMethod = decoded.success.merge_method?.trim().toLowerCase(); | ||
| 507 | const squashOption = decoded.success.squash_option?.trim().toLowerCase(); | ||
| 508 | return Result.succeed({ | ||
| 509 | merge: mergeMethod === "merge", | ||
| 510 | // Both semi-linear and fast-forward histories are reached by rebasing onto the target. | ||
| 511 | rebase: mergeMethod === "rebase_merge" || mergeMethod === "ff", | ||
| 512 | // Only GitLab's own enabling values. An absent or unrecognized setting offers nothing, | ||
| 513 | // rather than offering a squash the project may forbid. | ||
| 514 | squash: | ||
| 515 | squashOption === "always" || squashOption === "default_on" || squashOption === "default_off", | ||
| 516 | }); | ||
| 517 | } | ||
| 518 | |||
| 519 | /** | ||
| 520 | * Comments only. System notes are GitLab's own activity feed entries, and a `DiffNote` is the | ||
| 521 | * root of a line-level discussion, which is what the review-comment kind means. | ||
| 522 | * | ||
| 523 | * The raw note count comes back alongside, because dropping notes hides whether the page was | ||
| 524 | * full: a caller cannot tell "no more notes" from "a page of activity entries" without it. | ||
| 525 | */ | ||
| 526 | /** The three revisions a positioned comment is written against. */ | ||
| 527 | export interface GitLabDiffRefs { | ||
| 528 | readonly baseSha: string; | ||
| 529 | readonly headSha: string; | ||
| 530 | readonly startSha: string; | ||
| 531 | } | ||
| 532 | |||
| 533 | export interface GitLabDiscussions { | ||
| 534 | readonly threads: ReadonlyArray<PullRequestReviewThread>; | ||
| 535 | /** Discussions GitLab returned, counted before decoding, so a skipped one still counts. */ | ||
| 536 | readonly rawCount: number; | ||
| 537 | } | ||
| 538 | |||
| 539 | /** | ||
| 540 | * Positioned discussions only. GitLab returns the merge request's whole conversation here, | ||
| 541 | * including the plain notes the timeline already shows, and only a positioned one belongs | ||
| 542 | * against a line of the diff. | ||
| 543 | */ | ||
| 544 | export function decodeDiscussionsJson( | ||
| 545 | raw: string, | ||
| 546 | ): Result.Result<GitLabDiscussions, DecodeFailure> { | ||
| 547 | const decoded = decodeUnknownList(raw); | ||
| 548 | if (!Result.isSuccess(decoded)) { | ||
| 549 | return Result.fail(decoded.failure); | ||
| 550 | } | ||
| 551 | const threads: PullRequestReviewThread[] = []; | ||
| 552 | for (const entry of decoded.success) { | ||
| 553 | const discussion = decodeDiscussionEntry(entry); | ||
| 554 | if (!Exit.isSuccess(discussion)) continue; | ||
| 555 | const notes = (discussion.value.notes ?? []).filter((note) => note.system !== true); | ||
| 556 | const root = notes[0]; | ||
| 557 | const position = root?.position; | ||
| 558 | if (root === undefined || !position || position.position_type !== "text") continue; | ||
| 559 | // A comment on an added or context line carries `new_line`; one on a removed line carries | ||
| 560 | // only `old_line`, and belongs against the file as it was. | ||
| 561 | const side = position.new_line === null || position.new_line === undefined ? "left" : "right"; | ||
| 562 | const path = trimmed(side === "left" ? position.old_path : position.new_path); | ||
| 563 | const line = side === "left" ? position.old_line : position.new_line; | ||
| 564 | if (path === null) continue; | ||
| 565 | threads.push({ | ||
| 566 | id: discussion.value.id, | ||
| 567 | path, | ||
| 568 | line: typeof line === "number" && line > 0 ? line : null, | ||
| 569 | side, | ||
| 570 | isResolved: root.resolved === true, | ||
| 571 | // GitLab reports no equivalent of "written against a line that has since moved", so a | ||
| 572 | // thread the diff cannot place is worked out from the diff itself rather than claimed | ||
| 573 | // here. | ||
| 574 | isOutdated: false, | ||
| 575 | comments: notes.map((note) => ({ | ||
| 576 | id: String(note.id), | ||
| 577 | author: toActor(note.author), | ||
| 578 | body: note.body ?? "", | ||
| 579 | createdAt: note.created_at, | ||
| 580 | url: null, | ||
| 581 | })), | ||
| 582 | }); | ||
| 583 | } | ||
| 584 | return Result.succeed({ threads, rawCount: decoded.success.length }); | ||
| 585 | } | ||
| 586 | |||
| 587 | export function decodeDiffRefsJson( | ||
| 588 | raw: string, | ||
| 589 | ): Result.Result<GitLabDiffRefs | null, DecodeFailure> { | ||
| 590 | const decoded = decodeDiffRefs(raw); | ||
| 591 | if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); | ||
| 592 | const refs = decoded.success.diff_refs; | ||
| 593 | return Result.succeed( | ||
| 594 | refs ? { baseSha: refs.base_sha, headSha: refs.head_sha, startSha: refs.start_sha } : null, | ||
| 595 | ); | ||
| 596 | } | ||
| 597 | |||
| 598 | export function decodeNotesJson( | ||
| 599 | raw: string, | ||
| 600 | ): Result.Result< | ||
| 601 | { readonly comments: ReadonlyArray<PullRequestComment>; readonly rawCount: number }, | ||
| 602 | DecodeFailure | ||
| 603 | > { | ||
| 604 | const decoded = decodeUnknownList(raw); | ||
| 605 | if (!Result.isSuccess(decoded)) { | ||
| 606 | return Result.fail(decoded.failure); | ||
| 607 | } | ||
| 608 | const comments: PullRequestComment[] = []; | ||
| 609 | for (const entry of decoded.success) { | ||
| 610 | const note = decodeNoteEntry(entry); | ||
| 611 | if (Exit.isFailure(note)) continue; | ||
| 612 | const value = note.value; | ||
| 613 | if (value.system === true) continue; | ||
| 614 | const body = value.body ?? ""; | ||
| 615 | if (body.trim().length === 0) continue; | ||
| 616 | const isDiffNote = value.type?.trim() === "DiffNote"; | ||
| 617 | comments.push({ | ||
| 618 | id: String(value.id), | ||
| 619 | kind: isDiffNote ? "review-comment" : "issue-comment", | ||
| 620 | author: toActor(value.author), | ||
| 621 | body, | ||
| 622 | createdAt: value.created_at, | ||
| 623 | url: null, | ||
| 624 | path: trimmed(value.position?.new_path) ?? trimmed(value.position?.old_path), | ||
| 625 | reviewState: null, | ||
| 626 | }); | ||
| 627 | } | ||
| 628 | return Result.succeed({ comments, rawCount: decoded.success.length }); | ||
| 629 | } | ||
| 630 | |||
| 631 | export function decodeCommitsJson( | ||
| 632 | raw: string, | ||
| 633 | ): Result.Result<ReadonlyArray<PullRequestCommit>, DecodeFailure> { | ||
| 634 | const decoded = decodeUnknownList(raw); | ||
| 635 | if (!Result.isSuccess(decoded)) { | ||
| 636 | return Result.fail(decoded.failure); | ||
| 637 | } | ||
| 638 | const commits: PullRequestCommit[] = []; | ||
| 639 | for (const entry of decoded.success) { | ||
| 640 | const commit = decodeCommitEntry(entry); | ||
| 641 | if (Exit.isFailure(commit)) continue; | ||
| 642 | const committedDate = trimmed(commit.value.committed_date) ?? trimmed(commit.value.created_at); | ||
| 643 | if (committedDate === null) continue; | ||
| 644 | commits.push({ | ||
| 645 | oid: commit.value.id, | ||
| 646 | messageHeadline: commit.value.title ?? "", | ||
| 647 | committedDate, | ||
| 648 | ...(commit.value.stats === null || commit.value.stats === undefined | ||
| 649 | ? {} | ||
| 650 | : { | ||
| 651 | additions: Math.max(0, commit.value.stats.additions ?? 0), | ||
| 652 | deletions: Math.max(0, commit.value.stats.deletions ?? 0), | ||
| 653 | }), | ||
| 654 | authors: (() => { | ||
| 655 | const login = trimmed(commit.value.author_name) ?? trimmed(commit.value.author_email); | ||
| 656 | return login === null | ||
| 657 | ? [] | ||
| 658 | : [{ login, name: trimmed(commit.value.author_name), avatarUrl: null }]; | ||
| 659 | })(), | ||
| 660 | }); | ||
| 661 | } | ||
| 662 | // GitLab lists a merge request's commits newest first; the timeline reads oldest first. | ||
| 663 | return Result.succeed(commits.toReversed()); | ||
| 664 | } | ||
| 665 | |||
| 666 | /** The exact comparison GitLab uses for a commit-scoped diff. */ | ||
| 667 | export function decodeCommitDiffRefsJson( | ||
| 668 | raw: string, | ||
| 669 | ): Result.Result<GitLabDiffRefs | null, DecodeFailure> { | ||
| 670 | const decoded = decodeCommit(raw); | ||
| 671 | if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); | ||
| 672 | const baseSha = trimmed(decoded.success.parent_ids?.[0]); | ||
| 673 | const headSha = trimmed(decoded.success.id); | ||
| 674 | return Result.succeed( | ||
| 675 | baseSha === null || headSha === null ? null : { baseSha, headSha, startSha: baseSha }, | ||
| 676 | ); | ||
| 677 | } | ||
| 678 | |||
| 679 | function diffHeaderPaths(raw: Schema.Schema.Type<typeof RawDiffSchema>): { | ||
| 680 | readonly from: string; | ||
| 681 | readonly to: string; | ||
| 682 | } { | ||
| 683 | return { | ||
| 684 | from: raw.new_file === true ? "/dev/null" : quoteGitPatchPath(`a/${raw.old_path}`), | ||
| 685 | to: raw.deleted_file === true ? "/dev/null" : quoteGitPatchPath(`b/${raw.new_path}`), | ||
| 686 | }; | ||
| 687 | } | ||
| 688 | |||
| 689 | export interface GitLabMergeRequestPatch { | ||
| 690 | readonly patch: string; | ||
| 691 | /** At least one file's hunks were withheld by GitLab as too large to inline. */ | ||
| 692 | readonly truncated: boolean; | ||
| 693 | /** Files GitLab returned, counted before decoding, so the caller can page. */ | ||
| 694 | readonly rawCount: number; | ||
| 695 | } | ||
| 696 | |||
| 697 | /** | ||
| 698 | * GitLab returns hunks per file with no `diff --git` header, so the unified patch every diff | ||
| 699 | * viewer expects is assembled here. This decodes one page; walking pages is the caller's job, | ||
| 700 | * which is why the raw file count comes back with the patch. | ||
| 701 | */ | ||
| 702 | export function decodeMergeRequestDiffsJson( | ||
| 703 | raw: string, | ||
| 704 | ): Result.Result<GitLabMergeRequestPatch, DecodeFailure> { | ||
| 705 | const decoded = decodeUnknownList(raw); | ||
| 706 | if (!Result.isSuccess(decoded)) { | ||
| 707 | return Result.fail(decoded.failure); | ||
| 708 | } | ||
| 709 | const sections: string[] = []; | ||
| 710 | let truncated = false; | ||
| 711 | for (const entry of decoded.success) { | ||
| 712 | const file = decodeDiffEntry(entry); | ||
| 713 | if (Exit.isFailure(file)) continue; | ||
| 714 | const value = file.value; | ||
| 715 | const hunks = value.diff ?? ""; | ||
| 716 | if (hunks.length === 0) { | ||
| 717 | // A file GitLab declined to inline still belongs in the file list, header only. | ||
| 718 | truncated = truncated || value.too_large === true || value.collapsed === true; | ||
| 719 | } | ||
| 720 | const { from, to } = diffHeaderPaths(value); | ||
| 721 | const header = [ | ||
| 722 | `diff --git ${quoteGitPatchPath(`a/${value.old_path}`)} ${quoteGitPatchPath(`b/${value.new_path}`)}`, | ||
| 723 | ...(value.new_file === true ? [`new file mode ${value.b_mode ?? "100644"}`] : []), | ||
| 724 | ...(value.deleted_file === true ? [`deleted file mode ${value.a_mode ?? "100644"}`] : []), | ||
| 725 | ...(value.renamed_file === true | ||
| 726 | ? [ | ||
| 727 | `rename from ${quoteGitPatchPath(value.old_path)}`, | ||
| 728 | `rename to ${quoteGitPatchPath(value.new_path)}`, | ||
| 729 | ] | ||
| 730 | : []), | ||
| 731 | `--- ${from}`, | ||
| 732 | `+++ ${to}`, | ||
| 733 | ].join("\n"); | ||
| 734 | sections.push(hunks.length === 0 ? header : `${header}\n${hunks.replace(/\n?$/, "\n")}`); | ||
| 735 | } | ||
| 736 | return Result.succeed({ | ||
| 737 | patch: sections.join("\n"), | ||
| 738 | truncated, | ||
| 739 | rawCount: decoded.success.length, | ||
| 740 | }); | ||
| 741 | } | ||
| 742 | |||
| 743 | /** GitLab's award names for the eight reactions the contract carries. */ | ||
| 744 | const GITLAB_AWARD_BY_CONTENT: Readonly<Record<PullRequestReactionContent, string>> = { | ||
| 745 | "thumbs-up": "thumbsup", | ||
| 746 | "thumbs-down": "thumbsdown", | ||
| 747 | laugh: "laughing", | ||
| 748 | hooray: "tada", | ||
| 749 | confused: "confused", | ||
| 750 | heart: "heart", | ||
| 751 | rocket: "rocket", | ||
| 752 | eyes: "eyes", | ||
| 753 | }; | ||
| 754 | |||
| 755 | const CONTENT_BY_GITLAB_AWARD: Readonly<Record<string, PullRequestReactionContent>> = | ||
| 756 | Object.fromEntries( | ||
| 757 | Object.entries(GITLAB_AWARD_BY_CONTENT).map(([content, name]) => [name, content]), | ||
| 758 | ) as Readonly<Record<string, PullRequestReactionContent>>; | ||
| 759 | |||
| 760 | export function gitLabAwardName(content: PullRequestReactionContent): string { | ||
| 761 | return GITLAB_AWARD_BY_CONTENT[content]; | ||
| 762 | } | ||
| 763 | |||
| 764 | /** | ||
| 765 | * Awards on the merge request and on every note of it, in one read. The REST notes endpoint the | ||
| 766 | * conversation comes from carries no award at all, and asking per note would be a request each. | ||
| 767 | * | ||
| 768 | * `currentUser` rides along because GitLab names who awarded but never says whether that is the | ||
| 769 | * reader — so the comparison is made here rather than paid for with a request of its own. | ||
| 770 | */ | ||
| 771 | export const AWARD_EMOJI_GRAPHQL_QUERY = `query($fullPath: ID!, $iid: String!, $cursor: String) { | ||
| 772 | currentUser { username } | ||
| 773 | project(fullPath: $fullPath) { | ||
| 774 | mergeRequest(iid: $iid) { | ||
| 775 | awardEmoji { nodes { name user { username } } } | ||
| 776 | notes(first: 100, after: $cursor) { | ||
| 777 | pageInfo { hasNextPage endCursor } | ||
| 778 | nodes { id awardEmoji { nodes { name user { username } } } } | ||
| 779 | } | ||
| 780 | } | ||
| 781 | } | ||
| 782 | }`; | ||
| 783 | |||
| 784 | const RawAwardEmojiNodesSchema = Schema.optional( | ||
| 785 | Schema.NullOr( | ||
| 786 | Schema.Struct({ | ||
| 787 | nodes: Schema.optional( | ||
| 788 | Schema.NullOr( | ||
| 789 | Schema.Array( | ||
| 790 | Schema.NullOr( | ||
| 791 | Schema.Struct({ | ||
| 792 | name: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 793 | user: Schema.optional( | ||
| 794 | Schema.NullOr( | ||
| 795 | Schema.Struct({ username: Schema.optional(Schema.NullOr(Schema.String)) }), | ||
| 796 | ), | ||
| 797 | ), | ||
| 798 | }), | ||
| 799 | ), | ||
| 800 | ), | ||
| 801 | ), | ||
| 802 | ), | ||
| 803 | }), | ||
| 804 | ), | ||
| 805 | ); | ||
| 806 | |||
| 807 | const RawAwardEmojiPageSchema = Schema.Struct({ | ||
| 808 | data: Schema.Struct({ | ||
| 809 | currentUser: Schema.optional( | ||
| 810 | Schema.NullOr(Schema.Struct({ username: Schema.optional(Schema.NullOr(Schema.String)) })), | ||
| 811 | ), | ||
| 812 | project: Schema.NullOr( | ||
| 813 | Schema.Struct({ | ||
| 814 | mergeRequest: Schema.NullOr( | ||
| 815 | Schema.Struct({ | ||
| 816 | awardEmoji: RawAwardEmojiNodesSchema, | ||
| 817 | notes: Schema.optional( | ||
| 818 | Schema.NullOr( | ||
| 819 | Schema.Struct({ | ||
| 820 | pageInfo: Schema.optional( | ||
| 821 | Schema.Struct({ | ||
| 822 | hasNextPage: Schema.optional(Schema.Boolean), | ||
| 823 | endCursor: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 824 | }), | ||
| 825 | ), | ||
| 826 | nodes: Schema.Array( | ||
| 827 | Schema.NullOr( | ||
| 828 | Schema.Struct({ | ||
| 829 | id: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 830 | awardEmoji: RawAwardEmojiNodesSchema, | ||
| 831 | }), | ||
| 832 | ), | ||
| 833 | ), | ||
| 834 | }), | ||
| 835 | ), | ||
| 836 | ), | ||
| 837 | }), | ||
| 838 | ), | ||
| 839 | }), | ||
| 840 | ), | ||
| 841 | }), | ||
| 842 | }); | ||
| 843 | |||
| 844 | const decodeAwardEmojiPage = decodeJsonResult(RawAwardEmojiPageSchema); | ||
| 845 | |||
| 846 | /** | ||
| 847 | * The awards on one subject, grouped the way a reaction pill is drawn. The viewer's own username | ||
| 848 | * is left out of `actors` — the page names them "You" instead, and leaving it in would name them | ||
| 849 | * twice — but `count` still counts them along with everyone else. | ||
| 850 | */ | ||
| 851 | function toReactions( | ||
| 852 | nodes: Schema.Schema.Type<typeof RawAwardEmojiNodesSchema>, | ||
| 853 | viewer: string | null, | ||
| 854 | ): ReadonlyArray<PullRequestReaction> { | ||
| 855 | const normalizedViewer = viewer?.toLowerCase() ?? null; | ||
| 856 | const groups = new Map< | ||
| 857 | PullRequestReactionContent, | ||
| 858 | { count: number; actors: string[]; viewer: boolean } | ||
| 859 | >(); | ||
| 860 | for (const node of nodes?.nodes ?? []) { | ||
| 861 | // An award outside the eight is left out rather than shown under a name the picker has no | ||
| 862 | // way to take back: GitLab accepts any emoji, and the other hosts accept none of them. | ||
| 863 | const content = CONTENT_BY_GITLAB_AWARD[trimmed(node?.name)?.toLowerCase() ?? ""]; | ||
| 864 | if (content === undefined) continue; | ||
| 865 | const username = trimmed(node?.user?.username); | ||
| 866 | if (username === null) continue; | ||
| 867 | const group = groups.get(content) ?? { count: 0, actors: [], viewer: false }; | ||
| 868 | group.count++; | ||
| 869 | if (normalizedViewer !== null && username.toLowerCase() === normalizedViewer) { | ||
| 870 | group.viewer = true; | ||
| 871 | } else { | ||
| 872 | group.actors.push(username); | ||
| 873 | } | ||
| 874 | groups.set(content, group); | ||
| 875 | } | ||
| 876 | return [...groups].flatMap(([content, group]) => | ||
| 877 | group.count === 0 | ||
| 878 | ? [] | ||
| 879 | : [{ content, count: group.count, actors: group.actors, viewerHasReacted: group.viewer }], | ||
| 880 | ); | ||
| 881 | } | ||
| 882 | |||
| 883 | /** `gid://gitlab/DiffNote/42` is note 42, which is the id the REST conversation carries. */ | ||
| 884 | function noteIdOf(gid: string | null | undefined): string | null { | ||
| 885 | const id = trimmed(gid)?.split("/").at(-1); | ||
| 886 | return id !== undefined && /^\d+$/.test(id) ? id : null; | ||
| 887 | } | ||
| 888 | |||
| 889 | export interface GitLabAwardEmojiPage { | ||
| 890 | /** The merge request's own awards, which are the ones on its description. */ | ||
| 891 | readonly reactions: ReadonlyArray<PullRequestReaction>; | ||
| 892 | readonly reactionsByNoteId: ReadonlyMap<string, ReadonlyArray<PullRequestReaction>>; | ||
| 893 | readonly nextCursor: string | null; | ||
| 894 | } | ||
| 895 | |||
| 896 | export function decodeAwardEmojiJson( | ||
| 897 | raw: string, | ||
| 898 | ): Result.Result<GitLabAwardEmojiPage, DecodeFailure> { | ||
| 899 | const decoded = decodeAwardEmojiPage(raw); | ||
| 900 | if (!Result.isSuccess(decoded)) { | ||
| 901 | return Result.fail(decoded.failure); | ||
| 902 | } | ||
| 903 | const data = decoded.success.data; | ||
| 904 | const viewer = trimmed(data.currentUser?.username); | ||
| 905 | const mergeRequest = data.project?.mergeRequest; | ||
| 906 | const reactionsByNoteId = new Map<string, ReadonlyArray<PullRequestReaction>>(); | ||
| 907 | for (const node of mergeRequest?.notes?.nodes ?? []) { | ||
| 908 | const id = noteIdOf(node?.id); | ||
| 909 | if (id === null) continue; | ||
| 910 | const reactions = toReactions(node?.awardEmoji, viewer); | ||
| 911 | if (reactions.length > 0) reactionsByNoteId.set(id, reactions); | ||
| 912 | } | ||
| 913 | const pageInfo = mergeRequest?.notes?.pageInfo; | ||
| 914 | return Result.succeed({ | ||
| 915 | reactions: toReactions(mergeRequest?.awardEmoji, viewer), | ||
| 916 | reactionsByNoteId, | ||
| 917 | nextCursor: pageInfo?.hasNextPage === true ? (trimmed(pageInfo.endCursor) ?? null) : null, | ||
| 918 | }); | ||
| 919 | } | ||
| 920 | |||
| 921 | const RawAwardSchema = Schema.Struct({ | ||
| 922 | id: Schema.Int, | ||
| 923 | name: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 924 | user: Schema.optional( | ||
| 925 | Schema.NullOr(Schema.Struct({ username: Schema.optional(Schema.NullOr(Schema.String)) })), | ||
| 926 | ), | ||
| 927 | }); | ||
| 928 | |||
| 929 | const decodeAward = Schema.decodeUnknownExit(RawAwardSchema); | ||
| 930 | |||
| 931 | /** | ||
| 932 | * The reader's own award of one name on a subject, which is what taking a reaction back is | ||
| 933 | * addressed by: GitLab deletes an award by its id and has no way to name one by its emoji. | ||
| 934 | */ | ||
| 935 | export function decodeOwnAwardIdJson( | ||
| 936 | raw: string, | ||
| 937 | input: { readonly content: PullRequestReactionContent; readonly viewer: string }, | ||
| 938 | ): Result.Result<number | null, DecodeFailure> { | ||
| 939 | const decoded = decodeUnknownList(raw); | ||
| 940 | if (!Result.isSuccess(decoded)) { | ||
| 941 | return Result.fail(decoded.failure); | ||
| 942 | } | ||
| 943 | const name = gitLabAwardName(input.content); | ||
| 944 | for (const entry of decoded.success) { | ||
| 945 | const award = decodeAward(entry); | ||
| 946 | if (Exit.isFailure(award)) continue; | ||
| 947 | const value = award.value; | ||
| 948 | if (trimmed(value.name)?.toLowerCase() !== name) continue; | ||
| 949 | if (trimmed(value.user?.username) !== input.viewer) continue; | ||
| 950 | return Result.succeed(value.id); | ||
| 951 | } | ||
| 952 | return Result.succeed(null); | ||
| 953 | } | ||
| 954 | |||
| 955 | /** | ||
| 956 | * What the given paths are at one revision, as blob ids. Asked for by path rather than by walking | ||
| 957 | * the tree, since GitLab charges this query by how many paths it is given. A path the revision | ||
| 958 | * does not have comes back missing rather than as an error, which is the answer for a deleted file. | ||
| 959 | */ | ||
| 960 | export const REPOSITORY_BLOBS_GRAPHQL_QUERY = `query($fullPath: ID!, $ref: String!, $paths: [String!]!) { | ||
| 961 | project(fullPath: $fullPath) { | ||
| 962 | repository { | ||
| 963 | blobs(ref: $ref, paths: $paths) { | ||
| 964 | nodes { path oid } | ||
| 965 | } | ||
| 966 | } | ||
| 967 | } | ||
| 968 | }`; | ||
| 969 | |||
| 970 | const RawRepositoryBlobsSchema = Schema.Struct({ | ||
| 971 | data: Schema.Struct({ | ||
| 972 | project: Schema.NullOr( | ||
| 973 | Schema.Struct({ | ||
| 974 | repository: Schema.optional( | ||
| 975 | Schema.NullOr( | ||
| 976 | Schema.Struct({ | ||
| 977 | blobs: Schema.optional( | ||
| 978 | Schema.NullOr( | ||
| 979 | Schema.Struct({ | ||
| 980 | nodes: Schema.optional( | ||
| 981 | Schema.NullOr( | ||
| 982 | Schema.Array( | ||
| 983 | Schema.NullOr( | ||
| 984 | Schema.Struct({ | ||
| 985 | path: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 986 | oid: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 987 | }), | ||
| 988 | ), | ||
| 989 | ), | ||
| 990 | ), | ||
| 991 | ), | ||
| 992 | }), | ||
| 993 | ), | ||
| 994 | ), | ||
| 995 | }), | ||
| 996 | ), | ||
| 997 | ), | ||
| 998 | }), | ||
| 999 | ), | ||
| 1000 | }), | ||
| 1001 | }); | ||
| 1002 | |||
| 1003 | const decodeRepositoryBlobs = decodeJsonResult(RawRepositoryBlobsSchema); | ||
| 1004 | |||
| 1005 | /** | ||
| 1006 | * Blob ids by path, or null where GitLab did not answer the query at all (a project the token | ||
| 1007 | * cannot see, or a repository with no blobs connection). That case must be told apart from an | ||
| 1008 | * empty answer: read as "the revision has none of these files", it would report every cleared | ||
| 1009 | * file as changed again. A node missing either half is left out, since the caller treats an | ||
| 1010 | * absent path as one the revision does not carry. | ||
| 1011 | */ | ||
| 1012 | export function decodeRepositoryBlobsJson( | ||
| 1013 | raw: string, | ||
| 1014 | ): Result.Result<ReadonlyMap<string, string> | null, DecodeFailure> { | ||
| 1015 | const decoded = decodeRepositoryBlobs(raw); | ||
| 1016 | if (!Result.isSuccess(decoded)) { | ||
| 1017 | return Result.fail(decoded.failure); | ||
| 1018 | } | ||
| 1019 | const nodes = decoded.success.data.project?.repository?.blobs?.nodes; | ||
| 1020 | if (nodes === undefined || nodes === null) return Result.succeed(null); | ||
| 1021 | const blobs = new Map<string, string>(); | ||
| 1022 | for (const node of nodes) { | ||
| 1023 | // Not trimmed, unlike everything else read out of this payload: a leading or trailing space | ||
| 1024 | // is a legal part of a file's name, and trimming it would key this map under a name the | ||
| 1025 | // caller's asked-for path never matches. | ||
| 1026 | const path = node?.path; | ||
| 1027 | const oid = trimmed(node?.oid); | ||
| 1028 | if (path === undefined || path === null || path.length === 0 || oid === null) continue; | ||
| 1029 | blobs.set(path, oid); | ||
| 1030 | } | ||
| 1031 | return Result.succeed(blobs); | ||
| 1032 | } | ||
packages/source-control-gitlab/src/server/gitLabMergeRequests.ts created+161| ... | @@ -0,0 +1,161 @@ | ||
| 1 | import * as Cause from "effect/Cause"; | ||
| 2 | import * as DateTime from "effect/DateTime"; | ||
| 3 | import * as Exit from "effect/Exit"; | ||
| 4 | import * as Option from "effect/Option"; | ||
| 5 | import * as Result from "effect/Result"; | ||
| 6 | import * as Schema from "effect/Schema"; | ||
| 7 | import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; | ||
| 8 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; | ||
| 9 | |||
| 10 | export interface NormalizedGitLabMergeRequestRecord { | ||
| 11 | readonly number: number; | ||
| 12 | readonly title: string; | ||
| 13 | readonly url: string; | ||
| 14 | readonly baseRefName: string; | ||
| 15 | readonly headRefName: string; | ||
| 16 | readonly state: "open" | "closed" | "merged"; | ||
| 17 | readonly isDraft?: boolean; | ||
| 18 | readonly closedAt?: string | null; | ||
| 19 | readonly mergedAt?: string | null; | ||
| 20 | readonly updatedAt: Option.Option<DateTime.Utc>; | ||
| 21 | readonly isCrossRepository?: boolean; | ||
| 22 | readonly headRepositoryNameWithOwner?: string | null; | ||
| 23 | readonly headRepositoryOwnerLogin?: string | null; | ||
| 24 | } | ||
| 25 | |||
| 26 | const GitLabProjectReferenceSchema = Schema.Struct({ | ||
| 27 | path_with_namespace: Schema.optional(Schema.String), | ||
| 28 | pathWithNamespace: Schema.optional(Schema.String), | ||
| 29 | namespace: Schema.optional( | ||
| 30 | Schema.NullOr( | ||
| 31 | Schema.Struct({ | ||
| 32 | path: Schema.optional(Schema.String), | ||
| 33 | full_path: Schema.optional(Schema.String), | ||
| 34 | fullPath: Schema.optional(Schema.String), | ||
| 35 | }), | ||
| 36 | ), | ||
| 37 | ), | ||
| 38 | }); | ||
| 39 | |||
| 40 | const GitLabMergeRequestSchema = Schema.Struct({ | ||
| 41 | iid: PositiveInt, | ||
| 42 | title: TrimmedNonEmptyString, | ||
| 43 | web_url: TrimmedNonEmptyString, | ||
| 44 | source_branch: TrimmedNonEmptyString, | ||
| 45 | target_branch: TrimmedNonEmptyString, | ||
| 46 | state: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 47 | draft: Schema.optional(Schema.Boolean), | ||
| 48 | work_in_progress: Schema.optional(Schema.Boolean), | ||
| 49 | closed_at: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 50 | merged_at: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 51 | updated_at: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), | ||
| 52 | source_project_id: Schema.optional(Schema.NullOr(Schema.Number)), | ||
| 53 | target_project_id: Schema.optional(Schema.NullOr(Schema.Number)), | ||
| 54 | source_project: Schema.optional(Schema.NullOr(GitLabProjectReferenceSchema)), | ||
| 55 | target_project: Schema.optional(Schema.NullOr(GitLabProjectReferenceSchema)), | ||
| 56 | }); | ||
| 57 | |||
| 58 | function trimOptionalString(value: string | null | undefined): string | null { | ||
| 59 | const trimmed = value?.trim() ?? ""; | ||
| 60 | return trimmed.length > 0 ? trimmed : null; | ||
| 61 | } | ||
| 62 | |||
| 63 | function normalizeGitLabMergeRequestState( | ||
| 64 | state: string | null | undefined, | ||
| 65 | ): "open" | "closed" | "merged" { | ||
| 66 | const normalized = state?.trim().toLowerCase(); | ||
| 67 | if (normalized === "merged") { | ||
| 68 | return "merged"; | ||
| 69 | } | ||
| 70 | if (normalized === "closed") { | ||
| 71 | return "closed"; | ||
| 72 | } | ||
| 73 | return "open"; | ||
| 74 | } | ||
| 75 | |||
| 76 | function projectPathWithNamespace( | ||
| 77 | project: Schema.Schema.Type<typeof GitLabProjectReferenceSchema> | null | undefined, | ||
| 78 | ): string | null { | ||
| 79 | const explicit = | ||
| 80 | trimOptionalString(project?.path_with_namespace) ?? | ||
| 81 | trimOptionalString(project?.pathWithNamespace); | ||
| 82 | if (explicit) { | ||
| 83 | return explicit; | ||
| 84 | } | ||
| 85 | |||
| 86 | const namespacePath = | ||
| 87 | trimOptionalString(project?.namespace?.full_path) ?? | ||
| 88 | trimOptionalString(project?.namespace?.fullPath) ?? | ||
| 89 | trimOptionalString(project?.namespace?.path); | ||
| 90 | return namespacePath; | ||
| 91 | } | ||
| 92 | |||
| 93 | function ownerLoginFromPathWithNamespace(pathWithNamespace: string | null): string | null { | ||
| 94 | const [owner] = pathWithNamespace?.split("/") ?? []; | ||
| 95 | return trimOptionalString(owner); | ||
| 96 | } | ||
| 97 | |||
| 98 | function normalizeGitLabMergeRequestRecord( | ||
| 99 | raw: Schema.Schema.Type<typeof GitLabMergeRequestSchema>, | ||
| 100 | ): NormalizedGitLabMergeRequestRecord { | ||
| 101 | const sourceProjectPath = projectPathWithNamespace(raw.source_project); | ||
| 102 | const targetProjectPath = projectPathWithNamespace(raw.target_project); | ||
| 103 | const isCrossRepository = | ||
| 104 | typeof raw.source_project_id === "number" && typeof raw.target_project_id === "number" | ||
| 105 | ? raw.source_project_id !== raw.target_project_id | ||
| 106 | : sourceProjectPath !== null && targetProjectPath !== null | ||
| 107 | ? sourceProjectPath.toLowerCase() !== targetProjectPath.toLowerCase() | ||
| 108 | : undefined; | ||
| 109 | const headRepositoryOwnerLogin = ownerLoginFromPathWithNamespace(sourceProjectPath); | ||
| 110 | |||
| 111 | return { | ||
| 112 | number: raw.iid, | ||
| 113 | title: raw.title, | ||
| 114 | url: raw.web_url, | ||
| 115 | baseRefName: raw.target_branch, | ||
| 116 | headRefName: raw.source_branch, | ||
| 117 | state: normalizeGitLabMergeRequestState(raw.state), | ||
| 118 | ...(raw.draft === true || raw.work_in_progress === true ? { isDraft: true } : {}), | ||
| 119 | closedAt: raw.closed_at ?? null, | ||
| 120 | mergedAt: raw.merged_at ?? null, | ||
| 121 | updatedAt: raw.updated_at ?? Option.none(), | ||
| 122 | ...(typeof isCrossRepository === "boolean" ? { isCrossRepository } : {}), | ||
| 123 | ...(sourceProjectPath ? { headRepositoryNameWithOwner: sourceProjectPath } : {}), | ||
| 124 | ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}), | ||
| 125 | }; | ||
| 126 | } | ||
| 127 | |||
| 128 | const decodeGitLabMergeRequestList = decodeJsonResult(Schema.Array(Schema.Unknown)); | ||
| 129 | const decodeGitLabMergeRequest = decodeJsonResult(GitLabMergeRequestSchema); | ||
| 130 | const decodeGitLabMergeRequestEntry = Schema.decodeUnknownExit(GitLabMergeRequestSchema); | ||
| 131 | |||
| 132 | export function decodeGitLabMergeRequestListJson( | ||
| 133 | raw: string, | ||
| 134 | ): Result.Result< | ||
| 135 | ReadonlyArray<NormalizedGitLabMergeRequestRecord>, | ||
| 136 | Cause.Cause<Schema.SchemaError> | ||
| 137 | > { | ||
| 138 | const result = decodeGitLabMergeRequestList(raw); | ||
| 139 | if (Result.isSuccess(result)) { | ||
| 140 | const mergeRequests: NormalizedGitLabMergeRequestRecord[] = []; | ||
| 141 | for (const entry of result.success) { | ||
| 142 | const decodedEntry = decodeGitLabMergeRequestEntry(entry); | ||
| 143 | if (Exit.isFailure(decodedEntry)) { | ||
| 144 | continue; | ||
| 145 | } | ||
| 146 | mergeRequests.push(normalizeGitLabMergeRequestRecord(decodedEntry.value)); | ||
| 147 | } | ||
| 148 | return Result.succeed(mergeRequests); | ||
| 149 | } | ||
| 150 | return Result.fail(result.failure); | ||
| 151 | } | ||
| 152 | |||
| 153 | export function decodeGitLabMergeRequestJson( | ||
| 154 | raw: string, | ||
| 155 | ): Result.Result<NormalizedGitLabMergeRequestRecord, Cause.Cause<Schema.SchemaError>> { | ||
| 156 | const result = decodeGitLabMergeRequest(raw); | ||
| 157 | if (Result.isSuccess(result)) { | ||
| 158 | return Result.succeed(normalizeGitLabMergeRequestRecord(result.success)); | ||
| 159 | } | ||
| 160 | return Result.fail(result.failure); | ||
| 161 | } | ||
packages/source-control-gitlab/tsconfig.json created+7| ... | @@ -0,0 +1,7 @@ | ||
| 1 | { | ||
| 2 | "extends": "../../tsconfig.base.json", | ||
| 3 | "compilerOptions": { | ||
| 4 | "types": ["node"] | ||
| 5 | }, | ||
| 6 | "include": ["src"] | ||
| 7 | } | ||
pnpm-lock.yaml+34| ... | @@ -657,6 +657,9 @@ importers: | ... | @@ -657,6 +657,9 @@ importers: |
| 657 | '@t3tools/source-control-forgejo': | 657 | '@t3tools/source-control-forgejo': |
| 658 | specifier: workspace:* | 658 | specifier: workspace:* |
| 659 | version: link:../../packages/source-control-forgejo | 659 | version: link:../../packages/source-control-forgejo |
| 660 | '@t3tools/source-control-gitlab': | ||
| 661 | specifier: workspace:* | ||
| 662 | version: link:../../packages/source-control-gitlab | ||
| 660 | effect: | 663 | effect: |
| 661 | specifier: 4.0.2 | 664 | specifier: 4.0.2 |
| 662 | version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688) | 665 | version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688) |
| ... | @@ -700,6 +703,9 @@ importers: | ... | @@ -700,6 +703,9 @@ importers: |
| 700 | '@t3tools/shared': | 703 | '@t3tools/shared': |
| 701 | specifier: workspace:* | 704 | specifier: workspace:* |
| 702 | version: link:../../packages/shared | 705 | version: link:../../packages/shared |
| 706 | '@t3tools/source-control-testing': | ||
| 707 | specifier: workspace:* | ||
| 708 | version: link:../../packages/source-control-testing | ||
| 703 | '@t3tools/ssh': | 709 | '@t3tools/ssh': |
| 704 | specifier: workspace:* | 710 | specifier: workspace:* |
| 705 | version: link:../../packages/ssh | 711 | version: link:../../packages/ssh |
| ... | @@ -1603,6 +1609,34 @@ importers: | ... | @@ -1603,6 +1609,34 @@ importers: |
| 1603 | specifier: 1.0.0 | 1609 | specifier: 1.0.0 |
| 1604 | 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) | 1610 | 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) |
| 1605 | 1611 | ||
| 1612 | packages/source-control-gitlab: | ||
| 1613 | dependencies: | ||
| 1614 | '@t3tools/contracts': | ||
| 1615 | specifier: workspace:* | ||
| 1616 | version: link:../contracts | ||
| 1617 | '@t3tools/shared': | ||
| 1618 | specifier: workspace:* | ||
| 1619 | version: link:../shared | ||
| 1620 | '@t3tools/source-control-core': | ||
| 1621 | specifier: workspace:* | ||
| 1622 | version: link:../source-control-core | ||
| 1623 | effect: | ||
| 1624 | specifier: 4.0.2 | ||
| 1625 | version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688) | ||
| 1626 | devDependencies: | ||
| 1627 | '@effect/vitest': | ||
| 1628 | specifier: 4.0.2 | ||
| 1629 | 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) | ||
| 1630 | '@t3tools/source-control-testing': | ||
| 1631 | specifier: workspace:* | ||
| 1632 | version: link:../source-control-testing | ||
| 1633 | '@types/node': | ||
| 1634 | specifier: 24.12.4 | ||
| 1635 | version: 24.12.4 | ||
| 1636 | vite-plus: | ||
| 1637 | specifier: 1.0.0 | ||
| 1638 | 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) | ||
| 1639 | |||
| 1606 | packages/source-control-testing: | 1640 | packages/source-control-testing: |
| 1607 | dependencies: | 1641 | dependencies: |
| 1608 | '@t3tools/contracts': | 1642 | '@t3tools/contracts': |
scripts/release-smoke.ts+1| ... | @@ -40,6 +40,7 @@ const workspaceFiles = [ | ... | @@ -40,6 +40,7 @@ const workspaceFiles = [ |
| 40 | "packages/source-control-azure-devops/package.json", | 40 | "packages/source-control-azure-devops/package.json", |
| 41 | "packages/source-control-core/package.json", | 41 | "packages/source-control-core/package.json", |
| 42 | "packages/source-control-forgejo/package.json", | 42 | "packages/source-control-forgejo/package.json", |
| 43 | "packages/source-control-gitlab/package.json", | ||
| 43 | "packages/source-control-testing/package.json", | 44 | "packages/source-control-testing/package.json", |
| 44 | "packages/effect-acp/package.json", | 45 | "packages/effect-acp/package.json", |
| 45 | "packages/effect-codex-app-server/package.json", | 46 | "packages/effect-codex-app-server/package.json", |