| author | |
| committer | |
| log | 8d1858d9d608d78b67268cdc52bceb4302ae8870 |
| tree | 0a6797844004741fa890b2c39f6b1a77555e84d4 |
| parent | eb459b5afaf42621a6cb2e698715fa017e26e5d7 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>43 files changed, 5498 insertions(+), 5308 deletions(-)
apps/server/package.json+1| ... | ... | @@ -46,6 +46,7 @@ |
| 46 | 46 | "@t3tools/provider-opencode": "workspace:*", |
| 47 | 47 | "@t3tools/provider-pi": "workspace:*", |
| 48 | 48 | "@t3tools/source-control-azure-devops": "workspace:*", |
| 49 | "@t3tools/source-control-bitbucket": "workspace:*", | |
| 49 | 50 | "@t3tools/source-control-core": "workspace:*", |
| 50 | 51 | "@t3tools/source-control-forgejo": "workspace:*", |
| 51 | 52 | "@t3tools/source-control-gitlab": "workspace:*", |
apps/server/scripts/evaluate-thread-titles.ts+1-7| ... | ... | @@ -25,7 +25,6 @@ import * as ThreadTitleLinks from "../src/textGeneration/ThreadTitleLinks.ts"; |
| 25 | 25 | import * as SourceControlProviderRegistry from "../src/sourceControl/SourceControlProviderRegistry.ts"; |
| 26 | 26 | import * as GitHubApi from "../src/sourceControl/GitHubApi.ts"; |
| 27 | 27 | import * as SourceControlBuiltInDrivers from "../src/sourceControl/builtInDrivers.ts"; |
| 28 | import * as BitbucketApi from "../src/sourceControl/BitbucketApi.ts"; | |
| 29 | 28 | import * as ServerSettings from "../src/serverSettings.ts"; |
| 30 | 29 | import * as VcsProcess from "../src/vcs/VcsProcess.ts"; |
| 31 | 30 | import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts"; |
| ... | ... | @@ -149,12 +148,7 @@ await Effect.runPromise( |
| 149 | 148 | ProcessRunner.layer, |
| 150 | 149 | SourceControlProviderRegistry.layer.pipe( |
| 151 | 150 | Layer.provide( |
| 152 | Layer.mergeAll( | |
| 153 | GitHubApi.layerWithDependencies, | |
| 154 | SourceControlBuiltInDrivers.layer, | |
| 155 | // No saved credentials here; Bitbucket falls back to T3CODE_BITBUCKET_* variables. | |
| 156 | BitbucketApi.layer, | |
| 157 | ), | |
| 151 | Layer.mergeAll(GitHubApi.layerWithDependencies, SourceControlBuiltInDrivers.layer), | |
| 158 | 152 | ), |
| 159 | 153 | // Default settings: no saved Bitbucket token, gh's own GitHub account choice. |
| 160 | 154 | Layer.provide(ServerSettings.layerTest()), |
apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts deleted-1127| ... | ... | @@ -1,1127 +0,0 @@ |
| 1 | import { afterEach, assert, expect, it, vi } from "@effect/vitest"; | |
| 2 | import * as Duration from "effect/Duration"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Layer from "effect/Layer"; | |
| 5 | import * as TestClock from "effect/testing/TestClock"; | |
| 6 | ||
| 7 | import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; | |
| 8 | import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts"; | |
| 9 | ||
| 10 | const mockedRequest = vi.fn<BitbucketApi.BitbucketApi["Service"]["request"]>(); | |
| 11 | ||
| 12 | const layer = it.layer( | |
| 13 | BitbucketPullRequestApi.layer.pipe( | |
| 14 | Layer.provide( | |
| 15 | Layer.mock(BitbucketApi.BitbucketApi)({ | |
| 16 | request: mockedRequest, | |
| 17 | }), | |
| 18 | ), | |
| 19 | ), | |
| 20 | ); | |
| 21 | ||
| 22 | /** The shape `request` answers with: a body plus whether it had to be cut short. */ | |
| 23 | function response(body: string) { | |
| 24 | return { body, truncated: false }; | |
| 25 | } | |
| 26 | ||
| 27 | function page(count: number, firstNumber: number, next?: string): string { | |
| 28 | return JSON.stringify({ | |
| 29 | pagelen: 50, | |
| 30 | size: count, | |
| 31 | values: Array.from({ length: count }, (_, index) => ({ | |
| 32 | id: firstNumber + index, | |
| 33 | title: `Pull request ${firstNumber + index}`, | |
| 34 | state: "OPEN", | |
| 35 | created_on: "2026-06-16T05:04:32+00:00", | |
| 36 | updated_on: "2026-06-16T05:04:33+00:00", | |
| 37 | source: { branch: { name: "feat/page" } }, | |
| 38 | destination: { branch: { name: "master" } }, | |
| 39 | links: { html: { href: `https://bitbucket.org/acme/web/pull-requests/${firstNumber}` } }, | |
| 40 | })), | |
| 41 | ...(next === undefined ? {} : { next }), | |
| 42 | }); | |
| 43 | } | |
| 44 | ||
| 45 | function valuePage(values: ReadonlyArray<unknown>, next?: string): string { | |
| 46 | return JSON.stringify({ values, ...(next === undefined ? {} : { next }) }); | |
| 47 | } | |
| 48 | ||
| 49 | /** Who opened the pull request, and two accounts that could review it. */ | |
| 50 | const bilal = { uuid: "{bilal}", nickname: "bilal" }; | |
| 51 | const octocat = { uuid: "{octocat}", nickname: "octocat" }; | |
| 52 | const hubot = { uuid: "{hubot}", nickname: "hubot" }; | |
| 53 | ||
| 54 | /** One pull request as `/pullrequests/{id}` answers with it. */ | |
| 55 | function pullRequestJson(overrides: Record<string, unknown>): string { | |
| 56 | return JSON.stringify({ | |
| 57 | id: 7, | |
| 58 | title: "Pull request 7", | |
| 59 | state: "OPEN", | |
| 60 | author: bilal, | |
| 61 | created_on: "2026-06-16T05:04:32+00:00", | |
| 62 | updated_on: "2026-06-16T05:04:33+00:00", | |
| 63 | source: { branch: { name: "feat/page" } }, | |
| 64 | destination: { branch: { name: "master" } }, | |
| 65 | links: { html: { href: "https://bitbucket.org/acme/web/pull-requests/7" } }, | |
| 66 | ...overrides, | |
| 67 | }); | |
| 68 | } | |
| 69 | ||
| 70 | /** The request the nth call made. */ | |
| 71 | function callAt(index: number) { | |
| 72 | const call = mockedRequest.mock.calls[index]; | |
| 73 | assert.isDefined(call); | |
| 74 | return call[0]; | |
| 75 | } | |
| 76 | ||
| 77 | /** The filter expression of the nth request, read back out of its query string. */ | |
| 78 | function filterOfCall(index: number): string | null { | |
| 79 | const url = callAt(index).url; | |
| 80 | return new URLSearchParams(url.slice(url.indexOf("?") + 1)).get("q"); | |
| 81 | } | |
| 82 | ||
| 83 | afterEach(() => { | |
| 84 | mockedRequest.mockReset(); | |
| 85 | }); | |
| 86 | ||
| 87 | layer("BitbucketPullRequestApi.layer", (it) => { | |
| 88 | it.effect("asks for reviewers, newest first, at Bitbucket's page ceiling", () => | |
| 89 | Effect.gen(function* () { | |
| 90 | mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(3, 1)))); | |
| 91 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 92 | ||
| 93 | const batch = yield* api.listPullRequests({ | |
| 94 | repository: "acme/web", | |
| 95 | state: "open", | |
| 96 | limit: 50, | |
| 97 | }); | |
| 98 | ||
| 99 | assert.strictEqual(batch.items.length, 3); | |
| 100 | assert.isFalse(batch.truncated); | |
| 101 | const url = callAt(0).url; | |
| 102 | expect(url).toContain("/repositories/acme/web/pullrequests"); | |
| 103 | expect(url).toContain("state=OPEN"); | |
| 104 | // Over 50 Bitbucket answers with an empty page and no error, so it is never exceeded. | |
| 105 | expect(url).toContain("pagelen=50"); | |
| 106 | expect(url).toContain("sort=-updated_on"); | |
| 107 | expect(url).toContain("fields=%2Bvalues.reviewers"); | |
| 108 | }), | |
| 109 | ); | |
| 110 | ||
| 111 | it.effect("follows the cursor Bitbucket sends rather than counting offsets", () => | |
| 112 | Effect.gen(function* () { | |
| 113 | const next = "https://api.bitbucket.org/2.0/repositories/acme/web/pullrequests?page=2"; | |
| 114 | mockedRequest | |
| 115 | .mockReturnValueOnce(Effect.succeed(response(page(50, 1, next)))) | |
| 116 | .mockReturnValueOnce(Effect.succeed(response(page(50, 51)))); | |
| 117 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 118 | ||
| 119 | const batch = yield* api.listPullRequests({ | |
| 120 | repository: "acme/web", | |
| 121 | state: "open", | |
| 122 | limit: 100, | |
| 123 | }); | |
| 124 | ||
| 125 | assert.strictEqual(batch.items.length, 100); | |
| 126 | assert.isFalse(batch.truncated); | |
| 127 | assert.strictEqual(callAt(1).url, next); | |
| 128 | }), | |
| 129 | ); | |
| 130 | ||
| 131 | it.effect("stops at the caller's page and says more remain", () => | |
| 132 | Effect.gen(function* () { | |
| 133 | const next = "https://api.bitbucket.org/2.0/repositories/acme/web/pullrequests?page=2"; | |
| 134 | mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(50, 1, next)))); | |
| 135 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 136 | ||
| 137 | const batch = yield* api.listPullRequests({ | |
| 138 | repository: "acme/web", | |
| 139 | state: "open", | |
| 140 | limit: 50, | |
| 141 | }); | |
| 142 | ||
| 143 | assert.strictEqual(batch.items.length, 50); | |
| 144 | assert.isTrue(batch.truncated); | |
| 145 | assert.strictEqual(mockedRequest.mock.calls.length, 1); | |
| 146 | }), | |
| 147 | ); | |
| 148 | ||
| 149 | it.effect("counts the rows it walked past as more to come", () => | |
| 150 | Effect.gen(function* () { | |
| 151 | // Bitbucket pages in fifties whatever was asked for, so a request for ninety-nine reads a | |
| 152 | // hundred and drops one. That row is more results, and saying otherwise takes the "load | |
| 153 | // more" away from a listing that has not finished. | |
| 154 | const next = "https://api.bitbucket.org/2.0/repositories/acme/web/pullrequests?page=2"; | |
| 155 | mockedRequest | |
| 156 | .mockReturnValueOnce(Effect.succeed(response(page(50, 1, next)))) | |
| 157 | .mockReturnValueOnce(Effect.succeed(response(page(50, 51)))); | |
| 158 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 159 | ||
| 160 | const batch = yield* api.listPullRequests({ | |
| 161 | repository: "acme/web", | |
| 162 | state: "open", | |
| 163 | limit: 99, | |
| 164 | }); | |
| 165 | ||
| 166 | assert.strictEqual(batch.items.length, 99); | |
| 167 | assert.isTrue(batch.truncated); | |
| 168 | }), | |
| 169 | ); | |
| 170 | ||
| 171 | it.effect("searches with a filter expression, which is all Bitbucket offers", () => | |
| 172 | Effect.gen(function* () { | |
| 173 | mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); | |
| 174 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 175 | ||
| 176 | yield* api.listPullRequests({ | |
| 177 | repository: "acme/web", | |
| 178 | state: "open", | |
| 179 | limit: 50, | |
| 180 | query: "page", | |
| 181 | }); | |
| 182 | ||
| 183 | expect(filterOfCall(0)).toBe('(title ~ "page" OR description ~ "page")'); | |
| 184 | // The state filter beside it still stands, which the brackets are there to keep. | |
| 185 | expect(callAt(0).url).toContain("state=OPEN"); | |
| 186 | }), | |
| 187 | ); | |
| 188 | ||
| 189 | it.effect("escapes a quote and a backslash, so a search cannot reshape the filter", () => | |
| 190 | Effect.gen(function* () { | |
| 191 | mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); | |
| 192 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 193 | ||
| 194 | yield* api.listPullRequests({ | |
| 195 | repository: "acme/web", | |
| 196 | state: "open", | |
| 197 | limit: 50, | |
| 198 | query: String.raw`a\" OR state = "MERGED"`, | |
| 199 | }); | |
| 200 | ||
| 201 | const literal = String.raw`a\\\" OR state = \"MERGED\"`; | |
| 202 | expect(filterOfCall(0)).toBe(`(title ~ "${literal}" OR description ~ "${literal}")`); | |
| 203 | }), | |
| 204 | ); | |
| 205 | ||
| 206 | it.effect("asks for no filter at all when the reader typed only spaces", () => | |
| 207 | Effect.gen(function* () { | |
| 208 | mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); | |
| 209 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 210 | ||
| 211 | yield* api.listPullRequests({ | |
| 212 | repository: "acme/web", | |
| 213 | state: "open", | |
| 214 | limit: 50, | |
| 215 | query: " ", | |
| 216 | }); | |
| 217 | ||
| 218 | assert.isNull(filterOfCall(0)); | |
| 219 | }), | |
| 220 | ); | |
| 221 | ||
| 222 | it.effect("carries on from the instant the last slice ended on", () => | |
| 223 | Effect.gen(function* () { | |
| 224 | mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); | |
| 225 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 226 | ||
| 227 | yield* api.listPullRequests({ | |
| 228 | repository: "acme/web", | |
| 229 | state: "open", | |
| 230 | limit: 50, | |
| 231 | cursor: { updatedBefore: "2026-07-02T00:00:00.123456+00:00", delivered: 50 }, | |
| 232 | }); | |
| 233 | ||
| 234 | // Inclusive, so the rows already sent at that instant come back for the caller to drop. | |
| 235 | expect(filterOfCall(0)).toBe("updated_on <= 2026-07-02T00:00:00.123456+00:00"); | |
| 236 | expect(callAt(0).url).toContain("sort=-updated_on"); | |
| 237 | }), | |
| 238 | ); | |
| 239 | ||
| 240 | it.effect("narrows by the reader's words and by where it left off at once", () => | |
| 241 | Effect.gen(function* () { | |
| 242 | mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); | |
| 243 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 244 | ||
| 245 | yield* api.listPullRequests({ | |
| 246 | repository: "acme/web", | |
| 247 | state: "open", | |
| 248 | limit: 50, | |
| 249 | query: "page", | |
| 250 | cursor: { updatedBefore: "2026-07-02T00:00:00+00:00", delivered: 50 }, | |
| 251 | }); | |
| 252 | ||
| 253 | // Bitbucket takes one `q`, so the two narrowings are joined rather than one replacing the | |
| 254 | // other — and the search keeps its brackets, which is what keeps the AND out of its OR. | |
| 255 | expect(filterOfCall(0)).toBe( | |
| 256 | '(title ~ "page" OR description ~ "page") AND updated_on <= 2026-07-02T00:00:00+00:00', | |
| 257 | ); | |
| 258 | }), | |
| 259 | ); | |
| 260 | ||
| 261 | it.effect("asks for declined pull requests on the closed tab", () => | |
| 262 | Effect.gen(function* () { | |
| 263 | mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); | |
| 264 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 265 | ||
| 266 | yield* api.listPullRequests({ repository: "acme/web", state: "closed", limit: 50 }); | |
| 267 | ||
| 268 | expect(callAt(0).url).toContain("state=DECLINED"); | |
| 269 | }), | |
| 270 | ); | |
| 271 | ||
| 272 | it.effect("asks for every state at once on the All tab", () => | |
| 273 | Effect.gen(function* () { | |
| 274 | mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); | |
| 275 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 276 | ||
| 277 | yield* api.listPullRequests({ repository: "acme/web", state: "all", limit: 50 }); | |
| 278 | ||
| 279 | // Bitbucket unions repeated state parameters, which is the only way to span them. | |
| 280 | const url = callAt(0).url; | |
| 281 | for (const state of ["OPEN", "MERGED", "DECLINED", "SUPERSEDED"]) { | |
| 282 | expect(url).toContain(`state=${state}`); | |
| 283 | } | |
| 284 | }), | |
| 285 | ); | |
| 286 | ||
| 287 | it.effect("counts a superseded pull request as closed", () => | |
| 288 | Effect.gen(function* () { | |
| 289 | mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); | |
| 290 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 291 | ||
| 292 | yield* api.listPullRequests({ repository: "acme/web", state: "closed", limit: 50 }); | |
| 293 | ||
| 294 | expect(callAt(0).url).toContain("state=DECLINED"); | |
| 295 | expect(callAt(0).url).toContain("state=SUPERSEDED"); | |
| 296 | }), | |
| 297 | ); | |
| 298 | ||
| 299 | it.effect("refuses a repository that is not workspace and slug", () => | |
| 300 | Effect.gen(function* () { | |
| 301 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 302 | ||
| 303 | const error = yield* Effect.flip( | |
| 304 | api.listPullRequests({ repository: "acme/team/web", state: "open", limit: 50 }), | |
| 305 | ); | |
| 306 | ||
| 307 | assert.strictEqual(error._tag, "BitbucketRepositoryUnsupportedError"); | |
| 308 | assert.strictEqual(mockedRequest.mock.calls.length, 0); | |
| 309 | }), | |
| 310 | ); | |
| 311 | ||
| 312 | it.effect("returns the diff verbatim, because Bitbucket already sends a patch", () => | |
| 313 | Effect.gen(function* () { | |
| 314 | const patch = "diff --git a/a.ts b/a.ts\n--- a/a.ts\n+++ b/a.ts\n@@ -1 +1 @@\n-a\n+b\n"; | |
| 315 | mockedRequest.mockReturnValueOnce(Effect.succeed(response(patch))); | |
| 316 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 317 | ||
| 318 | const diff = yield* api.getPullRequestDiff({ repository: "acme/web", number: 7 }); | |
| 319 | ||
| 320 | assert.strictEqual(diff.patch, patch); | |
| 321 | assert.isFalse(diff.truncated); | |
| 322 | expect(callAt(0)).toMatchObject({ | |
| 323 | url: "/repositories/acme/web/pullrequests/7/diff", | |
| 324 | // A diff of any size would otherwise be read into memory whole. | |
| 325 | maxBytes: 8 * 1024 * 1024, | |
| 326 | }); | |
| 327 | }), | |
| 328 | ); | |
| 329 | ||
| 330 | it.effect("reads a named commit's own patch, which pages no further than the whole of it", () => | |
| 331 | Effect.gen(function* () { | |
| 332 | const patch = "diff --git a/a.ts b/a.ts\n--- a/a.ts\n+++ b/a.ts\n@@ -1 +1 @@\n-a\n+b\n"; | |
| 333 | mockedRequest.mockReturnValueOnce(Effect.succeed(response(patch))); | |
| 334 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 335 | ||
| 336 | const diff = yield* api.getPullRequestDiff({ | |
| 337 | repository: "acme/web", | |
| 338 | number: 7, | |
| 339 | commit: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", | |
| 340 | }); | |
| 341 | ||
| 342 | assert.strictEqual(diff.patch, patch); | |
| 343 | expect(callAt(0)).toMatchObject({ | |
| 344 | url: "/repositories/acme/web/diff/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", | |
| 345 | maxBytes: 8 * 1024 * 1024, | |
| 346 | }); | |
| 347 | }), | |
| 348 | ); | |
| 349 | ||
| 350 | it.effect("refuses a commit that is not a sha rather than reading it into a URL", () => | |
| 351 | Effect.gen(function* () { | |
| 352 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 353 | ||
| 354 | const error = yield* Effect.flip( | |
| 355 | api.getPullRequestDiff({ | |
| 356 | repository: "acme/web", | |
| 357 | number: 7, | |
| 358 | commit: "../../acme/other/diff/deadbeef", | |
| 359 | }), | |
| 360 | ); | |
| 361 | ||
| 362 | assert.strictEqual(error._tag, "BitbucketDiffCommitError"); | |
| 363 | assert.strictEqual(mockedRequest.mock.calls.length, 0); | |
| 364 | }), | |
| 365 | ); | |
| 366 | ||
| 367 | it.effect("reads every file version the patch states, not only the paths asked about", () => | |
| 368 | Effect.gen(function* () { | |
| 369 | mockedRequest.mockReturnValueOnce( | |
| 370 | Effect.succeed( | |
| 371 | response( | |
| 372 | [ | |
| 373 | "diff --git a/a.ts b/a.ts", | |
| 374 | "index 1111111..2222222 100644", | |
| 375 | "--- a/a.ts", | |
| 376 | "+++ b/a.ts", | |
| 377 | "@@ -1 +1 @@", | |
| 378 | "-a", | |
| 379 | "+b", | |
| 380 | "diff --git a/b.ts b/b.ts", | |
| 381 | "index 3333333..4444444 100644", | |
| 382 | "--- a/b.ts", | |
| 383 | "+++ b/b.ts", | |
| 384 | "@@ -1 +1 @@", | |
| 385 | "-c", | |
| 386 | "+d", | |
| 387 | "", | |
| 388 | ].join("\n"), | |
| 389 | ), | |
| 390 | ), | |
| 391 | ); | |
| 392 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 393 | ||
| 394 | const revisions = yield* api.getFileRevisions({ | |
| 395 | repository: "acme/web", | |
| 396 | number: 71, | |
| 397 | paths: ["a.ts", "missing.ts"], | |
| 398 | }); | |
| 399 | ||
| 400 | // `b.ts` was not asked about and is reported anyway: parsing the patch for `a.ts` read it | |
| 401 | // too, and the caller holding it is what stops the next tick paying for the patch again. | |
| 402 | // `missing.ts` was asked about and the whole patch was read without finding it, which is | |
| 403 | // what a file this pull request deletes looks like, so it is answered as the empty version. | |
| 404 | assert.deepStrictEqual( | |
| 405 | [...revisions.revisions], | |
| 406 | [ | |
| 407 | ["a.ts", "2222222"], | |
| 408 | ["b.ts", "4444444"], | |
| 409 | ["missing.ts", ""], | |
| 410 | ], | |
| 411 | ); | |
| 412 | assert.strictEqual(revisions.complete, true); | |
| 413 | expect(callAt(0)).toMatchObject({ url: "/repositories/acme/web/pullrequests/71/diff" }); | |
| 414 | }), | |
| 415 | ); | |
| 416 | ||
| 417 | it.effect("says nothing about the files past the end of a patch it could not read whole", () => | |
| 418 | Effect.gen(function* () { | |
| 419 | // Bitbucket's patch is read up to a byte ceiling, and a file past the cut was not looked at. | |
| 420 | // Answering for it as deleted would clear a mark on it once and for good. | |
| 421 | mockedRequest.mockReturnValueOnce( | |
| 422 | Effect.succeed({ | |
| 423 | body: "diff --git a/a.ts b/a.ts\nindex 1111111..2222222 100644\n@@ -1 +1 @@\n", | |
| 424 | truncated: true, | |
| 425 | }), | |
| 426 | ); | |
| 427 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 428 | ||
| 429 | const revisions = yield* api.getFileRevisions({ | |
| 430 | repository: "acme/web", | |
| 431 | number: 72, | |
| 432 | paths: ["a.ts", "past-the-cut.ts"], | |
| 433 | }); | |
| 434 | ||
| 435 | assert.deepStrictEqual([...revisions.revisions], [["a.ts", "2222222"]]); | |
| 436 | // `past-the-cut.ts` gets no empty version, and nothing here may be held as the whole story. | |
| 437 | assert.strictEqual(revisions.complete, false); | |
| 438 | }), | |
| 439 | ); | |
| 440 | ||
| 441 | it.effect("reads the patch once for a run of ticks, not once a tick", () => | |
| 442 | Effect.gen(function* () { | |
| 443 | mockedRequest.mockReturnValue( | |
| 444 | Effect.succeed( | |
| 445 | response( | |
| 446 | [ | |
| 447 | "diff --git a/a.ts b/a.ts", | |
| 448 | "index 1111111..2222222 100644", | |
| 449 | "@@ -1 +1 @@", | |
| 450 | "diff --git a/b.ts b/b.ts", | |
| 451 | "index 3333333..4444444 100644", | |
| 452 | "@@ -1 +1 @@", | |
| 453 | "", | |
| 454 | ].join("\n"), | |
| 455 | ), | |
| 456 | ), | |
| 457 | ); | |
| 458 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 459 | ||
| 460 | const first = yield* api.getFileRevisions({ | |
| 461 | repository: "acme/web", | |
| 462 | number: 74, | |
| 463 | paths: ["a.ts"], | |
| 464 | }); | |
| 465 | // A path nobody has asked about before, which is what every tick after the first names. | |
| 466 | const second = yield* api.getFileRevisions({ | |
| 467 | repository: "acme/web", | |
| 468 | number: 74, | |
| 469 | paths: ["b.ts"], | |
| 470 | }); | |
| 471 | ||
| 472 | const both = [ | |
| 473 | ["a.ts", "2222222"], | |
| 474 | ["b.ts", "4444444"], | |
| 475 | ]; | |
| 476 | assert.deepStrictEqual([...first.revisions], both); | |
| 477 | assert.deepStrictEqual([...second.revisions], both); | |
| 478 | assert.strictEqual(mockedRequest.mock.calls.length, 1); | |
| 479 | }), | |
| 480 | ); | |
| 481 | ||
| 482 | it.effect("reads the patch afresh once the one it held has aged out", () => | |
| 483 | Effect.gen(function* () { | |
| 484 | mockedRequest.mockReturnValue( | |
| 485 | Effect.succeed( | |
| 486 | response("diff --git a/a.ts b/a.ts\nindex 1111111..2222222 100644\n@@ -1 +1 @@\n"), | |
| 487 | ), | |
| 488 | ); | |
| 489 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 490 | const read = () => | |
| 491 | api.getFileRevisions({ repository: "acme/web", number: 75, paths: ["a.ts"] }); | |
| 492 | ||
| 493 | yield* read(); | |
| 494 | yield* read(); | |
| 495 | assert.strictEqual(mockedRequest.mock.calls.length, 1); | |
| 496 | ||
| 497 | // Well inside the window the caller holds versions for: a refresh drops what it holds so | |
| 498 | // that the read after it reaches Bitbucket, and this must not answer that read instead. | |
| 499 | yield* TestClock.adjust(Duration.seconds(30)); | |
| 500 | yield* read(); | |
| 501 | assert.strictEqual(mockedRequest.mock.calls.length, 2); | |
| 502 | }), | |
| 503 | ); | |
| 504 | ||
| 505 | it.effect("asks Bitbucket nothing when no file has been ticked off", () => | |
| 506 | Effect.gen(function* () { | |
| 507 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 508 | ||
| 509 | const revisions = yield* api.getFileRevisions({ | |
| 510 | repository: "acme/web", | |
| 511 | number: 73, | |
| 512 | paths: [], | |
| 513 | }); | |
| 514 | ||
| 515 | assert.strictEqual(revisions.revisions.size, 0); | |
| 516 | assert.strictEqual(mockedRequest.mock.calls.length, 0); | |
| 517 | }), | |
| 518 | ); | |
| 519 | ||
| 520 | it.effect("aggregates every diffstat page", () => | |
| 521 | Effect.gen(function* () { | |
| 522 | const next = "https://api.bitbucket.org/2.0/diffstat?page=2"; | |
| 523 | mockedRequest | |
| 524 | .mockReturnValueOnce( | |
| 525 | Effect.succeed( | |
| 526 | response( | |
| 527 | valuePage( | |
| 528 | [ | |
| 529 | { lines_added: 9, lines_removed: 2 }, | |
| 530 | { lines_added: 3, lines_removed: 1 }, | |
| 531 | ], | |
| 532 | next, | |
| 533 | ), | |
| 534 | ), | |
| 535 | ), | |
| 536 | ) | |
| 537 | .mockReturnValueOnce( | |
| 538 | Effect.succeed(response(valuePage([{ lines_added: 4, lines_removed: 7 }]))), | |
| 539 | ); | |
| 540 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 541 | ||
| 542 | const stat = yield* api.getDiffStat({ repository: "acme/web", number: 7 }); | |
| 543 | ||
| 544 | expect(stat).toEqual({ additions: 16, deletions: 10, changedFiles: 3 }); | |
| 545 | expect(callAt(1).url).toBe(next); | |
| 546 | }), | |
| 547 | ); | |
| 548 | ||
| 549 | it.effect("returns the complete commit timeline oldest first across pages", () => | |
| 550 | Effect.gen(function* () { | |
| 551 | const next = "https://api.bitbucket.org/2.0/commits?page=2"; | |
| 552 | mockedRequest | |
| 553 | .mockReturnValueOnce( | |
| 554 | Effect.succeed( | |
| 555 | response( | |
| 556 | valuePage( | |
| 557 | [ | |
| 558 | { hash: "ddd", message: "fourth", date: "2026-07-04T00:00:00Z" }, | |
| 559 | { hash: "ccc", message: "third", date: "2026-07-03T00:00:00Z" }, | |
| 560 | ], | |
| 561 | next, | |
| 562 | ), | |
| 563 | ), | |
| 564 | ), | |
| 565 | ) | |
| 566 | .mockReturnValueOnce( | |
| 567 | Effect.succeed( | |
| 568 | response( | |
| 569 | valuePage([ | |
| 570 | { hash: "bbb", message: "second", date: "2026-07-02T00:00:00Z" }, | |
| 571 | { hash: "aaa", message: "first", date: "2026-07-01T00:00:00Z" }, | |
| 572 | ]), | |
| 573 | ), | |
| 574 | ), | |
| 575 | ); | |
| 576 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 577 | ||
| 578 | const commits = yield* api.listCommits({ repository: "acme/web", number: 7 }); | |
| 579 | ||
| 580 | expect(commits.map((commit) => commit.oid)).toEqual(["aaa", "bbb", "ccc", "ddd"]); | |
| 581 | expect(callAt(1).url).toBe(next); | |
| 582 | }), | |
| 583 | ); | |
| 584 | ||
| 585 | it.effect("returns build statuses from every page", () => | |
| 586 | Effect.gen(function* () { | |
| 587 | const next = "https://api.bitbucket.org/2.0/statuses?page=2"; | |
| 588 | mockedRequest | |
| 589 | .mockReturnValueOnce( | |
| 590 | Effect.succeed(response(valuePage([{ name: "Build", state: "SUCCESSFUL" }], next))), | |
| 591 | ) | |
| 592 | .mockReturnValueOnce( | |
| 593 | Effect.succeed(response(valuePage([{ name: "Lint", state: "FAILED" }]))), | |
| 594 | ); | |
| 595 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 596 | ||
| 597 | const checks = yield* api.listChecks({ repository: "acme/web", number: 7 }); | |
| 598 | ||
| 599 | expect(checks.map((check) => [check.name, check.status])).toEqual([ | |
| 600 | ["Build", "success"], | |
| 601 | ["Lint", "failure"], | |
| 602 | ]); | |
| 603 | expect(callAt(1).url).toBe(next); | |
| 604 | }), | |
| 605 | ); | |
| 606 | ||
| 607 | it.effect("reads an empty conflict list as mergeable", () => | |
| 608 | Effect.gen(function* () { | |
| 609 | mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); | |
| 610 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 611 | ||
| 612 | const mergeability = yield* api.getMergeability({ repository: "acme/web", number: 7 }); | |
| 613 | ||
| 614 | assert.strictEqual(mergeability, "mergeable"); | |
| 615 | expect(callAt(0).url).toBe("/repositories/acme/web/pullrequests/7/conflicts"); | |
| 616 | }), | |
| 617 | ); | |
| 618 | ||
| 619 | it.effect("merges with Bitbucket's own name for the strategy", () => | |
| 620 | Effect.gen(function* () { | |
| 621 | mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); | |
| 622 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 623 | ||
| 624 | yield* api.runAction({ | |
| 625 | repository: "acme/web", | |
| 626 | number: 7, | |
| 627 | action: "merge", | |
| 628 | mergeMethod: "rebase", | |
| 629 | }); | |
| 630 | ||
| 631 | expect(callAt(0)).toMatchObject({ | |
| 632 | method: "POST", | |
| 633 | url: "/repositories/acme/web/pullrequests/7/merge", | |
| 634 | body: '{"merge_strategy":"rebase_fast_forward"}', | |
| 635 | }); | |
| 636 | }), | |
| 637 | ); | |
| 638 | ||
| 639 | it.effect("closes a pull request by declining it", () => | |
| 640 | Effect.gen(function* () { | |
| 641 | mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); | |
| 642 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 643 | ||
| 644 | yield* api.runAction({ repository: "acme/web", number: 7, action: "close" }); | |
| 645 | ||
| 646 | expect(callAt(0)).toMatchObject({ | |
| 647 | method: "POST", | |
| 648 | url: "/repositories/acme/web/pullrequests/7/decline", | |
| 649 | }); | |
| 650 | }), | |
| 651 | ); | |
| 652 | ||
| 653 | it.effect("posts a comment as a JSON document, so the body stays text", () => | |
| 654 | Effect.gen(function* () { | |
| 655 | mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); | |
| 656 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 657 | ||
| 658 | yield* api.comment({ repository: "acme/web", number: 7, body: "true" }); | |
| 659 | ||
| 660 | expect(callAt(0)).toMatchObject({ | |
| 661 | method: "POST", | |
| 662 | url: "/repositories/acme/web/pullrequests/7/comments", | |
| 663 | body: '{"content":{"raw":"true"}}', | |
| 664 | }); | |
| 665 | }), | |
| 666 | ); | |
| 667 | ||
| 668 | it.effect("rewrites a title alone, without touching anything else", () => | |
| 669 | Effect.gen(function* () { | |
| 670 | mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); | |
| 671 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 672 | ||
| 673 | yield* api.updateChangeRequest({ repository: "acme/web", number: 7, title: "A new title" }); | |
| 674 | ||
| 675 | const call = callAt(0); | |
| 676 | expect(call.method).toBe("PUT"); | |
| 677 | expect(call.url).toBe("/repositories/acme/web/pullrequests/7"); | |
| 678 | // Bitbucket's PUT is a partial update, so a field left out of the body is left as it was. | |
| 679 | expect(JSON.parse(call.body ?? "")).toEqual({ title: "A new title" }); | |
| 680 | }), | |
| 681 | ); | |
| 682 | ||
| 683 | it.effect("leaves out the half of the pull request it was not asked about", () => | |
| 684 | Effect.gen(function* () { | |
| 685 | mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); | |
| 686 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 687 | ||
| 688 | yield* api.updateChangeRequest({ repository: "acme/web", number: 7, body: "New body." }); | |
| 689 | ||
| 690 | expect(JSON.parse(callAt(0).body ?? "")).toEqual({ description: "New body." }); | |
| 691 | }), | |
| 692 | ); | |
| 693 | ||
| 694 | it.effect("writes both fields when both were rewritten", () => | |
| 695 | Effect.gen(function* () { | |
| 696 | mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); | |
| 697 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 698 | ||
| 699 | yield* api.updateChangeRequest({ | |
| 700 | repository: "acme/web", | |
| 701 | number: 7, | |
| 702 | title: "A new title", | |
| 703 | body: "New body.", | |
| 704 | }); | |
| 705 | ||
| 706 | expect(JSON.parse(callAt(0).body ?? "")).toEqual({ | |
| 707 | title: "A new title", | |
| 708 | description: "New body.", | |
| 709 | }); | |
| 710 | }), | |
| 711 | ); | |
| 712 | ||
| 713 | it.effect("rewrites a comment where it stands, whichever kind it is", () => | |
| 714 | Effect.gen(function* () { | |
| 715 | mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); | |
| 716 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 717 | ||
| 718 | yield* api.updateComment({ | |
| 719 | repository: "acme/web", | |
| 720 | number: 7, | |
| 721 | commentId: "10", | |
| 722 | body: "Edited.", | |
| 723 | }); | |
| 724 | ||
| 725 | expect(callAt(0)).toMatchObject({ | |
| 726 | method: "PUT", | |
| 727 | url: "/repositories/acme/web/pullrequests/7/comments/10", | |
| 728 | body: '{"content":{"raw":"Edited."}}', | |
| 729 | }); | |
| 730 | }), | |
| 731 | ); | |
| 732 | ||
| 733 | it.effect("fails the read when Bitbucket answers with something unreadable", () => | |
| 734 | Effect.gen(function* () { | |
| 735 | mockedRequest.mockReturnValueOnce( | |
| 736 | Effect.succeed(response(JSON.stringify({ error: "nope" }))), | |
| 737 | ); | |
| 738 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 739 | ||
| 740 | const error = yield* Effect.flip(api.getPullRequest({ repository: "acme/web", number: 7 })); | |
| 741 | ||
| 742 | assert.strictEqual(error._tag, "BitbucketPullRequestReadError"); | |
| 743 | }), | |
| 744 | ); | |
| 745 | ||
| 746 | it.effect("states a failure once, without stacking one message inside another", () => | |
| 747 | Effect.gen(function* () { | |
| 748 | mockedRequest.mockReturnValueOnce( | |
| 749 | Effect.fail( | |
| 750 | new BitbucketApi.BitbucketResponseError({ | |
| 751 | operation: "request", | |
| 752 | status: 500, | |
| 753 | responseBodyLength: 0, | |
| 754 | }), | |
| 755 | ), | |
| 756 | ); | |
| 757 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 758 | ||
| 759 | const error = yield* Effect.flip(api.getViewer()); | |
| 760 | ||
| 761 | // The fact only; the provider adds the operation around it. | |
| 762 | assert.strictEqual(error.detail, "Bitbucket returned HTTP 500."); | |
| 763 | }), | |
| 764 | ); | |
| 765 | ||
| 766 | it.effect("fails when the credentials belong to no named account", () => | |
| 767 | Effect.gen(function* () { | |
| 768 | mockedRequest.mockReturnValueOnce(Effect.succeed(response(JSON.stringify({})))); | |
| 769 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 770 | ||
| 771 | const error = yield* Effect.flip(api.getViewer()); | |
| 772 | ||
| 773 | assert.strictEqual(error._tag, "BitbucketViewerUnavailableError"); | |
| 774 | }), | |
| 775 | ); | |
| 776 | ||
| 777 | it.effect("follows Bitbucket's cursor and reassembles a thread that spans two pages", () => | |
| 778 | Effect.gen(function* () { | |
| 779 | mockedRequest.mockReturnValueOnce( | |
| 780 | Effect.succeed( | |
| 781 | response( | |
| 782 | JSON.stringify({ | |
| 783 | next: "https://api.bitbucket.org/2.0/comments?page=2", | |
| 784 | values: [ | |
| 785 | { | |
| 786 | id: 10, | |
| 787 | content: { raw: "rename this" }, | |
| 788 | user: { nickname: "bilal" }, | |
| 789 | created_on: "2026-06-16T05:04:32+00:00", | |
| 790 | inline: { path: "src/a.ts", to: 12 }, | |
| 791 | }, | |
| 792 | ], | |
| 793 | }), | |
| 794 | ), | |
| 795 | ), | |
| 796 | ); | |
| 797 | mockedRequest.mockReturnValueOnce( | |
| 798 | Effect.succeed( | |
| 799 | response( | |
| 800 | // The reply arrives a page after the remark it answers, which is why the threads | |
| 801 | // are only assembled once every page is in hand. | |
| 802 | JSON.stringify({ | |
| 803 | values: [ | |
| 804 | { | |
| 805 | id: 11, | |
| 806 | content: { raw: "done" }, | |
| 807 | user: { nickname: "julius" }, | |
| 808 | created_on: "2026-06-16T06:04:32+00:00", | |
| 809 | parent: { id: 10 }, | |
| 810 | }, | |
| 811 | ], | |
| 812 | }), | |
| 813 | ), | |
| 814 | ), | |
| 815 | ); | |
| 816 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 817 | ||
| 818 | const { comments, threads, truncated } = yield* api.listComments({ | |
| 819 | repository: "acme/web", | |
| 820 | number: 7, | |
| 821 | }); | |
| 822 | ||
| 823 | expect(callAt(1).url).toBe("https://api.bitbucket.org/2.0/comments?page=2"); | |
| 824 | expect(comments.map((comment) => comment.id)).toEqual(["10", "11"]); | |
| 825 | expect(threads[0]?.comments.map((comment) => comment.id)).toEqual(["10", "11"]); | |
| 826 | assert.isFalse(truncated); | |
| 827 | }), | |
| 828 | ); | |
| 829 | ||
| 830 | it.effect("stops the comment walk at its bound and says the conversation was cut short", () => | |
| 831 | Effect.gen(function* () { | |
| 832 | // Bitbucket that always names a next page: the walk has to end itself. | |
| 833 | mockedRequest.mockReturnValue( | |
| 834 | Effect.succeed( | |
| 835 | response( | |
| 836 | JSON.stringify({ | |
| 837 | next: "https://api.bitbucket.org/2.0/comments?page=2", | |
| 838 | values: [ | |
| 839 | { | |
| 840 | id: 10, | |
| 841 | content: { raw: "again" }, | |
| 842 | created_on: "2026-06-16T05:04:32+00:00", | |
| 843 | }, | |
| 844 | ], | |
| 845 | }), | |
| 846 | ), | |
| 847 | ), | |
| 848 | ); | |
| 849 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 850 | ||
| 851 | const { truncated } = yield* api.listComments({ repository: "acme/web", number: 7 }); | |
| 852 | ||
| 853 | assert.strictEqual(mockedRequest.mock.calls.length, 10); | |
| 854 | assert.isTrue(truncated); | |
| 855 | }), | |
| 856 | ); | |
| 857 | ||
| 858 | it.effect("reassembles a thread from the flat comment list, replies included", () => | |
| 859 | Effect.gen(function* () { | |
| 860 | mockedRequest.mockReturnValueOnce( | |
| 861 | Effect.succeed( | |
| 862 | response( | |
| 863 | JSON.stringify({ | |
| 864 | values: [ | |
| 865 | { | |
| 866 | id: 10, | |
| 867 | content: { raw: "rename this" }, | |
| 868 | user: { nickname: "bilal" }, | |
| 869 | created_on: "2026-06-16T05:04:32+00:00", | |
| 870 | inline: { path: "src/a.ts", to: 12, from: null }, | |
| 871 | resolution: { type: "pullrequest_comment_resolution" }, | |
| 872 | }, | |
| 873 | { | |
| 874 | id: 11, | |
| 875 | content: { raw: "done" }, | |
| 876 | user: { nickname: "julius" }, | |
| 877 | created_on: "2026-06-16T06:04:32+00:00", | |
| 878 | parent: { id: 10 }, | |
| 879 | }, | |
| 880 | // A reply to a reply still belongs to the thread its root opened. | |
| 881 | { | |
| 882 | id: 12, | |
| 883 | content: { raw: "thanks" }, | |
| 884 | user: { nickname: "bilal" }, | |
| 885 | created_on: "2026-06-16T07:04:32+00:00", | |
| 886 | parent: { id: 11 }, | |
| 887 | }, | |
| 888 | { | |
| 889 | id: 13, | |
| 890 | content: { raw: "ship it" }, | |
| 891 | user: { nickname: "bilal" }, | |
| 892 | created_on: "2026-06-16T08:04:32+00:00", | |
| 893 | }, | |
| 894 | ], | |
| 895 | }), | |
| 896 | ), | |
| 897 | ), | |
| 898 | ); | |
| 899 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 900 | ||
| 901 | const { threads } = yield* api.listComments({ repository: "acme/web", number: 7 }); | |
| 902 | ||
| 903 | assert.strictEqual(threads.length, 1); | |
| 904 | expect(threads[0]).toMatchObject({ | |
| 905 | id: "10", | |
| 906 | path: "src/a.ts", | |
| 907 | line: 12, | |
| 908 | side: "right", | |
| 909 | isResolved: true, | |
| 910 | }); | |
| 911 | expect(threads[0]?.comments.map((comment) => comment.id)).toEqual(["10", "11", "12"]); | |
| 912 | }), | |
| 913 | ); | |
| 914 | ||
| 915 | it.effect("writes a review's line comments, its summary, then its verdict", () => | |
| 916 | Effect.gen(function* () { | |
| 917 | mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); | |
| 918 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 919 | ||
| 920 | yield* api.submitReview({ | |
| 921 | repository: "acme/web", | |
| 922 | number: 7, | |
| 923 | verdict: "request-changes", | |
| 924 | body: "Two things.", | |
| 925 | comments: [ | |
| 926 | { | |
| 927 | path: "src/a.ts", | |
| 928 | position: { kind: "deleted", oldLine: 12 }, | |
| 929 | body: "why remove?", | |
| 930 | }, | |
| 931 | ], | |
| 932 | }); | |
| 933 | ||
| 934 | expect(callAt(0).url).toContain("/pullrequests/7/comments"); | |
| 935 | expect(JSON.parse(callAt(0).body ?? "")).toEqual({ | |
| 936 | content: { raw: "why remove?" }, | |
| 937 | inline: { path: "src/a.ts", from: 12 }, | |
| 938 | }); | |
| 939 | expect(callAt(1).url).toContain("/pullrequests/7/comments"); | |
| 940 | // The verdict goes last, so a review that failed part-way is never a rejection either. | |
| 941 | expect(callAt(2).url).toContain("/pullrequests/7/request-changes"); | |
| 942 | }), | |
| 943 | ); | |
| 944 | ||
| 945 | it.effect("resolves by creating the sub-resource and unresolves by deleting it", () => | |
| 946 | Effect.gen(function* () { | |
| 947 | mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); | |
| 948 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 949 | ||
| 950 | yield* api.setCommentResolution({ | |
| 951 | repository: "acme/web", | |
| 952 | number: 7, | |
| 953 | commentId: "10", | |
| 954 | resolved: true, | |
| 955 | }); | |
| 956 | yield* api.setCommentResolution({ | |
| 957 | repository: "acme/web", | |
| 958 | number: 7, | |
| 959 | commentId: "10", | |
| 960 | resolved: false, | |
| 961 | }); | |
| 962 | ||
| 963 | assert.strictEqual(callAt(0).method, "POST"); | |
| 964 | assert.strictEqual(callAt(1).method, "DELETE"); | |
| 965 | expect(callAt(0).url).toContain("/comments/10/resolve"); | |
| 966 | }), | |
| 967 | ); | |
| 968 | ||
| 969 | it.effect("replies by naming the comment it answers", () => | |
| 970 | Effect.gen(function* () { | |
| 971 | mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); | |
| 972 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 973 | ||
| 974 | yield* api.replyToComment({ | |
| 975 | repository: "acme/web", | |
| 976 | number: 7, | |
| 977 | commentId: "10", | |
| 978 | body: "Fixed.", | |
| 979 | }); | |
| 980 | ||
| 981 | expect(JSON.parse(callAt(0).body ?? "")).toEqual({ | |
| 982 | content: { raw: "Fixed." }, | |
| 983 | parent: { id: 10 }, | |
| 984 | }); | |
| 985 | }), | |
| 986 | ); | |
| 987 | ||
| 988 | it.effect("asks for the credentials' permission on this repository, and nobody else's", () => | |
| 989 | Effect.gen(function* () { | |
| 990 | mockedRequest.mockReturnValue( | |
| 991 | Effect.succeed( | |
| 992 | response( | |
| 993 | JSON.stringify({ values: [{ type: "repository_permission", permission: "read" }] }), | |
| 994 | ), | |
| 995 | ), | |
| 996 | ); | |
| 997 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 998 | ||
| 999 | assert.isFalse(yield* api.getRepositoryPermission({ repository: "acme/web" })); | |
| 1000 | ||
| 1001 | expect(callAt(0).url).toContain("/user/permissions/repositories"); | |
| 1002 | assert.strictEqual(filterOfCall(0), 'repository.full_name="acme/web"'); | |
| 1003 | }), | |
| 1004 | ); | |
| 1005 | ||
| 1006 | it.effect("escapes a repository name before it goes inside a filter literal", () => | |
| 1007 | Effect.gen(function* () { | |
| 1008 | mockedRequest.mockReturnValue(Effect.succeed(response(JSON.stringify({ values: [] })))); | |
| 1009 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 1010 | ||
| 1011 | yield* api.getRepositoryPermission({ repository: 'acme/we"b' }); | |
| 1012 | ||
| 1013 | // A quote would otherwise end the literal and leave the rest standing as filter syntax. | |
| 1014 | assert.strictEqual(filterOfCall(0), 'repository.full_name="acme/we\\"b"'); | |
| 1015 | }), | |
| 1016 | ); | |
| 1017 | ||
| 1018 | it.effect( | |
| 1019 | "reads a removed permissions endpoint as granted rather than failing the merge on it", | |
| 1020 | () => | |
| 1021 | Effect.gen(function* () { | |
| 1022 | // Bitbucket retired /user/permissions/repositories under CHANGE-2770: every account now | |
| 1023 | // gets HTTP 410 here, whatever it may do. | |
| 1024 | mockedRequest.mockReturnValue( | |
| 1025 | Effect.fail( | |
| 1026 | new BitbucketApi.BitbucketResponseError({ | |
| 1027 | operation: "request", | |
| 1028 | status: 410, | |
| 1029 | responseBodyLength: 0, | |
| 1030 | }), | |
| 1031 | ), | |
| 1032 | ); | |
| 1033 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 1034 | ||
| 1035 | assert.isTrue(yield* api.getRepositoryPermission({ repository: "acme/web" })); | |
| 1036 | }), | |
| 1037 | ); | |
| 1038 | ||
| 1039 | it.effect("still fails the permission read on a failure that is not the removed endpoint", () => | |
| 1040 | Effect.gen(function* () { | |
| 1041 | mockedRequest.mockReturnValue( | |
| 1042 | Effect.fail( | |
| 1043 | new BitbucketApi.BitbucketResponseError({ | |
| 1044 | operation: "request", | |
| 1045 | status: 401, | |
| 1046 | responseBodyLength: 0, | |
| 1047 | }), | |
| 1048 | ), | |
| 1049 | ); | |
| 1050 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 1051 | ||
| 1052 | const error = yield* Effect.flip(api.getRepositoryPermission({ repository: "acme/web" })); | |
| 1053 | ||
| 1054 | assert.strictEqual(error._tag, "BitbucketResponseError"); | |
| 1055 | }), | |
| 1056 | ); | |
| 1057 | ||
| 1058 | it.effect("reads the workspace's people and marks whoever is already a reviewer", () => | |
| 1059 | Effect.gen(function* () { | |
| 1060 | mockedRequest | |
| 1061 | .mockReturnValueOnce(Effect.succeed(response(pullRequestJson({ reviewers: [octocat] })))) | |
| 1062 | .mockReturnValueOnce( | |
| 1063 | Effect.succeed( | |
| 1064 | response( | |
| 1065 | JSON.stringify({ values: [{ user: bilal }, { user: octocat }, { user: hubot }] }), | |
| 1066 | ), | |
| 1067 | ), | |
| 1068 | ); | |
| 1069 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 1070 | ||
| 1071 | const list = yield* api.listReviewerCandidates({ repository: "acme/web", number: 7 }); | |
| 1072 | ||
| 1073 | // The people live on the workspace: nothing on a repository lists who may review it. | |
| 1074 | expect(callAt(1).url).toBe("/workspaces/acme/members?pagelen=50"); | |
| 1075 | expect(list.candidates.map((candidate) => [candidate.id, candidate.isRequested])).toEqual([ | |
| 1076 | ["{octocat}", true], | |
| 1077 | ["{hubot}", false], | |
| 1078 | ]); | |
| 1079 | assert.isFalse(list.truncated); | |
| 1080 | }), | |
| 1081 | ); | |
| 1082 | ||
| 1083 | it.effect("writes the reviewer set back with the one being asked added to it", () => | |
| 1084 | Effect.gen(function* () { | |
| 1085 | mockedRequest | |
| 1086 | .mockReturnValueOnce(Effect.succeed(response(pullRequestJson({ reviewers: [octocat] })))) | |
| 1087 | .mockReturnValueOnce(Effect.succeed(response("{}"))); | |
| 1088 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 1089 | ||
| 1090 | yield* api.setReviewerRequest({ | |
| 1091 | repository: "acme/web", | |
| 1092 | number: 7, | |
| 1093 | reviewers: [{ id: "{hubot}" }], | |
| 1094 | requested: true, | |
| 1095 | }); | |
| 1096 | ||
| 1097 | // Bitbucket writes `reviewers` whole, so the one already on the pull request travels with | |
| 1098 | // the new one or the request would take them off it. | |
| 1099 | const call = callAt(1); | |
| 1100 | expect(call.method).toBe("PUT"); | |
| 1101 | expect(call.url).toBe("/repositories/acme/web/pullrequests/7"); | |
| 1102 | expect(JSON.parse(call.body ?? "")).toEqual({ | |
| 1103 | reviewers: [{ uuid: "{octocat}" }, { uuid: "{hubot}" }], | |
| 1104 | }); | |
| 1105 | }), | |
| 1106 | ); | |
| 1107 | ||
| 1108 | it.effect("takes a reviewer out of the set rather than clearing it", () => | |
| 1109 | Effect.gen(function* () { | |
| 1110 | mockedRequest | |
| 1111 | .mockReturnValueOnce( | |
| 1112 | Effect.succeed(response(pullRequestJson({ reviewers: [octocat, hubot] }))), | |
| 1113 | ) | |
| 1114 | .mockReturnValueOnce(Effect.succeed(response("{}"))); | |
| 1115 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 1116 | ||
| 1117 | yield* api.setReviewerRequest({ | |
| 1118 | repository: "acme/web", | |
| 1119 | number: 7, | |
| 1120 | reviewers: [{ id: "{hubot}" }], | |
| 1121 | requested: false, | |
| 1122 | }); | |
| 1123 | ||
| 1124 | expect(JSON.parse(callAt(1).body ?? "")).toEqual({ reviewers: [{ uuid: "{octocat}" }] }); | |
| 1125 | }), | |
| 1126 | ); | |
| 1127 | }); |
apps/server/src/pullRequest/BitbucketPullRequestApi.ts deleted-943| ... | ... | @@ -1,943 +0,0 @@ |
| 1 | import * as Cache from "effect/Cache"; | |
| 2 | import * as Context from "effect/Context"; | |
| 3 | import * as Duration from "effect/Duration"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Exit from "effect/Exit"; | |
| 6 | import * as Layer from "effect/Layer"; | |
| 7 | import * as Result from "effect/Result"; | |
| 8 | import * as Schema from "effect/Schema"; | |
| 9 | import type { | |
| 10 | PullRequestAction, | |
| 11 | PullRequestCheck, | |
| 12 | PullRequestComment, | |
| 13 | PullRequestCommit, | |
| 14 | PullRequestListState, | |
| 15 | PullRequestMergeMethod, | |
| 16 | PullRequestMergeability, | |
| 17 | PullRequestReviewCommentDraft, | |
| 18 | PullRequestReviewPosition, | |
| 19 | PullRequestReviewThread, | |
| 20 | PullRequestReviewVerdict, | |
| 21 | PullRequestReviewerCandidateList, | |
| 22 | } from "@t3tools/contracts"; | |
| 23 | ||
| 24 | import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; | |
| 25 | import { parseDiffFileRevisions } from "@t3tools/source-control-core/server/diffFileRevisions"; | |
| 26 | import { | |
| 27 | buildReviewThreads, | |
| 28 | decodeCommentsJson, | |
| 29 | decodeCommitsJson, | |
| 30 | decodeConflictsJson, | |
| 31 | decodeDiffstatJson, | |
| 32 | decodePullRequestJson, | |
| 33 | decodePullRequestPageJson, | |
| 34 | decodeRepositoryPermissionJson, | |
| 35 | decodeStatusesJson, | |
| 36 | decodeViewerJson, | |
| 37 | decodeWorkspaceMembersJson, | |
| 38 | type BitbucketDiffStat, | |
| 39 | type BitbucketPullRequest, | |
| 40 | type BitbucketRawComment, | |
| 41 | } from "./bitbucketPullRequestJson.ts"; | |
| 42 | import type { ProviderListCursor } from "@t3tools/source-control-core/server/PullRequestProvider"; | |
| 43 | ||
| 44 | /** | |
| 45 | * Names the read that produced unusable output, so a failure reports the call it came from | |
| 46 | * rather than borrowing another operation's message. | |
| 47 | */ | |
| 48 | export class BitbucketPullRequestReadError extends Schema.TaggedError<BitbucketPullRequestReadError>()( | |
| 49 | "BitbucketPullRequestReadError", | |
| 50 | { | |
| 51 | operation: Schema.String, | |
| 52 | cause: Schema.Defect(), | |
| 53 | }, | |
| 54 | ) { | |
| 55 | get detail(): string { | |
| 56 | return `Bitbucket returned an unreadable ${this.operation} response.`; | |
| 57 | } | |
| 58 | ||
| 59 | override get message(): string { | |
| 60 | return `Bitbucket failed in ${this.operation}: ${this.detail}`; | |
| 61 | } | |
| 62 | } | |
| 63 | ||
| 64 | /** Not a decode failure: Bitbucket answered, the account it answered for just has no handle. */ | |
| 65 | export class BitbucketViewerUnavailableError extends Schema.TaggedError<BitbucketViewerUnavailableError>()( | |
| 66 | "BitbucketViewerUnavailableError", | |
| 67 | {}, | |
| 68 | ) { | |
| 69 | get detail(): string { | |
| 70 | return "Bitbucket returned no account name for the configured credentials."; | |
| 71 | } | |
| 72 | ||
| 73 | override get message(): string { | |
| 74 | return `Bitbucket failed in getViewer: ${this.detail}`; | |
| 75 | } | |
| 76 | } | |
| 77 | ||
| 78 | /** A repository that is not `workspace/slug`, which is the only form Bitbucket addresses. */ | |
| 79 | export class BitbucketRepositoryUnsupportedError extends Schema.TaggedError<BitbucketRepositoryUnsupportedError>()( | |
| 80 | "BitbucketRepositoryUnsupportedError", | |
| 81 | { | |
| 82 | repository: Schema.String, | |
| 83 | }, | |
| 84 | ) { | |
| 85 | get detail(): string { | |
| 86 | return "A Bitbucket repository is addressed as workspace/repository."; | |
| 87 | } | |
| 88 | ||
| 89 | override get message(): string { | |
| 90 | return `Bitbucket failed in resolveRepository: ${this.detail}`; | |
| 91 | } | |
| 92 | } | |
| 93 | ||
| 94 | /** Not a decode failure: the reader named a commit that is not a sha this repository could hold. */ | |
| 95 | export class BitbucketDiffCommitError extends Schema.TaggedError<BitbucketDiffCommitError>()( | |
| 96 | "BitbucketDiffCommitError", | |
| 97 | {}, | |
| 98 | ) { | |
| 99 | get detail(): string { | |
| 100 | return "The named commit was not a commit sha."; | |
| 101 | } | |
| 102 | ||
| 103 | override get message(): string { | |
| 104 | return `Bitbucket failed in getPullRequestDiff: ${this.detail}`; | |
| 105 | } | |
| 106 | } | |
| 107 | ||
| 108 | export type BitbucketPullRequestApiError = | |
| 109 | | BitbucketApi.BitbucketApiError | |
| 110 | | BitbucketPullRequestReadError | |
| 111 | | BitbucketViewerUnavailableError | |
| 112 | | BitbucketRepositoryUnsupportedError | |
| 113 | | BitbucketDiffCommitError; | |
| 114 | ||
| 115 | /** | |
| 116 | * `/user/permissions/repositories` answering CHANGE-2770's removal notice rather than a | |
| 117 | * permission — Bitbucket sends this for every account now, not only ones it would have refused. | |
| 118 | */ | |
| 119 | function isRepositoryPermissionRemovedError( | |
| 120 | error: BitbucketPullRequestApiError, | |
| 121 | ): error is BitbucketApi.BitbucketResponseError { | |
| 122 | return error._tag === "BitbucketResponseError" && error.status === 410; | |
| 123 | } | |
| 124 | ||
| 125 | /** | |
| 126 | * Bitbucket's own ceiling. Asking for more does not fail — it answers with an empty page and no | |
| 127 | * error at all, so this is a number to respect rather than to push against. | |
| 128 | */ | |
| 129 | const MAX_PAGE_SIZE = 50; | |
| 130 | /** Pages to walk before a listing is reported as truncated. */ | |
| 131 | const MAX_LIST_PAGES = 10; | |
| 132 | /** The page size for pull request conversations, commits, and checks. */ | |
| 133 | const CONVERSATION_PAGE_SIZE = 50; | |
| 134 | /** | |
| 135 | * Pages of the conversation to follow before it is reported as truncated. Bitbucket serves | |
| 136 | * fifty comments a page, so this is five hundred — beyond any pull request a person is reading, | |
| 137 | * and an end to a walk whose only other stop is Bitbucket running out. | |
| 138 | */ | |
| 139 | const CONVERSATION_PAGES = 10; | |
| 140 | /** The same ceiling the gh and glab diff reads use. */ | |
| 141 | const DIFF_MAX_BYTES = 8 * 1024 * 1024; | |
| 142 | /** | |
| 143 | * Deliberately far shorter than the window the caller holds versions for: a refresh drops what the | |
| 144 | * caller holds precisely so the next read reaches Bitbucket, and this must not be what answers it | |
| 145 | * instead. | |
| 146 | */ | |
| 147 | const REVISION_PATCH_TTL = Duration.seconds(5); | |
| 148 | const REVISION_PATCH_CAPACITY = 16; | |
| 149 | export interface BitbucketPullRequestBatch { | |
| 150 | readonly items: ReadonlyArray<BitbucketPullRequest>; | |
| 151 | readonly truncated: boolean; | |
| 152 | } | |
| 153 | ||
| 154 | export class BitbucketPullRequestApi extends Context.Service< | |
| 155 | BitbucketPullRequestApi, | |
| 156 | { | |
| 157 | /** A function rather than a value, so the request is built per call and not at layer time. */ | |
| 158 | readonly getViewer: () => Effect.Effect<string, BitbucketPullRequestApiError>; | |
| 159 | ||
| 160 | readonly listPullRequests: (input: { | |
| 161 | readonly repository: string; | |
| 162 | readonly state: PullRequestListState; | |
| 163 | readonly limit: number; | |
| 164 | /** Free text, matched against a pull request's title and description. */ | |
| 165 | readonly query?: string | undefined; | |
| 166 | /** Where to carry on from, as a predicate on `updated_on` beside any other. */ | |
| 167 | readonly cursor?: ProviderListCursor | undefined; | |
| 168 | }) => Effect.Effect<BitbucketPullRequestBatch, BitbucketPullRequestApiError>; | |
| 169 | ||
| 170 | readonly getPullRequest: (input: { | |
| 171 | readonly repository: string; | |
| 172 | readonly number: number; | |
| 173 | }) => Effect.Effect<BitbucketPullRequest, BitbucketPullRequestApiError>; | |
| 174 | ||
| 175 | /** True where the credentials can write to the repository, which is what merging needs. */ | |
| 176 | readonly getRepositoryPermission: (input: { | |
| 177 | readonly repository: string; | |
| 178 | }) => Effect.Effect<boolean, BitbucketPullRequestApiError>; | |
| 179 | ||
| 180 | readonly getPullRequestDiff: (input: { | |
| 181 | readonly repository: string; | |
| 182 | readonly number: number; | |
| 183 | /** One commit's own changes, rather than everything the pull request carries. */ | |
| 184 | readonly commit?: string | undefined; | |
| 185 | }) => Effect.Effect< | |
| 186 | { readonly patch: string; readonly truncated: boolean }, | |
| 187 | BitbucketPullRequestApiError | |
| 188 | >; | |
| 189 | ||
| 190 | readonly getDiffStat: (input: { | |
| 191 | readonly repository: string; | |
| 192 | readonly number: number; | |
| 193 | }) => Effect.Effect<BitbucketDiffStat, BitbucketPullRequestApiError>; | |
| 194 | ||
| 195 | /** | |
| 196 | * What the pull request's head has of each of these paths, as opaque ids, read off the pull | |
| 197 | * request's own patch, the only place Bitbucket states a file's version. A path the patch does | |
| 198 | * not carry is answered as the empty revision, and left out altogether when the patch was cut | |
| 199 | * short at the byte ceiling and so cannot be spoken for. | |
| 200 | * | |
| 201 | * Answers with every file the patch carries rather than only the paths asked about, since | |
| 202 | * reading one file's version here means parsing all of them. `complete` is false for a patch | |
| 203 | * cut short, which cannot speak for what came after the cut. | |
| 204 | */ | |
| 205 | readonly getFileRevisions: (input: { | |
| 206 | readonly repository: string; | |
| 207 | readonly number: number; | |
| 208 | readonly paths: ReadonlyArray<string>; | |
| 209 | }) => Effect.Effect< | |
| 210 | { readonly revisions: ReadonlyMap<string, string>; readonly complete: boolean }, | |
| 211 | BitbucketPullRequestApiError | |
| 212 | >; | |
| 213 | ||
| 214 | readonly getMergeability: (input: { | |
| 215 | readonly repository: string; | |
| 216 | readonly number: number; | |
| 217 | }) => Effect.Effect<PullRequestMergeability, BitbucketPullRequestApiError>; | |
| 218 | ||
| 219 | readonly listComments: (input: { | |
| 220 | readonly repository: string; | |
| 221 | readonly number: number; | |
| 222 | }) => Effect.Effect< | |
| 223 | { | |
| 224 | readonly comments: ReadonlyArray<PullRequestComment>; | |
| 225 | readonly threads: ReadonlyArray<PullRequestReviewThread>; | |
| 226 | readonly truncated: boolean; | |
| 227 | }, | |
| 228 | BitbucketPullRequestApiError | |
| 229 | >; | |
| 230 | ||
| 231 | readonly listCommits: (input: { | |
| 232 | readonly repository: string; | |
| 233 | readonly number: number; | |
| 234 | }) => Effect.Effect<ReadonlyArray<PullRequestCommit>, BitbucketPullRequestApiError>; | |
| 235 | ||
| 236 | readonly listChecks: (input: { | |
| 237 | readonly repository: string; | |
| 238 | readonly number: number; | |
| 239 | }) => Effect.Effect<ReadonlyArray<PullRequestCheck>, BitbucketPullRequestApiError>; | |
| 240 | ||
| 241 | /** | |
| 242 | * Who this pull request may be sent to, and who it has already been sent to. Two reads at | |
| 243 | * once, because Bitbucket keeps the people on the workspace and the reviewers on the pull | |
| 244 | * request, and neither answers for the other. | |
| 245 | */ | |
| 246 | readonly listReviewerCandidates: (input: { | |
| 247 | readonly repository: string; | |
| 248 | readonly number: number; | |
| 249 | }) => Effect.Effect<PullRequestReviewerCandidateList, BitbucketPullRequestApiError>; | |
| 250 | ||
| 251 | readonly setReviewerRequest: (input: { | |
| 252 | readonly repository: string; | |
| 253 | readonly number: number; | |
| 254 | readonly reviewers: ReadonlyArray<{ readonly id: string }>; | |
| 255 | readonly requested: boolean; | |
| 256 | }) => Effect.Effect<void, BitbucketPullRequestApiError>; | |
| 257 | ||
| 258 | readonly runAction: (input: { | |
| 259 | readonly repository: string; | |
| 260 | readonly number: number; | |
| 261 | readonly action: PullRequestAction; | |
| 262 | readonly mergeMethod?: PullRequestMergeMethod; | |
| 263 | }) => Effect.Effect<void, BitbucketPullRequestApiError>; | |
| 264 | ||
| 265 | readonly updateChangeRequest: (input: { | |
| 266 | readonly repository: string; | |
| 267 | readonly number: number; | |
| 268 | readonly title?: string | undefined; | |
| 269 | readonly body?: string | undefined; | |
| 270 | }) => Effect.Effect<void, BitbucketPullRequestApiError>; | |
| 271 | ||
| 272 | readonly comment: (input: { | |
| 273 | readonly repository: string; | |
| 274 | readonly number: number; | |
| 275 | readonly body: string; | |
| 276 | }) => Effect.Effect<void, BitbucketPullRequestApiError>; | |
| 277 | ||
| 278 | readonly updateComment: (input: { | |
| 279 | readonly repository: string; | |
| 280 | readonly number: number; | |
| 281 | readonly commentId: string; | |
| 282 | readonly body: string; | |
| 283 | }) => Effect.Effect<void, BitbucketPullRequestApiError>; | |
| 284 | ||
| 285 | readonly submitReview: (input: { | |
| 286 | readonly repository: string; | |
| 287 | readonly number: number; | |
| 288 | readonly verdict: PullRequestReviewVerdict; | |
| 289 | readonly body: string; | |
| 290 | readonly comments: ReadonlyArray<PullRequestReviewCommentDraft>; | |
| 291 | }) => Effect.Effect<void, BitbucketPullRequestApiError>; | |
| 292 | ||
| 293 | readonly replyToComment: (input: { | |
| 294 | readonly repository: string; | |
| 295 | readonly number: number; | |
| 296 | readonly commentId: string; | |
| 297 | readonly body: string; | |
| 298 | }) => Effect.Effect<void, BitbucketPullRequestApiError>; | |
| 299 | ||
| 300 | readonly setCommentResolution: (input: { | |
| 301 | readonly repository: string; | |
| 302 | readonly number: number; | |
| 303 | readonly commentId: string; | |
| 304 | readonly resolved: boolean; | |
| 305 | }) => Effect.Effect<void, BitbucketPullRequestApiError>; | |
| 306 | } | |
| 307 | >()("t3/pullRequest/BitbucketPullRequestApi") {} | |
| 308 | ||
| 309 | /** `workspace/slug`; Bitbucket has no deeper nesting to address. */ | |
| 310 | function repositorySegments( | |
| 311 | repository: string, | |
| 312 | ): Result.Result< | |
| 313 | { readonly workspace: string; readonly slug: string }, | |
| 314 | BitbucketRepositoryUnsupportedError | |
| 315 | > { | |
| 316 | const segments = repository | |
| 317 | .split("/") | |
| 318 | .map((segment) => segment.trim()) | |
| 319 | .filter((segment) => segment.length > 0); | |
| 320 | const [workspace, slug] = segments; | |
| 321 | if (segments.length !== 2 || workspace === undefined || slug === undefined) { | |
| 322 | return Result.fail(new BitbucketRepositoryUnsupportedError({ repository })); | |
| 323 | } | |
| 324 | return Result.succeed({ workspace, slug }); | |
| 325 | } | |
| 326 | ||
| 327 | function repositoryPathOf(segments: { readonly workspace: string; readonly slug: string }): string { | |
| 328 | return `/repositories/${encodeURIComponent(segments.workspace)}/${encodeURIComponent( | |
| 329 | segments.slug, | |
| 330 | )}`; | |
| 331 | } | |
| 332 | ||
| 333 | /** | |
| 334 | * A commit sha arrives from the reader and goes straight into a request path, so it is checked | |
| 335 | * rather than trusted: hexadecimal only, from the shortest abbreviation a host prints up to a | |
| 336 | * whole sha. | |
| 337 | */ | |
| 338 | function isCommitSha(value: string): boolean { | |
| 339 | return /^[0-9a-f]{7,64}$/i.test(value); | |
| 340 | } | |
| 341 | ||
| 342 | /** | |
| 343 | * Bitbucket unions repeated `state` parameters, so a tab that spans several of its states asks | |
| 344 | * for each. It separates a declined pull request from one superseded by another, and both read | |
| 345 | * as closed here. | |
| 346 | */ | |
| 347 | function stateParams(state: PullRequestListState): ReadonlyArray<string> { | |
| 348 | switch (state) { | |
| 349 | case "open": | |
| 350 | return ["OPEN"]; | |
| 351 | case "merged": | |
| 352 | return ["MERGED"]; | |
| 353 | case "closed": | |
| 354 | return ["DECLINED", "SUPERSEDED"]; | |
| 355 | case "all": | |
| 356 | return ["OPEN", "MERGED", "DECLINED", "SUPERSEDED"]; | |
| 357 | } | |
| 358 | } | |
| 359 | ||
| 360 | /** | |
| 361 | * Bitbucket has no search term, only a filter expression, so free text becomes one: a | |
| 362 | * case-insensitive contains against the two fields a pull request carries words in. The | |
| 363 | * parentheses matter, because the expression is ANDed with the state filter beside it and an | |
| 364 | * unbracketed `OR` would swallow it. | |
| 365 | * | |
| 366 | * A string literal in that grammar is delimited by double quotes, so the reader's text is | |
| 367 | * escaped before it goes inside one — a quote would otherwise end the literal and leave the | |
| 368 | * rest of the text standing as filter syntax. The whole expression is then URL-encoded, so | |
| 369 | * nothing in it reaches the query string as a parameter of its own. | |
| 370 | */ | |
| 371 | function searchFilter(query: string): string { | |
| 372 | const literal = filterLiteral(query); | |
| 373 | return `(title ~ "${literal}" OR description ~ "${literal}")`; | |
| 374 | } | |
| 375 | ||
| 376 | /** | |
| 377 | * Text as a string literal of Bitbucket's filter grammar. The backslash is escaped first, or | |
| 378 | * escaping the quote would only produce a literal backslash followed by a live quote. | |
| 379 | */ | |
| 380 | function filterLiteral(value: string): string { | |
| 381 | return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); | |
| 382 | } | |
| 383 | ||
| 384 | /** Bitbucket's merge strategies, named differently from the three the contract carries. */ | |
| 385 | function mergeStrategy(method: PullRequestMergeMethod | undefined): string { | |
| 386 | switch (method) { | |
| 387 | case "squash": | |
| 388 | return "squash"; | |
| 389 | case "rebase": | |
| 390 | // The linear history GitHub calls "rebase and merge". | |
| 391 | return "rebase_fast_forward"; | |
| 392 | default: | |
| 393 | return "merge_commit"; | |
| 394 | } | |
| 395 | } | |
| 396 | ||
| 397 | function bitbucketReviewPosition( | |
| 398 | position: PullRequestReviewPosition, | |
| 399 | ): { readonly from: number } | { readonly to: number } { | |
| 400 | switch (position.kind) { | |
| 401 | case "added": | |
| 402 | return { to: position.newLine }; | |
| 403 | case "deleted": | |
| 404 | return { from: position.oldLine }; | |
| 405 | case "context": | |
| 406 | return position.side === "left" ? { from: position.oldLine } : { to: position.newLine }; | |
| 407 | } | |
| 408 | } | |
| 409 | ||
| 410 | /** @public Service construction is part of the canonical Effect module API. */ | |
| 411 | export const make = Effect.gen(function* () { | |
| 412 | const bitbucket = yield* BitbucketApi.BitbucketApi; | |
| 413 | ||
| 414 | /** | |
| 415 | * The repository's own path, and the workspace above it — which the people who may review are | |
| 416 | * kept on rather than on the repository, so both are handed over at once. | |
| 417 | */ | |
| 418 | const withRepository = <A>( | |
| 419 | repository: string, | |
| 420 | use: (path: string, workspace: string) => Effect.Effect<A, BitbucketPullRequestApiError>, | |
| 421 | ): Effect.Effect<A, BitbucketPullRequestApiError> => { | |
| 422 | const segments = repositorySegments(repository); | |
| 423 | return Result.isSuccess(segments) | |
| 424 | ? use(repositoryPathOf(segments.success), segments.success.workspace) | |
| 425 | : Effect.fail(segments.failure); | |
| 426 | }; | |
| 427 | ||
| 428 | /** | |
| 429 | * Bitbucket pages with a cursor rather than an offset, so the walk follows the `next` URL it | |
| 430 | * sends. It stops once the caller's page is filled, when Bitbucket reports no next page, or at | |
| 431 | * the page cap — and anything but running out of pages means there is more to be had. | |
| 432 | */ | |
| 433 | const listPage = (input: { | |
| 434 | readonly url: string; | |
| 435 | readonly limit: number; | |
| 436 | readonly page: number; | |
| 437 | readonly collected: ReadonlyArray<BitbucketPullRequest>; | |
| 438 | }): Effect.Effect<BitbucketPullRequestBatch, BitbucketPullRequestApiError> => | |
| 439 | bitbucket.request({ method: "GET", url: input.url }).pipe( | |
| 440 | Effect.flatMap((response) => { | |
| 441 | const decoded = decodePullRequestPageJson(response.body); | |
| 442 | if (!Result.isSuccess(decoded)) { | |
| 443 | return Effect.fail( | |
| 444 | new BitbucketPullRequestReadError({ | |
| 445 | operation: "listPullRequests", | |
| 446 | cause: decoded.failure, | |
| 447 | }), | |
| 448 | ); | |
| 449 | } | |
| 450 | const collected = [...input.collected, ...decoded.success.items]; | |
| 451 | const next = decoded.success.next; | |
| 452 | if (next === null || collected.length >= input.limit || input.page >= MAX_LIST_PAGES) { | |
| 453 | return Effect.succeed({ | |
| 454 | items: collected.slice(0, input.limit), | |
| 455 | // Bitbucket pages in fifties whatever was asked for, so a walk that stopped on the | |
| 456 | // count rather than on the last page is holding rows it is about to drop. Those are | |
| 457 | // more results just as surely as another page would be. | |
| 458 | truncated: next !== null || collected.length > input.limit, | |
| 459 | }); | |
| 460 | } | |
| 461 | return listPage({ ...input, url: next, page: input.page + 1, collected }); | |
| 462 | }), | |
| 463 | ); | |
| 464 | ||
| 465 | const readPage = <A>(input: { | |
| 466 | readonly operation: string; | |
| 467 | readonly url: string; | |
| 468 | readonly decode: (body: string) => Result.Result<A, unknown>; | |
| 469 | }): Effect.Effect<A, BitbucketPullRequestApiError> => | |
| 470 | bitbucket.request({ method: "GET", url: input.url }).pipe( | |
| 471 | Effect.flatMap((response) => { | |
| 472 | const decoded = input.decode(response.body); | |
| 473 | return Result.isSuccess(decoded) | |
| 474 | ? Effect.succeed(decoded.success) | |
| 475 | : Effect.fail( | |
| 476 | new BitbucketPullRequestReadError({ | |
| 477 | operation: input.operation, | |
| 478 | cause: decoded.failure, | |
| 479 | }), | |
| 480 | ); | |
| 481 | }), | |
| 482 | ); | |
| 483 | ||
| 484 | /** | |
| 485 | * The conversation, following the `next` Bitbucket sends until it sends none. Threads are | |
| 486 | * assembled once at the end rather than per page, because a reply and the remark it answers | |
| 487 | * can land either side of a page boundary. | |
| 488 | */ | |
| 489 | const commentsPage = (input: { | |
| 490 | readonly url: string; | |
| 491 | readonly page: number; | |
| 492 | readonly comments: ReadonlyArray<PullRequestComment>; | |
| 493 | readonly entries: ReadonlyArray<BitbucketRawComment>; | |
| 494 | }): Effect.Effect< | |
| 495 | { | |
| 496 | readonly comments: ReadonlyArray<PullRequestComment>; | |
| 497 | readonly threads: ReadonlyArray<PullRequestReviewThread>; | |
| 498 | readonly truncated: boolean; | |
| 499 | }, | |
| 500 | BitbucketPullRequestApiError | |
| 501 | > => | |
| 502 | readPage({ operation: "listComments", url: input.url, decode: decodeCommentsJson }).pipe( | |
| 503 | Effect.flatMap((page) => { | |
| 504 | const comments = [...input.comments, ...page.comments]; | |
| 505 | const entries = [...input.entries, ...page.entries]; | |
| 506 | if (page.next !== null && input.page < CONVERSATION_PAGES) { | |
| 507 | return commentsPage({ url: page.next, page: input.page + 1, comments, entries }); | |
| 508 | } | |
| 509 | return Effect.succeed({ | |
| 510 | comments, | |
| 511 | threads: buildReviewThreads(entries), | |
| 512 | truncated: page.next !== null, | |
| 513 | }); | |
| 514 | }), | |
| 515 | ); | |
| 516 | ||
| 517 | /** Walks a Bitbucket cursor to its end and combines every decoded item. */ | |
| 518 | const itemPages = <A>(input: { | |
| 519 | readonly operation: string; | |
| 520 | readonly url: string; | |
| 521 | readonly decode: ( | |
| 522 | body: string, | |
| 523 | ) => Result.Result<{ readonly items: ReadonlyArray<A>; readonly next: string | null }, unknown>; | |
| 524 | readonly items: ReadonlyArray<A>; | |
| 525 | /** Commit pages are individually oldest-first, so older pages are prepended. */ | |
| 526 | readonly prepend: boolean; | |
| 527 | }): Effect.Effect<ReadonlyArray<A>, BitbucketPullRequestApiError> => | |
| 528 | readPage({ operation: input.operation, url: input.url, decode: input.decode }).pipe( | |
| 529 | Effect.flatMap((page) => { | |
| 530 | const items = input.prepend | |
| 531 | ? [...page.items, ...input.items] | |
| 532 | : [...input.items, ...page.items]; | |
| 533 | return page.next === null | |
| 534 | ? Effect.succeed(items) | |
| 535 | : itemPages({ ...input, url: page.next, items }); | |
| 536 | }), | |
| 537 | ); | |
| 538 | ||
| 539 | /** Diffstat has one aggregate per page, so its totals are folded while following `next`. */ | |
| 540 | const diffStatPages = (input: { | |
| 541 | readonly url: string; | |
| 542 | readonly totals: BitbucketDiffStat; | |
| 543 | }): Effect.Effect<BitbucketDiffStat, BitbucketPullRequestApiError> => | |
| 544 | readPage({ operation: "getDiffStat", url: input.url, decode: decodeDiffstatJson }).pipe( | |
| 545 | Effect.flatMap((page) => { | |
| 546 | const totals = { | |
| 547 | additions: input.totals.additions + page.additions, | |
| 548 | deletions: input.totals.deletions + page.deletions, | |
| 549 | changedFiles: input.totals.changedFiles + page.changedFiles, | |
| 550 | }; | |
| 551 | return page.next === null | |
| 552 | ? Effect.succeed(totals) | |
| 553 | : diffStatPages({ url: page.next, totals }); | |
| 554 | }), | |
| 555 | ); | |
| 556 | ||
| 557 | const pullRequestDiff = (input: { | |
| 558 | readonly repository: string; | |
| 559 | readonly number: number; | |
| 560 | readonly commit?: string | undefined; | |
| 561 | }): Effect.Effect< | |
| 562 | { readonly patch: string; readonly truncated: boolean }, | |
| 563 | BitbucketPullRequestApiError | |
| 564 | > => | |
| 565 | input.commit !== undefined && !isCommitSha(input.commit) | |
| 566 | ? Effect.fail(new BitbucketDiffCommitError()) | |
| 567 | : withRepository(input.repository, (path) => | |
| 568 | // Already a unified patch, so it needs no decoding at all, only a bound, which a | |
| 569 | // diff of any size would otherwise ignore. A commit's own patch sits beside the pull | |
| 570 | // request's at `/diff/{sha}` and reads the same way. | |
| 571 | bitbucket | |
| 572 | .request({ | |
| 573 | method: "GET", | |
| 574 | url: | |
| 575 | input.commit === undefined | |
| 576 | ? `${path}/pullrequests/${input.number}/diff` | |
| 577 | : `${path}/diff/${input.commit}`, | |
| 578 | maxBytes: DIFF_MAX_BYTES, | |
| 579 | }) | |
| 580 | .pipe( | |
| 581 | Effect.map((response) => ({ patch: response.body, truncated: response.truncated })), | |
| 582 | ), | |
| 583 | ); | |
| 584 | ||
| 585 | /** | |
| 586 | * What the version reads that come one tick at a time want out of the pull request's whole | |
| 587 | * patch, shared between them. The parsed answer rather than the patch, which at this capacity | |
| 588 | * would hold sixteen bodies of up to the byte ceiling each resident, and saves walking a patch | |
| 589 | * of a hundred thousand lines again on every tick. | |
| 590 | */ | |
| 591 | const revisionPatches = yield* Cache.makeWith( | |
| 592 | (key: string) => { | |
| 593 | const [repository, number] = JSON.parse(key) as [string, number]; | |
| 594 | return pullRequestDiff({ repository, number }).pipe( | |
| 595 | Effect.map((diff) => ({ | |
| 596 | revisions: parseDiffFileRevisions(diff.patch), | |
| 597 | truncated: diff.truncated, | |
| 598 | })), | |
| 599 | ); | |
| 600 | }, | |
| 601 | { | |
| 602 | capacity: REVISION_PATCH_CAPACITY, | |
| 603 | // A failure is not held: the tick after it should reach Bitbucket rather than be handed the | |
| 604 | // same error for as long as a good patch would have lasted. | |
| 605 | timeToLive: (exit) => (Exit.isSuccess(exit) ? REVISION_PATCH_TTL : Duration.zero), | |
| 606 | }, | |
| 607 | ); | |
| 608 | ||
| 609 | return BitbucketPullRequestApi.of({ | |
| 610 | getViewer: () => | |
| 611 | bitbucket.request({ method: "GET", url: "/user" }).pipe( | |
| 612 | Effect.flatMap((response): Effect.Effect<string, BitbucketPullRequestApiError> => { | |
| 613 | const decoded = decodeViewerJson(response.body); | |
| 614 | if (!Result.isSuccess(decoded)) { | |
| 615 | return Effect.fail( | |
| 616 | new BitbucketPullRequestReadError({ operation: "getViewer", cause: decoded.failure }), | |
| 617 | ); | |
| 618 | } | |
| 619 | return decoded.success === null | |
| 620 | ? Effect.fail(new BitbucketViewerUnavailableError()) | |
| 621 | : Effect.succeed(decoded.success); | |
| 622 | }), | |
| 623 | ), | |
| 624 | ||
| 625 | listPullRequests: (input) => | |
| 626 | withRepository(input.repository, (path) => { | |
| 627 | const search = input.query?.trim() ?? ""; | |
| 628 | // Both narrowings share the one `q` Bitbucket takes, so they are ANDed rather than one | |
| 629 | // replacing the other. The boundary instant is read inclusively — the rows already sent | |
| 630 | // at it come back and the caller drops them, which is what keeps their neighbours at the | |
| 631 | // same instant from being skipped. A date is a bare literal in this grammar, and this one | |
| 632 | // was checked against a timestamp's shape before it got here. | |
| 633 | const predicates = [ | |
| 634 | ...(search.length === 0 ? [] : [searchFilter(search)]), | |
| 635 | ...(input.cursor === undefined ? [] : [`updated_on <= ${input.cursor.updatedBefore}`]), | |
| 636 | ]; | |
| 637 | return listPage({ | |
| 638 | // Reviewers are not on a listing by default, and `viewerReviewRequested` needs them. | |
| 639 | url: `${path}/pullrequests?${stateParams(input.state) | |
| 640 | .map((state) => `state=${state}`) | |
| 641 | .join("&")}&pagelen=${MAX_PAGE_SIZE}&sort=-updated_on&fields=%2Bvalues.reviewers${ | |
| 642 | predicates.length === 0 ? "" : `&q=${encodeURIComponent(predicates.join(" AND "))}` | |
| 643 | }`, | |
| 644 | limit: input.limit, | |
| 645 | page: 1, | |
| 646 | collected: [], | |
| 647 | }); | |
| 648 | }), | |
| 649 | ||
| 650 | getPullRequest: (input) => | |
| 651 | withRepository(input.repository, (path) => | |
| 652 | readPage({ | |
| 653 | operation: "getPullRequest", | |
| 654 | url: `${path}/pullrequests/${input.number}`, | |
| 655 | decode: decodePullRequestJson, | |
| 656 | }), | |
| 657 | ), | |
| 658 | ||
| 659 | // Nothing on the repository, the pull request or the workspace states what the credentials | |
| 660 | // may do, so this endpoint is the one request Bitbucket makes unavoidable. It is asked | |
| 661 | // alongside the reads the detail was already making, so it costs no round trip of its own. | |
| 662 | // | |
| 663 | // Bitbucket permanently removed this endpoint (CHANGE-2770): every account now gets HTTP 410 | |
| 664 | // in place of an answer, whatever it may do. That is the deprecated-endpoint signal, not a | |
| 665 | // permission being refused, so it is read the same way an unreachable read already is | |
| 666 | // elsewhere — as a permission that could not be learned, which grants rather than blocks, and | |
| 667 | // leaves the actual merge or write to say why if the account may not do it. Any other failure | |
| 668 | // (a bad token, a network fault, an unreadable body) still fails as it did before. | |
| 669 | getRepositoryPermission: (input) => | |
| 670 | withRepository(input.repository, () => | |
| 671 | readPage({ | |
| 672 | operation: "getRepositoryPermission", | |
| 673 | url: `/user/permissions/repositories?q=${encodeURIComponent( | |
| 674 | `repository.full_name="${filterLiteral(input.repository.trim())}"`, | |
| 675 | )}`, | |
| 676 | decode: decodeRepositoryPermissionJson, | |
| 677 | }), | |
| 678 | ).pipe(Effect.catchIf(isRepositoryPermissionRemovedError, () => Effect.succeed(true))), | |
| 679 | ||
| 680 | getPullRequestDiff: pullRequestDiff, | |
| 681 | ||
| 682 | getFileRevisions: (input) => | |
| 683 | input.paths.length === 0 | |
| 684 | ? Effect.succeed({ revisions: new Map(), complete: false }) | |
| 685 | : Cache.get(revisionPatches, JSON.stringify([input.repository, input.number])).pipe( | |
| 686 | Effect.map((diff) => { | |
| 687 | const revisions = new Map(diff.revisions); | |
| 688 | // A patch cut short at the byte ceiling says nothing about the files past the cut, | |
| 689 | // so those paths are left out rather than reported as removed. | |
| 690 | if (!diff.truncated) { | |
| 691 | for (const path of input.paths) { | |
| 692 | if (!revisions.has(path)) revisions.set(path, ""); | |
| 693 | } | |
| 694 | } | |
| 695 | return { revisions, complete: !diff.truncated }; | |
| 696 | }), | |
| 697 | ), | |
| 698 | ||
| 699 | getDiffStat: (input) => | |
| 700 | withRepository(input.repository, (path) => | |
| 701 | diffStatPages({ | |
| 702 | url: `${path}/pullrequests/${input.number}/diffstat?pagelen=${MAX_PAGE_SIZE}`, | |
| 703 | totals: { additions: 0, deletions: 0, changedFiles: 0 }, | |
| 704 | }), | |
| 705 | ), | |
| 706 | ||
| 707 | getMergeability: (input) => | |
| 708 | withRepository(input.repository, (path) => | |
| 709 | readPage({ | |
| 710 | operation: "getMergeability", | |
| 711 | url: `${path}/pullrequests/${input.number}/conflicts`, | |
| 712 | decode: decodeConflictsJson, | |
| 713 | }), | |
| 714 | ), | |
| 715 | ||
| 716 | listComments: (input) => | |
| 717 | withRepository(input.repository, (path) => | |
| 718 | commentsPage({ | |
| 719 | url: `${path}/pullrequests/${input.number}/comments?pagelen=${CONVERSATION_PAGE_SIZE}`, | |
| 720 | page: 1, | |
| 721 | comments: [], | |
| 722 | entries: [], | |
| 723 | }), | |
| 724 | ), | |
| 725 | ||
| 726 | listCommits: (input) => | |
| 727 | withRepository(input.repository, (path) => | |
| 728 | itemPages({ | |
| 729 | operation: "listCommits", | |
| 730 | url: `${path}/pullrequests/${input.number}/commits?pagelen=${CONVERSATION_PAGE_SIZE}`, | |
| 731 | decode: decodeCommitsJson, | |
| 732 | items: [], | |
| 733 | prepend: true, | |
| 734 | }), | |
| 735 | ), | |
| 736 | ||
| 737 | listChecks: (input) => | |
| 738 | withRepository(input.repository, (path) => | |
| 739 | itemPages({ | |
| 740 | operation: "listChecks", | |
| 741 | url: `${path}/pullrequests/${input.number}/statuses?pagelen=${CONVERSATION_PAGE_SIZE}`, | |
| 742 | decode: decodeStatusesJson, | |
| 743 | items: [], | |
| 744 | prepend: false, | |
| 745 | }), | |
| 746 | ), | |
| 747 | ||
| 748 | listReviewerCandidates: (input) => | |
| 749 | withRepository(input.repository, (path, workspace) => | |
| 750 | Effect.all( | |
| 751 | [ | |
| 752 | readPage({ | |
| 753 | operation: "getPullRequest", | |
| 754 | url: `${path}/pullrequests/${input.number}`, | |
| 755 | decode: decodePullRequestJson, | |
| 756 | }), | |
| 757 | readPage({ | |
| 758 | operation: "listReviewerCandidates", | |
| 759 | url: `/workspaces/${encodeURIComponent(workspace)}/members?pagelen=${MAX_PAGE_SIZE}`, | |
| 760 | decode: decodeWorkspaceMembersJson, | |
| 761 | }), | |
| 762 | ], | |
| 763 | { concurrency: 2 }, | |
| 764 | ).pipe( | |
| 765 | Effect.map(([pullRequest, members]) => { | |
| 766 | const requested = new Set(pullRequest.reviewerIds); | |
| 767 | const author = pullRequest.author?.login; | |
| 768 | return { | |
| 769 | // The author is dropped rather than shown unusable: Bitbucket refuses to make the | |
| 770 | // person who opened a pull request its reviewer. | |
| 771 | candidates: members.items.flatMap((candidate) => | |
| 772 | candidate.login === author | |
| 773 | ? [] | |
| 774 | : [{ ...candidate, isRequested: requested.has(candidate.id) }], | |
| 775 | ), | |
| 776 | truncated: members.next !== null, | |
| 777 | }; | |
| 778 | }), | |
| 779 | ), | |
| 780 | ), | |
| 781 | ||
| 782 | setReviewerRequest: (input) => | |
| 783 | withRepository(input.repository, (path) => { | |
| 784 | const pullRequest = `${path}/pullrequests/${input.number}`; | |
| 785 | return readPage({ | |
| 786 | operation: "getPullRequest", | |
| 787 | url: pullRequest, | |
| 788 | decode: decodePullRequestJson, | |
| 789 | }).pipe( | |
| 790 | Effect.flatMap((current) => { | |
| 791 | // Bitbucket has no endpoint that adds or removes one reviewer: the pull request's | |
| 792 | // `reviewers` is written whole, so the set that is already there is read first and | |
| 793 | // the change applied to it. Everything else about the pull request is left out of | |
| 794 | // the body, which leaves it as it was. | |
| 795 | const uuids = new Set(current.reviewerIds); | |
| 796 | for (const reviewer of input.reviewers) { | |
| 797 | if (input.requested) uuids.add(reviewer.id); | |
| 798 | else uuids.delete(reviewer.id); | |
| 799 | } | |
| 800 | return bitbucket.request({ | |
| 801 | method: "PUT", | |
| 802 | url: pullRequest, | |
| 803 | body: JSON.stringify({ reviewers: [...uuids].map((uuid) => ({ uuid })) }), | |
| 804 | }); | |
| 805 | }), | |
| 806 | Effect.asVoid, | |
| 807 | ); | |
| 808 | }), | |
| 809 | ||
| 810 | runAction: (input) => | |
| 811 | withRepository(input.repository, (path) => { | |
| 812 | const pullRequest = `${path}/pullrequests/${input.number}`; | |
| 813 | // Only merge and close reach here: the provider declares the others unsupported, so the | |
| 814 | // surface never offers them. | |
| 815 | if (input.action === "merge") { | |
| 816 | return bitbucket | |
| 817 | .request({ | |
| 818 | method: "POST", | |
| 819 | url: `${pullRequest}/merge`, | |
| 820 | body: JSON.stringify({ merge_strategy: mergeStrategy(input.mergeMethod) }), | |
| 821 | }) | |
| 822 | .pipe(Effect.asVoid); | |
| 823 | } | |
| 824 | return bitbucket | |
| 825 | .request({ method: "POST", url: `${pullRequest}/decline` }) | |
| 826 | .pipe(Effect.asVoid); | |
| 827 | }), | |
| 828 | ||
| 829 | updateChangeRequest: (input) => | |
| 830 | withRepository(input.repository, (path) => | |
| 831 | // Only the words this call rewrites travel in the body: as `setReviewerRequest` above | |
| 832 | // relies on, Bitbucket's PUT is a partial update, so any field left out is left as it | |
| 833 | // was — sending `reviewers` back here would overwrite a change another user made to it | |
| 834 | // between this call being issued and the request landing. | |
| 835 | bitbucket | |
| 836 | .request({ | |
| 837 | method: "PUT", | |
| 838 | url: `${path}/pullrequests/${input.number}`, | |
| 839 | body: JSON.stringify({ | |
| 840 | ...(input.title === undefined ? {} : { title: input.title }), | |
| 841 | ...(input.body === undefined ? {} : { description: input.body }), | |
| 842 | }), | |
| 843 | }) | |
| 844 | .pipe(Effect.asVoid), | |
| 845 | ), | |
| 846 | ||
| 847 | comment: (input) => | |
| 848 | withRepository(input.repository, (path) => | |
| 849 | bitbucket | |
| 850 | .request({ | |
| 851 | method: "POST", | |
| 852 | url: `${path}/pullrequests/${input.number}/comments`, | |
| 853 | // A JSON document rather than a form field, so the body stays text whatever it says. | |
| 854 | body: JSON.stringify({ content: { raw: input.body } }), | |
| 855 | }) | |
| 856 | .pipe(Effect.asVoid), | |
| 857 | ), | |
| 858 | ||
| 859 | updateComment: (input) => | |
| 860 | withRepository(input.repository, (path) => | |
| 861 | bitbucket | |
| 862 | .request({ | |
| 863 | // Bitbucket keeps a pull request's remarks and its line comments in the one | |
| 864 | // collection, so this endpoint rewrites either kind. | |
| 865 | method: "PUT", | |
| 866 | url: `${path}/pullrequests/${input.number}/comments/${encodeURIComponent( | |
| 867 | input.commentId, | |
| 868 | )}`, | |
| 869 | body: JSON.stringify({ content: { raw: input.body } }), | |
| 870 | }) | |
| 871 | .pipe(Effect.asVoid), | |
| 872 | ), | |
| 873 | ||
| 874 | submitReview: (input) => | |
| 875 | withRepository(input.repository, (path) => | |
| 876 | Effect.gen(function* () { | |
| 877 | const pullRequest = `${path}/pullrequests/${input.number}`; | |
| 878 | // Bitbucket has no pending review, so a review is replayed as the requests it is | |
| 879 | // made of: the line comments, then the summary, then the verdict. The verdict goes | |
| 880 | // last so a review that fails part-way is never left standing as an approval. | |
| 881 | yield* Effect.forEach( | |
| 882 | input.comments, | |
| 883 | (comment) => | |
| 884 | bitbucket.request({ | |
| 885 | method: "POST", | |
| 886 | url: `${pullRequest}/comments`, | |
| 887 | body: JSON.stringify({ | |
| 888 | content: { raw: comment.body }, | |
| 889 | inline: { | |
| 890 | path: comment.path, | |
| 891 | ...bitbucketReviewPosition(comment.position), | |
| 892 | }, | |
| 893 | }), | |
| 894 | }), | |
| 895 | { discard: true }, | |
| 896 | ); | |
| 897 | if (input.body.trim().length > 0) { | |
| 898 | yield* bitbucket.request({ | |
| 899 | method: "POST", | |
| 900 | url: `${pullRequest}/comments`, | |
| 901 | // @effect-diagnostics-next-line preferSchemaOverJson:off | |
| 902 | body: JSON.stringify({ content: { raw: input.body } }), | |
| 903 | }); | |
| 904 | } | |
| 905 | if (input.verdict === "approve") { | |
| 906 | yield* bitbucket.request({ method: "POST", url: `${pullRequest}/approve` }); | |
| 907 | } | |
| 908 | if (input.verdict === "request-changes") { | |
| 909 | yield* bitbucket.request({ method: "POST", url: `${pullRequest}/request-changes` }); | |
| 910 | } | |
| 911 | }), | |
| 912 | ), | |
| 913 | ||
| 914 | replyToComment: (input) => | |
| 915 | withRepository(input.repository, (path) => | |
| 916 | bitbucket | |
| 917 | .request({ | |
| 918 | method: "POST", | |
| 919 | url: `${path}/pullrequests/${input.number}/comments`, | |
| 920 | body: JSON.stringify({ | |
| 921 | content: { raw: input.body }, | |
| 922 | parent: { id: Number(input.commentId) }, | |
| 923 | }), | |
| 924 | }) | |
| 925 | .pipe(Effect.asVoid), | |
| 926 | ), | |
| 927 | ||
| 928 | setCommentResolution: (input) => | |
| 929 | withRepository(input.repository, (path) => | |
| 930 | bitbucket | |
| 931 | .request({ | |
| 932 | // Resolving is a sub-resource that is created and deleted, rather than a field. | |
| 933 | method: input.resolved ? "POST" : "DELETE", | |
| 934 | url: `${path}/pullrequests/${input.number}/comments/${encodeURIComponent( | |
| 935 | input.commentId, | |
| 936 | )}/resolve`, | |
| 937 | }) | |
| 938 | .pipe(Effect.asVoid), | |
| 939 | ), | |
| 940 | }); | |
| 941 | }); | |
| 942 | ||
| 943 | export const layer = Layer.effect(BitbucketPullRequestApi, make); |
apps/server/src/pullRequest/BitbucketPullRequestProvider.test.ts deleted-175| ... | ... | @@ -1,175 +0,0 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Layer from "effect/Layer"; | |
| 4 | import * as Result from "effect/Result"; | |
| 5 | ||
| 6 | import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; | |
| 7 | import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts"; | |
| 8 | import { decodePullRequestJson } from "./bitbucketPullRequestJson.ts"; | |
| 9 | import { | |
| 10 | bitbucketProviderFailure, | |
| 11 | bitbucketViewerPermissions, | |
| 12 | make, | |
| 13 | } from "./BitbucketPullRequestProvider.ts"; | |
| 14 | ||
| 15 | for (const operation of [ | |
| 16 | "getMergeability", | |
| 17 | "listChecks", | |
| 18 | "getRepositoryPermission", | |
| 19 | "listComments", | |
| 20 | "listCommits", | |
| 21 | ] as const) { | |
| 22 | it.effect.each(["response", "body read"])( | |
| 23 | `preserves rate limits from ${operation} on %s errors while recovering other optional-read failures`, | |
| 24 | (variant) => | |
| 25 | Effect.gen(function* () { | |
| 26 | const pullRequest = Result.getOrThrow( | |
| 27 | decodePullRequestJson(`{ | |
| 28 | "id": 1, "title": "Check polling", "state": "OPEN", | |
| 29 | "source": { "branch": { "name": "feature" } }, | |
| 30 | "destination": { "branch": { "name": "main" } }, | |
| 31 | "created_on": "2026-09-16T00:00:00Z", | |
| 32 | "updated_on": "2026-09-16T00:00:00Z", | |
| 33 | "links": { "html": { "href": "https://bitbucket.org/acme/web/pull-requests/1" } } | |
| 34 | }`), | |
| 35 | ); | |
| 36 | for (const status of [429, 403]) { | |
| 37 | const provider = yield* make.pipe( | |
| 38 | Effect.provide( | |
| 39 | Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({ | |
| 40 | getPullRequest: () => Effect.succeed(pullRequest), | |
| 41 | getDiffStat: () => Effect.succeed({ additions: 0, deletions: 0, changedFiles: 0 }), | |
| 42 | getMergeability: () => Effect.succeed("unknown" as const), | |
| 43 | listChecks: () => Effect.succeed([]), | |
| 44 | getRepositoryPermission: () => Effect.succeed(true), | |
| 45 | listComments: () => Effect.succeed({ comments: [], threads: [], truncated: false }), | |
| 46 | listCommits: () => Effect.succeed([]), | |
| 47 | [operation]: () => | |
| 48 | Effect.fail( | |
| 49 | variant === "response" | |
| 50 | ? new BitbucketApi.BitbucketResponseError({ | |
| 51 | operation: "request", | |
| 52 | status, | |
| 53 | responseBodyLength: 0, | |
| 54 | retryAt: 120_000, | |
| 55 | }) | |
| 56 | : new BitbucketApi.BitbucketResponseBodyReadError({ | |
| 57 | operation: "request", | |
| 58 | status, | |
| 59 | cause: new Error("response stream failed"), | |
| 60 | retryAt: 120_000, | |
| 61 | }), | |
| 62 | ), | |
| 63 | }), | |
| 64 | ), | |
| 65 | ); | |
| 66 | const reference = { | |
| 67 | cwd: "/repo", | |
| 68 | repository: "acme/web", | |
| 69 | number: 1, | |
| 70 | host: "bitbucket.org", | |
| 71 | }; | |
| 72 | const result = yield* operation === "listComments" || operation === "listCommits" | |
| 73 | ? Effect.result(provider.getChangeRequestActivity(reference)) | |
| 74 | : Effect.result(provider.getChangeRequest(reference)); | |
| 75 | if (status === 429) { | |
| 76 | expect(result).toMatchObject({ | |
| 77 | _tag: "Failure", | |
| 78 | failure: { reason: "rate-limited", retryAt: 120_000 }, | |
| 79 | }); | |
| 80 | } else { | |
| 81 | expect(result._tag).toBe("Success"); | |
| 82 | } | |
| 83 | } | |
| 84 | }), | |
| 85 | ); | |
| 86 | } | |
| 87 | ||
| 88 | it.effect("reads checks and PR state without diff, mergeability, or permission requests", () => | |
| 89 | Effect.gen(function* () { | |
| 90 | const pullRequest = Result.getOrThrow( | |
| 91 | decodePullRequestJson(`{ | |
| 92 | "id": 1, "title": "Checks", "state": "OPEN", | |
| 93 | "source": { "branch": { "name": "feature" } }, | |
| 94 | "destination": { "branch": { "name": "main" } }, | |
| 95 | "created_on": "2026-09-16T00:00:00Z", "updated_on": "2026-09-16T00:00:00Z", | |
| 96 | "links": { "html": { "href": "https://bitbucket.org/acme/web/pull-requests/1" } } | |
| 97 | }`), | |
| 98 | ); | |
| 99 | let limited = false; | |
| 100 | const provider = yield* make.pipe( | |
| 101 | Effect.provide( | |
| 102 | Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({ | |
| 103 | getPullRequest: () => Effect.succeed(pullRequest), | |
| 104 | listChecks: () => | |
| 105 | limited | |
| 106 | ? Effect.fail( | |
| 107 | new BitbucketApi.BitbucketResponseError({ | |
| 108 | operation: "request", | |
| 109 | status: 429, | |
| 110 | responseBodyLength: 0, | |
| 111 | retryAt: 120_000, | |
| 112 | }), | |
| 113 | ) | |
| 114 | : Effect.succeed([ | |
| 115 | { name: "build", status: "failure" as const, description: null, url: null }, | |
| 116 | ]), | |
| 117 | }), | |
| 118 | ), | |
| 119 | ); | |
| 120 | const read = provider.getChangeRequestChecks; | |
| 121 | if (read === undefined) return yield* Effect.die("checks read missing"); | |
| 122 | const input = { cwd: "/repo", repository: "acme/web", host: "bitbucket.org", number: 1 }; | |
| 123 | expect((yield* read(input)).checks[0]?.status).toBe("failure"); | |
| 124 | limited = true; | |
| 125 | expect(yield* Effect.flip(read(input))).toMatchObject({ | |
| 126 | reason: "rate-limited", | |
| 127 | retryAt: 120_000, | |
| 128 | }); | |
| 129 | }), | |
| 130 | ); | |
| 131 | ||
| 132 | describe("bitbucketProviderFailure", () => { | |
| 133 | it("treats only an HTTP 401 as unusable credentials", () => { | |
| 134 | const responseError = (status: number) => | |
| 135 | new BitbucketApi.BitbucketResponseError({ | |
| 136 | operation: "request", | |
| 137 | status, | |
| 138 | responseBodyLength: 0, | |
| 139 | }); | |
| 140 | ||
| 141 | expect(bitbucketProviderFailure(responseError(401)).reason).toBe("unauthenticated"); | |
| 142 | expect(bitbucketProviderFailure(responseError(403)).reason).toBe("failed"); | |
| 143 | }); | |
| 144 | }); | |
| 145 | ||
| 146 | describe("bitbucketViewerPermissions", () => { | |
| 147 | it("offers both actions to credentials with write access", () => { | |
| 148 | expect(bitbucketViewerPermissions({ canWrite: true })).toEqual({ | |
| 149 | actions: ["merge", "close"], | |
| 150 | comment: true, | |
| 151 | resolve: true, | |
| 152 | verdicts: ["comment", "approve", "request-changes"], | |
| 153 | // Bitbucket says nothing about who may set a reviewer, and an unreported permission is | |
| 154 | // granted. | |
| 155 | requestReviewers: true, | |
| 156 | }); | |
| 157 | }); | |
| 158 | ||
| 159 | it("keeps merge from credentials that can only read the repository", () => { | |
| 160 | expect(bitbucketViewerPermissions({ canWrite: false })).toEqual({ | |
| 161 | actions: ["close"], | |
| 162 | comment: true, | |
| 163 | resolve: true, | |
| 164 | verdicts: ["comment", "approve", "request-changes"], | |
| 165 | requestReviewers: true, | |
| 166 | }); | |
| 167 | }); | |
| 168 | ||
| 169 | it("treats an author with read access as any other reader, which is all Bitbucket says", () => { | |
| 170 | // The repository permission is the whole of what Bitbucket reports per account; it says | |
| 171 | // nothing about who opened this pull request, and its author may decline it with read access | |
| 172 | // alone — so declining stays offered rather than being taken from them. | |
| 173 | expect(bitbucketViewerPermissions({ canWrite: false }).actions).toEqual(["close"]); | |
| 174 | }); | |
| 175 | }); |
apps/server/src/pullRequest/BitbucketPullRequestProvider.ts deleted-385| ... | ... | @@ -1,385 +0,0 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts"; | |
| 3 | ||
| 4 | import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts"; | |
| 5 | import { | |
| 6 | PullRequestProviderError, | |
| 7 | type PullRequestProviderFailure, | |
| 8 | type ProviderChangeRequest, | |
| 9 | type ProviderChangeRequestActivity, | |
| 10 | type ProviderChangeRequestDetail, | |
| 11 | type PullRequestProviderApi, | |
| 12 | } from "@t3tools/source-control-core/server/PullRequestProvider"; | |
| 13 | import type { BitbucketPullRequest } from "./bitbucketPullRequestJson.ts"; | |
| 14 | ||
| 15 | const CAPABILITIES: PullRequestCapabilities = { | |
| 16 | diff: true, | |
| 17 | comment: true, | |
| 18 | // Bitbucket has no endpoint that reopens a declined pull request, and nothing documented that | |
| 19 | // moves one in or out of draft, so neither is offered rather than failing when pressed. | |
| 20 | actions: ["merge", "close"], | |
| 21 | mergeMethods: ["merge", "squash", "rebase"], | |
| 22 | search: true, | |
| 23 | // Bitbucket Cloud's API exposes no reaction on a pull request or on a comment, so none is | |
| 24 | // read and none is offered. | |
| 25 | reactions: false, | |
| 26 | review: { | |
| 27 | inlineComment: true, | |
| 28 | reply: true, | |
| 29 | resolve: true, | |
| 30 | verdicts: ["comment", "approve", "request-changes"], | |
| 31 | }, | |
| 32 | reviewers: { request: true, listCandidates: true }, | |
| 33 | edit: { changeRequest: true, comment: true }, | |
| 34 | // Bitbucket Cloud states nothing about what a reviewer has already read: no endpoint carries a | |
| 35 | // viewed file, and the per-pull-request properties it does offer are one value shared by | |
| 36 | // everyone rather than one per reader. So the marks are kept here, and the client says whose | |
| 37 | // they are rather than implying bitbucket.org will show them. | |
| 38 | viewedFiles: "environment", | |
| 39 | }; | |
| 40 | ||
| 41 | /** | |
| 42 | * What the configured account may do here, from the one thing Bitbucket states per viewer: the | |
| 43 | * repository permission. Merging needs `write` or `admin`, so that is what narrows. | |
| 44 | * | |
| 45 | * Declining stays offered whatever the permission. Bitbucket lets the author of a pull request | |
| 46 | * decline their own with no more than read access, and the permission response says nothing about | |
| 47 | * who opened this one — so withholding the control from the one person entitled to it is the | |
| 48 | * worse of the two mistakes. Commenting and reviewing are not narrowed either: read access is | |
| 49 | * enough to say something, to approve and to ask for changes. | |
| 50 | * | |
| 51 | * Asking for a review is left open for the same reason: Bitbucket takes a reviewer set from the | |
| 52 | * author of a pull request as well as from whoever can write, and says nothing here about which | |
| 53 | * of the two this account is. | |
| 54 | */ | |
| 55 | export function bitbucketViewerPermissions(input: { | |
| 56 | readonly canWrite: boolean; | |
| 57 | }): PullRequestViewerPermissions { | |
| 58 | return { | |
| 59 | actions: CAPABILITIES.actions.filter((action) => action !== "merge" || input.canWrite), | |
| 60 | comment: true, | |
| 61 | resolve: true, | |
| 62 | verdicts: CAPABILITIES.review.verdicts, | |
| 63 | requestReviewers: true, | |
| 64 | }; | |
| 65 | } | |
| 66 | ||
| 67 | /** The failures that mean the credentials are the problem, rather than one request. */ | |
| 68 | export function bitbucketProviderFailure( | |
| 69 | error: BitbucketPullRequestApi.BitbucketPullRequestApiError, | |
| 70 | ): PullRequestProviderFailure { | |
| 71 | // Bitbucket is read over HTTP with credentials from the environment, so there is no tool to be | |
| 72 | // missing: unusable always means the credentials are absent or refused. | |
| 73 | if (error._tag === "BitbucketResponseError" && error.status === 401) { | |
| 74 | return { reason: "unauthenticated" }; | |
| 75 | } | |
| 76 | if ( | |
| 77 | (error._tag === "BitbucketResponseError" || error._tag === "BitbucketResponseBodyReadError") && | |
| 78 | error.status === 429 | |
| 79 | ) { | |
| 80 | return { | |
| 81 | reason: "rate-limited", | |
| 82 | ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }), | |
| 83 | }; | |
| 84 | } | |
| 85 | if ( | |
| 86 | (error._tag === "BitbucketResponseError" || error._tag === "BitbucketResponseBodyReadError") && | |
| 87 | error.status === 404 | |
| 88 | ) { | |
| 89 | return { reason: "not-found" }; | |
| 90 | } | |
| 91 | return { reason: "failed" }; | |
| 92 | } | |
| 93 | ||
| 94 | function toChangeRequest(pullRequest: BitbucketPullRequest): ProviderChangeRequest { | |
| 95 | return { | |
| 96 | number: pullRequest.number, | |
| 97 | title: pullRequest.title, | |
| 98 | url: pullRequest.url, | |
| 99 | author: pullRequest.author, | |
| 100 | headBranch: pullRequest.headBranch, | |
| 101 | ...(pullRequest.headRepositoryNameWithOwner | |
| 102 | ? { headRepositoryNameWithOwner: pullRequest.headRepositoryNameWithOwner } | |
| 103 | : {}), | |
| 104 | baseBranch: pullRequest.baseBranch, | |
| 105 | state: pullRequest.state, | |
| 106 | isDraft: pullRequest.isDraft, | |
| 107 | mergeability: pullRequest.mergeability, | |
| 108 | // Line counts are a separate read, which only the detail is worth spending on. | |
| 109 | additions: 0, | |
| 110 | deletions: 0, | |
| 111 | createdAt: pullRequest.createdAt, | |
| 112 | updatedAt: pullRequest.updatedAt, | |
| 113 | reviewRequestLogins: pullRequest.reviewRequestLogins, | |
| 114 | // Bitbucket has no labels on a pull request. | |
| 115 | labels: [], | |
| 116 | }; | |
| 117 | } | |
| 118 | ||
| 119 | export const make = Effect.gen(function* () { | |
| 120 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 121 | ||
| 122 | const fail = | |
| 123 | (operation: string) => (error: BitbucketPullRequestApi.BitbucketPullRequestApiError) => | |
| 124 | new PullRequestProviderError({ | |
| 125 | provider: "bitbucket", | |
| 126 | operation, | |
| 127 | ...bitbucketProviderFailure(error), | |
| 128 | // Every Bitbucket failure states its own fact; this names the operation around it, so | |
| 129 | // the two do not stack into "failed in x: failed in y: ...". | |
| 130 | detail: error.detail, | |
| 131 | cause: error, | |
| 132 | }); | |
| 133 | ||
| 134 | const recoverRead = <A>( | |
| 135 | read: Effect.Effect<A, BitbucketPullRequestApi.BitbucketPullRequestApiError>, | |
| 136 | fallback: A, | |
| 137 | ) => { | |
| 138 | const recover = () => Effect.succeed(fallback); | |
| 139 | return Effect.catchTags(read, { | |
| 140 | BitbucketResponseError: (error) => (error.status === 429 ? Effect.fail(error) : recover()), | |
| 141 | BitbucketUntrustedUrlError: recover, | |
| 142 | BitbucketRepositoryLocatorError: recover, | |
| 143 | BitbucketRequestError: recover, | |
| 144 | BitbucketResponseBodyReadError: (error) => | |
| 145 | error.status === 429 ? Effect.fail(error) : recover(), | |
| 146 | BitbucketResponseDecodeError: recover, | |
| 147 | BitbucketRepositoryVcsResolveError: recover, | |
| 148 | BitbucketRepositoryRemotesListError: recover, | |
| 149 | BitbucketRepositoryRemoteNotFoundError: recover, | |
| 150 | BitbucketPullRequestBodyReadError: recover, | |
| 151 | BitbucketCheckoutError: recover, | |
| 152 | BitbucketPullRequestReadError: recover, | |
| 153 | BitbucketViewerUnavailableError: recover, | |
| 154 | BitbucketRepositoryUnsupportedError: recover, | |
| 155 | BitbucketDiffCommitError: recover, | |
| 156 | }); | |
| 157 | }; | |
| 158 | ||
| 159 | const provider: PullRequestProviderApi = { | |
| 160 | kind: "bitbucket", | |
| 161 | capabilities: CAPABILITIES, | |
| 162 | ||
| 163 | // Bitbucket credentials come from the server's environment rather than a checkout, so the | |
| 164 | // account is the same whichever workspace asks. | |
| 165 | getViewer: () => api.getViewer().pipe(Effect.mapError(fail("getViewer"))), | |
| 166 | ||
| 167 | listChangeRequests: (input) => | |
| 168 | api | |
| 169 | .listPullRequests({ | |
| 170 | repository: input.repository, | |
| 171 | state: input.state, | |
| 172 | limit: input.limit, | |
| 173 | query: input.query, | |
| 174 | cursor: input.cursor, | |
| 175 | }) | |
| 176 | .pipe( | |
| 177 | Effect.mapError(fail("listChangeRequests")), | |
| 178 | Effect.map((batch) => ({ | |
| 179 | items: batch.items.map(toChangeRequest), | |
| 180 | truncated: batch.truncated, | |
| 181 | // Bitbucket is asked for `-updated_on` whether or not it is being carried on from, | |
| 182 | // so every page it answers is one a cursor can continue. | |
| 183 | continues: true, | |
| 184 | })), | |
| 185 | ), | |
| 186 | ||
| 187 | getChangeRequestChecks: (input) => | |
| 188 | Effect.all([api.getPullRequest(input), api.listChecks(input)], { concurrency: 2 }).pipe( | |
| 189 | Effect.map(([pullRequest, checks]) => ({ state: pullRequest.state, checks })), | |
| 190 | Effect.mapError(fail("getChangeRequestChecks")), | |
| 191 | ), | |
| 192 | ||
| 193 | getChangeRequest: (input) => { | |
| 194 | const target = { repository: input.repository, number: input.number }; | |
| 195 | return Effect.all( | |
| 196 | [ | |
| 197 | api.getPullRequest(target), | |
| 198 | api.getDiffStat(target), | |
| 199 | recoverRead(api.getMergeability(target), "unknown" as const), | |
| 200 | recoverRead(api.listChecks(target), []), | |
| 201 | // A permission that could not be read is an unknown one, which is granted: a hidden | |
| 202 | // Merge leaves someone entitled to it with no way through, and one Bitbucket refuses | |
| 203 | // at least says why. | |
| 204 | recoverRead(api.getRepositoryPermission(target), true), | |
| 205 | ], | |
| 206 | { concurrency: 5 }, | |
| 207 | ).pipe( | |
| 208 | Effect.mapError(fail("getChangeRequest")), | |
| 209 | Effect.map( | |
| 210 | ([ | |
| 211 | pullRequest, | |
| 212 | diffStat, | |
| 213 | mergeability, | |
| 214 | checks, | |
| 215 | canWrite, | |
| 216 | ]): ProviderChangeRequestDetail => ({ | |
| 217 | ...toChangeRequest(pullRequest), | |
| 218 | mergeability, | |
| 219 | additions: diffStat.additions, | |
| 220 | deletions: diffStat.deletions, | |
| 221 | changedFiles: diffStat.changedFiles, | |
| 222 | body: pullRequest.body, | |
| 223 | mergedAt: null, | |
| 224 | closedAt: null, | |
| 225 | reviewers: pullRequest.reviewers, | |
| 226 | checks, | |
| 227 | // Bitbucket publishes no per-repository list of allowed strategies, so the ones it | |
| 228 | // supports are all offered and a strategy the repository forbids fails on merge. | |
| 229 | mergeCapabilities: { merge: true, squash: true, rebase: true }, | |
| 230 | viewerPermissions: bitbucketViewerPermissions({ canWrite }), | |
| 231 | }), | |
| 232 | ), | |
| 233 | ); | |
| 234 | }, | |
| 235 | ||
| 236 | getChangeRequestActivity: (input) => { | |
| 237 | const target = { repository: input.repository, number: input.number }; | |
| 238 | return Effect.all( | |
| 239 | [ | |
| 240 | // Reviews ride on the pull request itself, so this inexpensive core read is repeated | |
| 241 | // here rather than making the core response wait for the conversation endpoints. | |
| 242 | api.getPullRequest(target), | |
| 243 | recoverRead(api.listComments(target), { comments: [], threads: [], truncated: true }), | |
| 244 | recoverRead(api.listCommits(target), []), | |
| 245 | ], | |
| 246 | { concurrency: 3 }, | |
| 247 | ).pipe( | |
| 248 | Effect.mapError(fail("getChangeRequestActivity")), | |
| 249 | Effect.map(([pullRequest, comments, commits]): ProviderChangeRequestActivity => ({ | |
| 250 | comments: [...comments.comments, ...pullRequest.reviews].toSorted((left, right) => | |
| 251 | left.createdAt.localeCompare(right.createdAt), | |
| 252 | ), | |
| 253 | commentCount: comments.comments.length + pullRequest.reviews.length, | |
| 254 | commentsTruncated: comments.truncated, | |
| 255 | reviewThreads: comments.threads, | |
| 256 | commits, | |
| 257 | })), | |
| 258 | ); | |
| 259 | }, | |
| 260 | ||
| 261 | getViewerPermissions: (input) => | |
| 262 | api.getRepositoryPermission({ repository: input.repository }).pipe( | |
| 263 | Effect.mapError(fail("getViewerPermissions")), | |
| 264 | Effect.map((canWrite) => bitbucketViewerPermissions({ canWrite })), | |
| 265 | ), | |
| 266 | ||
| 267 | // `/diff` answers with the whole patch and pages nothing, so the first slice is the last. | |
| 268 | getDiff: (input) => | |
| 269 | api | |
| 270 | .getPullRequestDiff({ | |
| 271 | repository: input.repository, | |
| 272 | number: input.number, | |
| 273 | ...(input.commit === undefined ? {} : { commit: input.commit }), | |
| 274 | }) | |
| 275 | .pipe( | |
| 276 | Effect.mapError(fail("getDiff")), | |
| 277 | Effect.map((diff) => ({ ...diff, nextCursor: null })), | |
| 278 | ), | |
| 279 | ||
| 280 | getFileRevisions: (input) => | |
| 281 | api | |
| 282 | .getFileRevisions({ | |
| 283 | repository: input.repository, | |
| 284 | number: input.number, | |
| 285 | paths: input.paths, | |
| 286 | }) | |
| 287 | .pipe(Effect.mapError(fail("getFileRevisions"))), | |
| 288 | ||
| 289 | // Users only: Bitbucket requests a review of an account, and has no group that stands in for | |
| 290 | // one on a pull request. | |
| 291 | listReviewerCandidates: (input) => | |
| 292 | api | |
| 293 | .listReviewerCandidates({ repository: input.repository, number: input.number }) | |
| 294 | .pipe(Effect.mapError(fail("listReviewerCandidates"))), | |
| 295 | ||
| 296 | setReviewerRequest: (input) => | |
| 297 | api | |
| 298 | .setReviewerRequest({ | |
| 299 | repository: input.repository, | |
| 300 | number: input.number, | |
| 301 | reviewers: input.reviewers, | |
| 302 | requested: input.requested, | |
| 303 | }) | |
| 304 | .pipe(Effect.mapError(fail("setReviewerRequest"))), | |
| 305 | ||
| 306 | runAction: (input) => | |
| 307 | api | |
| 308 | .runAction({ | |
| 309 | repository: input.repository, | |
| 310 | number: input.number, | |
| 311 | action: input.action, | |
| 312 | ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), | |
| 313 | }) | |
| 314 | .pipe(Effect.mapError(fail("runAction"))), | |
| 315 | ||
| 316 | updateChangeRequest: (input) => | |
| 317 | api | |
| 318 | .updateChangeRequest({ | |
| 319 | repository: input.repository, | |
| 320 | number: input.number, | |
| 321 | title: input.title, | |
| 322 | body: input.body, | |
| 323 | }) | |
| 324 | .pipe(Effect.mapError(fail("updateChangeRequest"))), | |
| 325 | ||
| 326 | comment: (input) => | |
| 327 | api | |
| 328 | .comment({ repository: input.repository, number: input.number, body: input.body }) | |
| 329 | .pipe(Effect.mapError(fail("comment"))), | |
| 330 | ||
| 331 | updateComment: (input) => | |
| 332 | api | |
| 333 | .updateComment({ | |
| 334 | repository: input.repository, | |
| 335 | number: input.number, | |
| 336 | commentId: input.commentId, | |
| 337 | body: input.body, | |
| 338 | }) | |
| 339 | .pipe(Effect.mapError(fail("updateComment"))), | |
| 340 | ||
| 341 | submitReview: (input) => | |
| 342 | api | |
| 343 | .submitReview({ | |
| 344 | repository: input.repository, | |
| 345 | number: input.number, | |
| 346 | verdict: input.verdict, | |
| 347 | body: input.body, | |
| 348 | comments: input.comments, | |
| 349 | }) | |
| 350 | .pipe(Effect.mapError(fail("submitReview"))), | |
| 351 | ||
| 352 | replyToThread: (input) => | |
| 353 | api | |
| 354 | .replyToComment({ | |
| 355 | repository: input.repository, | |
| 356 | number: input.number, | |
| 357 | commentId: input.threadId, | |
| 358 | body: input.body, | |
| 359 | }) | |
| 360 | .pipe(Effect.mapError(fail("replyToThread"))), | |
| 361 | ||
| 362 | // Never called: `capabilities.reactions` is false, and the service refuses without it. | |
| 363 | setReaction: () => | |
| 364 | Effect.fail( | |
| 365 | new PullRequestProviderError({ | |
| 366 | provider: "bitbucket", | |
| 367 | operation: "setReaction", | |
| 368 | reason: "failed", | |
| 369 | detail: "Bitbucket does not support reactions.", | |
| 370 | }), | |
| 371 | ), | |
| 372 | ||
| 373 | setThreadResolution: (input) => | |
| 374 | api | |
| 375 | .setCommentResolution({ | |
| 376 | repository: input.repository, | |
| 377 | number: input.number, | |
| 378 | commentId: input.threadId, | |
| 379 | resolved: input.resolved, | |
| 380 | }) | |
| 381 | .pipe(Effect.mapError(fail("setThreadResolution"))), | |
| 382 | }; | |
| 383 | ||
| 384 | return provider; | |
| 385 | }); |
apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts+2-2| ... | ... | @@ -1,12 +1,12 @@ |
| 1 | 1 | import { assert, it } from "@effect/vitest"; |
| 2 | 2 | |
| 3 | 3 | import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli"; |
| 4 | import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; | |
| 4 | import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; | |
| 5 | 5 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; |
| 6 | 6 | import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; |
| 7 | 7 | import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; |
| 8 | 8 | import { azureDevOpsProviderFailure } from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestProvider"; |
| 9 | import { bitbucketProviderFailure } from "./BitbucketPullRequestProvider.ts"; | |
| 9 | import { bitbucketProviderFailure } from "@t3tools/source-control-bitbucket/server/BitbucketPullRequestProvider"; | |
| 10 | 10 | import { gitHubProviderFailure } from "./GitHubPullRequestProvider.ts"; |
| 11 | 11 | import { gitLabProviderFailure } from "@t3tools/source-control-gitlab/server/GitLabPullRequestProvider"; |
| 12 | 12 |
apps/server/src/pullRequest/PullRequestProviderRegistry.ts+1-8| ... | ... | @@ -3,10 +3,7 @@ import * as Effect from "effect/Effect"; |
| 3 | 3 | import * as Layer from "effect/Layer"; |
| 4 | 4 | import type { SourceControlProviderKind } from "@t3tools/contracts"; |
| 5 | 5 | |
| 6 | import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; | |
| 7 | 6 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; |
| 8 | import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts"; | |
| 9 | import * as BitbucketPullRequestProvider from "./BitbucketPullRequestProvider.ts"; | |
| 10 | 7 | import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; |
| 11 | 8 | import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts"; |
| 12 | 9 | import * as BuiltInDrivers from "../sourceControl/builtInDrivers.ts"; |
| ... | ... | @@ -39,10 +36,7 @@ export function fromProviders( |
| 39 | 36 | * @public Service construction is part of the canonical Effect module API. |
| 40 | 37 | */ |
| 41 | 38 | export const make = Effect.gen(function* () { |
| 42 | const providers = yield* Effect.all([ | |
| 43 | GitHubPullRequestProvider.make, | |
| 44 | BitbucketPullRequestProvider.make, | |
| 45 | ]); | |
| 39 | const providers = yield* Effect.all([GitHubPullRequestProvider.make]); | |
| 46 | 40 | const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) => |
| 47 | 41 | driver.make.pipe(Effect.map((instance) => instance.pullRequests)), |
| 48 | 42 | ); |
| ... | ... | @@ -62,5 +56,4 @@ export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe( |
| 62 | 56 | ), |
| 63 | 57 | ), |
| 64 | 58 | Layer.provide(BuiltInDrivers.layer), |
| 65 | Layer.provide(BitbucketPullRequestApi.layer.pipe(Layer.provide(BitbucketApi.layer))), | |
| 66 | 59 | ); |
apps/server/src/pullRequest/bitbucketPullRequestJson.test.ts deleted-377| ... | ... | @@ -1,377 +0,0 @@ |
| 1 | import * as Result from "effect/Result"; | |
| 2 | import { describe, expect, it } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { | |
| 5 | decodeCommentsJson, | |
| 6 | decodeCommitsJson, | |
| 7 | decodeConflictsJson, | |
| 8 | decodeDiffstatJson, | |
| 9 | decodePullRequestJson, | |
| 10 | decodePullRequestPageJson, | |
| 11 | decodeRepositoryPermissionJson, | |
| 12 | decodeStatusesJson, | |
| 13 | decodeViewerJson, | |
| 14 | } from "./bitbucketPullRequestJson.ts"; | |
| 15 | ||
| 16 | /** Shaped after a real api.bitbucket.org pull request, trimmed to the fields that are read. */ | |
| 17 | function pullRequest(overrides: Record<string, unknown> = {}): Record<string, unknown> { | |
| 18 | return { | |
| 19 | id: 897, | |
| 20 | title: "Add trustabl-pipe", | |
| 21 | description: "# Add trustabl-pipe", | |
| 22 | state: "OPEN", | |
| 23 | draft: false, | |
| 24 | created_on: "2026-06-16T05:04:32.258456+00:00", | |
| 25 | updated_on: "2026-06-16T05:04:33.750542+00:00", | |
| 26 | author: { display_name: "Bilal Hassan", nickname: "bilal", type: "user" }, | |
| 27 | source: { branch: { name: "feat/page" }, repository: { full_name: "fork/web" } }, | |
| 28 | destination: { branch: { name: "master" } }, | |
| 29 | links: { html: { href: "https://bitbucket.org/acme/web/pull-requests/897" } }, | |
| 30 | ...overrides, | |
| 31 | }; | |
| 32 | } | |
| 33 | ||
| 34 | function page(values: ReadonlyArray<unknown>, extra: Record<string, unknown> = {}): string { | |
| 35 | return JSON.stringify({ pagelen: 50, page: 1, size: values.length, values, ...extra }); | |
| 36 | } | |
| 37 | ||
| 38 | function expectSuccess<A>(result: Result.Result<A, unknown>): A { | |
| 39 | expect(Result.isSuccess(result)).toBe(true); | |
| 40 | if (!Result.isSuccess(result)) throw new Error("expected a successful decode"); | |
| 41 | return result.success; | |
| 42 | } | |
| 43 | ||
| 44 | describe("decodePullRequestPageJson", () => { | |
| 45 | it("reads a pull request as a change request", () => { | |
| 46 | const decoded = expectSuccess(decodePullRequestPageJson(page([pullRequest()]))); | |
| 47 | ||
| 48 | expect(decoded.items).toHaveLength(1); | |
| 49 | expect(decoded.items[0]).toMatchObject({ | |
| 50 | number: 897, | |
| 51 | title: "Add trustabl-pipe", | |
| 52 | url: "https://bitbucket.org/acme/web/pull-requests/897", | |
| 53 | author: { login: "bilal", name: "Bilal Hassan" }, | |
| 54 | headBranch: "feat/page", | |
| 55 | headRepositoryNameWithOwner: "fork/web", | |
| 56 | baseBranch: "master", | |
| 57 | state: "open", | |
| 58 | isDraft: false, | |
| 59 | // Bitbucket says nothing about conflicts on the pull request itself. | |
| 60 | mergeability: "unknown", | |
| 61 | }); | |
| 62 | expect(decoded.next).toBeNull(); | |
| 63 | }); | |
| 64 | ||
| 65 | it("normalizes Bitbucket's offset timestamps, which the page sorts against other hosts", () => { | |
| 66 | const decoded = expectSuccess(decodePullRequestPageJson(page([pullRequest()]))); | |
| 67 | ||
| 68 | expect(decoded.items[0]).toMatchObject({ | |
| 69 | createdAt: "2026-06-16T05:04:32.258Z", | |
| 70 | updatedAt: "2026-06-16T05:04:33.750Z", | |
| 71 | }); | |
| 72 | }); | |
| 73 | ||
| 74 | it("reports the next page as the whole URL Bitbucket sends", () => { | |
| 75 | const next = "https://api.bitbucket.org/2.0/repositories/acme/web/pullrequests?page=2"; | |
| 76 | const decoded = expectSuccess(decodePullRequestPageJson(page([pullRequest()], { next }))); | |
| 77 | ||
| 78 | expect(decoded.next).toBe(next); | |
| 79 | }); | |
| 80 | ||
| 81 | it.each([ | |
| 82 | ["MERGED", "merged"], | |
| 83 | ["DECLINED", "closed"], | |
| 84 | ["SUPERSEDED", "closed"], | |
| 85 | ["OPEN", "open"], | |
| 86 | ["something new", "open"], | |
| 87 | ])("reads the %s state as %s", (state, expected) => { | |
| 88 | const decoded = expectSuccess(decodePullRequestPageJson(page([pullRequest({ state })]))); | |
| 89 | ||
| 90 | expect(decoded.items[0]?.state).toBe(expected); | |
| 91 | }); | |
| 92 | ||
| 93 | it("skips a malformed row rather than failing the page", () => { | |
| 94 | const decoded = expectSuccess( | |
| 95 | decodePullRequestPageJson(page([{ id: "not a number" }, pullRequest()])), | |
| 96 | ); | |
| 97 | ||
| 98 | expect(decoded.items).toHaveLength(1); | |
| 99 | }); | |
| 100 | ||
| 101 | it("fails when Bitbucket did not answer with a page", () => { | |
| 102 | expect(Result.isFailure(decodePullRequestPageJson(JSON.stringify({ error: "nope" })))).toBe( | |
| 103 | true, | |
| 104 | ); | |
| 105 | }); | |
| 106 | }); | |
| 107 | ||
| 108 | describe("decodePullRequestJson", () => { | |
| 109 | it("reads reviewers as review requests", () => { | |
| 110 | const decoded = expectSuccess( | |
| 111 | decodePullRequestJson( | |
| 112 | JSON.stringify( | |
| 113 | pullRequest({ | |
| 114 | reviewers: [{ nickname: "julius", display_name: "Julius" }], | |
| 115 | }), | |
| 116 | ), | |
| 117 | ), | |
| 118 | ); | |
| 119 | ||
| 120 | expect(decoded.reviewRequestLogins).toEqual(["julius"]); | |
| 121 | expect(decoded.reviewers).toEqual([{ login: "julius", name: "Julius", avatarUrl: null }]); | |
| 122 | }); | |
| 123 | ||
| 124 | it("reads a participant's vote as a review", () => { | |
| 125 | const decoded = expectSuccess( | |
| 126 | decodePullRequestJson( | |
| 127 | JSON.stringify( | |
| 128 | pullRequest({ | |
| 129 | participants: [ | |
| 130 | { | |
| 131 | user: { nickname: "julius", display_name: "Julius" }, | |
| 132 | role: "REVIEWER", | |
| 133 | approved: true, | |
| 134 | state: "approved", | |
| 135 | participated_on: "2026-06-17T09:00:00+00:00", | |
| 136 | }, | |
| 137 | // Added as a reviewer but has not voted, so there is no verdict to show. | |
| 138 | { | |
| 139 | user: { nickname: "sam", display_name: "Sam" }, | |
| 140 | role: "REVIEWER", | |
| 141 | approved: false, | |
| 142 | state: null, | |
| 143 | participated_on: null, | |
| 144 | }, | |
| 145 | ], | |
| 146 | }), | |
| 147 | ), | |
| 148 | ), | |
| 149 | ); | |
| 150 | ||
| 151 | expect(decoded.reviews).toHaveLength(1); | |
| 152 | expect(decoded.reviews[0]).toMatchObject({ | |
| 153 | kind: "review", | |
| 154 | author: { login: "julius" }, | |
| 155 | reviewState: "approved", | |
| 156 | createdAt: "2026-06-17T09:00:00.000Z", | |
| 157 | }); | |
| 158 | }); | |
| 159 | }); | |
| 160 | ||
| 161 | describe("decodeViewerJson", () => { | |
| 162 | it("reads the signed-in nickname", () => { | |
| 163 | const decoded = decodeViewerJson(JSON.stringify({ nickname: "bilal", display_name: "Bilal" })); | |
| 164 | ||
| 165 | expect(expectSuccess(decoded)).toBe("bilal"); | |
| 166 | }); | |
| 167 | ||
| 168 | it("falls back to the display name, which app accounts have instead", () => { | |
| 169 | const decoded = decodeViewerJson(JSON.stringify({ display_name: "Release Bot" })); | |
| 170 | ||
| 171 | expect(expectSuccess(decoded)).toBe("Release Bot"); | |
| 172 | }); | |
| 173 | ||
| 174 | it("returns nothing when the account has neither", () => { | |
| 175 | expect(expectSuccess(decodeViewerJson(JSON.stringify({})))).toBeNull(); | |
| 176 | }); | |
| 177 | }); | |
| 178 | ||
| 179 | describe("decodeCommentsJson", () => { | |
| 180 | it("keeps a posted comment and drops deleted and unposted ones", () => { | |
| 181 | const decoded = expectSuccess( | |
| 182 | decodeCommentsJson( | |
| 183 | page([ | |
| 184 | { | |
| 185 | id: 797230941, | |
| 186 | content: { raw: "The issue is ready for review." }, | |
| 187 | user: { display_name: "Release Bot", type: "app_user" }, | |
| 188 | created_on: "2026-05-15T01:58:38.220690+00:00", | |
| 189 | deleted: false, | |
| 190 | pending: false, | |
| 191 | links: { html: { href: "https://bitbucket.org/acme/web/pull-requests/892#c1" } }, | |
| 192 | }, | |
| 193 | { | |
| 194 | id: 2, | |
| 195 | content: { raw: "gone" }, | |
| 196 | created_on: "2026-05-15T02:00:00+00:00", | |
| 197 | deleted: true, | |
| 198 | }, | |
| 199 | { | |
| 200 | id: 3, | |
| 201 | content: { raw: "wip" }, | |
| 202 | created_on: "2026-05-15T02:00:00+00:00", | |
| 203 | pending: true, | |
| 204 | }, | |
| 205 | { id: 4, content: { raw: " " }, created_on: "2026-05-15T02:00:00+00:00" }, | |
| 206 | ]), | |
| 207 | ), | |
| 208 | ); | |
| 209 | ||
| 210 | expect(decoded.comments).toHaveLength(1); | |
| 211 | expect(decoded.comments[0]).toMatchObject({ | |
| 212 | id: "797230941", | |
| 213 | kind: "issue-comment", | |
| 214 | // An app account has no nickname, so its display name is the only handle it has. | |
| 215 | author: { login: "Release Bot" }, | |
| 216 | createdAt: "2026-05-15T01:58:38.220Z", | |
| 217 | }); | |
| 218 | }); | |
| 219 | ||
| 220 | it("reads a comment pinned to a file as a review comment", () => { | |
| 221 | const decoded = expectSuccess( | |
| 222 | decodeCommentsJson( | |
| 223 | page([ | |
| 224 | { | |
| 225 | id: 5, | |
| 226 | content: { raw: "Rename this." }, | |
| 227 | created_on: "2026-05-15T02:00:00+00:00", | |
| 228 | inline: { path: "src/app.ts" }, | |
| 229 | }, | |
| 230 | ]), | |
| 231 | ), | |
| 232 | ); | |
| 233 | ||
| 234 | expect(decoded.comments[0]).toMatchObject({ kind: "review-comment", path: "src/app.ts" }); | |
| 235 | }); | |
| 236 | }); | |
| 237 | ||
| 238 | describe("decodeCommitsJson", () => { | |
| 239 | it("returns commits oldest first with only the subject line", () => { | |
| 240 | const decoded = expectSuccess( | |
| 241 | decodeCommitsJson( | |
| 242 | page([ | |
| 243 | { hash: "bbb", message: "second\n\nbody text\n", date: "2026-06-16T04:51:00+00:00" }, | |
| 244 | { | |
| 245 | hash: "aaa", | |
| 246 | message: "first\n", | |
| 247 | date: "2026-06-16T04:50:49+00:00", | |
| 248 | author: { | |
| 249 | raw: "Ada Lovelace <ada@example.com>", | |
| 250 | user: { nickname: "ada", display_name: "Ada Lovelace" }, | |
| 251 | }, | |
| 252 | }, | |
| 253 | ]), | |
| 254 | ), | |
| 255 | ); | |
| 256 | ||
| 257 | expect(decoded.items.map((commit) => commit.oid)).toEqual(["aaa", "bbb"]); | |
| 258 | expect(decoded.items[0]?.authors).toEqual([ | |
| 259 | { login: "ada", name: "Ada Lovelace", avatarUrl: null }, | |
| 260 | ]); | |
| 261 | expect(decoded.items[1]?.messageHeadline).toBe("second"); | |
| 262 | expect(decoded.next).toBeNull(); | |
| 263 | }); | |
| 264 | ||
| 265 | it("skips commits whose hash is empty", () => { | |
| 266 | const decoded = expectSuccess( | |
| 267 | decodeCommitsJson( | |
| 268 | page([ | |
| 269 | { hash: " ", message: "invalid", date: "2026-06-16T04:51:00+00:00" }, | |
| 270 | { hash: "aaa", date: "2026-06-16T04:50:49+00:00" }, | |
| 271 | ]), | |
| 272 | ), | |
| 273 | ); | |
| 274 | ||
| 275 | expect(decoded.items.map((commit) => commit.oid)).toEqual(["aaa"]); | |
| 276 | }); | |
| 277 | }); | |
| 278 | ||
| 279 | describe("decodeStatusesJson", () => { | |
| 280 | it("reads a build status as a check", () => { | |
| 281 | const decoded = expectSuccess( | |
| 282 | decodeStatusesJson( | |
| 283 | page([ | |
| 284 | { | |
| 285 | key: "custom:check-version-and-pr", | |
| 286 | name: "Pipeline - custom: check-version-and-pr", | |
| 287 | state: "SUCCESSFUL", | |
| 288 | description: "", | |
| 289 | url: "https://bitbucket.org/acme/web/pipelines/results/8126", | |
| 290 | }, | |
| 291 | ]), | |
| 292 | ), | |
| 293 | ); | |
| 294 | ||
| 295 | expect(decoded).toEqual({ | |
| 296 | items: [ | |
| 297 | { | |
| 298 | name: "Pipeline - custom: check-version-and-pr", | |
| 299 | status: "success", | |
| 300 | description: null, | |
| 301 | url: "https://bitbucket.org/acme/web/pipelines/results/8126", | |
| 302 | }, | |
| 303 | ], | |
| 304 | next: null, | |
| 305 | }); | |
| 306 | }); | |
| 307 | ||
| 308 | it.each([ | |
| 309 | ["SUCCESSFUL", "success"], | |
| 310 | ["FAILED", "failure"], | |
| 311 | ["INPROGRESS", "pending"], | |
| 312 | ["STOPPED", "cancelled"], | |
| 313 | ["something new", "neutral"], | |
| 314 | ])("reads the %s build state as %s", (state, expected) => { | |
| 315 | const decoded = expectSuccess(decodeStatusesJson(page([{ name: "Pipeline", state }]))); | |
| 316 | ||
| 317 | expect(decoded.items[0]?.status).toBe(expected); | |
| 318 | }); | |
| 319 | ||
| 320 | it("keeps two statuses that share a display name but have different keys", () => { | |
| 321 | const decoded = expectSuccess( | |
| 322 | decodeStatusesJson( | |
| 323 | page([ | |
| 324 | { key: "build", name: "Pipeline", state: "SUCCESSFUL" }, | |
| 325 | { key: "deploy", name: "Pipeline", state: "FAILED" }, | |
| 326 | ]), | |
| 327 | ), | |
| 328 | ); | |
| 329 | ||
| 330 | expect(decoded.items.map((check) => [check.name, check.status])).toEqual([ | |
| 331 | ["build / Pipeline", "success"], | |
| 332 | ["deploy / Pipeline", "failure"], | |
| 333 | ]); | |
| 334 | }); | |
| 335 | }); | |
| 336 | ||
| 337 | describe("decodeDiffstatJson", () => { | |
| 338 | it("adds up the per-file counts", () => { | |
| 339 | const decoded = expectSuccess( | |
| 340 | decodeDiffstatJson( | |
| 341 | page([ | |
| 342 | { lines_added: 9, lines_removed: 2 }, | |
| 343 | { lines_added: 32, lines_removed: 14 }, | |
| 344 | ]), | |
| 345 | ), | |
| 346 | ); | |
| 347 | ||
| 348 | expect(decoded).toEqual({ additions: 41, deletions: 16, changedFiles: 2, next: null }); | |
| 349 | }); | |
| 350 | }); | |
| 351 | ||
| 352 | describe("decodeConflictsJson", () => { | |
| 353 | it("calls an empty conflict list mergeable", () => { | |
| 354 | expect(expectSuccess(decodeConflictsJson(page([])))).toBe("mergeable"); | |
| 355 | }); | |
| 356 | ||
| 357 | it("calls any reported conflict conflicting", () => { | |
| 358 | expect(expectSuccess(decodeConflictsJson(page([{ path: "src/app.ts" }])))).toBe("conflicting"); | |
| 359 | }); | |
| 360 | }); | |
| 361 | ||
| 362 | describe("repository permission decoding", () => { | |
| 363 | const permissionPage = (permission: string) => | |
| 364 | page([{ type: "repository_permission", permission }]); | |
| 365 | ||
| 366 | it("counts admin and write as write, and read as not", () => { | |
| 367 | expect(expectSuccess(decodeRepositoryPermissionJson(permissionPage("admin")))).toBe(true); | |
| 368 | expect(expectSuccess(decodeRepositoryPermissionJson(permissionPage("write")))).toBe(true); | |
| 369 | expect(expectSuccess(decodeRepositoryPermissionJson(permissionPage("read")))).toBe(false); | |
| 370 | }); | |
| 371 | ||
| 372 | it("grants write where Bitbucket named no permission at all", () => { | |
| 373 | // An empty page is Bitbucket declining to say, which is an unknown standing rather than a | |
| 374 | // refusal — and an unknown one is granted. | |
| 375 | expect(expectSuccess(decodeRepositoryPermissionJson(page([])))).toBe(true); | |
| 376 | }); | |
| 377 | }); |
apps/server/src/pullRequest/bitbucketPullRequestJson.ts deleted-631| ... | ... | @@ -1,631 +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 type { | |
| 8 | PullRequestActor, | |
| 9 | PullRequestCheck, | |
| 10 | PullRequestCheckStatus, | |
| 11 | PullRequestComment, | |
| 12 | PullRequestCommit, | |
| 13 | PullRequestMergeability, | |
| 14 | PullRequestReviewThread, | |
| 15 | PullRequestReviewerCandidate, | |
| 16 | PullRequestState, | |
| 17 | } from "@t3tools/contracts"; | |
| 18 | import { TrimmedNonEmptyString } from "@t3tools/contracts"; | |
| 19 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; | |
| 20 | ||
| 21 | import { dedupeChecks } from "@t3tools/source-control-core/server/pullRequestChecks"; | |
| 22 | ||
| 23 | /** | |
| 24 | * Bitbucket's enums are decoded as plain strings and normalized here, in the same tolerant | |
| 25 | * style as the GitHub and GitLab decoders: a new pull request state or build status must not | |
| 26 | * fail a whole payload. | |
| 27 | */ | |
| 28 | const RawUserSchema = Schema.Struct({ | |
| 29 | /** | |
| 30 | * How Bitbucket addresses an account when a reviewer set is written; the handles it shows are | |
| 31 | * not accepted there. Braced, and sent back exactly as it arrived. | |
| 32 | */ | |
| 33 | uuid: Schema.optional(Schema.NullOr(Schema.String)), | |
| 34 | /** Absent on an app account, which is why `display_name` has to stand in for it. */ | |
| 35 | nickname: Schema.optional(Schema.NullOr(Schema.String)), | |
| 36 | display_name: Schema.optional(Schema.NullOr(Schema.String)), | |
| 37 | links: Schema.optional( | |
| 38 | Schema.NullOr( | |
| 39 | Schema.Struct({ | |
| 40 | avatar: Schema.optional( | |
| 41 | Schema.NullOr(Schema.Struct({ href: Schema.optional(Schema.String) })), | |
| 42 | ), | |
| 43 | }), | |
| 44 | ), | |
| 45 | ), | |
| 46 | }); | |
| 47 | ||
| 48 | /** | |
| 49 | * Required, and required to be non-empty: the wire contract will not carry a change request | |
| 50 | * without a branch or a link, so a row missing one is skipped rather than breaking the response | |
| 51 | * it travels in. | |
| 52 | */ | |
| 53 | const RawBranchSchema = Schema.Struct({ | |
| 54 | branch: Schema.Struct({ name: TrimmedNonEmptyString }), | |
| 55 | repository: Schema.optional(Schema.NullOr(Schema.Struct({ full_name: TrimmedNonEmptyString }))), | |
| 56 | }); | |
| 57 | ||
| 58 | const RawLinkSchema = Schema.Struct({ href: Schema.optional(Schema.String) }); | |
| 59 | ||
| 60 | const RawPullRequestSchema = Schema.Struct({ | |
| 61 | id: Schema.Int, | |
| 62 | title: Schema.String, | |
| 63 | description: Schema.optional(Schema.NullOr(Schema.String)), | |
| 64 | state: Schema.optional(Schema.NullOr(Schema.String)), | |
| 65 | draft: Schema.optional(Schema.Boolean), | |
| 66 | author: Schema.optional(Schema.NullOr(RawUserSchema)), | |
| 67 | source: RawBranchSchema, | |
| 68 | destination: RawBranchSchema, | |
| 69 | created_on: Schema.String, | |
| 70 | updated_on: Schema.String, | |
| 71 | reviewers: Schema.optional(Schema.NullOr(Schema.Array(RawUserSchema))), | |
| 72 | participants: Schema.optional( | |
| 73 | Schema.NullOr( | |
| 74 | Schema.Array( | |
| 75 | Schema.Struct({ | |
| 76 | user: Schema.optional(Schema.NullOr(RawUserSchema)), | |
| 77 | role: Schema.optional(Schema.NullOr(Schema.String)), | |
| 78 | approved: Schema.optional(Schema.Boolean), | |
| 79 | state: Schema.optional(Schema.NullOr(Schema.String)), | |
| 80 | participated_on: Schema.optional(Schema.NullOr(Schema.String)), | |
| 81 | }), | |
| 82 | ), | |
| 83 | ), | |
| 84 | ), | |
| 85 | links: Schema.Struct({ html: Schema.Struct({ href: TrimmedNonEmptyString }) }), | |
| 86 | }); | |
| 87 | ||
| 88 | const RawPageSchema = Schema.Struct({ | |
| 89 | values: Schema.Array(Schema.Unknown), | |
| 90 | /** A total count, which Bitbucket omits on some endpoints. */ | |
| 91 | size: Schema.optional(Schema.NullOr(Schema.Int)), | |
| 92 | /** Present only while a further page exists. */ | |
| 93 | next: Schema.optional(Schema.NullOr(Schema.String)), | |
| 94 | }); | |
| 95 | ||
| 96 | const RawCommentSchema = Schema.Struct({ | |
| 97 | id: Schema.Int, | |
| 98 | content: Schema.optional(Schema.NullOr(Schema.Struct({ raw: Schema.optional(Schema.String) }))), | |
| 99 | user: Schema.optional(Schema.NullOr(RawUserSchema)), | |
| 100 | created_on: Schema.String, | |
| 101 | deleted: Schema.optional(Schema.Boolean), | |
| 102 | /** A comment still being drafted by its author. */ | |
| 103 | pending: Schema.optional(Schema.Boolean), | |
| 104 | /** Set on a reply, to the comment it answers — which may itself be a reply. */ | |
| 105 | parent: Schema.optional(Schema.NullOr(Schema.Struct({ id: Schema.Int }))), | |
| 106 | inline: Schema.optional( | |
| 107 | Schema.NullOr( | |
| 108 | Schema.Struct({ | |
| 109 | path: Schema.optional(Schema.NullOr(Schema.String)), | |
| 110 | /** The line in the file as it was; set instead of `to` on a removed line. */ | |
| 111 | from: Schema.optional(Schema.NullOr(Schema.Int)), | |
| 112 | /** The line in the file as it is now. */ | |
| 113 | to: Schema.optional(Schema.NullOr(Schema.Int)), | |
| 114 | outdated: Schema.optional(Schema.NullOr(Schema.Boolean)), | |
| 115 | }), | |
| 116 | ), | |
| 117 | ), | |
| 118 | /** Non-null once someone has marked the thread resolved. */ | |
| 119 | resolution: Schema.optional(Schema.NullOr(Schema.Unknown)), | |
| 120 | links: Schema.optional( | |
| 121 | Schema.NullOr(Schema.Struct({ html: Schema.optional(Schema.NullOr(RawLinkSchema)) })), | |
| 122 | ), | |
| 123 | }); | |
| 124 | ||
| 125 | const RawCommitSchema = Schema.Struct({ | |
| 126 | hash: TrimmedNonEmptyString, | |
| 127 | message: Schema.optional(Schema.NullOr(Schema.String)), | |
| 128 | date: Schema.optional(Schema.NullOr(Schema.String)), | |
| 129 | author: Schema.optional( | |
| 130 | Schema.NullOr( | |
| 131 | Schema.Struct({ | |
| 132 | raw: Schema.optional(Schema.NullOr(Schema.String)), | |
| 133 | user: Schema.optional(Schema.NullOr(RawUserSchema)), | |
| 134 | }), | |
| 135 | ), | |
| 136 | ), | |
| 137 | }); | |
| 138 | ||
| 139 | const RawStatusSchema = Schema.Struct({ | |
| 140 | key: Schema.optional(Schema.NullOr(Schema.String)), | |
| 141 | name: Schema.optional(Schema.NullOr(Schema.String)), | |
| 142 | state: Schema.optional(Schema.NullOr(Schema.String)), | |
| 143 | description: Schema.optional(Schema.NullOr(Schema.String)), | |
| 144 | url: Schema.optional(Schema.NullOr(Schema.String)), | |
| 145 | }); | |
| 146 | ||
| 147 | const RawDiffstatSchema = Schema.Struct({ | |
| 148 | lines_added: Schema.optional(Schema.NullOr(Schema.Int)), | |
| 149 | lines_removed: Schema.optional(Schema.NullOr(Schema.Int)), | |
| 150 | }); | |
| 151 | ||
| 152 | /** One row of `/workspaces/{workspace}/members`, which wraps the account it is about. */ | |
| 153 | const RawMemberSchema = Schema.Struct({ | |
| 154 | user: Schema.optional(Schema.NullOr(RawUserSchema)), | |
| 155 | }); | |
| 156 | ||
| 157 | const RawViewerSchema = Schema.Struct({ | |
| 158 | nickname: Schema.optional(Schema.NullOr(Schema.String)), | |
| 159 | display_name: Schema.optional(Schema.NullOr(Schema.String)), | |
| 160 | }); | |
| 161 | ||
| 162 | /** | |
| 163 | * `/user/permissions/repositories` filtered to one repository, which is the only place Bitbucket | |
| 164 | * states what the credentials may do with it: nothing on the repository, the pull request or the | |
| 165 | * workspace carries it. One row, or none where Bitbucket names no permission for this account. | |
| 166 | */ | |
| 167 | const RawRepositoryPermissionsSchema = Schema.Struct({ | |
| 168 | values: Schema.optional( | |
| 169 | Schema.NullOr( | |
| 170 | Schema.Array(Schema.Struct({ permission: Schema.optional(Schema.NullOr(Schema.String)) })), | |
| 171 | ), | |
| 172 | ), | |
| 173 | }); | |
| 174 | ||
| 175 | export interface BitbucketPullRequest { | |
| 176 | readonly number: number; | |
| 177 | readonly title: string; | |
| 178 | readonly url: string; | |
| 179 | readonly author: PullRequestActor | null; | |
| 180 | readonly headBranch: string; | |
| 181 | readonly headRepositoryNameWithOwner: string | null; | |
| 182 | readonly baseBranch: string; | |
| 183 | readonly state: PullRequestState; | |
| 184 | readonly isDraft: boolean; | |
| 185 | /** | |
| 186 | * Bitbucket reports no conflict state on a pull request, so the list leaves it unknown. The | |
| 187 | * detail read asks the conflicts endpoint, which does answer. | |
| 188 | */ | |
| 189 | readonly mergeability: PullRequestMergeability; | |
| 190 | readonly createdAt: string; | |
| 191 | readonly updatedAt: string; | |
| 192 | readonly body: string; | |
| 193 | readonly reviewRequestLogins: ReadonlyArray<string>; | |
| 194 | readonly reviewers: ReadonlyArray<PullRequestActor>; | |
| 195 | /** The reviewers as Bitbucket addresses them, which is what writing the set back takes. */ | |
| 196 | readonly reviewerIds: ReadonlyArray<string>; | |
| 197 | /** Approvals and change requests, which Bitbucket keeps on its participants. */ | |
| 198 | readonly reviews: ReadonlyArray<PullRequestComment>; | |
| 199 | } | |
| 200 | ||
| 201 | function trimmed(value: string | null | undefined): string | null { | |
| 202 | const text = value?.trim() ?? ""; | |
| 203 | return text.length > 0 ? text : null; | |
| 204 | } | |
| 205 | ||
| 206 | /** | |
| 207 | * Bitbucket stamps times as `+00:00` with microseconds. The page sorts change requests from | |
| 208 | * every host against each other as plain strings, so they are normalized to the same `Z` form | |
| 209 | * the other hosts already use. | |
| 210 | */ | |
| 211 | function toIsoUtc(value: string): string { | |
| 212 | return Option.match(DateTime.make(value), { | |
| 213 | onNone: () => value, | |
| 214 | onSome: DateTime.formatIso, | |
| 215 | }); | |
| 216 | } | |
| 217 | ||
| 218 | /** An app account has no nickname, so the display name is the only handle it has. */ | |
| 219 | function toActor(raw: Schema.Schema.Type<typeof RawUserSchema> | null | undefined) { | |
| 220 | const login = trimmed(raw?.nickname) ?? trimmed(raw?.display_name); | |
| 221 | return login === null | |
| 222 | ? null | |
| 223 | : { | |
| 224 | login, | |
| 225 | name: trimmed(raw?.display_name), | |
| 226 | avatarUrl: trimmed(raw?.links?.avatar?.href), | |
| 227 | }; | |
| 228 | } | |
| 229 | ||
| 230 | function toState(raw: Schema.Schema.Type<typeof RawPullRequestSchema>): PullRequestState { | |
| 231 | switch (raw.state?.trim().toUpperCase()) { | |
| 232 | case "MERGED": | |
| 233 | return "merged"; | |
| 234 | case "DECLINED": | |
| 235 | case "SUPERSEDED": | |
| 236 | return "closed"; | |
| 237 | default: | |
| 238 | return "open"; | |
| 239 | } | |
| 240 | } | |
| 241 | ||
| 242 | function toBuildStatus(value: string | null | undefined): PullRequestCheckStatus { | |
| 243 | switch (value?.trim().toUpperCase()) { | |
| 244 | case "SUCCESSFUL": | |
| 245 | return "success"; | |
| 246 | case "FAILED": | |
| 247 | return "failure"; | |
| 248 | case "STOPPED": | |
| 249 | return "cancelled"; | |
| 250 | case "INPROGRESS": | |
| 251 | return "pending"; | |
| 252 | default: | |
| 253 | return "neutral"; | |
| 254 | } | |
| 255 | } | |
| 256 | ||
| 257 | /** | |
| 258 | * A participant who has voted is the closest Bitbucket has to a review, so it reads as one in | |
| 259 | * the conversation. Participants who have only been added carry no verdict and are skipped. | |
| 260 | */ | |
| 261 | function toReviews( | |
| 262 | raw: Schema.Schema.Type<typeof RawPullRequestSchema>, | |
| 263 | ): ReadonlyArray<PullRequestComment> { | |
| 264 | return (raw.participants ?? []).flatMap((participant): ReadonlyArray<PullRequestComment> => { | |
| 265 | const author = toActor(participant.user); | |
| 266 | const votedAt = trimmed(participant.participated_on); | |
| 267 | const reviewState = | |
| 268 | trimmed(participant.state) ?? (participant.approved === true ? "approved" : null); | |
| 269 | if (author === null || votedAt === null || reviewState === null) return []; | |
| 270 | return [ | |
| 271 | { | |
| 272 | id: `${raw.id}:${author.login}`, | |
| 273 | kind: "review", | |
| 274 | author, | |
| 275 | body: "", | |
| 276 | createdAt: toIsoUtc(votedAt), | |
| 277 | url: null, | |
| 278 | path: null, | |
| 279 | reviewState, | |
| 280 | }, | |
| 281 | ]; | |
| 282 | }); | |
| 283 | } | |
| 284 | ||
| 285 | function toPullRequest(raw: Schema.Schema.Type<typeof RawPullRequestSchema>): BitbucketPullRequest { | |
| 286 | const reviewers = (raw.reviewers ?? []).flatMap((reviewer) => { | |
| 287 | const actor = toActor(reviewer); | |
| 288 | return actor === null ? [] : [actor]; | |
| 289 | }); | |
| 290 | return { | |
| 291 | number: raw.id, | |
| 292 | title: raw.title, | |
| 293 | url: raw.links.html.href, | |
| 294 | author: toActor(raw.author), | |
| 295 | headBranch: raw.source.branch.name, | |
| 296 | headRepositoryNameWithOwner: raw.source.repository?.full_name ?? null, | |
| 297 | baseBranch: raw.destination.branch.name, | |
| 298 | state: toState(raw), | |
| 299 | isDraft: raw.draft ?? false, | |
| 300 | mergeability: "unknown", | |
| 301 | createdAt: toIsoUtc(raw.created_on), | |
| 302 | updatedAt: toIsoUtc(raw.updated_on), | |
| 303 | body: raw.description ?? "", | |
| 304 | reviewRequestLogins: reviewers.map((reviewer) => reviewer.login), | |
| 305 | reviewers, | |
| 306 | reviewerIds: (raw.reviewers ?? []).flatMap((reviewer) => trimmed(reviewer.uuid) ?? []), | |
| 307 | reviews: toReviews(raw), | |
| 308 | }; | |
| 309 | } | |
| 310 | ||
| 311 | const decodePage = decodeJsonResult(RawPageSchema); | |
| 312 | const decodePullRequestEntry = Schema.decodeUnknownExit(RawPullRequestSchema); | |
| 313 | const decodePullRequest = decodeJsonResult(RawPullRequestSchema); | |
| 314 | const decodeCommentEntry = Schema.decodeUnknownExit(RawCommentSchema); | |
| 315 | const decodeCommitEntry = Schema.decodeUnknownExit(RawCommitSchema); | |
| 316 | const decodeStatusEntry = Schema.decodeUnknownExit(RawStatusSchema); | |
| 317 | const decodeDiffstatEntry = Schema.decodeUnknownExit(RawDiffstatSchema); | |
| 318 | const decodeMemberEntry = Schema.decodeUnknownExit(RawMemberSchema); | |
| 319 | const decodeViewer = decodeJsonResult(RawViewerSchema); | |
| 320 | const decodeConflicts = decodeJsonResult(RawPageSchema); | |
| 321 | const decodeRepositoryPermissions = decodeJsonResult(RawRepositoryPermissionsSchema); | |
| 322 | ||
| 323 | type DecodeFailure = Cause.Cause<Schema.SchemaError>; | |
| 324 | ||
| 325 | export interface BitbucketPage<A> { | |
| 326 | readonly items: ReadonlyArray<A>; | |
| 327 | /** The whole URL of the next page, which Bitbucket sends rather than an offset. */ | |
| 328 | readonly next: string | null; | |
| 329 | } | |
| 330 | ||
| 331 | /** Malformed entries are skipped rather than failing the page, as on the other hosts. */ | |
| 332 | export function decodePullRequestPageJson( | |
| 333 | raw: string, | |
| 334 | ): Result.Result<BitbucketPage<BitbucketPullRequest>, DecodeFailure> { | |
| 335 | const decoded = decodePage(raw); | |
| 336 | if (!Result.isSuccess(decoded)) { | |
| 337 | return Result.fail(decoded.failure); | |
| 338 | } | |
| 339 | const items: BitbucketPullRequest[] = []; | |
| 340 | for (const entry of decoded.success.values) { | |
| 341 | const item = decodePullRequestEntry(entry); | |
| 342 | if (Exit.isSuccess(item)) { | |
| 343 | items.push(toPullRequest(item.value)); | |
| 344 | } | |
| 345 | } | |
| 346 | return Result.succeed({ items, next: trimmed(decoded.success.next) }); | |
| 347 | } | |
| 348 | ||
| 349 | export function decodePullRequestJson( | |
| 350 | raw: string, | |
| 351 | ): Result.Result<BitbucketPullRequest, DecodeFailure> { | |
| 352 | const decoded = decodePullRequest(raw); | |
| 353 | return Result.isSuccess(decoded) | |
| 354 | ? Result.succeed(toPullRequest(decoded.success)) | |
| 355 | : Result.fail(decoded.failure); | |
| 356 | } | |
| 357 | ||
| 358 | export function decodeViewerJson(raw: string): Result.Result<string | null, DecodeFailure> { | |
| 359 | const decoded = decodeViewer(raw); | |
| 360 | return Result.isSuccess(decoded) | |
| 361 | ? Result.succeed(trimmed(decoded.success.nickname) ?? trimmed(decoded.success.display_name)) | |
| 362 | : Result.fail(decoded.failure); | |
| 363 | } | |
| 364 | ||
| 365 | /** | |
| 366 | * Whether the configured credentials can write to the repository, which is what merging needs. | |
| 367 | * Bitbucket answers `admin`, `write` or `read`, and an empty page means it named no permission at | |
| 368 | * all for this account — an unknown standing, which is granted rather than guessed away. | |
| 369 | */ | |
| 370 | export function decodeRepositoryPermissionJson(raw: string): Result.Result<boolean, DecodeFailure> { | |
| 371 | const decoded = decodeRepositoryPermissions(raw); | |
| 372 | if (!Result.isSuccess(decoded)) { | |
| 373 | return Result.fail(decoded.failure); | |
| 374 | } | |
| 375 | const permission = trimmed(decoded.success.values?.[0]?.permission)?.toLowerCase() ?? null; | |
| 376 | return Result.succeed(permission === null || permission === "admin" || permission === "write"); | |
| 377 | } | |
| 378 | ||
| 379 | /** | |
| 380 | * The workspace's members, which is the nearest thing Bitbucket has to "who may review this". | |
| 381 | * Nothing on a repository lists the people with access to it — `permissions-config/users` is for | |
| 382 | * administrators only — and a pull request can be sent to anyone in the workspace, so this is the | |
| 383 | * list Bitbucket's own reviewer field is filled from too. | |
| 384 | * | |
| 385 | * Nobody is marked requested here: who has been asked lives on the pull request, and only the | |
| 386 | * caller holds both. | |
| 387 | */ | |
| 388 | export function decodeWorkspaceMembersJson( | |
| 389 | raw: string, | |
| 390 | ): Result.Result<BitbucketPage<PullRequestReviewerCandidate>, DecodeFailure> { | |
| 391 | const decoded = decodePage(raw); | |
| 392 | if (!Result.isSuccess(decoded)) { | |
| 393 | return Result.fail(decoded.failure); | |
| 394 | } | |
| 395 | const items: PullRequestReviewerCandidate[] = []; | |
| 396 | for (const entry of decoded.success.values) { | |
| 397 | const member = decodeMemberEntry(entry); | |
| 398 | if (Exit.isFailure(member)) continue; | |
| 399 | const uuid = trimmed(member.value.user?.uuid); | |
| 400 | const actor = toActor(member.value.user); | |
| 401 | if (uuid === null || actor === null) continue; | |
| 402 | items.push({ ...actor, id: uuid, kind: "user", isRequested: false }); | |
| 403 | } | |
| 404 | return Result.succeed({ items, next: trimmed(decoded.success.next) }); | |
| 405 | } | |
| 406 | ||
| 407 | /** One comment as Bitbucket sent it, kept so threads can be assembled across pages. */ | |
| 408 | export type BitbucketRawComment = Schema.Schema.Type<typeof RawCommentSchema>; | |
| 409 | ||
| 410 | export interface BitbucketComments { | |
| 411 | readonly comments: ReadonlyArray<PullRequestComment>; | |
| 412 | /** | |
| 413 | * The same comments unread, for `buildReviewThreads`. A reply and the remark it answers can | |
| 414 | * land on different pages, and only the caller holding every page can put them together. | |
| 415 | */ | |
| 416 | readonly entries: ReadonlyArray<BitbucketRawComment>; | |
| 417 | readonly next: string | null; | |
| 418 | } | |
| 419 | ||
| 420 | /** | |
| 421 | * Bitbucket returns one flat list, so a thread is reassembled from it: a comment pinned to a | |
| 422 | * line opens a thread, and every reply that leads back to it belongs in it. A reply whose | |
| 423 | * parent is on a page that was not read has nowhere to go, and is left out rather than shown | |
| 424 | * as a thread of its own — it still stands in the flat conversation, which needs no parent. | |
| 425 | */ | |
| 426 | export function buildReviewThreads( | |
| 427 | comments: ReadonlyArray<BitbucketRawComment>, | |
| 428 | ): ReadonlyArray<PullRequestReviewThread> { | |
| 429 | const byId = new Map(comments.map((comment) => [comment.id, comment])); | |
| 430 | const rootOf = (comment: Schema.Schema.Type<typeof RawCommentSchema>) => { | |
| 431 | // Bounded by the number of comments read, so a parent cycle cannot spin here. | |
| 432 | let current = comment; | |
| 433 | for (let step = 0; step < byId.size; step += 1) { | |
| 434 | const parent = current.parent === null ? undefined : byId.get(current.parent?.id ?? -1); | |
| 435 | if (parent === undefined) return current; | |
| 436 | current = parent; | |
| 437 | } | |
| 438 | return current; | |
| 439 | }; | |
| 440 | ||
| 441 | const threads = new Map<number, PullRequestReviewThread>(); | |
| 442 | const replies = new Map<number, Array<Schema.Schema.Type<typeof RawCommentSchema>>>(); | |
| 443 | for (const comment of comments) { | |
| 444 | const root = rootOf(comment); | |
| 445 | const inline = root.inline; | |
| 446 | const path = trimmed(inline?.path); | |
| 447 | if (path === null) continue; | |
| 448 | if (root.id === comment.id) { | |
| 449 | // `to` is the line as the file stands now, `from` the line it replaced; a comment that | |
| 450 | // carries only `from` was written against the removed side. | |
| 451 | const side = inline?.to === null || inline?.to === undefined ? "left" : "right"; | |
| 452 | const line = side === "left" ? inline?.from : inline?.to; | |
| 453 | threads.set(root.id, { | |
| 454 | id: String(root.id), | |
| 455 | path, | |
| 456 | line: typeof line === "number" && line > 0 ? line : null, | |
| 457 | side, | |
| 458 | isResolved: root.resolution !== null && root.resolution !== undefined, | |
| 459 | isOutdated: inline?.outdated === true, | |
| 460 | comments: [], | |
| 461 | }); | |
| 462 | } | |
| 463 | const bucket = replies.get(root.id); | |
| 464 | if (bucket === undefined) replies.set(root.id, [comment]); | |
| 465 | else bucket.push(comment); | |
| 466 | } | |
| 467 | ||
| 468 | return [...threads.values()].flatMap((thread) => { | |
| 469 | const entries = (replies.get(Number(thread.id)) ?? []) | |
| 470 | .toSorted((left, right) => left.created_on.localeCompare(right.created_on)) | |
| 471 | .map((comment) => ({ | |
| 472 | id: String(comment.id), | |
| 473 | author: toActor(comment.user), | |
| 474 | body: comment.content?.raw ?? "", | |
| 475 | createdAt: toIsoUtc(comment.created_on), | |
| 476 | url: trimmed(comment.links?.html?.href), | |
| 477 | })); | |
| 478 | return entries.length === 0 ? [] : [{ ...thread, comments: entries }]; | |
| 479 | }); | |
| 480 | } | |
| 481 | ||
| 482 | /** | |
| 483 | * Deleted comments and ones their author has not posted yet carry nothing to show. A comment | |
| 484 | * pinned to a file is a line-level review comment, which is what that kind means. | |
| 485 | */ | |
| 486 | export function decodeCommentsJson(raw: string): Result.Result<BitbucketComments, DecodeFailure> { | |
| 487 | const decoded = decodePage(raw); | |
| 488 | if (!Result.isSuccess(decoded)) { | |
| 489 | return Result.fail(decoded.failure); | |
| 490 | } | |
| 491 | const comments: PullRequestComment[] = []; | |
| 492 | const kept: Array<BitbucketRawComment> = []; | |
| 493 | for (const entry of decoded.success.values) { | |
| 494 | const decodedComment = decodeCommentEntry(entry); | |
| 495 | if (Exit.isFailure(decodedComment)) continue; | |
| 496 | const comment = decodedComment.value; | |
| 497 | if (comment.deleted === true || comment.pending === true) continue; | |
| 498 | const body = comment.content?.raw ?? ""; | |
| 499 | if (body.trim().length === 0) continue; | |
| 500 | kept.push(comment); | |
| 501 | const path = trimmed(comment.inline?.path); | |
| 502 | comments.push({ | |
| 503 | id: String(comment.id), | |
| 504 | kind: path === null ? "issue-comment" : "review-comment", | |
| 505 | author: toActor(comment.user), | |
| 506 | body, | |
| 507 | createdAt: toIsoUtc(comment.created_on), | |
| 508 | url: trimmed(comment.links?.html?.href), | |
| 509 | path, | |
| 510 | reviewState: null, | |
| 511 | }); | |
| 512 | } | |
| 513 | return Result.succeed({ comments, entries: kept, next: trimmed(decoded.success.next) }); | |
| 514 | } | |
| 515 | ||
| 516 | export function decodeCommitsJson( | |
| 517 | raw: string, | |
| 518 | ): Result.Result<BitbucketPage<PullRequestCommit>, DecodeFailure> { | |
| 519 | const decoded = decodePage(raw); | |
| 520 | if (!Result.isSuccess(decoded)) { | |
| 521 | return Result.fail(decoded.failure); | |
| 522 | } | |
| 523 | const commits: PullRequestCommit[] = []; | |
| 524 | for (const entry of decoded.success.values) { | |
| 525 | const decodedCommit = decodeCommitEntry(entry); | |
| 526 | if (Exit.isFailure(decodedCommit)) continue; | |
| 527 | const commit = decodedCommit.value; | |
| 528 | const committedDate = trimmed(commit.date); | |
| 529 | if (committedDate === null) continue; | |
| 530 | const linkedAuthor = toActor(commit.author?.user); | |
| 531 | const rawAuthor = trimmed(commit.author?.raw); | |
| 532 | commits.push({ | |
| 533 | oid: commit.hash, | |
| 534 | messageHeadline: (commit.message ?? "").split("\n")[0] ?? "", | |
| 535 | committedDate: toIsoUtc(committedDate), | |
| 536 | authors: | |
| 537 | linkedAuthor !== null | |
| 538 | ? [linkedAuthor] | |
| 539 | : rawAuthor === null | |
| 540 | ? [] | |
| 541 | : [{ login: rawAuthor, name: rawAuthor, avatarUrl: null }], | |
| 542 | }); | |
| 543 | } | |
| 544 | // Bitbucket lists a pull request's commits newest first; the timeline reads oldest first. | |
| 545 | return Result.succeed({ items: commits.toReversed(), next: trimmed(decoded.success.next) }); | |
| 546 | } | |
| 547 | ||
| 548 | export function decodeStatusesJson( | |
| 549 | raw: string, | |
| 550 | ): Result.Result<BitbucketPage<PullRequestCheck>, DecodeFailure> { | |
| 551 | const decoded = decodePage(raw); | |
| 552 | if (!Result.isSuccess(decoded)) { | |
| 553 | return Result.fail(decoded.failure); | |
| 554 | } | |
| 555 | const checks: Array<{ | |
| 556 | readonly check: PullRequestCheck; | |
| 557 | readonly workflowName: string | null; | |
| 558 | readonly at: string | null; | |
| 559 | }> = []; | |
| 560 | for (const entry of decoded.success.values) { | |
| 561 | const decodedStatus = decodeStatusEntry(entry); | |
| 562 | if (Exit.isFailure(decodedStatus)) continue; | |
| 563 | const status = decodedStatus.value; | |
| 564 | const name = trimmed(status.name) ?? trimmed(status.key); | |
| 565 | if (name === null) continue; | |
| 566 | // Bitbucket re-uses a status key when a pipeline is run again, so the same check can appear | |
| 567 | // twice on one page. Nothing decoded here says which copy is newer, so the later one wins, | |
| 568 | // which is the order Bitbucket writes an update in. The key is kept as the workflow name so | |
| 569 | // two different pipelines that display the same name are not folded into one. | |
| 570 | checks.push({ | |
| 571 | check: { | |
| 572 | name, | |
| 573 | status: toBuildStatus(status.state), | |
| 574 | description: trimmed(status.description), | |
| 575 | url: trimmed(status.url), | |
| 576 | }, | |
| 577 | workflowName: trimmed(status.key), | |
| 578 | at: null, | |
| 579 | }); | |
| 580 | } | |
| 581 | return Result.succeed({ items: dedupeChecks(checks), next: trimmed(decoded.success.next) }); | |
| 582 | } | |
| 583 | ||
| 584 | export interface BitbucketDiffStat { | |
| 585 | readonly additions: number; | |
| 586 | readonly deletions: number; | |
| 587 | readonly changedFiles: number; | |
| 588 | } | |
| 589 | ||
| 590 | export interface BitbucketDiffStatPage extends BitbucketDiffStat { | |
| 591 | readonly next: string | null; | |
| 592 | } | |
| 593 | ||
| 594 | /** One entry per changed file, each carrying that file's line counts. */ | |
| 595 | export function decodeDiffstatJson( | |
| 596 | raw: string, | |
| 597 | ): Result.Result<BitbucketDiffStatPage, DecodeFailure> { | |
| 598 | const decoded = decodePage(raw); | |
| 599 | if (!Result.isSuccess(decoded)) { | |
| 600 | return Result.fail(decoded.failure); | |
| 601 | } | |
| 602 | let additions = 0; | |
| 603 | let deletions = 0; | |
| 604 | let changedFiles = 0; | |
| 605 | for (const entry of decoded.success.values) { | |
| 606 | const decodedStat = decodeDiffstatEntry(entry); | |
| 607 | if (Exit.isFailure(decodedStat)) continue; | |
| 608 | additions += decodedStat.value.lines_added ?? 0; | |
| 609 | deletions += decodedStat.value.lines_removed ?? 0; | |
| 610 | changedFiles += 1; | |
| 611 | } | |
| 612 | return Result.succeed({ | |
| 613 | additions, | |
| 614 | deletions, | |
| 615 | changedFiles, | |
| 616 | next: trimmed(decoded.success.next), | |
| 617 | }); | |
| 618 | } | |
| 619 | ||
| 620 | /** | |
| 621 | * The conflicts endpoint answers with one entry per conflicting path, so an empty page is the | |
| 622 | * only statement Bitbucket makes that a pull request merges cleanly. | |
| 623 | */ | |
| 624 | export function decodeConflictsJson( | |
| 625 | raw: string, | |
| 626 | ): Result.Result<PullRequestMergeability, DecodeFailure> { | |
| 627 | const decoded = decodeConflicts(raw); | |
| 628 | return Result.isSuccess(decoded) | |
| 629 | ? Result.succeed(decoded.success.values.length === 0 ? "mergeable" : "conflicting") | |
| 630 | : Result.fail(decoded.failure); | |
| 631 | } |
apps/server/src/server.ts+1-8| ... | ... | @@ -52,7 +52,6 @@ import * as ProviderHostLive from "./provider/ProviderHostLive.ts"; |
| 52 | 52 | import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRegistrySupport"; |
| 53 | 53 | import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; |
| 54 | 54 | import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; |
| 55 | import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; | |
| 56 | 55 | import * as GitHubApi from "./sourceControl/GitHubApi.ts"; |
| 57 | 56 | import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; |
| 58 | 57 | import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts"; |
| ... | ... | @@ -297,13 +296,7 @@ const layerPersistence = Layer.empty.pipe(Layer.provideMerge(SqlitePersistence.l |
| 297 | 296 | const layerVcsDriverRegistry = VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer)); |
| 298 | 297 | |
| 299 | 298 | const layerSourceControlProviderRegistry = SourceControlProviderRegistry.layer.pipe( |
| 300 | Layer.provide( | |
| 301 | Layer.mergeAll( | |
| 302 | BitbucketApi.layer, | |
| 303 | GitHubApi.layerWithDependencies, | |
| 304 | SourceControlBuiltInDrivers.layer, | |
| 305 | ), | |
| 306 | ), | |
| 299 | Layer.provide(Layer.mergeAll(GitHubApi.layerWithDependencies, SourceControlBuiltInDrivers.layer)), | |
| 307 | 300 | Layer.provideMerge(GitVcsDriver.layer), |
| 308 | 301 | Layer.provideMerge(layerVcsDriverRegistry), |
| 309 | 302 | ); |
apps/server/src/sourceControl/BitbucketApi.test.ts+5-1| ... | ... | @@ -11,11 +11,13 @@ import * as TestClock from "effect/testing/TestClock"; |
| 11 | 11 | import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http"; |
| 12 | 12 | |
| 13 | 13 | import { GitCommandError } from "@t3tools/contracts"; |
| 14 | import * as BitbucketApi from "./BitbucketApi.ts"; | |
| 14 | import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; | |
| 15 | 15 | import * as ServerSettings from "../serverSettings.ts"; |
| 16 | 16 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; |
| 17 | 17 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; |
| 18 | 18 | import type * as VcsDriver from "../vcs/VcsDriver.ts"; |
| 19 | import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; | |
| 20 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | |
| 19 | 21 | |
| 20 | 22 | const isBitbucketRepositoryLocatorError = Schema.is(BitbucketApi.BitbucketRepositoryLocatorError); |
| 21 | 23 | |
| ... | ... | @@ -139,6 +141,8 @@ function makeLayer(input: { |
| 139 | 141 | ); |
| 140 | 142 | |
| 141 | 143 | const layer = BitbucketApi.layer.pipe( |
| 144 | Layer.provide(ServerSourceControlHost.layer), | |
| 145 | Layer.provide(Layer.mock(VcsProcess.VcsProcess)({})), | |
| 142 | 146 | Layer.provide( |
| 143 | 147 | Layer.succeed( |
| 144 | 148 | HttpClient.HttpClient, |
apps/server/src/sourceControl/BitbucketApi.ts deleted-1163| ... | ... | @@ -1,1163 +0,0 @@ |
| 1 | import * as Clock from "effect/Clock"; | |
| 2 | import * as Config from "effect/Config"; | |
| 3 | import * as Context from "effect/Context"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as FileSystem from "effect/FileSystem"; | |
| 6 | import * as Layer from "effect/Layer"; | |
| 7 | import * as Option from "effect/Option"; | |
| 8 | import * as Schema from "effect/Schema"; | |
| 9 | import { | |
| 10 | DEFAULT_SERVER_SETTINGS, | |
| 11 | NonNegativeInt, | |
| 12 | TrimmedNonEmptyString, | |
| 13 | type BitbucketSettings, | |
| 14 | type SourceControlProviderAuth, | |
| 15 | type SourceControlRepositoryCloneUrls, | |
| 16 | type SourceControlRepositoryVisibility, | |
| 17 | } from "@t3tools/contracts"; | |
| 18 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; | |
| 19 | import { sanitizeBranchFragment } from "@t3tools/shared/git"; | |
| 20 | import { | |
| 21 | detectSourceControlProviderFromRemoteUrl, | |
| 22 | isSshRemoteUrl, | |
| 23 | } from "@t3tools/shared/sourceControl"; | |
| 24 | ||
| 25 | import { | |
| 26 | BitbucketPullRequestListSchema, | |
| 27 | BitbucketPullRequestSchema, | |
| 28 | normalizeBitbucketPullRequestRecord, | |
| 29 | type NormalizedBitbucketPullRequestRecord, | |
| 30 | } from "./bitbucketPullRequests.ts"; | |
| 31 | import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText"; | |
| 32 | import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; | |
| 33 | import * as ServerSettings from "../serverSettings.ts"; | |
| 34 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | |
| 35 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; | |
| 36 | import { retryAtFromHeader } from "@t3tools/source-control-core/server/SourceControlRateLimit"; | |
| 37 | ||
| 38 | const DEFAULT_API_BASE_URL = "https://api.bitbucket.org/2.0"; | |
| 39 | /** A response body past this is cut short, so one huge diff cannot exhaust the server. */ | |
| 40 | const DEFAULT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; | |
| 41 | /** Bitbucket redirects a diff once; this leaves room without following a chain forever. */ | |
| 42 | const MAX_REDIRECTS = 3; | |
| 43 | ||
| 44 | const BitbucketApiEnvConfig = Config.all({ | |
| 45 | baseUrl: Config.String("T3CODE_BITBUCKET_API_BASE_URL").pipe( | |
| 46 | Config.withDefault(DEFAULT_API_BASE_URL), | |
| 47 | ), | |
| 48 | accessToken: Config.String("T3CODE_BITBUCKET_ACCESS_TOKEN").pipe(Config.option), | |
| 49 | email: Config.String("T3CODE_BITBUCKET_EMAIL").pipe(Config.option), | |
| 50 | apiToken: Config.String("T3CODE_BITBUCKET_API_TOKEN").pipe(Config.option), | |
| 51 | }); | |
| 52 | ||
| 53 | const BitbucketApiOperation = Schema.Literals([ | |
| 54 | "resolveRepository", | |
| 55 | "getRepository", | |
| 56 | "getBranchingModel", | |
| 57 | "getPullRequest", | |
| 58 | "listPullRequests", | |
| 59 | "createRepository", | |
| 60 | "createPullRequest", | |
| 61 | "probeAuth", | |
| 62 | "checkoutPullRequest", | |
| 63 | // The raw escape hatch. Callers name their own operation in their own error, the way the | |
| 64 | // pull request wrappers do on top of `gh` and `glab`. | |
| 65 | "request", | |
| 66 | ]); | |
| 67 | type BitbucketApiOperation = typeof BitbucketApiOperation.Type; | |
| 68 | ||
| 69 | export class BitbucketRepositoryLocatorError extends Schema.TaggedError<BitbucketRepositoryLocatorError>()( | |
| 70 | "BitbucketRepositoryLocatorError", | |
| 71 | { | |
| 72 | repository: Schema.String, | |
| 73 | }, | |
| 74 | ) { | |
| 75 | static readonly detail = "Bitbucket repositories must be specified as workspace/repository."; | |
| 76 | ||
| 77 | get detail(): string { | |
| 78 | return BitbucketRepositoryLocatorError.detail; | |
| 79 | } | |
| 80 | ||
| 81 | override get message(): string { | |
| 82 | return `Bitbucket API failed: ${this.detail}`; | |
| 83 | } | |
| 84 | } | |
| 85 | ||
| 86 | export class BitbucketRequestError extends Schema.TaggedError<BitbucketRequestError>()( | |
| 87 | "BitbucketRequestError", | |
| 88 | { | |
| 89 | operation: BitbucketApiOperation, | |
| 90 | cause: Schema.Defect(), | |
| 91 | }, | |
| 92 | ) { | |
| 93 | get detail(): string { | |
| 94 | return "Failed to send the Bitbucket request."; | |
| 95 | } | |
| 96 | ||
| 97 | override get message(): string { | |
| 98 | return `Bitbucket API failed in ${this.operation}: ${this.detail}`; | |
| 99 | } | |
| 100 | } | |
| 101 | ||
| 102 | export class BitbucketResponseError extends Schema.TaggedError<BitbucketResponseError>()( | |
| 103 | "BitbucketResponseError", | |
| 104 | { | |
| 105 | operation: BitbucketApiOperation, | |
| 106 | status: Schema.Int, | |
| 107 | responseBodyLength: NonNegativeInt, | |
| 108 | retryAt: Schema.optional(Schema.Number), | |
| 109 | }, | |
| 110 | ) { | |
| 111 | get detail(): string { | |
| 112 | return `Bitbucket returned HTTP ${this.status}.`; | |
| 113 | } | |
| 114 | ||
| 115 | override get message(): string { | |
| 116 | return `Bitbucket API failed in ${this.operation}: ${this.detail}`; | |
| 117 | } | |
| 118 | } | |
| 119 | ||
| 120 | export class BitbucketResponseBodyReadError extends Schema.TaggedError<BitbucketResponseBodyReadError>()( | |
| 121 | "BitbucketResponseBodyReadError", | |
| 122 | { | |
| 123 | operation: BitbucketApiOperation, | |
| 124 | status: Schema.Int, | |
| 125 | retryAt: Schema.optional(Schema.Number), | |
| 126 | cause: Schema.Defect(), | |
| 127 | }, | |
| 128 | ) { | |
| 129 | get detail(): string { | |
| 130 | return `Bitbucket returned HTTP ${this.status}.`; | |
| 131 | } | |
| 132 | ||
| 133 | override get message(): string { | |
| 134 | return `Bitbucket API failed in ${this.operation}: ${this.detail}`; | |
| 135 | } | |
| 136 | } | |
| 137 | ||
| 138 | export class BitbucketResponseDecodeError extends Schema.TaggedError<BitbucketResponseDecodeError>()( | |
| 139 | "BitbucketResponseDecodeError", | |
| 140 | { | |
| 141 | operation: BitbucketApiOperation, | |
| 142 | status: Schema.Int, | |
| 143 | cause: Schema.Defect(), | |
| 144 | }, | |
| 145 | ) { | |
| 146 | get detail(): string { | |
| 147 | return "Bitbucket returned invalid JSON for the requested resource."; | |
| 148 | } | |
| 149 | ||
| 150 | override get message(): string { | |
| 151 | return `Bitbucket API failed in ${this.operation}: ${this.detail}`; | |
| 152 | } | |
| 153 | } | |
| 154 | ||
| 155 | export class BitbucketRepositoryVcsResolveError extends Schema.TaggedError<BitbucketRepositoryVcsResolveError>()( | |
| 156 | "BitbucketRepositoryVcsResolveError", | |
| 157 | { | |
| 158 | cwd: Schema.String, | |
| 159 | cause: Schema.Defect(), | |
| 160 | }, | |
| 161 | ) { | |
| 162 | get detail(): string { | |
| 163 | return `Failed to resolve VCS repository for ${this.cwd}.`; | |
| 164 | } | |
| 165 | ||
| 166 | override get message(): string { | |
| 167 | return `Bitbucket API failed in resolveRepository: ${this.detail}`; | |
| 168 | } | |
| 169 | } | |
| 170 | ||
| 171 | export class BitbucketRepositoryRemotesListError extends Schema.TaggedError<BitbucketRepositoryRemotesListError>()( | |
| 172 | "BitbucketRepositoryRemotesListError", | |
| 173 | { | |
| 174 | cwd: Schema.String, | |
| 175 | cause: Schema.Defect(), | |
| 176 | }, | |
| 177 | ) { | |
| 178 | get detail(): string { | |
| 179 | return `Failed to list remotes for ${this.cwd}.`; | |
| 180 | } | |
| 181 | ||
| 182 | override get message(): string { | |
| 183 | return `Bitbucket API failed in resolveRepository: ${this.detail}`; | |
| 184 | } | |
| 185 | } | |
| 186 | ||
| 187 | export class BitbucketRepositoryRemoteNotFoundError extends Schema.TaggedError<BitbucketRepositoryRemoteNotFoundError>()( | |
| 188 | "BitbucketRepositoryRemoteNotFoundError", | |
| 189 | { | |
| 190 | cwd: Schema.String, | |
| 191 | }, | |
| 192 | ) { | |
| 193 | get detail(): string { | |
| 194 | return `No Bitbucket repository remote was detected for ${this.cwd}.`; | |
| 195 | } | |
| 196 | ||
| 197 | override get message(): string { | |
| 198 | return `Bitbucket API failed in resolveRepository: ${this.detail}`; | |
| 199 | } | |
| 200 | } | |
| 201 | ||
| 202 | export class BitbucketPullRequestBodyReadError extends Schema.TaggedError<BitbucketPullRequestBodyReadError>()( | |
| 203 | "BitbucketPullRequestBodyReadError", | |
| 204 | { | |
| 205 | cwd: Schema.String, | |
| 206 | bodyFile: Schema.String, | |
| 207 | cause: Schema.Defect(), | |
| 208 | }, | |
| 209 | ) { | |
| 210 | get detail(): string { | |
| 211 | return `Failed to read pull request body file ${this.bodyFile}.`; | |
| 212 | } | |
| 213 | ||
| 214 | override get message(): string { | |
| 215 | return `Bitbucket API failed in createPullRequest: ${this.detail}`; | |
| 216 | } | |
| 217 | } | |
| 218 | ||
| 219 | export class BitbucketCheckoutError extends Schema.TaggedError<BitbucketCheckoutError>()( | |
| 220 | "BitbucketCheckoutError", | |
| 221 | { | |
| 222 | cwd: Schema.String, | |
| 223 | reference: Schema.String, | |
| 224 | cause: Schema.Defect(), | |
| 225 | }, | |
| 226 | ) { | |
| 227 | get detail(): string { | |
| 228 | return "Failed to check out the Bitbucket pull request."; | |
| 229 | } | |
| 230 | ||
| 231 | override get message(): string { | |
| 232 | return `Bitbucket API failed in checkoutPullRequest: ${this.detail}`; | |
| 233 | } | |
| 234 | } | |
| 235 | ||
| 236 | /** | |
| 237 | * A url that does not belong to the configured Bitbucket. Refused rather than followed, because | |
| 238 | * the request carries the account's credentials and a url that came back in a response — a | |
| 239 | * pagination cursor, or the target of a redirect — is not this server's to trust. | |
| 240 | */ | |
| 241 | export class BitbucketUntrustedUrlError extends Schema.TaggedError<BitbucketUntrustedUrlError>()( | |
| 242 | "BitbucketUntrustedUrlError", | |
| 243 | { | |
| 244 | /** The host only. A rejected hop is often a signed url, whose query carries a credential. */ | |
| 245 | host: Schema.String, | |
| 246 | }, | |
| 247 | ) { | |
| 248 | get detail(): string { | |
| 249 | return `The response pointed at ${this.host}, outside the configured Bitbucket.`; | |
| 250 | } | |
| 251 | ||
| 252 | override get message(): string { | |
| 253 | return `Bitbucket API failed in request: ${this.detail}`; | |
| 254 | } | |
| 255 | } | |
| 256 | ||
| 257 | export const BitbucketApiError = Schema.Union([ | |
| 258 | BitbucketUntrustedUrlError, | |
| 259 | BitbucketRepositoryLocatorError, | |
| 260 | BitbucketRequestError, | |
| 261 | BitbucketResponseError, | |
| 262 | BitbucketResponseBodyReadError, | |
| 263 | BitbucketResponseDecodeError, | |
| 264 | BitbucketRepositoryVcsResolveError, | |
| 265 | BitbucketRepositoryRemotesListError, | |
| 266 | BitbucketRepositoryRemoteNotFoundError, | |
| 267 | BitbucketPullRequestBodyReadError, | |
| 268 | BitbucketCheckoutError, | |
| 269 | ]); | |
| 270 | export type BitbucketApiError = typeof BitbucketApiError.Type; | |
| 271 | const isBitbucketApiError = Schema.is(BitbucketApiError); | |
| 272 | ||
| 273 | const RawBitbucketRepositorySchema = Schema.Struct({ | |
| 274 | full_name: TrimmedNonEmptyString, | |
| 275 | links: Schema.Struct({ | |
| 276 | html: Schema.optional( | |
| 277 | Schema.Struct({ | |
| 278 | href: TrimmedNonEmptyString, | |
| 279 | }), | |
| 280 | ), | |
| 281 | clone: Schema.optional( | |
| 282 | Schema.Array( | |
| 283 | Schema.Struct({ | |
| 284 | name: TrimmedNonEmptyString, | |
| 285 | href: TrimmedNonEmptyString, | |
| 286 | }), | |
| 287 | ), | |
| 288 | ), | |
| 289 | }), | |
| 290 | mainbranch: Schema.optional( | |
| 291 | Schema.NullOr( | |
| 292 | Schema.Struct({ | |
| 293 | name: TrimmedNonEmptyString, | |
| 294 | }), | |
| 295 | ), | |
| 296 | ), | |
| 297 | }); | |
| 298 | ||
| 299 | const RawBitbucketBranchingModelSchema = Schema.Struct({ | |
| 300 | development: Schema.optional( | |
| 301 | Schema.Struct({ | |
| 302 | branch: Schema.optional( | |
| 303 | Schema.NullOr( | |
| 304 | Schema.Struct({ | |
| 305 | name: Schema.optional(TrimmedNonEmptyString), | |
| 306 | }), | |
| 307 | ), | |
| 308 | ), | |
| 309 | is_valid: Schema.optional(Schema.Boolean), | |
| 310 | name: Schema.optional(Schema.NullOr(Schema.String)), | |
| 311 | use_mainbranch: Schema.optional(Schema.Boolean), | |
| 312 | }), | |
| 313 | ), | |
| 314 | }); | |
| 315 | ||
| 316 | const BitbucketUserSchema = Schema.Struct({ | |
| 317 | username: Schema.optional(TrimmedNonEmptyString), | |
| 318 | display_name: Schema.optional(TrimmedNonEmptyString), | |
| 319 | account_id: Schema.optional(TrimmedNonEmptyString), | |
| 320 | }); | |
| 321 | ||
| 322 | export interface BitbucketRepositoryLocator { | |
| 323 | readonly workspace: string; | |
| 324 | readonly repoSlug: string; | |
| 325 | } | |
| 326 | ||
| 327 | export class BitbucketApi extends Context.Service< | |
| 328 | BitbucketApi, | |
| 329 | { | |
| 330 | readonly probeAuth: Effect.Effect<SourceControlProviderAuth, never>; | |
| 331 | ||
| 332 | /** | |
| 333 | * One authenticated request, returning the body verbatim. Bitbucket answers most endpoints | |
| 334 | * with JSON and a few — a pull request diff, for one — with plain text, so the body is | |
| 335 | * handed back undecoded for the caller to read as it sees fit. | |
| 336 | */ | |
| 337 | readonly request: (input: { | |
| 338 | readonly method: "GET" | "POST" | "PUT" | "DELETE"; | |
| 339 | /** | |
| 340 | * A path below the API base, or a whole URL as a paged response reports its next page. | |
| 341 | * A whole URL is refused unless it belongs to the configured Bitbucket. | |
| 342 | */ | |
| 343 | readonly url: string; | |
| 344 | /** A JSON document, for the endpoints that take one. */ | |
| 345 | readonly body?: string; | |
| 346 | /** Response bytes to keep; past this the body comes back cut short and marked. */ | |
| 347 | readonly maxBytes?: number; | |
| 348 | }) => Effect.Effect<{ readonly body: string; readonly truncated: boolean }, BitbucketApiError>; | |
| 349 | readonly listPullRequests: (input: { | |
| 350 | readonly cwd: string; | |
| 351 | readonly context?: SourceControlProvider.SourceControlProviderContext; | |
| 352 | readonly headSelector: string; | |
| 353 | readonly source?: SourceControlProvider.SourceControlRefSelector; | |
| 354 | readonly state: "open" | "closed" | "merged" | "all"; | |
| 355 | readonly limit?: number; | |
| 356 | }) => Effect.Effect<ReadonlyArray<NormalizedBitbucketPullRequestRecord>, BitbucketApiError>; | |
| 357 | readonly getPullRequest: (input: { | |
| 358 | readonly cwd: string; | |
| 359 | readonly context?: SourceControlProvider.SourceControlProviderContext; | |
| 360 | readonly reference: string; | |
| 361 | }) => Effect.Effect<NormalizedBitbucketPullRequestRecord, BitbucketApiError>; | |
| 362 | readonly getRepositoryCloneUrls: (input: { | |
| 363 | readonly cwd: string; | |
| 364 | readonly context?: SourceControlProvider.SourceControlProviderContext; | |
| 365 | readonly repository: string; | |
| 366 | }) => Effect.Effect<SourceControlRepositoryCloneUrls, BitbucketApiError>; | |
| 367 | readonly createRepository: (input: { | |
| 368 | readonly cwd: string; | |
| 369 | readonly repository: string; | |
| 370 | readonly visibility: SourceControlRepositoryVisibility; | |
| 371 | }) => Effect.Effect<SourceControlRepositoryCloneUrls, BitbucketApiError>; | |
| 372 | readonly createPullRequest: (input: { | |
| 373 | readonly cwd: string; | |
| 374 | readonly context?: SourceControlProvider.SourceControlProviderContext; | |
| 375 | readonly baseBranch: string; | |
| 376 | readonly headSelector: string; | |
| 377 | readonly source?: SourceControlProvider.SourceControlRefSelector; | |
| 378 | readonly target?: SourceControlProvider.SourceControlRefSelector; | |
| 379 | readonly title: string; | |
| 380 | readonly bodyFile: string; | |
| 381 | }) => Effect.Effect<void, BitbucketApiError>; | |
| 382 | readonly getDefaultBranch: (input: { | |
| 383 | readonly cwd: string; | |
| 384 | readonly context?: SourceControlProvider.SourceControlProviderContext; | |
| 385 | }) => Effect.Effect<string | null, BitbucketApiError>; | |
| 386 | readonly checkoutPullRequest: (input: { | |
| 387 | readonly cwd: string; | |
| 388 | readonly context?: SourceControlProvider.SourceControlProviderContext; | |
| 389 | readonly reference: string; | |
| 390 | readonly force?: boolean; | |
| 391 | }) => Effect.Effect<void, BitbucketApiError>; | |
| 392 | } | |
| 393 | >()("t3/sourceControl/BitbucketApi") {} | |
| 394 | ||
| 395 | function nonEmpty(value: string | undefined): Option.Option<string> { | |
| 396 | const trimmed = value?.trim(); | |
| 397 | return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); | |
| 398 | } | |
| 399 | ||
| 400 | function normalizeChangeRequestId(reference: string): string { | |
| 401 | const trimmed = reference.trim().replace(/^#/, ""); | |
| 402 | const urlMatch = /(?:pull-requests|pullrequests|pull-request|pull|pr)\/(\d+)(?:\D.*)?$/i.exec( | |
| 403 | trimmed, | |
| 404 | ); | |
| 405 | return urlMatch?.[1] ?? trimmed; | |
| 406 | } | |
| 407 | ||
| 408 | function sourceWorkspace(input: { | |
| 409 | readonly headSelector: string; | |
| 410 | readonly source?: SourceControlProvider.SourceControlRefSelector; | |
| 411 | }): string | undefined { | |
| 412 | if (input.source?.owner) return input.source.owner; | |
| 413 | return SourceControlProvider.parseSourceControlOwnerRef(input.headSelector)?.owner; | |
| 414 | } | |
| 415 | ||
| 416 | function toBitbucketStates(state: "open" | "closed" | "merged" | "all"): ReadonlyArray<string> { | |
| 417 | switch (state) { | |
| 418 | case "open": | |
| 419 | return ["OPEN"]; | |
| 420 | case "closed": | |
| 421 | return ["DECLINED", "SUPERSEDED"]; | |
| 422 | case "merged": | |
| 423 | return ["MERGED"]; | |
| 424 | case "all": | |
| 425 | return ["OPEN", "MERGED", "DECLINED", "SUPERSEDED"]; | |
| 426 | } | |
| 427 | } | |
| 428 | ||
| 429 | function bitbucketQueryString(filters: ReadonlyArray<string>): string { | |
| 430 | return filters.join(" AND "); | |
| 431 | } | |
| 432 | ||
| 433 | function bitbucketStateFilter(states: ReadonlyArray<string>): string { | |
| 434 | return states.length === 1 | |
| 435 | ? `state = "${states[0]}"` | |
| 436 | : `(${states.map((state) => `state = "${state}"`).join(" OR ")})`; | |
| 437 | } | |
| 438 | ||
| 439 | function parseBitbucketRepositorySlug(value: string): BitbucketRepositoryLocator | null { | |
| 440 | const normalized = value.trim().replace(/\.git$/u, ""); | |
| 441 | const parts = normalized.split("/").filter((part) => part.length > 0); | |
| 442 | if (parts.length < 2) return null; | |
| 443 | const workspace = parts.at(-2); | |
| 444 | const repoSlug = parts.at(-1); | |
| 445 | return workspace && repoSlug ? { workspace, repoSlug } : null; | |
| 446 | } | |
| 447 | ||
| 448 | function requireRepositoryLocator( | |
| 449 | repository: string, | |
| 450 | ): Effect.Effect<BitbucketRepositoryLocator, BitbucketApiError> { | |
| 451 | const locator = parseBitbucketRepositorySlug(repository); | |
| 452 | return locator | |
| 453 | ? Effect.succeed(locator) | |
| 454 | : Effect.fail( | |
| 455 | new BitbucketRepositoryLocatorError({ | |
| 456 | repository, | |
| 457 | }), | |
| 458 | ); | |
| 459 | } | |
| 460 | ||
| 461 | function parseBitbucketRemoteUrl(remoteUrl: string): BitbucketRepositoryLocator | null { | |
| 462 | const trimmed = remoteUrl.trim(); | |
| 463 | const scpMatch = /^[a-zA-Z0-9._-]+@[^:/]+:(.+)$/.exec(trimmed); | |
| 464 | if (scpMatch?.[1]) { | |
| 465 | return parseBitbucketRepositorySlug(scpMatch[1]); | |
| 466 | } | |
| 467 | ||
| 468 | try { | |
| 469 | return parseBitbucketRepositorySlug(new URL(trimmed).pathname); | |
| 470 | } catch { | |
| 471 | return null; | |
| 472 | } | |
| 473 | } | |
| 474 | ||
| 475 | function normalizeRepositoryCloneUrls( | |
| 476 | raw: typeof RawBitbucketRepositorySchema.Type, | |
| 477 | ): SourceControlRepositoryCloneUrls { | |
| 478 | const httpClone = | |
| 479 | raw.links.clone?.find((entry) => entry.name.toLowerCase() === "https")?.href ?? | |
| 480 | raw.links.html?.href; | |
| 481 | const sshClone = raw.links.clone?.find((entry) => entry.name.toLowerCase() === "ssh")?.href; | |
| 482 | ||
| 483 | return { | |
| 484 | nameWithOwner: raw.full_name, | |
| 485 | url: httpClone ?? raw.links.html?.href ?? raw.full_name, | |
| 486 | sshUrl: sshClone ?? httpClone ?? raw.full_name, | |
| 487 | }; | |
| 488 | } | |
| 489 | ||
| 490 | function defaultChangeRequestTargetBranch(input: { | |
| 491 | readonly repository: typeof RawBitbucketRepositorySchema.Type; | |
| 492 | readonly branchingModel: typeof RawBitbucketBranchingModelSchema.Type | null; | |
| 493 | }): string | null { | |
| 494 | const repositoryMainBranch = input.repository.mainbranch?.name ?? null; | |
| 495 | const development = input.branchingModel?.development; | |
| 496 | if (!development || development.use_mainbranch === true || development.is_valid === false) { | |
| 497 | return repositoryMainBranch; | |
| 498 | } | |
| 499 | ||
| 500 | const developmentBranch = development.branch?.name?.trim() ?? development.name?.trim() ?? ""; | |
| 501 | if (developmentBranch.length === 0 || developmentBranch === "null") { | |
| 502 | return repositoryMainBranch; | |
| 503 | } | |
| 504 | ||
| 505 | return developmentBranch; | |
| 506 | } | |
| 507 | ||
| 508 | function shouldPreferSshRemote(originRemoteUrl: string | null): boolean { | |
| 509 | if (!originRemoteUrl) return false; | |
| 510 | return isSshRemoteUrl(originRemoteUrl); | |
| 511 | } | |
| 512 | ||
| 513 | function selectCloneUrl(input: { | |
| 514 | readonly cloneUrls: SourceControlRepositoryCloneUrls; | |
| 515 | readonly originRemoteUrl: string | null; | |
| 516 | }): string { | |
| 517 | return shouldPreferSshRemote(input.originRemoteUrl) | |
| 518 | ? input.cloneUrls.sshUrl | |
| 519 | : input.cloneUrls.url; | |
| 520 | } | |
| 521 | ||
| 522 | function checkoutBranchName(input: { | |
| 523 | readonly pullRequestId: number; | |
| 524 | readonly headBranch: string; | |
| 525 | readonly isCrossRepository: boolean; | |
| 526 | }): string { | |
| 527 | if (!input.isCrossRepository) { | |
| 528 | return input.headBranch; | |
| 529 | } | |
| 530 | ||
| 531 | return `t3code/pr-${input.pullRequestId}/${sanitizeBranchFragment(input.headBranch)}`; | |
| 532 | } | |
| 533 | ||
| 534 | function repositoryNameWithOwner( | |
| 535 | repository: Schema.Schema.Type<typeof BitbucketPullRequestSchema>["source"]["repository"], | |
| 536 | ): string | null { | |
| 537 | const fullName = repository?.full_name?.trim() ?? ""; | |
| 538 | return fullName.length > 0 ? fullName : null; | |
| 539 | } | |
| 540 | ||
| 541 | function repositoryOwnerName(repositoryName: string): string { | |
| 542 | return repositoryName.split("/")[0]?.trim() || "bitbucket"; | |
| 543 | } | |
| 544 | ||
| 545 | type BitbucketCredential = | |
| 546 | | { readonly kind: "access-token"; readonly accessToken: string } | |
| 547 | | { readonly kind: "api-token"; readonly email: string; readonly apiToken: string }; | |
| 548 | ||
| 549 | /** | |
| 550 | * Visible ASCII only. A value the HTTP stack rejects makes it throw an error quoting the whole | |
| 551 | * header, and that error travels to clients as a cause, so an unusable token is treated as unset. | |
| 552 | */ | |
| 553 | const HEADER_SAFE = /^[\x21-\x7e]+$/u; | |
| 554 | ||
| 555 | function credentialFrom(input: { | |
| 556 | readonly accessToken: string; | |
| 557 | readonly email: string; | |
| 558 | readonly apiToken: string; | |
| 559 | }): BitbucketCredential | null { | |
| 560 | if (HEADER_SAFE.test(input.accessToken)) { | |
| 561 | return { kind: "access-token", accessToken: input.accessToken }; | |
| 562 | } | |
| 563 | if (HEADER_SAFE.test(input.email) && HEADER_SAFE.test(input.apiToken)) { | |
| 564 | return { kind: "api-token", email: input.email, apiToken: input.apiToken }; | |
| 565 | } | |
| 566 | return null; | |
| 567 | } | |
| 568 | ||
| 569 | /** | |
| 570 | * Credentials saved in settings win over the `T3CODE_BITBUCKET_*` environment variables, which | |
| 571 | * stay as a fallback. Within each source the access token wins. | |
| 572 | */ | |
| 573 | function resolveCredential( | |
| 574 | settings: BitbucketSettings, | |
| 575 | env: Config.Success<typeof BitbucketApiEnvConfig>, | |
| 576 | ): BitbucketCredential | null { | |
| 577 | return ( | |
| 578 | credentialFrom(settings) ?? | |
| 579 | credentialFrom({ | |
| 580 | accessToken: Option.getOrElse(env.accessToken, () => ""), | |
| 581 | email: Option.getOrElse(env.email, () => ""), | |
| 582 | apiToken: Option.getOrElse(env.apiToken, () => ""), | |
| 583 | }) | |
| 584 | ); | |
| 585 | } | |
| 586 | ||
| 587 | function authFromCredential(credential: BitbucketCredential | null): SourceControlProviderAuth { | |
| 588 | if (credential?.kind === "access-token") { | |
| 589 | return { | |
| 590 | status: "unknown", | |
| 591 | account: Option.none(), | |
| 592 | host: Option.some("bitbucket.org"), | |
| 593 | detail: Option.some("An access token is configured."), | |
| 594 | }; | |
| 595 | } | |
| 596 | ||
| 597 | if (credential?.kind === "api-token") { | |
| 598 | return { | |
| 599 | status: "unknown", | |
| 600 | account: Option.some(credential.email), | |
| 601 | host: Option.some("bitbucket.org"), | |
| 602 | detail: Option.some("An API token is configured."), | |
| 603 | }; | |
| 604 | } | |
| 605 | ||
| 606 | return { | |
| 607 | status: "unauthenticated", | |
| 608 | account: Option.none(), | |
| 609 | host: Option.some("bitbucket.org"), | |
| 610 | detail: Option.some( | |
| 611 | "Add a Bitbucket token in Settings → Source Control, or set the T3CODE_BITBUCKET_* environment variables on the server.", | |
| 612 | ), | |
| 613 | }; | |
| 614 | } | |
| 615 | ||
| 616 | /** Null for anything that is not a url at all, which is never the configured Bitbucket. */ | |
| 617 | function originOf(value: string): string | null { | |
| 618 | try { | |
| 619 | return new URL(value).origin; | |
| 620 | } catch { | |
| 621 | return null; | |
| 622 | } | |
| 623 | } | |
| 624 | ||
| 625 | function responseError( | |
| 626 | operation: BitbucketApiOperation, | |
| 627 | response: HttpClientResponse.HttpClientResponse, | |
| 628 | ): Effect.Effect<never, BitbucketApiError> { | |
| 629 | // Bounded like any other body: an error response is no smaller than a successful one, and | |
| 630 | // only its length is reported anyway. | |
| 631 | return Effect.gen(function* () { | |
| 632 | const now = yield* Clock.currentTimeMillis; | |
| 633 | const retryAt = retryAtFromHeader(response.headers["retry-after"], now); | |
| 634 | const collected = yield* collectUint8StreamText({ | |
| 635 | stream: response.stream, | |
| 636 | maxBytes: DEFAULT_MAX_RESPONSE_BYTES, | |
| 637 | }).pipe( | |
| 638 | Effect.mapError( | |
| 639 | (cause) => | |
| 640 | new BitbucketResponseBodyReadError({ | |
| 641 | operation, | |
| 642 | status: response.status, | |
| 643 | retryAt, | |
| 644 | cause, | |
| 645 | }), | |
| 646 | ), | |
| 647 | ); | |
| 648 | return yield* new BitbucketResponseError({ | |
| 649 | operation, | |
| 650 | status: response.status, | |
| 651 | responseBodyLength: collected.text.length, | |
| 652 | retryAt, | |
| 653 | }); | |
| 654 | }); | |
| 655 | } | |
| 656 | ||
| 657 | /** @public Service construction is part of the canonical Effect module API. */ | |
| 658 | export const make = Effect.gen(function* () { | |
| 659 | const config = yield* BitbucketApiEnvConfig; | |
| 660 | const serverSettings = yield* ServerSettings.ServerSettingsService; | |
| 661 | const httpClient = yield* HttpClient.HttpClient; | |
| 662 | const fileSystem = yield* FileSystem.FileSystem; | |
| 663 | const git = yield* GitVcsDriver.GitVcsDriver; | |
| 664 | const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry; | |
| 665 | ||
| 666 | const apiUrl = (path: string) => `${config.baseUrl.replace(/\/+$/u, "")}${path}`; | |
| 667 | ||
| 668 | // Read on every request so credentials saved in settings apply without a restart. | |
| 669 | const currentCredential = serverSettings.getSettings.pipe( | |
| 670 | Effect.map((settings) => resolveCredential(settings.bitbucket, config)), | |
| 671 | Effect.catch((error) => | |
| 672 | // No cause: a settings decode error can quote a hand-edited token. | |
| 673 | Effect.logWarning("failed to read Bitbucket credentials from settings", { | |
| 674 | operation: error.operation, | |
| 675 | }).pipe(Effect.as(resolveCredential(DEFAULT_SERVER_SETTINGS.bitbucket, config))), | |
| 676 | ), | |
| 677 | ); | |
| 678 | ||
| 679 | const withAuth = (request: HttpClientRequest.HttpClientRequest) => | |
| 680 | currentCredential.pipe( | |
| 681 | Effect.map((credential) => | |
| 682 | credential === null | |
| 683 | ? request | |
| 684 | : credential.kind === "access-token" | |
| 685 | ? request.pipe(HttpClientRequest.bearerToken(credential.accessToken)) | |
| 686 | : request.pipe(HttpClientRequest.basicAuth(credential.email, credential.apiToken)), | |
| 687 | ), | |
| 688 | ); | |
| 689 | ||
| 690 | const decodeResponse = <S extends Schema.Top>( | |
| 691 | operation: BitbucketApiOperation, | |
| 692 | schema: S, | |
| 693 | response: HttpClientResponse.HttpClientResponse, | |
| 694 | ): Effect.Effect<S["Type"], BitbucketApiError, S["DecodingServices"]> => | |
| 695 | HttpClientResponse.matchStatus({ | |
| 696 | "2xx": (success) => | |
| 697 | HttpClientResponse.schemaBodyJson(schema)(success).pipe( | |
| 698 | Effect.mapError( | |
| 699 | (cause) => | |
| 700 | new BitbucketResponseDecodeError({ | |
| 701 | operation, | |
| 702 | status: success.status, | |
| 703 | cause, | |
| 704 | }), | |
| 705 | ), | |
| 706 | ), | |
| 707 | orElse: (failed) => responseError(operation, failed), | |
| 708 | })(response); | |
| 709 | ||
| 710 | const executeJson = <S extends Schema.Top>( | |
| 711 | operation: BitbucketApiOperation, | |
| 712 | request: HttpClientRequest.HttpClientRequest, | |
| 713 | schema: S, | |
| 714 | ): Effect.Effect<S["Type"], BitbucketApiError, S["DecodingServices"]> => | |
| 715 | withAuth(request.pipe(HttpClientRequest.acceptJson)).pipe( | |
| 716 | Effect.flatMap(httpClient.execute), | |
| 717 | Effect.mapError( | |
| 718 | (cause) => | |
| 719 | new BitbucketRequestError({ | |
| 720 | operation, | |
| 721 | cause, | |
| 722 | }), | |
| 723 | ), | |
| 724 | Effect.flatMap((response) => decodeResponse(operation, schema, response)), | |
| 725 | ); | |
| 726 | ||
| 727 | const resolveRepository = Effect.fn("BitbucketApi.resolveRepository")(function* (input: { | |
| 728 | readonly cwd: string; | |
| 729 | readonly context?: SourceControlProvider.SourceControlProviderContext; | |
| 730 | readonly repository?: string; | |
| 731 | }) { | |
| 732 | if (input.repository !== undefined) { | |
| 733 | return yield* requireRepositoryLocator(input.repository); | |
| 734 | } | |
| 735 | ||
| 736 | const fromContext = | |
| 737 | input.context?.provider.kind === "bitbucket" | |
| 738 | ? parseBitbucketRemoteUrl(input.context.remoteUrl) | |
| 739 | : null; | |
| 740 | if (fromContext) return fromContext; | |
| 741 | ||
| 742 | const handle = yield* vcsRegistry.resolve({ cwd: input.cwd }).pipe( | |
| 743 | Effect.mapError( | |
| 744 | (cause) => | |
| 745 | new BitbucketRepositoryVcsResolveError({ | |
| 746 | cwd: input.cwd, | |
| 747 | cause, | |
| 748 | }), | |
| 749 | ), | |
| 750 | ); | |
| 751 | const remotes = yield* handle.driver.listRemotes(input.cwd).pipe( | |
| 752 | Effect.mapError( | |
| 753 | (cause) => | |
| 754 | new BitbucketRepositoryRemotesListError({ | |
| 755 | cwd: input.cwd, | |
| 756 | cause, | |
| 757 | }), | |
| 758 | ), | |
| 759 | ); | |
| 760 | ||
| 761 | for (const remote of remotes.remotes) { | |
| 762 | if (detectSourceControlProviderFromRemoteUrl(remote.url)?.kind !== "bitbucket") continue; | |
| 763 | const parsed = parseBitbucketRemoteUrl(remote.url); | |
| 764 | if (parsed) return parsed; | |
| 765 | } | |
| 766 | ||
| 767 | return yield* new BitbucketRepositoryRemoteNotFoundError({ | |
| 768 | cwd: input.cwd, | |
| 769 | }); | |
| 770 | }); | |
| 771 | ||
| 772 | const getRepositoryFromLocator = (repository: BitbucketRepositoryLocator) => | |
| 773 | executeJson( | |
| 774 | "getRepository", | |
| 775 | HttpClientRequest.get( | |
| 776 | apiUrl( | |
| 777 | `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}`, | |
| 778 | ), | |
| 779 | ), | |
| 780 | RawBitbucketRepositorySchema, | |
| 781 | ); | |
| 782 | ||
| 783 | const getRepository = (input: { | |
| 784 | readonly cwd: string; | |
| 785 | readonly context?: SourceControlProvider.SourceControlProviderContext; | |
| 786 | readonly repository?: string; | |
| 787 | }) => resolveRepository(input).pipe(Effect.flatMap(getRepositoryFromLocator)); | |
| 788 | ||
| 789 | const getBranchingModelFromLocator = (repository: BitbucketRepositoryLocator) => | |
| 790 | executeJson( | |
| 791 | "getBranchingModel", | |
| 792 | HttpClientRequest.get( | |
| 793 | apiUrl( | |
| 794 | `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/branching-model`, | |
| 795 | ), | |
| 796 | ), | |
| 797 | RawBitbucketBranchingModelSchema, | |
| 798 | ); | |
| 799 | ||
| 800 | const getRawPullRequestFromRepository = ( | |
| 801 | repository: BitbucketRepositoryLocator, | |
| 802 | reference: string, | |
| 803 | ) => | |
| 804 | executeJson( | |
| 805 | "getPullRequest", | |
| 806 | HttpClientRequest.get( | |
| 807 | apiUrl( | |
| 808 | `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests/${encodeURIComponent(normalizeChangeRequestId(reference))}`, | |
| 809 | ), | |
| 810 | ), | |
| 811 | BitbucketPullRequestSchema, | |
| 812 | ); | |
| 813 | ||
| 814 | const getRawPullRequest = (input: { | |
| 815 | readonly cwd: string; | |
| 816 | readonly context?: SourceControlProvider.SourceControlProviderContext; | |
| 817 | readonly reference: string; | |
| 818 | }) => | |
| 819 | resolveRepository(input).pipe( | |
| 820 | Effect.flatMap((repository) => getRawPullRequestFromRepository(repository, input.reference)), | |
| 821 | ); | |
| 822 | ||
| 823 | const readConfigValueNullable = (cwd: string, key: string) => | |
| 824 | git.readConfigValue(cwd, key).pipe(Effect.orElseSucceed(() => null)); | |
| 825 | ||
| 826 | const resolveCheckoutRemote = Effect.fn("BitbucketApi.resolveCheckoutRemote")(function* (input: { | |
| 827 | readonly cwd: string; | |
| 828 | readonly context?: SourceControlProvider.SourceControlProviderContext; | |
| 829 | readonly destinationRepository: BitbucketRepositoryLocator; | |
| 830 | readonly sourceRepositoryName: string; | |
| 831 | readonly isCrossRepository: boolean; | |
| 832 | }) { | |
| 833 | if ( | |
| 834 | input.context?.provider.kind === "bitbucket" && | |
| 835 | !input.isCrossRepository && | |
| 836 | parseBitbucketRemoteUrl(input.context.remoteUrl) !== null | |
| 837 | ) { | |
| 838 | return input.context.remoteName; | |
| 839 | } | |
| 840 | ||
| 841 | if (!input.isCrossRepository) { | |
| 842 | const remoteName = yield* git | |
| 843 | .resolvePrimaryRemoteName(input.cwd) | |
| 844 | .pipe(Effect.orElseSucceed(() => null)); | |
| 845 | if (remoteName) return remoteName; | |
| 846 | } | |
| 847 | ||
| 848 | const cloneUrls = yield* getRepository({ | |
| 849 | cwd: input.cwd, | |
| 850 | repository: input.sourceRepositoryName, | |
| 851 | ...(input.context ? { context: input.context } : {}), | |
| 852 | }).pipe(Effect.map(normalizeRepositoryCloneUrls)); | |
| 853 | const originRemoteUrl = yield* readConfigValueNullable(input.cwd, "remote.origin.url"); | |
| 854 | return yield* git.ensureRemote({ | |
| 855 | cwd: input.cwd, | |
| 856 | preferredName: input.isCrossRepository | |
| 857 | ? repositoryOwnerName(input.sourceRepositoryName) | |
| 858 | : input.destinationRepository.workspace, | |
| 859 | url: selectCloneUrl({ cloneUrls, originRemoteUrl }), | |
| 860 | }); | |
| 861 | }); | |
| 862 | ||
| 863 | // A pull request's diff, diffstat and conflicts are served as redirects to a commit-range | |
| 864 | // URL, and the client does not follow redirects unless asked. The hop stays on the same host, | |
| 865 | // so the credentials travel with it. | |
| 866 | /** | |
| 867 | * The one host these credentials may be sent to. A url that came back inside a response — a | |
| 868 | * pagination cursor, or the target of a redirect — is data, not instruction, so it is checked | |
| 869 | * against this before the account's token travels with it. | |
| 870 | */ | |
| 871 | const apiOrigin = originOf(config.baseUrl); | |
| 872 | ||
| 873 | const trustedUrl = (value: string): string | null => { | |
| 874 | if (!/^https?:\/\//u.test(value)) return apiUrl(value); | |
| 875 | const origin = originOf(value); | |
| 876 | return origin !== null && origin === apiOrigin ? value : null; | |
| 877 | }; | |
| 878 | ||
| 879 | /** | |
| 880 | * Redirects are followed here rather than by the client, which forwards every header to | |
| 881 | * whatever host it is sent to. A pull request diff, diffstat and conflicts are all served as | |
| 882 | * redirects, so they have to be followed — but only back to the same Bitbucket. | |
| 883 | */ | |
| 884 | const send = (input: { | |
| 885 | readonly method: "GET" | "POST" | "PUT" | "DELETE"; | |
| 886 | readonly url: string; | |
| 887 | readonly body?: string; | |
| 888 | readonly redirects: number; | |
| 889 | }): Effect.Effect<HttpClientResponse.HttpClientResponse, BitbucketApiError> => { | |
| 890 | const url = trustedUrl(input.url); | |
| 891 | if (url === null) { | |
| 892 | return Effect.fail( | |
| 893 | new BitbucketUntrustedUrlError({ host: originOf(input.url) ?? "an unreadable url" }), | |
| 894 | ); | |
| 895 | } | |
| 896 | const base = | |
| 897 | input.method === "GET" | |
| 898 | ? HttpClientRequest.get(url) | |
| 899 | : input.method === "POST" | |
| 900 | ? HttpClientRequest.post(url) | |
| 901 | : input.method === "DELETE" | |
| 902 | ? HttpClientRequest.make("DELETE")(url) | |
| 903 | : HttpClientRequest.put(url); | |
| 904 | // No `Accept: application/json`: the diff endpoints answer with a patch, not JSON. | |
| 905 | const withBody = | |
| 906 | input.body === undefined | |
| 907 | ? base | |
| 908 | : base.pipe(HttpClientRequest.bodyText(input.body, "application/json")); | |
| 909 | return withAuth(withBody).pipe( | |
| 910 | Effect.flatMap(httpClient.execute), | |
| 911 | Effect.mapError( | |
| 912 | (cause): BitbucketApiError => new BitbucketRequestError({ operation: "request", cause }), | |
| 913 | ), | |
| 914 | Effect.flatMap((response) => { | |
| 915 | const location = response.headers.location; | |
| 916 | if ( | |
| 917 | response.status >= 300 && | |
| 918 | response.status < 400 && | |
| 919 | location !== undefined && | |
| 920 | input.redirects < MAX_REDIRECTS | |
| 921 | ) { | |
| 922 | return send({ | |
| 923 | ...input, | |
| 924 | url: new URL(location, url).toString(), | |
| 925 | redirects: input.redirects + 1, | |
| 926 | }); | |
| 927 | } | |
| 928 | return Effect.succeed(response); | |
| 929 | }), | |
| 930 | ); | |
| 931 | }; | |
| 932 | ||
| 933 | const request: BitbucketApi["Service"]["request"] = (input) => | |
| 934 | send({ ...input, redirects: 0 }).pipe( | |
| 935 | Effect.flatMap((response) => | |
| 936 | HttpClientResponse.matchStatus({ | |
| 937 | // Read through the body stream rather than `text`, so an oversized diff is stopped | |
| 938 | // as it arrives instead of being materialized whole and then cut. The same collector | |
| 939 | // the process runner bounds command output with. | |
| 940 | "2xx": (success) => | |
| 941 | collectUint8StreamText({ | |
| 942 | stream: success.stream, | |
| 943 | maxBytes: input.maxBytes ?? DEFAULT_MAX_RESPONSE_BYTES, | |
| 944 | }).pipe( | |
| 945 | Effect.mapError( | |
| 946 | (cause) => | |
| 947 | new BitbucketResponseBodyReadError({ | |
| 948 | operation: "request", | |
| 949 | status: success.status, | |
| 950 | cause, | |
| 951 | }), | |
| 952 | ), | |
| 953 | Effect.map((collected) => ({ | |
| 954 | body: collected.text, | |
| 955 | truncated: collected.truncated, | |
| 956 | })), | |
| 957 | ), | |
| 958 | orElse: (failed) => responseError("request", failed), | |
| 959 | })(response), | |
| 960 | ), | |
| 961 | ); | |
| 962 | ||
| 963 | return BitbucketApi.of({ | |
| 964 | request, | |
| 965 | probeAuth: executeJson( | |
| 966 | "probeAuth", | |
| 967 | HttpClientRequest.get(apiUrl("/user")), | |
| 968 | BitbucketUserSchema, | |
| 969 | ).pipe( | |
| 970 | Effect.map((user) => ({ | |
| 971 | status: "authenticated" as const, | |
| 972 | account: nonEmpty(user.username ?? user.display_name ?? user.account_id), | |
| 973 | host: Option.some("bitbucket.org"), | |
| 974 | detail: Option.none<string>(), | |
| 975 | })), | |
| 976 | Effect.catch(() => currentCredential.pipe(Effect.map(authFromCredential))), | |
| 977 | ), | |
| 978 | listPullRequests: (input) => | |
| 979 | resolveRepository(input).pipe( | |
| 980 | Effect.flatMap((repository) => { | |
| 981 | const states = toBitbucketStates(input.state); | |
| 982 | const query: Record<string, string | ReadonlyArray<string>> = { | |
| 983 | pagelen: String(Math.max(1, Math.min(input.limit ?? 20, 50))), | |
| 984 | sort: "-updated_on", | |
| 985 | q: bitbucketQueryString([ | |
| 986 | `source.branch.name = "${SourceControlProvider.sourceBranch(input).replaceAll('"', '\\"')}"`, | |
| 987 | bitbucketStateFilter(states), | |
| 988 | ]), | |
| 989 | state: states, | |
| 990 | }; | |
| 991 | ||
| 992 | return executeJson( | |
| 993 | "listPullRequests", | |
| 994 | HttpClientRequest.get( | |
| 995 | apiUrl( | |
| 996 | `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests`, | |
| 997 | ), | |
| 998 | { urlParams: query }, | |
| 999 | ), | |
| 1000 | BitbucketPullRequestListSchema, | |
| 1001 | ); | |
| 1002 | }), | |
| 1003 | Effect.map((list) => list.values.map(normalizeBitbucketPullRequestRecord)), | |
| 1004 | ), | |
| 1005 | getPullRequest: (input) => | |
| 1006 | getRawPullRequest(input).pipe(Effect.map(normalizeBitbucketPullRequestRecord)), | |
| 1007 | getRepositoryCloneUrls: (input) => | |
| 1008 | getRepository(input).pipe(Effect.map(normalizeRepositoryCloneUrls)), | |
| 1009 | createRepository: (input) => | |
| 1010 | requireRepositoryLocator(input.repository).pipe( | |
| 1011 | Effect.flatMap((repository) => | |
| 1012 | executeJson( | |
| 1013 | "createRepository", | |
| 1014 | HttpClientRequest.post( | |
| 1015 | apiUrl( | |
| 1016 | `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}`, | |
| 1017 | ), | |
| 1018 | ).pipe( | |
| 1019 | HttpClientRequest.bodyJsonUnsafe({ | |
| 1020 | scm: "git", | |
| 1021 | is_private: input.visibility === "private", | |
| 1022 | }), | |
| 1023 | ), | |
| 1024 | RawBitbucketRepositorySchema, | |
| 1025 | ), | |
| 1026 | ), | |
| 1027 | Effect.map(normalizeRepositoryCloneUrls), | |
| 1028 | ), | |
| 1029 | createPullRequest: (input) => | |
| 1030 | Effect.gen(function* () { | |
| 1031 | const repository = yield* resolveRepository(input); | |
| 1032 | const description = yield* fileSystem.readFileString(input.bodyFile).pipe( | |
| 1033 | Effect.mapError( | |
| 1034 | (cause) => | |
| 1035 | new BitbucketPullRequestBodyReadError({ | |
| 1036 | cwd: input.cwd, | |
| 1037 | bodyFile: input.bodyFile, | |
| 1038 | cause, | |
| 1039 | }), | |
| 1040 | ), | |
| 1041 | ); | |
| 1042 | const sourceOwner = sourceWorkspace(input); | |
| 1043 | const body = { | |
| 1044 | title: input.title, | |
| 1045 | description, | |
| 1046 | source: { | |
| 1047 | branch: { | |
| 1048 | name: SourceControlProvider.sourceBranch(input), | |
| 1049 | }, | |
| 1050 | ...(sourceOwner | |
| 1051 | ? { | |
| 1052 | repository: { | |
| 1053 | full_name: `${sourceOwner}/${input.source?.repository ?? repository.repoSlug}`, | |
| 1054 | }, | |
| 1055 | } | |
| 1056 | : {}), | |
| 1057 | }, | |
| 1058 | destination: { | |
| 1059 | branch: { | |
| 1060 | name: input.target?.refName ?? input.baseBranch, | |
| 1061 | }, | |
| 1062 | }, | |
| 1063 | }; | |
| 1064 | ||
| 1065 | yield* executeJson( | |
| 1066 | "createPullRequest", | |
| 1067 | HttpClientRequest.post( | |
| 1068 | apiUrl( | |
| 1069 | `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests`, | |
| 1070 | ), | |
| 1071 | ).pipe(HttpClientRequest.bodyJsonUnsafe(body)), | |
| 1072 | BitbucketPullRequestSchema, | |
| 1073 | ); | |
| 1074 | }), | |
| 1075 | getDefaultBranch: (input) => | |
| 1076 | resolveRepository(input).pipe( | |
| 1077 | Effect.flatMap((locator) => | |
| 1078 | Effect.all( | |
| 1079 | { | |
| 1080 | repository: getRepositoryFromLocator(locator), | |
| 1081 | branchingModel: getBranchingModelFromLocator(locator).pipe( | |
| 1082 | Effect.orElseSucceed( | |
| 1083 | (): typeof RawBitbucketBranchingModelSchema.Type | null => null, | |
| 1084 | ), | |
| 1085 | ), | |
| 1086 | }, | |
| 1087 | { concurrency: "unbounded" }, | |
| 1088 | ), | |
| 1089 | ), | |
| 1090 | Effect.map(defaultChangeRequestTargetBranch), | |
| 1091 | ), | |
| 1092 | // Bitbucket Cloud pull requests are Git-backed and Bitbucket does not provide | |
| 1093 | // an official checkout CLI. This provider-local path uses GitVcsDriver as a | |
| 1094 | // narrow escape hatch to materialize Bitbucket PR refs. Do not generalize this | |
| 1095 | // as the source-control provider model: if we support non-Git-compatible | |
| 1096 | // hosting providers or native JJ/Sapling checkout flows, move this into a | |
| 1097 | // VCS-specific change-request checkout capability. | |
| 1098 | checkoutPullRequest: (input) => | |
| 1099 | Effect.gen(function* () { | |
| 1100 | const destinationRepository = yield* resolveRepository(input); | |
| 1101 | const pullRequest = yield* getRawPullRequestFromRepository( | |
| 1102 | destinationRepository, | |
| 1103 | input.reference, | |
| 1104 | ); | |
| 1105 | const destinationRepositoryName = | |
| 1106 | repositoryNameWithOwner(pullRequest.destination.repository) ?? | |
| 1107 | `${destinationRepository.workspace}/${destinationRepository.repoSlug}`; | |
| 1108 | const sourceRepositoryName = | |
| 1109 | repositoryNameWithOwner(pullRequest.source.repository) ?? destinationRepositoryName; | |
| 1110 | const isCrossRepository = sourceRepositoryName !== destinationRepositoryName; | |
| 1111 | const remoteName = yield* resolveCheckoutRemote({ | |
| 1112 | cwd: input.cwd, | |
| 1113 | destinationRepository, | |
| 1114 | sourceRepositoryName, | |
| 1115 | isCrossRepository, | |
| 1116 | ...(input.context ? { context: input.context } : {}), | |
| 1117 | }); | |
| 1118 | const remoteBranch = pullRequest.source.branch.name; | |
| 1119 | const localBranch = checkoutBranchName({ | |
| 1120 | pullRequestId: pullRequest.id, | |
| 1121 | headBranch: remoteBranch, | |
| 1122 | isCrossRepository, | |
| 1123 | }); | |
| 1124 | const localBranchNames = yield* git.listLocalBranchNames(input.cwd); | |
| 1125 | const localBranchExists = localBranchNames.includes(localBranch); | |
| 1126 | ||
| 1127 | if (input.force === true || !localBranchExists) { | |
| 1128 | yield* git.fetchRemoteBranch({ | |
| 1129 | cwd: input.cwd, | |
| 1130 | remoteName, | |
| 1131 | remoteBranch, | |
| 1132 | localBranch, | |
| 1133 | }); | |
| 1134 | } else { | |
| 1135 | yield* git.fetchRemoteTrackingBranch({ | |
| 1136 | cwd: input.cwd, | |
| 1137 | remoteName, | |
| 1138 | remoteBranch, | |
| 1139 | }); | |
| 1140 | } | |
| 1141 | ||
| 1142 | yield* git.setBranchUpstream({ | |
| 1143 | cwd: input.cwd, | |
| 1144 | branch: localBranch, | |
| 1145 | remoteName, | |
| 1146 | remoteBranch, | |
| 1147 | }); | |
| 1148 | yield* Effect.scoped(git.switchRef({ cwd: input.cwd, refName: localBranch })); | |
| 1149 | }).pipe( | |
| 1150 | Effect.mapError((cause) => | |
| 1151 | isBitbucketApiError(cause) | |
| 1152 | ? cause | |
| 1153 | : new BitbucketCheckoutError({ | |
| 1154 | cwd: input.cwd, | |
| 1155 | reference: input.reference, | |
| 1156 | cause, | |
| 1157 | }), | |
| 1158 | ), | |
| 1159 | ), | |
| 1160 | }); | |
| 1161 | }); | |
| 1162 | ||
| 1163 | export const layer = Layer.effect(BitbucketApi, make); |
apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts deleted-151| ... | ... | @@ -1,151 +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 | ||
| 6 | import * as BitbucketApi from "./BitbucketApi.ts"; | |
| 7 | import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; | |
| 8 | ||
| 9 | function makeProvider(bitbucket: Partial<BitbucketApi.BitbucketApi["Service"]>) { | |
| 10 | return BitbucketSourceControlProvider.make.pipe( | |
| 11 | Effect.provide(Layer.mock(BitbucketApi.BitbucketApi)(bitbucket)), | |
| 12 | ); | |
| 13 | } | |
| 14 | ||
| 15 | it.effect("maps Bitbucket PR summaries into provider-neutral change requests", () => | |
| 16 | Effect.gen(function* () { | |
| 17 | const provider = yield* makeProvider({ | |
| 18 | getPullRequest: () => | |
| 19 | Effect.succeed({ | |
| 20 | number: 42, | |
| 21 | title: "Add Bitbucket provider", | |
| 22 | url: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42", | |
| 23 | baseRefName: "main", | |
| 24 | headRefName: "feature/source-control", | |
| 25 | state: "open", | |
| 26 | updatedAt: Option.none(), | |
| 27 | isCrossRepository: true, | |
| 28 | headRepositoryNameWithOwner: "fork/t3code", | |
| 29 | headRepositoryOwnerLogin: "fork", | |
| 30 | }), | |
| 31 | }); | |
| 32 | ||
| 33 | const changeRequest = yield* provider.getChangeRequest({ | |
| 34 | cwd: "/repo", | |
| 35 | reference: "42", | |
| 36 | }); | |
| 37 | ||
| 38 | assert.deepStrictEqual(changeRequest, { | |
| 39 | provider: "bitbucket", | |
| 40 | number: 42, | |
| 41 | title: "Add Bitbucket provider", | |
| 42 | url: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42", | |
| 43 | baseRefName: "main", | |
| 44 | headRefName: "feature/source-control", | |
| 45 | state: "open", | |
| 46 | updatedAt: Option.none(), | |
| 47 | isCrossRepository: true, | |
| 48 | headRepositoryNameWithOwner: "fork/t3code", | |
| 49 | headRepositoryOwnerLogin: "fork", | |
| 50 | }); | |
| 51 | }), | |
| 52 | ); | |
| 53 | ||
| 54 | it.effect("adds repository context while retaining Bitbucket API causes", () => | |
| 55 | Effect.gen(function* () { | |
| 56 | const upstreamCause = new Error("raw upstream failure"); | |
| 57 | const cause = new BitbucketApi.BitbucketRequestError({ | |
| 58 | operation: "getRepository", | |
| 59 | cause: upstreamCause, | |
| 60 | }); | |
| 61 | const provider = yield* makeProvider({ | |
| 62 | getRepositoryCloneUrls: () => Effect.fail(cause), | |
| 63 | }); | |
| 64 | ||
| 65 | const error = yield* provider | |
| 66 | .getRepositoryCloneUrls({ cwd: "/repo", repository: "owner/repo" }) | |
| 67 | .pipe(Effect.flip); | |
| 68 | ||
| 69 | assert.deepStrictEqual( | |
| 70 | { | |
| 71 | provider: error.provider, | |
| 72 | operation: error.operation, | |
| 73 | command: error.command, | |
| 74 | cwd: error.cwd, | |
| 75 | repository: error.repository, | |
| 76 | detail: error.detail, | |
| 77 | }, | |
| 78 | { | |
| 79 | provider: "bitbucket", | |
| 80 | operation: "getRepositoryCloneUrls", | |
| 81 | command: undefined, | |
| 82 | cwd: "/repo", | |
| 83 | repository: "owner/repo", | |
| 84 | detail: "Failed to get repository clone URLs.", | |
| 85 | }, | |
| 86 | ); | |
| 87 | assert.strictEqual(error.cause, cause); | |
| 88 | assert.equal(error.message.includes(upstreamCause.message), false); | |
| 89 | }), | |
| 90 | ); | |
| 91 | ||
| 92 | it.effect("lists Bitbucket PRs through provider-neutral input names", () => | |
| 93 | Effect.gen(function* () { | |
| 94 | let listInput: Parameters<BitbucketApi.BitbucketApi["Service"]["listPullRequests"]>[0] | null = | |
| 95 | null; | |
| 96 | const provider = yield* makeProvider({ | |
| 97 | listPullRequests: (input) => { | |
| 98 | listInput = input; | |
| 99 | return Effect.succeed([]); | |
| 100 | }, | |
| 101 | }); | |
| 102 | ||
| 103 | yield* provider.listChangeRequests({ | |
| 104 | cwd: "/repo", | |
| 105 | headSelector: "feature/provider", | |
| 106 | state: "all", | |
| 107 | limit: 10, | |
| 108 | }); | |
| 109 | ||
| 110 | assert.deepStrictEqual(listInput, { | |
| 111 | cwd: "/repo", | |
| 112 | headSelector: "feature/provider", | |
| 113 | state: "all", | |
| 114 | limit: 10, | |
| 115 | }); | |
| 116 | }), | |
| 117 | ); | |
| 118 | ||
| 119 | it.effect("creates Bitbucket PRs through provider-neutral input names", () => | |
| 120 | Effect.gen(function* () { | |
| 121 | let createInput: | |
| 122 | | Parameters<BitbucketApi.BitbucketApi["Service"]["createPullRequest"]>[0] | |
| 123 | | null = null; | |
| 124 | const provider = yield* makeProvider({ | |
| 125 | createPullRequest: (input) => { | |
| 126 | createInput = input; | |
| 127 | return Effect.void; | |
| 128 | }, | |
| 129 | }); | |
| 130 | ||
| 131 | yield* provider.createChangeRequest({ | |
| 132 | cwd: "/repo", | |
| 133 | baseRefName: "main", | |
| 134 | headSelector: "owner:feature/provider", | |
| 135 | title: "Provider PR", | |
| 136 | bodyFile: "/tmp/body.md", | |
| 137 | }); | |
| 138 | ||
| 139 | assert.deepStrictEqual(createInput, { | |
| 140 | cwd: "/repo", | |
| 141 | baseBranch: "main", | |
| 142 | headSelector: "owner:feature/provider", | |
| 143 | source: { | |
| 144 | owner: "owner", | |
| 145 | refName: "feature/provider", | |
| 146 | }, | |
| 147 | title: "Provider PR", | |
| 148 | bodyFile: "/tmp/body.md", | |
| 149 | }); | |
| 150 | }), | |
| 151 | ); |
apps/server/src/sourceControl/BitbucketSourceControlProvider.ts deleted-198| ... | ... | @@ -1,198 +0,0 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import * as Option from "effect/Option"; | |
| 3 | import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; | |
| 4 | ||
| 5 | import * as BitbucketApi from "./BitbucketApi.ts"; | |
| 6 | import type { NormalizedBitbucketPullRequestRecord } from "./bitbucketPullRequests.ts"; | |
| 7 | import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; | |
| 8 | import type { SourceControlApiDiscoverySpec } from "@t3tools/source-control-core/server/discovery"; | |
| 9 | ||
| 10 | function toChangeRequest(summary: NormalizedBitbucketPullRequestRecord): ChangeRequest { | |
| 11 | return { | |
| 12 | provider: "bitbucket", | |
| 13 | number: summary.number, | |
| 14 | title: summary.title, | |
| 15 | url: summary.url, | |
| 16 | baseRefName: summary.baseRefName, | |
| 17 | headRefName: summary.headRefName, | |
| 18 | state: summary.state, | |
| 19 | ...(summary.isDraft === true ? { isDraft: true } : {}), | |
| 20 | updatedAt: summary.updatedAt ?? Option.none(), | |
| 21 | ...(summary.isCrossRepository !== undefined | |
| 22 | ? { isCrossRepository: summary.isCrossRepository } | |
| 23 | : {}), | |
| 24 | ...(summary.headRepositoryNameWithOwner !== undefined | |
| 25 | ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner } | |
| 26 | : {}), | |
| 27 | ...(summary.headRepositoryOwnerLogin !== undefined | |
| 28 | ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin } | |
| 29 | : {}), | |
| 30 | }; | |
| 31 | } | |
| 32 | ||
| 33 | export const make = Effect.gen(function* () { | |
| 34 | const bitbucket = yield* BitbucketApi.BitbucketApi; | |
| 35 | ||
| 36 | return SourceControlProvider.SourceControlProvider.of({ | |
| 37 | kind: "bitbucket", | |
| 38 | listChangeRequests: (input) => { | |
| 39 | const source = SourceControlProvider.sourceControlRefFromInput(input); | |
| 40 | return bitbucket | |
| 41 | .listPullRequests({ | |
| 42 | cwd: input.cwd, | |
| 43 | ...(input.context ? { context: input.context } : {}), | |
| 44 | headSelector: input.headSelector, | |
| 45 | ...(source ? { source } : {}), | |
| 46 | state: input.state, | |
| 47 | ...(input.limit !== undefined ? { limit: input.limit } : {}), | |
| 48 | }) | |
| 49 | .pipe( | |
| 50 | Effect.map((items) => items.map(toChangeRequest)), | |
| 51 | Effect.mapError( | |
| 52 | (error) => | |
| 53 | new SourceControlProviderError({ | |
| 54 | provider: "bitbucket", | |
| 55 | operation: "listChangeRequests", | |
| 56 | cwd: input.cwd, | |
| 57 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | |
| 58 | input.headSelector, | |
| 59 | ), | |
| 60 | detail: "Failed to list change requests.", | |
| 61 | cause: error, | |
| 62 | }), | |
| 63 | ), | |
| 64 | ); | |
| 65 | }, | |
| 66 | getChangeRequest: (input) => | |
| 67 | bitbucket.getPullRequest(input).pipe( | |
| 68 | Effect.map(toChangeRequest), | |
| 69 | Effect.mapError( | |
| 70 | (error) => | |
| 71 | new SourceControlProviderError({ | |
| 72 | provider: "bitbucket", | |
| 73 | operation: "getChangeRequest", | |
| 74 | cwd: input.cwd, | |
| 75 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | |
| 76 | input.reference, | |
| 77 | ), | |
| 78 | detail: "Failed to get change request.", | |
| 79 | cause: error, | |
| 80 | }), | |
| 81 | ), | |
| 82 | ), | |
| 83 | createChangeRequest: (input) => { | |
| 84 | const source = SourceControlProvider.sourceControlRefFromInput(input); | |
| 85 | return bitbucket | |
| 86 | .createPullRequest({ | |
| 87 | cwd: input.cwd, | |
| 88 | ...(input.context ? { context: input.context } : {}), | |
| 89 | baseBranch: input.baseRefName, | |
| 90 | headSelector: input.headSelector, | |
| 91 | ...(source ? { source } : {}), | |
| 92 | ...(input.target ? { target: input.target } : {}), | |
| 93 | title: input.title, | |
| 94 | bodyFile: input.bodyFile, | |
| 95 | }) | |
| 96 | .pipe( | |
| 97 | Effect.mapError( | |
| 98 | (error) => | |
| 99 | new SourceControlProviderError({ | |
| 100 | provider: "bitbucket", | |
| 101 | operation: "createChangeRequest", | |
| 102 | cwd: input.cwd, | |
| 103 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | |
| 104 | input.headSelector, | |
| 105 | ), | |
| 106 | detail: "Failed to create change request.", | |
| 107 | cause: error, | |
| 108 | }), | |
| 109 | ), | |
| 110 | ); | |
| 111 | }, | |
| 112 | getRepositoryCloneUrls: (input) => | |
| 113 | bitbucket.getRepositoryCloneUrls(input).pipe( | |
| 114 | Effect.mapError( | |
| 115 | (error) => | |
| 116 | new SourceControlProviderError({ | |
| 117 | provider: "bitbucket", | |
| 118 | operation: "getRepositoryCloneUrls", | |
| 119 | cwd: input.cwd, | |
| 120 | repository: SourceControlProvider.transportSafeSourceControlErrorValue( | |
| 121 | input.repository, | |
| 122 | ), | |
| 123 | detail: "Failed to get repository clone URLs.", | |
| 124 | cause: error, | |
| 125 | }), | |
| 126 | ), | |
| 127 | ), | |
| 128 | createRepository: (input) => | |
| 129 | bitbucket.createRepository(input).pipe( | |
| 130 | Effect.mapError( | |
| 131 | (error) => | |
| 132 | new SourceControlProviderError({ | |
| 133 | provider: "bitbucket", | |
| 134 | operation: "createRepository", | |
| 135 | cwd: input.cwd, | |
| 136 | repository: SourceControlProvider.transportSafeSourceControlErrorValue( | |
| 137 | input.repository, | |
| 138 | ), | |
| 139 | detail: "Failed to create repository.", | |
| 140 | cause: error, | |
| 141 | }), | |
| 142 | ), | |
| 143 | ), | |
| 144 | getDefaultBranch: (input) => | |
| 145 | bitbucket | |
| 146 | .getDefaultBranch({ | |
| 147 | cwd: input.cwd, | |
| 148 | ...(input.context ? { context: input.context } : {}), | |
| 149 | }) | |
| 150 | .pipe( | |
| 151 | Effect.mapError( | |
| 152 | (error) => | |
| 153 | new SourceControlProviderError({ | |
| 154 | provider: "bitbucket", | |
| 155 | operation: "getDefaultBranch", | |
| 156 | cwd: input.cwd, | |
| 157 | detail: "Failed to get default branch.", | |
| 158 | cause: error, | |
| 159 | }), | |
| 160 | ), | |
| 161 | ), | |
| 162 | checkoutChangeRequest: (input) => | |
| 163 | bitbucket | |
| 164 | .checkoutPullRequest({ | |
| 165 | cwd: input.cwd, | |
| 166 | ...(input.context ? { context: input.context } : {}), | |
| 167 | reference: input.reference, | |
| 168 | ...(input.force !== undefined ? { force: input.force } : {}), | |
| 169 | }) | |
| 170 | .pipe( | |
| 171 | Effect.mapError( | |
| 172 | (error) => | |
| 173 | new SourceControlProviderError({ | |
| 174 | provider: "bitbucket", | |
| 175 | operation: "checkoutChangeRequest", | |
| 176 | cwd: input.cwd, | |
| 177 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | |
| 178 | input.reference, | |
| 179 | ), | |
| 180 | detail: "Failed to check out change request.", | |
| 181 | cause: error, | |
| 182 | }), | |
| 183 | ), | |
| 184 | ), | |
| 185 | }); | |
| 186 | }); | |
| 187 | ||
| 188 | export const makeDiscovery = Effect.gen(function* () { | |
| 189 | const bitbucket = yield* BitbucketApi.BitbucketApi; | |
| 190 | ||
| 191 | return { | |
| 192 | type: "api", | |
| 193 | kind: "bitbucket", | |
| 194 | label: "Bitbucket", | |
| 195 | installHint: "Add a Bitbucket token in Settings → Source Control.", | |
| 196 | probeAuth: bitbucket.probeAuth, | |
| 197 | } satisfies SourceControlApiDiscoverySpec; | |
| 198 | }); |
apps/server/src/sourceControl/ServerSourceControlHost.ts+16| ... | ... | @@ -9,6 +9,8 @@ import * as Effect from "effect/Effect"; |
| 9 | 9 | import * as Layer from "effect/Layer"; |
| 10 | 10 | |
| 11 | 11 | import * as ServerSettings from "../serverSettings.ts"; |
| 12 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | |
| 13 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; | |
| 12 | 14 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 13 | 15 | |
| 14 | 16 | export const layer = Layer.effect( |
| ... | ... | @@ -16,9 +18,23 @@ export const layer = Layer.effect( |
| 16 | 18 | Effect.gen(function* () { |
| 17 | 19 | const serverSettings = yield* ServerSettings.ServerSettingsService; |
| 18 | 20 | const process = yield* VcsProcess.VcsProcess; |
| 21 | const git = yield* GitVcsDriver.GitVcsDriver; | |
| 22 | const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry; | |
| 19 | 23 | return SourceControlHost.SourceControlHost.of({ |
| 20 | 24 | settings: { get: serverSettings.getSettings }, |
| 21 | 25 | process: { run: process.run }, |
| 26 | git: { | |
| 27 | remotes: (cwd) => | |
| 28 | vcsRegistry.resolve({ cwd }).pipe(Effect.map((handle) => handle.driver.listRemotes(cwd))), | |
| 29 | readConfigValue: git.readConfigValue, | |
| 30 | resolvePrimaryRemoteName: git.resolvePrimaryRemoteName, | |
| 31 | ensureRemote: git.ensureRemote, | |
| 32 | listLocalBranchNames: git.listLocalBranchNames, | |
| 33 | fetchRemoteBranch: git.fetchRemoteBranch, | |
| 34 | fetchRemoteTrackingBranch: git.fetchRemoteTrackingBranch, | |
| 35 | setBranchUpstream: git.setBranchUpstream, | |
| 36 | switchRef: git.switchRef, | |
| 37 | }, | |
| 22 | 38 | }); |
| 23 | 39 | }), |
| 24 | 40 | ); |
apps/server/src/sourceControl/SourceControlDiscovery.test.ts+9-2| ... | ... | @@ -18,7 +18,8 @@ import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; |
| 18 | 18 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 19 | 19 | import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli"; |
| 20 | 20 | import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli"; |
| 21 | import * as BitbucketApi from "./BitbucketApi.ts"; | |
| 21 | import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; | |
| 22 | import * as BitbucketPullRequestApi from "@t3tools/source-control-bitbucket/server/BitbucketPullRequestApi"; | |
| 22 | 23 | import * as GitHubApi from "./GitHubApi.ts"; |
| 23 | 24 | import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; |
| 24 | 25 | import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; |
| ... | ... | @@ -43,6 +44,7 @@ const layerSourceControlProviderRegistryTest = (input: { |
| 43 | 44 | Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), |
| 44 | 45 | Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({}), |
| 45 | 46 | Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket), |
| 47 | Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({}), | |
| 46 | 48 | ServerSettings.ServerSettingsService.layerTest(), |
| 47 | 49 | Layer.mock(GitHubApi.GitHubApi)({}), |
| 48 | 50 | Layer.mock(GitVcsDriver.GitVcsDriver)({}), |
| ... | ... | @@ -53,9 +55,11 @@ const layerSourceControlProviderRegistryTest = (input: { |
| 53 | 55 | Layer.mock(VcsProcess.VcsProcess)(input.process), |
| 54 | 56 | ServerSourceControlHost.layer.pipe( |
| 55 | 57 | Layer.provide( |
| 56 | Layer.merge( | |
| 58 | Layer.mergeAll( | |
| 57 | 59 | Layer.mock(VcsProcess.VcsProcess)(input.process), |
| 58 | 60 | ServerSettings.ServerSettingsService.layerTest(), |
| 61 | Layer.mock(GitVcsDriver.GitVcsDriver)({}), | |
| 62 | Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}), | |
| 59 | 63 | ), |
| 60 | 64 | ), |
| 61 | 65 | ), |
| ... | ... | @@ -395,6 +399,7 @@ it.effect( |
| 395 | 399 | SourceControlHost.SourceControlHost, |
| 396 | 400 | SourceControlHost.SourceControlHost.of({ |
| 397 | 401 | settings: host.settings, |
| 402 | git: host.git, | |
| 398 | 403 | process: { |
| 399 | 404 | run: (input) => { |
| 400 | 405 | if (input.args[0] !== "fetch") return git.run(input); |
| ... | ... | @@ -529,6 +534,8 @@ it.effect( |
| 529 | 534 | ServerSourceControlHost.layer.pipe( |
| 530 | 535 | Layer.provideMerge(VcsProcess.layer), |
| 531 | 536 | Layer.provideMerge(ServerSettings.ServerSettingsService.layerTest()), |
| 537 | Layer.provideMerge(Layer.mock(GitVcsDriver.GitVcsDriver)({})), | |
| 538 | Layer.provideMerge(Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({})), | |
| 532 | 539 | Layer.provideMerge(NodeServices.layer), |
| 533 | 540 | ), |
| 534 | 541 | ), |
apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts+9-2| ... | ... | @@ -15,7 +15,8 @@ import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; |
| 15 | 15 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 16 | 16 | import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli"; |
| 17 | 17 | import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli"; |
| 18 | import * as BitbucketApi from "./BitbucketApi.ts"; | |
| 18 | import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; | |
| 19 | import * as BitbucketPullRequestApi from "@t3tools/source-control-bitbucket/server/BitbucketPullRequestApi"; | |
| 19 | 20 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; |
| 20 | 21 | import * as GitHubApi from "./GitHubApi.ts"; |
| 21 | 22 | import * as GitHubCredentials from "./GitHubCredentials.ts"; |
| ... | ... | @@ -101,12 +102,18 @@ function makeRegistry(input: { |
| 101 | 102 | layerProcess, |
| 102 | 103 | ServerSourceControlHost.layer.pipe( |
| 103 | 104 | Layer.provide( |
| 104 | Layer.merge(layerProcess, ServerSettings.ServerSettingsService.layerTest()), | |
| 105 | Layer.mergeAll( | |
| 106 | layerProcess, | |
| 107 | layerRegistry, | |
| 108 | ServerSettings.ServerSettingsService.layerTest(), | |
| 109 | Layer.mock(GitVcsDriver.GitVcsDriver)({}), | |
| 110 | ), | |
| 105 | 111 | ), |
| 106 | 112 | ), |
| 107 | 113 | Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), |
| 108 | 114 | Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({}), |
| 109 | 115 | Layer.mock(BitbucketApi.BitbucketApi)({}), |
| 116 | Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({}), | |
| 110 | 117 | ServerSettings.ServerSettingsService.layerTest(), |
| 111 | 118 | Layer.mock(GitHubApi.GitHubApi)({ |
| 112 | 119 | // No GitHub credential unless a test supplies one, so custom hosts stay unclaimed. |
apps/server/src/sourceControl/SourceControlProviderRegistry.ts-8| ... | ... | @@ -11,7 +11,6 @@ import { |
| 11 | 11 | import type { SourceControlProviderKind } from "@t3tools/contracts"; |
| 12 | 12 | import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; |
| 13 | 13 | |
| 14 | import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; | |
| 15 | 14 | import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; |
| 16 | 15 | import * as BuiltInDrivers from "./builtInDrivers.ts"; |
| 17 | 16 | import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; |
| ... | ... | @@ -305,8 +304,6 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit |
| 305 | 304 | export const make = Effect.gen(function* () { |
| 306 | 305 | const github = yield* GitHubSourceControlProvider.make; |
| 307 | 306 | const githubDiscovery = yield* GitHubSourceControlProvider.makeDiscovery; |
| 308 | const bitbucket = yield* BitbucketSourceControlProvider.make; | |
| 309 | const bitbucketDiscovery = yield* BitbucketSourceControlProvider.makeDiscovery; | |
| 310 | 307 | const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) => |
| 311 | 308 | driver.make.pipe( |
| 312 | 309 | Effect.map((instance): SourceControlProviderRegistration => ({ |
| ... | ... | @@ -324,11 +321,6 @@ export const make = Effect.gen(function* () { |
| 324 | 321 | provider: github, |
| 325 | 322 | discovery: githubDiscovery, |
| 326 | 323 | }, |
| 327 | { | |
| 328 | kind: "bitbucket", | |
| 329 | provider: bitbucket, | |
| 330 | discovery: bitbucketDiscovery, | |
| 331 | }, | |
| 332 | 324 | ...drivers, |
| 333 | 325 | ]; |
| 334 | 326 | return yield* makeWithProviders( |
apps/server/src/sourceControl/SourceControlRepositoryService.test.ts+2-2| ... | ... | @@ -13,8 +13,8 @@ import { GitCommandError, SourceControlProviderError } from "@t3tools/contracts" |
| 13 | 13 | |
| 14 | 14 | import * as ServerConfig from "../config.ts"; |
| 15 | 15 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; |
| 16 | import * as BitbucketApi from "./BitbucketApi.ts"; | |
| 17 | import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; | |
| 16 | import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; | |
| 17 | import * as BitbucketSourceControlProvider from "@t3tools/source-control-bitbucket/server/BitbucketSourceControlProvider"; | |
| 18 | 18 | import type * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; |
| 19 | 19 | import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; |
| 20 | 20 | import * as SourceControlRepositoryService from "./SourceControlRepositoryService.ts"; |
apps/server/src/sourceControl/SourceControlRepositoryService.ts+1-1| ... | ... | @@ -27,7 +27,7 @@ import { |
| 27 | 27 | type GitCloneProgressLine, |
| 28 | 28 | } from "../project/gitCloneProgress.ts"; |
| 29 | 29 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; |
| 30 | import * as BitbucketApi from "./BitbucketApi.ts"; | |
| 30 | import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; | |
| 31 | 31 | import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; |
| 32 | 32 | const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError); |
| 33 | 33 | const isSourceControlProviderError = Schema.is(SourceControlProviderError); |
apps/server/src/sourceControl/bitbucketPullRequests.ts deleted-109| ... | ... | @@ -1,109 +0,0 @@ |
| 1 | import * as DateTime from "effect/DateTime"; | |
| 2 | import * as Option from "effect/Option"; | |
| 3 | import * as Schema from "effect/Schema"; | |
| 4 | import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; | |
| 5 | ||
| 6 | export interface NormalizedBitbucketPullRequestRecord { | |
| 7 | readonly number: number; | |
| 8 | readonly title: string; | |
| 9 | readonly url: string; | |
| 10 | readonly baseRefName: string; | |
| 11 | readonly headRefName: string; | |
| 12 | readonly state: "open" | "closed" | "merged"; | |
| 13 | readonly isDraft?: boolean; | |
| 14 | readonly updatedAt: Option.Option<DateTime.Utc>; | |
| 15 | readonly isCrossRepository?: boolean; | |
| 16 | readonly headRepositoryNameWithOwner?: string | null; | |
| 17 | readonly headRepositoryOwnerLogin?: string | null; | |
| 18 | } | |
| 19 | ||
| 20 | export const BitbucketRepositoryRefSchema = Schema.Struct({ | |
| 21 | full_name: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), | |
| 22 | workspace: Schema.optional( | |
| 23 | Schema.NullOr( | |
| 24 | Schema.Struct({ | |
| 25 | slug: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), | |
| 26 | }), | |
| 27 | ), | |
| 28 | ), | |
| 29 | }); | |
| 30 | ||
| 31 | export const BitbucketPullRequestBranchSchema = Schema.Struct({ | |
| 32 | repository: Schema.optional(Schema.NullOr(BitbucketRepositoryRefSchema)), | |
| 33 | branch: Schema.Struct({ | |
| 34 | name: TrimmedNonEmptyString, | |
| 35 | }), | |
| 36 | }); | |
| 37 | ||
| 38 | export const BitbucketPullRequestSchema = Schema.Struct({ | |
| 39 | id: PositiveInt, | |
| 40 | title: TrimmedNonEmptyString, | |
| 41 | state: Schema.optional(Schema.NullOr(Schema.String)), | |
| 42 | draft: Schema.optional(Schema.Boolean), | |
| 43 | updated_on: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), | |
| 44 | links: Schema.Struct({ | |
| 45 | html: Schema.Struct({ | |
| 46 | href: TrimmedNonEmptyString, | |
| 47 | }), | |
| 48 | }), | |
| 49 | source: BitbucketPullRequestBranchSchema, | |
| 50 | destination: BitbucketPullRequestBranchSchema, | |
| 51 | }); | |
| 52 | ||
| 53 | export const BitbucketPullRequestListSchema = Schema.Struct({ | |
| 54 | values: Schema.Array(BitbucketPullRequestSchema), | |
| 55 | next: Schema.optional(TrimmedNonEmptyString), | |
| 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 repositoryOwner(repository: Schema.Schema.Type<typeof BitbucketRepositoryRefSchema>) { | |
| 64 | return ( | |
| 65 | trimOptionalString(repository.workspace?.slug) ?? | |
| 66 | (repository.full_name?.includes("/") ? (repository.full_name.split("/")[0] ?? null) : null) | |
| 67 | ); | |
| 68 | } | |
| 69 | ||
| 70 | function normalizeBitbucketPullRequestState(state: string | null | undefined) { | |
| 71 | switch (state?.trim().toUpperCase()) { | |
| 72 | case "MERGED": | |
| 73 | return "merged" as const; | |
| 74 | case "DECLINED": | |
| 75 | case "SUPERSEDED": | |
| 76 | return "closed" as const; | |
| 77 | case "OPEN": | |
| 78 | default: | |
| 79 | return "open" as const; | |
| 80 | } | |
| 81 | } | |
| 82 | ||
| 83 | export function normalizeBitbucketPullRequestRecord( | |
| 84 | raw: Schema.Schema.Type<typeof BitbucketPullRequestSchema>, | |
| 85 | ): NormalizedBitbucketPullRequestRecord { | |
| 86 | const headRepositoryNameWithOwner = trimOptionalString(raw.source.repository?.full_name); | |
| 87 | const baseRepositoryNameWithOwner = trimOptionalString(raw.destination.repository?.full_name); | |
| 88 | const headRepositoryOwnerLogin = raw.source.repository | |
| 89 | ? repositoryOwner(raw.source.repository) | |
| 90 | : null; | |
| 91 | const isCrossRepository = | |
| 92 | headRepositoryNameWithOwner !== null && | |
| 93 | baseRepositoryNameWithOwner !== null && | |
| 94 | headRepositoryNameWithOwner !== baseRepositoryNameWithOwner; | |
| 95 | ||
| 96 | return { | |
| 97 | number: raw.id, | |
| 98 | title: raw.title, | |
| 99 | url: raw.links.html.href, | |
| 100 | baseRefName: raw.destination.branch.name, | |
| 101 | headRefName: raw.source.branch.name, | |
| 102 | state: normalizeBitbucketPullRequestState(raw.state), | |
| 103 | ...(raw.draft === true ? { isDraft: true } : {}), | |
| 104 | updatedAt: raw.updated_on ?? Option.none(), | |
| 105 | ...(isCrossRepository ? { isCrossRepository: true } : {}), | |
| 106 | ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}), | |
| 107 | ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}), | |
| 108 | }; | |
| 109 | } |
apps/server/src/sourceControl/builtInDrivers.ts+10-1| ... | ... | @@ -10,6 +10,9 @@ |
| 10 | 10 | import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli"; |
| 11 | 11 | import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli"; |
| 12 | 12 | import * as AzureDevOpsDriver from "@t3tools/source-control-azure-devops/server/driver"; |
| 13 | import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; | |
| 14 | import * as BitbucketPullRequestApi from "@t3tools/source-control-bitbucket/server/BitbucketPullRequestApi"; | |
| 15 | import * as BitbucketDriver from "@t3tools/source-control-bitbucket/server/driver"; | |
| 13 | 16 | import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; |
| 14 | 17 | import * as ForgejoDriver from "@t3tools/source-control-forgejo/server/driver"; |
| 15 | 18 | import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; |
| ... | ... | @@ -20,7 +23,12 @@ import * as Layer from "effect/Layer"; |
| 20 | 23 | |
| 21 | 24 | import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; |
| 22 | 25 | |
| 23 | const drivers = [GitLabDriver.driver, AzureDevOpsDriver.driver, ForgejoDriver.driver]; | |
| 26 | const drivers = [ | |
| 27 | GitLabDriver.driver, | |
| 28 | AzureDevOpsDriver.driver, | |
| 29 | BitbucketDriver.driver, | |
| 30 | ForgejoDriver.driver, | |
| 31 | ]; | |
| 24 | 32 | |
| 25 | 33 | /** Every service a built-in driver's `make` needs; the server's layers must provide them all. */ |
| 26 | 34 | export type BuiltInSourceControlDriversEnv = |
| ... | ... | @@ -33,6 +41,7 @@ export const BUILT_IN_SOURCE_CONTROL_DRIVERS: ReadonlyArray< |
| 33 | 41 | /** The services the built-in drivers' packages own, plus the host port they all run against. */ |
| 34 | 42 | export const layer = Layer.mergeAll( |
| 35 | 43 | AzureDevOpsPullRequestCli.layer.pipe(Layer.provideMerge(AzureDevOpsCli.layer)), |
| 44 | BitbucketPullRequestApi.layer.pipe(Layer.provideMerge(BitbucketApi.layer)), | |
| 36 | 45 | ForgejoCli.layer, |
| 37 | 46 | GitLabPullRequestCli.layer.pipe(Layer.provideMerge(GitLabCli.layer)), |
| 38 | 47 | ).pipe(Layer.provideMerge(ServerSourceControlHost.layer)); |
apps/server/src/ws.ts-2| ... | ... | @@ -229,7 +229,6 @@ import * as SqlClient from "effect/sql/SqlClient"; |
| 229 | 229 | import * as PullRequestSyncReactor from "./orchestration-v2/PullRequestSyncReactor.ts"; |
| 230 | 230 | import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; |
| 231 | 231 | import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; |
| 232 | import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; | |
| 233 | 232 | import * as GitHubApi from "./sourceControl/GitHubApi.ts"; |
| 234 | 233 | import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts"; |
| 235 | 234 | import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; |
| ... | ... | @@ -3202,7 +3201,6 @@ export const layer = Layer.unwrap( |
| 3202 | 3201 | SourceControlProviderRegistry.layer.pipe( |
| 3203 | 3202 | Layer.provide( |
| 3204 | 3203 | Layer.mergeAll( |
| 3205 | BitbucketApi.layer, | |
| 3206 | 3204 | GitHubApi.layerWithDependencies, |
| 3207 | 3205 | SourceControlBuiltInDrivers.layer, |
| 3208 | 3206 | ), |
docs/internals/pull-request-file-revisions.md+1-1| ... | ... | @@ -27,7 +27,7 @@ What "never got to look at" is belongs to the host, and the contract cannot know |
| 27 | 27 | - [Azure DevOps](../../packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts) reads every |
| 28 | 28 | version off one iteration listing, so a change too long to follow to its end leaves the paths |
| 29 | 29 | past that point out. |
| 30 | - [Bitbucket](../../apps/server/src/pullRequest/BitbucketPullRequestApi.ts) reads them off the | |
| 30 | - [Bitbucket](../../packages/source-control-bitbucket/src/server/BitbucketPullRequestApi.ts) reads them off the | |
| 31 | 31 | pull request's own patch, the only place it states a file's version, so a patch cut short at the |
| 32 | 32 | byte ceiling leaves the paths past the cut out. |
| 33 | 33 | - [GitLab](../../packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts) asks in batches, so a batch |
package.json+1-1| ... | ... | @@ -28,7 +28,7 @@ |
| 28 | 28 | "tc": "vp run -r --concurrency-limit 2 typecheck", |
| 29 | 29 | "lint": "vp lint --report-unused-disable-directives", |
| 30 | 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-gitlab --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-bitbucket --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 | 32 | "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", |
| 33 | 33 | "lint:mobile": "node scripts/mobile-native-static-check.ts", |
| 34 | 34 | "test": "vp run -r test", |
packages/source-control-bitbucket/package.json created+51| ... | ... | @@ -0,0 +1,51 @@ |
| 1 | { | |
| 2 | "name": "@t3tools/source-control-bitbucket", | |
| 3 | "private": true, | |
| 4 | "type": "module", | |
| 5 | "exports": { | |
| 6 | "./server/BitbucketApi": { | |
| 7 | "types": "./src/server/BitbucketApi.ts", | |
| 8 | "import": "./src/server/BitbucketApi.ts" | |
| 9 | }, | |
| 10 | "./server/BitbucketPullRequestApi": { | |
| 11 | "types": "./src/server/BitbucketPullRequestApi.ts", | |
| 12 | "import": "./src/server/BitbucketPullRequestApi.ts" | |
| 13 | }, | |
| 14 | "./server/bitbucketPullRequestJson": { | |
| 15 | "types": "./src/server/bitbucketPullRequestJson.ts", | |
| 16 | "import": "./src/server/bitbucketPullRequestJson.ts" | |
| 17 | }, | |
| 18 | "./server/BitbucketPullRequestProvider": { | |
| 19 | "types": "./src/server/BitbucketPullRequestProvider.ts", | |
| 20 | "import": "./src/server/BitbucketPullRequestProvider.ts" | |
| 21 | }, | |
| 22 | "./server/bitbucketPullRequests": { | |
| 23 | "types": "./src/server/bitbucketPullRequests.ts", | |
| 24 | "import": "./src/server/bitbucketPullRequests.ts" | |
| 25 | }, | |
| 26 | "./server/BitbucketSourceControlProvider": { | |
| 27 | "types": "./src/server/BitbucketSourceControlProvider.ts", | |
| 28 | "import": "./src/server/BitbucketSourceControlProvider.ts" | |
| 29 | }, | |
| 30 | "./server/driver": { | |
| 31 | "types": "./src/server/driver.ts", | |
| 32 | "import": "./src/server/driver.ts" | |
| 33 | } | |
| 34 | }, | |
| 35 | "scripts": { | |
| 36 | "typecheck": "tsc --noEmit", | |
| 37 | "test": "vp test run --config ../../vite.config.ts --dir ." | |
| 38 | }, | |
| 39 | "dependencies": { | |
| 40 | "@t3tools/contracts": "workspace:*", | |
| 41 | "@t3tools/provider-core": "workspace:*", | |
| 42 | "@t3tools/shared": "workspace:*", | |
| 43 | "@t3tools/source-control-core": "workspace:*", | |
| 44 | "effect": "catalog:" | |
| 45 | }, | |
| 46 | "devDependencies": { | |
| 47 | "@effect/vitest": "catalog:", | |
| 48 | "@types/node": "catalog:", | |
| 49 | "vite-plus": "catalog:" | |
| 50 | } | |
| 51 | } |
packages/source-control-bitbucket/src/server/BitbucketApi.ts created+1160| ... | ... | @@ -0,0 +1,1160 @@ |
| 1 | import * as Clock from "effect/Clock"; | |
| 2 | import * as Config from "effect/Config"; | |
| 3 | import * as Context from "effect/Context"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as FileSystem from "effect/FileSystem"; | |
| 6 | import * as Layer from "effect/Layer"; | |
| 7 | import * as Option from "effect/Option"; | |
| 8 | import * as Schema from "effect/Schema"; | |
| 9 | import { | |
| 10 | DEFAULT_SERVER_SETTINGS, | |
| 11 | NonNegativeInt, | |
| 12 | TrimmedNonEmptyString, | |
| 13 | type BitbucketSettings, | |
| 14 | type SourceControlProviderAuth, | |
| 15 | type SourceControlRepositoryCloneUrls, | |
| 16 | type SourceControlRepositoryVisibility, | |
| 17 | } from "@t3tools/contracts"; | |
| 18 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; | |
| 19 | import { sanitizeBranchFragment } from "@t3tools/shared/git"; | |
| 20 | import { | |
| 21 | detectSourceControlProviderFromRemoteUrl, | |
| 22 | isSshRemoteUrl, | |
| 23 | } from "@t3tools/shared/sourceControl"; | |
| 24 | ||
| 25 | import { | |
| 26 | BitbucketPullRequestListSchema, | |
| 27 | BitbucketPullRequestSchema, | |
| 28 | normalizeBitbucketPullRequestRecord, | |
| 29 | type NormalizedBitbucketPullRequestRecord, | |
| 30 | } from "./bitbucketPullRequests.ts"; | |
| 31 | import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText"; | |
| 32 | import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; | |
| 33 | import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; | |
| 34 | import { retryAtFromHeader } from "@t3tools/source-control-core/server/SourceControlRateLimit"; | |
| 35 | ||
| 36 | const DEFAULT_API_BASE_URL = "https://api.bitbucket.org/2.0"; | |
| 37 | /** A response body past this is cut short, so one huge diff cannot exhaust the server. */ | |
| 38 | const DEFAULT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; | |
| 39 | /** Bitbucket redirects a diff once; this leaves room without following a chain forever. */ | |
| 40 | const MAX_REDIRECTS = 3; | |
| 41 | ||
| 42 | const BitbucketApiEnvConfig = Config.all({ | |
| 43 | baseUrl: Config.String("T3CODE_BITBUCKET_API_BASE_URL").pipe( | |
| 44 | Config.withDefault(DEFAULT_API_BASE_URL), | |
| 45 | ), | |
| 46 | accessToken: Config.String("T3CODE_BITBUCKET_ACCESS_TOKEN").pipe(Config.option), | |
| 47 | email: Config.String("T3CODE_BITBUCKET_EMAIL").pipe(Config.option), | |
| 48 | apiToken: Config.String("T3CODE_BITBUCKET_API_TOKEN").pipe(Config.option), | |
| 49 | }); | |
| 50 | ||
| 51 | const BitbucketApiOperation = Schema.Literals([ | |
| 52 | "resolveRepository", | |
| 53 | "getRepository", | |
| 54 | "getBranchingModel", | |
| 55 | "getPullRequest", | |
| 56 | "listPullRequests", | |
| 57 | "createRepository", | |
| 58 | "createPullRequest", | |
| 59 | "probeAuth", | |
| 60 | "checkoutPullRequest", | |
| 61 | // The raw escape hatch. Callers name their own operation in their own error, the way the | |
| 62 | // pull request wrappers do on top of `gh` and `glab`. | |
| 63 | "request", | |
| 64 | ]); | |
| 65 | type BitbucketApiOperation = typeof BitbucketApiOperation.Type; | |
| 66 | ||
| 67 | export class BitbucketRepositoryLocatorError extends Schema.TaggedError<BitbucketRepositoryLocatorError>()( | |
| 68 | "BitbucketRepositoryLocatorError", | |
| 69 | { | |
| 70 | repository: Schema.String, | |
| 71 | }, | |
| 72 | ) { | |
| 73 | static readonly detail = "Bitbucket repositories must be specified as workspace/repository."; | |
| 74 | ||
| 75 | get detail(): string { | |
| 76 | return BitbucketRepositoryLocatorError.detail; | |
| 77 | } | |
| 78 | ||
| 79 | override get message(): string { | |
| 80 | return `Bitbucket API failed: ${this.detail}`; | |
| 81 | } | |
| 82 | } | |
| 83 | ||
| 84 | export class BitbucketRequestError extends Schema.TaggedError<BitbucketRequestError>()( | |
| 85 | "BitbucketRequestError", | |
| 86 | { | |
| 87 | operation: BitbucketApiOperation, | |
| 88 | cause: Schema.Defect(), | |
| 89 | }, | |
| 90 | ) { | |
| 91 | get detail(): string { | |
| 92 | return "Failed to send the Bitbucket request."; | |
| 93 | } | |
| 94 | ||
| 95 | override get message(): string { | |
| 96 | return `Bitbucket API failed in ${this.operation}: ${this.detail}`; | |
| 97 | } | |
| 98 | } | |
| 99 | ||
| 100 | export class BitbucketResponseError extends Schema.TaggedError<BitbucketResponseError>()( | |
| 101 | "BitbucketResponseError", | |
| 102 | { | |
| 103 | operation: BitbucketApiOperation, | |
| 104 | status: Schema.Int, | |
| 105 | responseBodyLength: NonNegativeInt, | |
| 106 | retryAt: Schema.optional(Schema.Number), | |
| 107 | }, | |
| 108 | ) { | |
| 109 | get detail(): string { | |
| 110 | return `Bitbucket returned HTTP ${this.status}.`; | |
| 111 | } | |
| 112 | ||
| 113 | override get message(): string { | |
| 114 | return `Bitbucket API failed in ${this.operation}: ${this.detail}`; | |
| 115 | } | |
| 116 | } | |
| 117 | ||
| 118 | export class BitbucketResponseBodyReadError extends Schema.TaggedError<BitbucketResponseBodyReadError>()( | |
| 119 | "BitbucketResponseBodyReadError", | |
| 120 | { | |
| 121 | operation: BitbucketApiOperation, | |
| 122 | status: Schema.Int, | |
| 123 | retryAt: Schema.optional(Schema.Number), | |
| 124 | cause: Schema.Defect(), | |
| 125 | }, | |
| 126 | ) { | |
| 127 | get detail(): string { | |
| 128 | return `Bitbucket returned HTTP ${this.status}.`; | |
| 129 | } | |
| 130 | ||
| 131 | override get message(): string { | |
| 132 | return `Bitbucket API failed in ${this.operation}: ${this.detail}`; | |
| 133 | } | |
| 134 | } | |
| 135 | ||
| 136 | export class BitbucketResponseDecodeError extends Schema.TaggedError<BitbucketResponseDecodeError>()( | |
| 137 | "BitbucketResponseDecodeError", | |
| 138 | { | |
| 139 | operation: BitbucketApiOperation, | |
| 140 | status: Schema.Int, | |
| 141 | cause: Schema.Defect(), | |
| 142 | }, | |
| 143 | ) { | |
| 144 | get detail(): string { | |
| 145 | return "Bitbucket returned invalid JSON for the requested resource."; | |
| 146 | } | |
| 147 | ||
| 148 | override get message(): string { | |
| 149 | return `Bitbucket API failed in ${this.operation}: ${this.detail}`; | |
| 150 | } | |
| 151 | } | |
| 152 | ||
| 153 | export class BitbucketRepositoryVcsResolveError extends Schema.TaggedError<BitbucketRepositoryVcsResolveError>()( | |
| 154 | "BitbucketRepositoryVcsResolveError", | |
| 155 | { | |
| 156 | cwd: Schema.String, | |
| 157 | cause: Schema.Defect(), | |
| 158 | }, | |
| 159 | ) { | |
| 160 | get detail(): string { | |
| 161 | return `Failed to resolve VCS repository for ${this.cwd}.`; | |
| 162 | } | |
| 163 | ||
| 164 | override get message(): string { | |
| 165 | return `Bitbucket API failed in resolveRepository: ${this.detail}`; | |
| 166 | } | |
| 167 | } | |
| 168 | ||
| 169 | export class BitbucketRepositoryRemotesListError extends Schema.TaggedError<BitbucketRepositoryRemotesListError>()( | |
| 170 | "BitbucketRepositoryRemotesListError", | |
| 171 | { | |
| 172 | cwd: Schema.String, | |
| 173 | cause: Schema.Defect(), | |
| 174 | }, | |
| 175 | ) { | |
| 176 | get detail(): string { | |
| 177 | return `Failed to list remotes for ${this.cwd}.`; | |
| 178 | } | |
| 179 | ||
| 180 | override get message(): string { | |
| 181 | return `Bitbucket API failed in resolveRepository: ${this.detail}`; | |
| 182 | } | |
| 183 | } | |
| 184 | ||
| 185 | export class BitbucketRepositoryRemoteNotFoundError extends Schema.TaggedError<BitbucketRepositoryRemoteNotFoundError>()( | |
| 186 | "BitbucketRepositoryRemoteNotFoundError", | |
| 187 | { | |
| 188 | cwd: Schema.String, | |
| 189 | }, | |
| 190 | ) { | |
| 191 | get detail(): string { | |
| 192 | return `No Bitbucket repository remote was detected for ${this.cwd}.`; | |
| 193 | } | |
| 194 | ||
| 195 | override get message(): string { | |
| 196 | return `Bitbucket API failed in resolveRepository: ${this.detail}`; | |
| 197 | } | |
| 198 | } | |
| 199 | ||
| 200 | export class BitbucketPullRequestBodyReadError extends Schema.TaggedError<BitbucketPullRequestBodyReadError>()( | |
| 201 | "BitbucketPullRequestBodyReadError", | |
| 202 | { | |
| 203 | cwd: Schema.String, | |
| 204 | bodyFile: Schema.String, | |
| 205 | cause: Schema.Defect(), | |
| 206 | }, | |
| 207 | ) { | |
| 208 | get detail(): string { | |
| 209 | return `Failed to read pull request body file ${this.bodyFile}.`; | |
| 210 | } | |
| 211 | ||
| 212 | override get message(): string { | |
| 213 | return `Bitbucket API failed in createPullRequest: ${this.detail}`; | |
| 214 | } | |
| 215 | } | |
| 216 | ||
| 217 | export class BitbucketCheckoutError extends Schema.TaggedError<BitbucketCheckoutError>()( | |
| 218 | "BitbucketCheckoutError", | |
| 219 | { | |
| 220 | cwd: Schema.String, | |
| 221 | reference: Schema.String, | |
| 222 | cause: Schema.Defect(), | |
| 223 | }, | |
| 224 | ) { | |
| 225 | get detail(): string { | |
| 226 | return "Failed to check out the Bitbucket pull request."; | |
| 227 | } | |
| 228 | ||
| 229 | override get message(): string { | |
| 230 | return `Bitbucket API failed in checkoutPullRequest: ${this.detail}`; | |
| 231 | } | |
| 232 | } | |
| 233 | ||
| 234 | /** | |
| 235 | * A url that does not belong to the configured Bitbucket. Refused rather than followed, because | |
| 236 | * the request carries the account's credentials and a url that came back in a response — a | |
| 237 | * pagination cursor, or the target of a redirect — is not this server's to trust. | |
| 238 | */ | |
| 239 | export class BitbucketUntrustedUrlError extends Schema.TaggedError<BitbucketUntrustedUrlError>()( | |
| 240 | "BitbucketUntrustedUrlError", | |
| 241 | { | |
| 242 | /** The host only. A rejected hop is often a signed url, whose query carries a credential. */ | |
| 243 | host: Schema.String, | |
| 244 | }, | |
| 245 | ) { | |
| 246 | get detail(): string { | |
| 247 | return `The response pointed at ${this.host}, outside the configured Bitbucket.`; | |
| 248 | } | |
| 249 | ||
| 250 | override get message(): string { | |
| 251 | return `Bitbucket API failed in request: ${this.detail}`; | |
| 252 | } | |
| 253 | } | |
| 254 | ||
| 255 | export const BitbucketApiError = Schema.Union([ | |
| 256 | BitbucketUntrustedUrlError, | |
| 257 | BitbucketRepositoryLocatorError, | |
| 258 | BitbucketRequestError, | |
| 259 | BitbucketResponseError, | |
| 260 | BitbucketResponseBodyReadError, | |
| 261 | BitbucketResponseDecodeError, | |
| 262 | BitbucketRepositoryVcsResolveError, | |
| 263 | BitbucketRepositoryRemotesListError, | |
| 264 | BitbucketRepositoryRemoteNotFoundError, | |
| 265 | BitbucketPullRequestBodyReadError, | |
| 266 | BitbucketCheckoutError, | |
| 267 | ]); | |
| 268 | export type BitbucketApiError = typeof BitbucketApiError.Type; | |
| 269 | const isBitbucketApiError = Schema.is(BitbucketApiError); | |
| 270 | ||
| 271 | const RawBitbucketRepositorySchema = Schema.Struct({ | |
| 272 | full_name: TrimmedNonEmptyString, | |
| 273 | links: Schema.Struct({ | |
| 274 | html: Schema.optional( | |
| 275 | Schema.Struct({ | |
| 276 | href: TrimmedNonEmptyString, | |
| 277 | }), | |
| 278 | ), | |
| 279 | clone: Schema.optional( | |
| 280 | Schema.Array( | |
| 281 | Schema.Struct({ | |
| 282 | name: TrimmedNonEmptyString, | |
| 283 | href: TrimmedNonEmptyString, | |
| 284 | }), | |
| 285 | ), | |
| 286 | ), | |
| 287 | }), | |
| 288 | mainbranch: Schema.optional( | |
| 289 | Schema.NullOr( | |
| 290 | Schema.Struct({ | |
| 291 | name: TrimmedNonEmptyString, | |
| 292 | }), | |
| 293 | ), | |
| 294 | ), | |
| 295 | }); | |
| 296 | ||
| 297 | const RawBitbucketBranchingModelSchema = Schema.Struct({ | |
| 298 | development: Schema.optional( | |
| 299 | Schema.Struct({ | |
| 300 | branch: Schema.optional( | |
| 301 | Schema.NullOr( | |
| 302 | Schema.Struct({ | |
| 303 | name: Schema.optional(TrimmedNonEmptyString), | |
| 304 | }), | |
| 305 | ), | |
| 306 | ), | |
| 307 | is_valid: Schema.optional(Schema.Boolean), | |
| 308 | name: Schema.optional(Schema.NullOr(Schema.String)), | |
| 309 | use_mainbranch: Schema.optional(Schema.Boolean), | |
| 310 | }), | |
| 311 | ), | |
| 312 | }); | |
| 313 | ||
| 314 | const BitbucketUserSchema = Schema.Struct({ | |
| 315 | username: Schema.optional(TrimmedNonEmptyString), | |
| 316 | display_name: Schema.optional(TrimmedNonEmptyString), | |
| 317 | account_id: Schema.optional(TrimmedNonEmptyString), | |
| 318 | }); | |
| 319 | ||
| 320 | export interface BitbucketRepositoryLocator { | |
| 321 | readonly workspace: string; | |
| 322 | readonly repoSlug: string; | |
| 323 | } | |
| 324 | ||
| 325 | export class BitbucketApi extends Context.Service< | |
| 326 | BitbucketApi, | |
| 327 | { | |
| 328 | readonly probeAuth: Effect.Effect<SourceControlProviderAuth, never>; | |
| 329 | ||
| 330 | /** | |
| 331 | * One authenticated request, returning the body verbatim. Bitbucket answers most endpoints | |
| 332 | * with JSON and a few — a pull request diff, for one — with plain text, so the body is | |
| 333 | * handed back undecoded for the caller to read as it sees fit. | |
| 334 | */ | |
| 335 | readonly request: (input: { | |
| 336 | readonly method: "GET" | "POST" | "PUT" | "DELETE"; | |
| 337 | /** | |
| 338 | * A path below the API base, or a whole URL as a paged response reports its next page. | |
| 339 | * A whole URL is refused unless it belongs to the configured Bitbucket. | |
| 340 | */ | |
| 341 | readonly url: string; | |
| 342 | /** A JSON document, for the endpoints that take one. */ | |
| 343 | readonly body?: string; | |
| 344 | /** Response bytes to keep; past this the body comes back cut short and marked. */ | |
| 345 | readonly maxBytes?: number; | |
| 346 | }) => Effect.Effect<{ readonly body: string; readonly truncated: boolean }, BitbucketApiError>; | |
| 347 | readonly listPullRequests: (input: { | |
| 348 | readonly cwd: string; | |
| 349 | readonly context?: SourceControlProvider.SourceControlProviderContext; | |
| 350 | readonly headSelector: string; | |
| 351 | readonly source?: SourceControlProvider.SourceControlRefSelector; | |
| 352 | readonly state: "open" | "closed" | "merged" | "all"; | |
| 353 | readonly limit?: number; | |
| 354 | }) => Effect.Effect<ReadonlyArray<NormalizedBitbucketPullRequestRecord>, BitbucketApiError>; | |
| 355 | readonly getPullRequest: (input: { | |
| 356 | readonly cwd: string; | |
| 357 | readonly context?: SourceControlProvider.SourceControlProviderContext; | |
| 358 | readonly reference: string; | |
| 359 | }) => Effect.Effect<NormalizedBitbucketPullRequestRecord, BitbucketApiError>; | |
| 360 | readonly getRepositoryCloneUrls: (input: { | |
| 361 | readonly cwd: string; | |
| 362 | readonly context?: SourceControlProvider.SourceControlProviderContext; | |
| 363 | readonly repository: string; | |
| 364 | }) => Effect.Effect<SourceControlRepositoryCloneUrls, BitbucketApiError>; | |
| 365 | readonly createRepository: (input: { | |
| 366 | readonly cwd: string; | |
| 367 | readonly repository: string; | |
| 368 | readonly visibility: SourceControlRepositoryVisibility; | |
| 369 | }) => Effect.Effect<SourceControlRepositoryCloneUrls, BitbucketApiError>; | |
| 370 | readonly createPullRequest: (input: { | |
| 371 | readonly cwd: string; | |
| 372 | readonly context?: SourceControlProvider.SourceControlProviderContext; | |
| 373 | readonly baseBranch: string; | |
| 374 | readonly headSelector: string; | |
| 375 | readonly source?: SourceControlProvider.SourceControlRefSelector; | |
| 376 | readonly target?: SourceControlProvider.SourceControlRefSelector; | |
| 377 | readonly title: string; | |
| 378 | readonly bodyFile: string; | |
| 379 | }) => Effect.Effect<void, BitbucketApiError>; | |
| 380 | readonly getDefaultBranch: (input: { | |
| 381 | readonly cwd: string; | |
| 382 | readonly context?: SourceControlProvider.SourceControlProviderContext; | |
| 383 | }) => Effect.Effect<string | null, BitbucketApiError>; | |
| 384 | readonly checkoutPullRequest: (input: { | |
| 385 | readonly cwd: string; | |
| 386 | readonly context?: SourceControlProvider.SourceControlProviderContext; | |
| 387 | readonly reference: string; | |
| 388 | readonly force?: boolean; | |
| 389 | }) => Effect.Effect<void, BitbucketApiError>; | |
| 390 | } | |
| 391 | >()("@t3tools/source-control-bitbucket/server/BitbucketApi") {} | |
| 392 | ||
| 393 | function nonEmpty(value: string | undefined): Option.Option<string> { | |
| 394 | const trimmed = value?.trim(); | |
| 395 | return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); | |
| 396 | } | |
| 397 | ||
| 398 | function normalizeChangeRequestId(reference: string): string { | |
| 399 | const trimmed = reference.trim().replace(/^#/, ""); | |
| 400 | const urlMatch = /(?:pull-requests|pullrequests|pull-request|pull|pr)\/(\d+)(?:\D.*)?$/i.exec( | |
| 401 | trimmed, | |
| 402 | ); | |
| 403 | return urlMatch?.[1] ?? trimmed; | |
| 404 | } | |
| 405 | ||
| 406 | function sourceWorkspace(input: { | |
| 407 | readonly headSelector: string; | |
| 408 | readonly source?: SourceControlProvider.SourceControlRefSelector; | |
| 409 | }): string | undefined { | |
| 410 | if (input.source?.owner) return input.source.owner; | |
| 411 | return SourceControlProvider.parseSourceControlOwnerRef(input.headSelector)?.owner; | |
| 412 | } | |
| 413 | ||
| 414 | function toBitbucketStates(state: "open" | "closed" | "merged" | "all"): ReadonlyArray<string> { | |
| 415 | switch (state) { | |
| 416 | case "open": | |
| 417 | return ["OPEN"]; | |
| 418 | case "closed": | |
| 419 | return ["DECLINED", "SUPERSEDED"]; | |
| 420 | case "merged": | |
| 421 | return ["MERGED"]; | |
| 422 | case "all": | |
| 423 | return ["OPEN", "MERGED", "DECLINED", "SUPERSEDED"]; | |
| 424 | } | |
| 425 | } | |
| 426 | ||
| 427 | function bitbucketQueryString(filters: ReadonlyArray<string>): string { | |
| 428 | return filters.join(" AND "); | |
| 429 | } | |
| 430 | ||
| 431 | function bitbucketStateFilter(states: ReadonlyArray<string>): string { | |
| 432 | return states.length === 1 | |
| 433 | ? `state = "${states[0]}"` | |
| 434 | : `(${states.map((state) => `state = "${state}"`).join(" OR ")})`; | |
| 435 | } | |
| 436 | ||
| 437 | function parseBitbucketRepositorySlug(value: string): BitbucketRepositoryLocator | null { | |
| 438 | const normalized = value.trim().replace(/\.git$/u, ""); | |
| 439 | const parts = normalized.split("/").filter((part) => part.length > 0); | |
| 440 | if (parts.length < 2) return null; | |
| 441 | const workspace = parts.at(-2); | |
| 442 | const repoSlug = parts.at(-1); | |
| 443 | return workspace && repoSlug ? { workspace, repoSlug } : null; | |
| 444 | } | |
| 445 | ||
| 446 | function requireRepositoryLocator( | |
| 447 | repository: string, | |
| 448 | ): Effect.Effect<BitbucketRepositoryLocator, BitbucketApiError> { | |
| 449 | const locator = parseBitbucketRepositorySlug(repository); | |
| 450 | return locator | |
| 451 | ? Effect.succeed(locator) | |
| 452 | : Effect.fail( | |
| 453 | new BitbucketRepositoryLocatorError({ | |
| 454 | repository, | |
| 455 | }), | |
| 456 | ); | |
| 457 | } | |
| 458 | ||
| 459 | function parseBitbucketRemoteUrl(remoteUrl: string): BitbucketRepositoryLocator | null { | |
| 460 | const trimmed = remoteUrl.trim(); | |
| 461 | const scpMatch = /^[a-zA-Z0-9._-]+@[^:/]+:(.+)$/.exec(trimmed); | |
| 462 | if (scpMatch?.[1]) { | |
| 463 | return parseBitbucketRepositorySlug(scpMatch[1]); | |
| 464 | } | |
| 465 | ||
| 466 | try { | |
| 467 | return parseBitbucketRepositorySlug(new URL(trimmed).pathname); | |
| 468 | } catch { | |
| 469 | return null; | |
| 470 | } | |
| 471 | } | |
| 472 | ||
| 473 | function normalizeRepositoryCloneUrls( | |
| 474 | raw: typeof RawBitbucketRepositorySchema.Type, | |
| 475 | ): SourceControlRepositoryCloneUrls { | |
| 476 | const httpClone = | |
| 477 | raw.links.clone?.find((entry) => entry.name.toLowerCase() === "https")?.href ?? | |
| 478 | raw.links.html?.href; | |
| 479 | const sshClone = raw.links.clone?.find((entry) => entry.name.toLowerCase() === "ssh")?.href; | |
| 480 | ||
| 481 | return { | |
| 482 | nameWithOwner: raw.full_name, | |
| 483 | url: httpClone ?? raw.links.html?.href ?? raw.full_name, | |
| 484 | sshUrl: sshClone ?? httpClone ?? raw.full_name, | |
| 485 | }; | |
| 486 | } | |
| 487 | ||
| 488 | function defaultChangeRequestTargetBranch(input: { | |
| 489 | readonly repository: typeof RawBitbucketRepositorySchema.Type; | |
| 490 | readonly branchingModel: typeof RawBitbucketBranchingModelSchema.Type | null; | |
| 491 | }): string | null { | |
| 492 | const repositoryMainBranch = input.repository.mainbranch?.name ?? null; | |
| 493 | const development = input.branchingModel?.development; | |
| 494 | if (!development || development.use_mainbranch === true || development.is_valid === false) { | |
| 495 | return repositoryMainBranch; | |
| 496 | } | |
| 497 | ||
| 498 | const developmentBranch = development.branch?.name?.trim() ?? development.name?.trim() ?? ""; | |
| 499 | if (developmentBranch.length === 0 || developmentBranch === "null") { | |
| 500 | return repositoryMainBranch; | |
| 501 | } | |
| 502 | ||
| 503 | return developmentBranch; | |
| 504 | } | |
| 505 | ||
| 506 | function shouldPreferSshRemote(originRemoteUrl: string | null): boolean { | |
| 507 | if (!originRemoteUrl) return false; | |
| 508 | return isSshRemoteUrl(originRemoteUrl); | |
| 509 | } | |
| 510 | ||
| 511 | function selectCloneUrl(input: { | |
| 512 | readonly cloneUrls: SourceControlRepositoryCloneUrls; | |
| 513 | readonly originRemoteUrl: string | null; | |
| 514 | }): string { | |
| 515 | return shouldPreferSshRemote(input.originRemoteUrl) | |
| 516 | ? input.cloneUrls.sshUrl | |
| 517 | : input.cloneUrls.url; | |
| 518 | } | |
| 519 | ||
| 520 | function checkoutBranchName(input: { | |
| 521 | readonly pullRequestId: number; | |
| 522 | readonly headBranch: string; | |
| 523 | readonly isCrossRepository: boolean; | |
| 524 | }): string { | |
| 525 | if (!input.isCrossRepository) { | |
| 526 | return input.headBranch; | |
| 527 | } | |
| 528 | ||
| 529 | return `t3code/pr-${input.pullRequestId}/${sanitizeBranchFragment(input.headBranch)}`; | |
| 530 | } | |
| 531 | ||
| 532 | function repositoryNameWithOwner( | |
| 533 | repository: Schema.Schema.Type<typeof BitbucketPullRequestSchema>["source"]["repository"], | |
| 534 | ): string | null { | |
| 535 | const fullName = repository?.full_name?.trim() ?? ""; | |
| 536 | return fullName.length > 0 ? fullName : null; | |
| 537 | } | |
| 538 | ||
| 539 | function repositoryOwnerName(repositoryName: string): string { | |
| 540 | return repositoryName.split("/")[0]?.trim() || "bitbucket"; | |
| 541 | } | |
| 542 | ||
| 543 | type BitbucketCredential = | |
| 544 | | { readonly kind: "access-token"; readonly accessToken: string } | |
| 545 | | { readonly kind: "api-token"; readonly email: string; readonly apiToken: string }; | |
| 546 | ||
| 547 | /** | |
| 548 | * Visible ASCII only. A value the HTTP stack rejects makes it throw an error quoting the whole | |
| 549 | * header, and that error travels to clients as a cause, so an unusable token is treated as unset. | |
| 550 | */ | |
| 551 | const HEADER_SAFE = /^[\x21-\x7e]+$/u; | |
| 552 | ||
| 553 | function credentialFrom(input: { | |
| 554 | readonly accessToken: string; | |
| 555 | readonly email: string; | |
| 556 | readonly apiToken: string; | |
| 557 | }): BitbucketCredential | null { | |
| 558 | if (HEADER_SAFE.test(input.accessToken)) { | |
| 559 | return { kind: "access-token", accessToken: input.accessToken }; | |
| 560 | } | |
| 561 | if (HEADER_SAFE.test(input.email) && HEADER_SAFE.test(input.apiToken)) { | |
| 562 | return { kind: "api-token", email: input.email, apiToken: input.apiToken }; | |
| 563 | } | |
| 564 | return null; | |
| 565 | } | |
| 566 | ||
| 567 | /** | |
| 568 | * Credentials saved in settings win over the `T3CODE_BITBUCKET_*` environment variables, which | |
| 569 | * stay as a fallback. Within each source the access token wins. | |
| 570 | */ | |
| 571 | function resolveCredential( | |
| 572 | settings: BitbucketSettings, | |
| 573 | env: Config.Success<typeof BitbucketApiEnvConfig>, | |
| 574 | ): BitbucketCredential | null { | |
| 575 | return ( | |
| 576 | credentialFrom(settings) ?? | |
| 577 | credentialFrom({ | |
| 578 | accessToken: Option.getOrElse(env.accessToken, () => ""), | |
| 579 | email: Option.getOrElse(env.email, () => ""), | |
| 580 | apiToken: Option.getOrElse(env.apiToken, () => ""), | |
| 581 | }) | |
| 582 | ); | |
| 583 | } | |
| 584 | ||
| 585 | function authFromCredential(credential: BitbucketCredential | null): SourceControlProviderAuth { | |
| 586 | if (credential?.kind === "access-token") { | |
| 587 | return { | |
| 588 | status: "unknown", | |
| 589 | account: Option.none(), | |
| 590 | host: Option.some("bitbucket.org"), | |
| 591 | detail: Option.some("An access token is configured."), | |
| 592 | }; | |
| 593 | } | |
| 594 | ||
| 595 | if (credential?.kind === "api-token") { | |
| 596 | return { | |
| 597 | status: "unknown", | |
| 598 | account: Option.some(credential.email), | |
| 599 | host: Option.some("bitbucket.org"), | |
| 600 | detail: Option.some("An API token is configured."), | |
| 601 | }; | |
| 602 | } | |
| 603 | ||
| 604 | return { | |
| 605 | status: "unauthenticated", | |
| 606 | account: Option.none(), | |
| 607 | host: Option.some("bitbucket.org"), | |
| 608 | detail: Option.some( | |
| 609 | "Add a Bitbucket token in Settings → Source Control, or set the T3CODE_BITBUCKET_* environment variables on the server.", | |
| 610 | ), | |
| 611 | }; | |
| 612 | } | |
| 613 | ||
| 614 | /** Null for anything that is not a url at all, which is never the configured Bitbucket. */ | |
| 615 | function originOf(value: string): string | null { | |
| 616 | try { | |
| 617 | return new URL(value).origin; | |
| 618 | } catch { | |
| 619 | return null; | |
| 620 | } | |
| 621 | } | |
| 622 | ||
| 623 | function responseError( | |
| 624 | operation: BitbucketApiOperation, | |
| 625 | response: HttpClientResponse.HttpClientResponse, | |
| 626 | ): Effect.Effect<never, BitbucketApiError> { | |
| 627 | // Bounded like any other body: an error response is no smaller than a successful one, and | |
| 628 | // only its length is reported anyway. | |
| 629 | return Effect.gen(function* () { | |
| 630 | const now = yield* Clock.currentTimeMillis; | |
| 631 | const retryAt = retryAtFromHeader(response.headers["retry-after"], now); | |
| 632 | const collected = yield* collectUint8StreamText({ | |
| 633 | stream: response.stream, | |
| 634 | maxBytes: DEFAULT_MAX_RESPONSE_BYTES, | |
| 635 | }).pipe( | |
| 636 | Effect.mapError( | |
| 637 | (cause) => | |
| 638 | new BitbucketResponseBodyReadError({ | |
| 639 | operation, | |
| 640 | status: response.status, | |
| 641 | retryAt, | |
| 642 | cause, | |
| 643 | }), | |
| 644 | ), | |
| 645 | ); | |
| 646 | return yield* new BitbucketResponseError({ | |
| 647 | operation, | |
| 648 | status: response.status, | |
| 649 | responseBodyLength: collected.text.length, | |
| 650 | retryAt, | |
| 651 | }); | |
| 652 | }); | |
| 653 | } | |
| 654 | ||
| 655 | /** @public Service construction is part of the canonical Effect module API. */ | |
| 656 | export const make = Effect.gen(function* () { | |
| 657 | const config = yield* BitbucketApiEnvConfig; | |
| 658 | const host = yield* SourceControlHost.SourceControlHost; | |
| 659 | const git = host.git; | |
| 660 | const httpClient = yield* HttpClient.HttpClient; | |
| 661 | const fileSystem = yield* FileSystem.FileSystem; | |
| 662 | ||
| 663 | const apiUrl = (path: string) => `${config.baseUrl.replace(/\/+$/u, "")}${path}`; | |
| 664 | ||
| 665 | // Read on every request so credentials saved in settings apply without a restart. | |
| 666 | const currentCredential = host.settings.get.pipe( | |
| 667 | Effect.map((settings) => resolveCredential(settings.bitbucket, config)), | |
| 668 | Effect.catch((error) => | |
| 669 | // No cause: a settings decode error can quote a hand-edited token. | |
| 670 | Effect.logWarning("failed to read Bitbucket credentials from settings", { | |
| 671 | operation: error.operation, | |
| 672 | }).pipe(Effect.as(resolveCredential(DEFAULT_SERVER_SETTINGS.bitbucket, config))), | |
| 673 | ), | |
| 674 | ); | |
| 675 | ||
| 676 | const withAuth = (request: HttpClientRequest.HttpClientRequest) => | |
| 677 | currentCredential.pipe( | |
| 678 | Effect.map((credential) => | |
| 679 | credential === null | |
| 680 | ? request | |
| 681 | : credential.kind === "access-token" | |
| 682 | ? request.pipe(HttpClientRequest.bearerToken(credential.accessToken)) | |
| 683 | : request.pipe(HttpClientRequest.basicAuth(credential.email, credential.apiToken)), | |
| 684 | ), | |
| 685 | ); | |
| 686 | ||
| 687 | const decodeResponse = <S extends Schema.Top>( | |
| 688 | operation: BitbucketApiOperation, | |
| 689 | schema: S, | |
| 690 | response: HttpClientResponse.HttpClientResponse, | |
| 691 | ): Effect.Effect<S["Type"], BitbucketApiError, S["DecodingServices"]> => | |
| 692 | HttpClientResponse.matchStatus({ | |
| 693 | "2xx": (success) => | |
| 694 | HttpClientResponse.schemaBodyJson(schema)(success).pipe( | |
| 695 | Effect.mapError( | |
| 696 | (cause) => | |
| 697 | new BitbucketResponseDecodeError({ | |
| 698 | operation, | |
| 699 | status: success.status, | |
| 700 | cause, | |
| 701 | }), | |
| 702 | ), | |
| 703 | ), | |
| 704 | orElse: (failed) => responseError(operation, failed), | |
| 705 | })(response); | |
| 706 | ||
| 707 | const executeJson = <S extends Schema.Top>( | |
| 708 | operation: BitbucketApiOperation, | |
| 709 | request: HttpClientRequest.HttpClientRequest, | |
| 710 | schema: S, | |
| 711 | ): Effect.Effect<S["Type"], BitbucketApiError, S["DecodingServices"]> => | |
| 712 | withAuth(request.pipe(HttpClientRequest.acceptJson)).pipe( | |
| 713 | Effect.flatMap(httpClient.execute), | |
| 714 | Effect.mapError( | |
| 715 | (cause) => | |
| 716 | new BitbucketRequestError({ | |
| 717 | operation, | |
| 718 | cause, | |
| 719 | }), | |
| 720 | ), | |
| 721 | Effect.flatMap((response) => decodeResponse(operation, schema, response)), | |
| 722 | ); | |
| 723 | ||
| 724 | const resolveRepository = Effect.fn("BitbucketApi.resolveRepository")(function* (input: { | |
| 725 | readonly cwd: string; | |
| 726 | readonly context?: SourceControlProvider.SourceControlProviderContext; | |
| 727 | readonly repository?: string; | |
| 728 | }) { | |
| 729 | if (input.repository !== undefined) { | |
| 730 | return yield* requireRepositoryLocator(input.repository); | |
| 731 | } | |
| 732 | ||
| 733 | const fromContext = | |
| 734 | input.context?.provider.kind === "bitbucket" | |
| 735 | ? parseBitbucketRemoteUrl(input.context.remoteUrl) | |
| 736 | : null; | |
| 737 | if (fromContext) return fromContext; | |
| 738 | ||
| 739 | const listRemotes = yield* git.remotes(input.cwd).pipe( | |
| 740 | Effect.mapError( | |
| 741 | (cause) => | |
| 742 | new BitbucketRepositoryVcsResolveError({ | |
| 743 | cwd: input.cwd, | |
| 744 | cause, | |
| 745 | }), | |
| 746 | ), | |
| 747 | ); | |
| 748 | const remotes = yield* listRemotes.pipe( | |
| 749 | Effect.mapError( | |
| 750 | (cause) => | |
| 751 | new BitbucketRepositoryRemotesListError({ | |
| 752 | cwd: input.cwd, | |
| 753 | cause, | |
| 754 | }), | |
| 755 | ), | |
| 756 | ); | |
| 757 | ||
| 758 | for (const remote of remotes.remotes) { | |
| 759 | if (detectSourceControlProviderFromRemoteUrl(remote.url)?.kind !== "bitbucket") continue; | |
| 760 | const parsed = parseBitbucketRemoteUrl(remote.url); | |
| 761 | if (parsed) return parsed; | |
| 762 | } | |
| 763 | ||
| 764 | return yield* new BitbucketRepositoryRemoteNotFoundError({ | |
| 765 | cwd: input.cwd, | |
| 766 | }); | |
| 767 | }); | |
| 768 | ||
| 769 | const getRepositoryFromLocator = (repository: BitbucketRepositoryLocator) => | |
| 770 | executeJson( | |
| 771 | "getRepository", | |
| 772 | HttpClientRequest.get( | |
| 773 | apiUrl( | |
| 774 | `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}`, | |
| 775 | ), | |
| 776 | ), | |
| 777 | RawBitbucketRepositorySchema, | |
| 778 | ); | |
| 779 | ||
| 780 | const getRepository = (input: { | |
| 781 | readonly cwd: string; | |
| 782 | readonly context?: SourceControlProvider.SourceControlProviderContext; | |
| 783 | readonly repository?: string; | |
| 784 | }) => resolveRepository(input).pipe(Effect.flatMap(getRepositoryFromLocator)); | |
| 785 | ||
| 786 | const getBranchingModelFromLocator = (repository: BitbucketRepositoryLocator) => | |
| 787 | executeJson( | |
| 788 | "getBranchingModel", | |
| 789 | HttpClientRequest.get( | |
| 790 | apiUrl( | |
| 791 | `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/branching-model`, | |
| 792 | ), | |
| 793 | ), | |
| 794 | RawBitbucketBranchingModelSchema, | |
| 795 | ); | |
| 796 | ||
| 797 | const getRawPullRequestFromRepository = ( | |
| 798 | repository: BitbucketRepositoryLocator, | |
| 799 | reference: string, | |
| 800 | ) => | |
| 801 | executeJson( | |
| 802 | "getPullRequest", | |
| 803 | HttpClientRequest.get( | |
| 804 | apiUrl( | |
| 805 | `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests/${encodeURIComponent(normalizeChangeRequestId(reference))}`, | |
| 806 | ), | |
| 807 | ), | |
| 808 | BitbucketPullRequestSchema, | |
| 809 | ); | |
| 810 | ||
| 811 | const getRawPullRequest = (input: { | |
| 812 | readonly cwd: string; | |
| 813 | readonly context?: SourceControlProvider.SourceControlProviderContext; | |
| 814 | readonly reference: string; | |
| 815 | }) => | |
| 816 | resolveRepository(input).pipe( | |
| 817 | Effect.flatMap((repository) => getRawPullRequestFromRepository(repository, input.reference)), | |
| 818 | ); | |
| 819 | ||
| 820 | const readConfigValueNullable = (cwd: string, key: string) => | |
| 821 | git.readConfigValue(cwd, key).pipe(Effect.orElseSucceed(() => null)); | |
| 822 | ||
| 823 | const resolveCheckoutRemote = Effect.fn("BitbucketApi.resolveCheckoutRemote")(function* (input: { | |
| 824 | readonly cwd: string; | |
| 825 | readonly context?: SourceControlProvider.SourceControlProviderContext; | |
| 826 | readonly destinationRepository: BitbucketRepositoryLocator; | |
| 827 | readonly sourceRepositoryName: string; | |
| 828 | readonly isCrossRepository: boolean; | |
| 829 | }) { | |
| 830 | if ( | |
| 831 | input.context?.provider.kind === "bitbucket" && | |
| 832 | !input.isCrossRepository && | |
| 833 | parseBitbucketRemoteUrl(input.context.remoteUrl) !== null | |
| 834 | ) { | |
| 835 | return input.context.remoteName; | |
| 836 | } | |
| 837 | ||
| 838 | if (!input.isCrossRepository) { | |
| 839 | const remoteName = yield* git | |
| 840 | .resolvePrimaryRemoteName(input.cwd) | |
| 841 | .pipe(Effect.orElseSucceed(() => null)); | |
| 842 | if (remoteName) return remoteName; | |
| 843 | } | |
| 844 | ||
| 845 | const cloneUrls = yield* getRepository({ | |
| 846 | cwd: input.cwd, | |
| 847 | repository: input.sourceRepositoryName, | |
| 848 | ...(input.context ? { context: input.context } : {}), | |
| 849 | }).pipe(Effect.map(normalizeRepositoryCloneUrls)); | |
| 850 | const originRemoteUrl = yield* readConfigValueNullable(input.cwd, "remote.origin.url"); | |
| 851 | return yield* git.ensureRemote({ | |
| 852 | cwd: input.cwd, | |
| 853 | preferredName: input.isCrossRepository | |
| 854 | ? repositoryOwnerName(input.sourceRepositoryName) | |
| 855 | : input.destinationRepository.workspace, | |
| 856 | url: selectCloneUrl({ cloneUrls, originRemoteUrl }), | |
| 857 | }); | |
| 858 | }); | |
| 859 | ||
| 860 | // A pull request's diff, diffstat and conflicts are served as redirects to a commit-range | |
| 861 | // URL, and the client does not follow redirects unless asked. The hop stays on the same host, | |
| 862 | // so the credentials travel with it. | |
| 863 | /** | |
| 864 | * The one host these credentials may be sent to. A url that came back inside a response — a | |
| 865 | * pagination cursor, or the target of a redirect — is data, not instruction, so it is checked | |
| 866 | * against this before the account's token travels with it. | |
| 867 | */ | |
| 868 | const apiOrigin = originOf(config.baseUrl); | |
| 869 | ||
| 870 | const trustedUrl = (value: string): string | null => { | |
| 871 | if (!/^https?:\/\//u.test(value)) return apiUrl(value); | |
| 872 | const origin = originOf(value); | |
| 873 | return origin !== null && origin === apiOrigin ? value : null; | |
| 874 | }; | |
| 875 | ||
| 876 | /** | |
| 877 | * Redirects are followed here rather than by the client, which forwards every header to | |
| 878 | * whatever host it is sent to. A pull request diff, diffstat and conflicts are all served as | |
| 879 | * redirects, so they have to be followed — but only back to the same Bitbucket. | |
| 880 | */ | |
| 881 | const send = (input: { | |
| 882 | readonly method: "GET" | "POST" | "PUT" | "DELETE"; | |
| 883 | readonly url: string; | |
| 884 | readonly body?: string; | |
| 885 | readonly redirects: number; | |
| 886 | }): Effect.Effect<HttpClientResponse.HttpClientResponse, BitbucketApiError> => { | |
| 887 | const url = trustedUrl(input.url); | |
| 888 | if (url === null) { | |
| 889 | return Effect.fail( | |
| 890 | new BitbucketUntrustedUrlError({ host: originOf(input.url) ?? "an unreadable url" }), | |
| 891 | ); | |
| 892 | } | |
| 893 | const base = | |
| 894 | input.method === "GET" | |
| 895 | ? HttpClientRequest.get(url) | |
| 896 | : input.method === "POST" | |
| 897 | ? HttpClientRequest.post(url) | |
| 898 | : input.method === "DELETE" | |
| 899 | ? HttpClientRequest.make("DELETE")(url) | |
| 900 | : HttpClientRequest.put(url); | |
| 901 | // No `Accept: application/json`: the diff endpoints answer with a patch, not JSON. | |
| 902 | const withBody = | |
| 903 | input.body === undefined | |
| 904 | ? base | |
| 905 | : base.pipe(HttpClientRequest.bodyText(input.body, "application/json")); | |
| 906 | return withAuth(withBody).pipe( | |
| 907 | Effect.flatMap(httpClient.execute), | |
| 908 | Effect.mapError( | |
| 909 | (cause): BitbucketApiError => new BitbucketRequestError({ operation: "request", cause }), | |
| 910 | ), | |
| 911 | Effect.flatMap((response) => { | |
| 912 | const location = response.headers.location; | |
| 913 | if ( | |
| 914 | response.status >= 300 && | |
| 915 | response.status < 400 && | |
| 916 | location !== undefined && | |
| 917 | input.redirects < MAX_REDIRECTS | |
| 918 | ) { | |
| 919 | return send({ | |
| 920 | ...input, | |
| 921 | url: new URL(location, url).toString(), | |
| 922 | redirects: input.redirects + 1, | |
| 923 | }); | |
| 924 | } | |
| 925 | return Effect.succeed(response); | |
| 926 | }), | |
| 927 | ); | |
| 928 | }; | |
| 929 | ||
| 930 | const request: BitbucketApi["Service"]["request"] = (input) => | |
| 931 | send({ ...input, redirects: 0 }).pipe( | |
| 932 | Effect.flatMap((response) => | |
| 933 | HttpClientResponse.matchStatus({ | |
| 934 | // Read through the body stream rather than `text`, so an oversized diff is stopped | |
| 935 | // as it arrives instead of being materialized whole and then cut. The same collector | |
| 936 | // the process runner bounds command output with. | |
| 937 | "2xx": (success) => | |
| 938 | collectUint8StreamText({ | |
| 939 | stream: success.stream, | |
| 940 | maxBytes: input.maxBytes ?? DEFAULT_MAX_RESPONSE_BYTES, | |
| 941 | }).pipe( | |
| 942 | Effect.mapError( | |
| 943 | (cause) => | |
| 944 | new BitbucketResponseBodyReadError({ | |
| 945 | operation: "request", | |
| 946 | status: success.status, | |
| 947 | cause, | |
| 948 | }), | |
| 949 | ), | |
| 950 | Effect.map((collected) => ({ | |
| 951 | body: collected.text, | |
| 952 | truncated: collected.truncated, | |
| 953 | })), | |
| 954 | ), | |
| 955 | orElse: (failed) => responseError("request", failed), | |
| 956 | })(response), | |
| 957 | ), | |
| 958 | ); | |
| 959 | ||
| 960 | return BitbucketApi.of({ | |
| 961 | request, | |
| 962 | probeAuth: executeJson( | |
| 963 | "probeAuth", | |
| 964 | HttpClientRequest.get(apiUrl("/user")), | |
| 965 | BitbucketUserSchema, | |
| 966 | ).pipe( | |
| 967 | Effect.map((user) => ({ | |
| 968 | status: "authenticated" as const, | |
| 969 | account: nonEmpty(user.username ?? user.display_name ?? user.account_id), | |
| 970 | host: Option.some("bitbucket.org"), | |
| 971 | detail: Option.none<string>(), | |
| 972 | })), | |
| 973 | Effect.catch(() => currentCredential.pipe(Effect.map(authFromCredential))), | |
| 974 | ), | |
| 975 | listPullRequests: (input) => | |
| 976 | resolveRepository(input).pipe( | |
| 977 | Effect.flatMap((repository) => { | |
| 978 | const states = toBitbucketStates(input.state); | |
| 979 | const query: Record<string, string | ReadonlyArray<string>> = { | |
| 980 | pagelen: String(Math.max(1, Math.min(input.limit ?? 20, 50))), | |
| 981 | sort: "-updated_on", | |
| 982 | q: bitbucketQueryString([ | |
| 983 | `source.branch.name = "${SourceControlProvider.sourceBranch(input).replaceAll('"', '\\"')}"`, | |
| 984 | bitbucketStateFilter(states), | |
| 985 | ]), | |
| 986 | state: states, | |
| 987 | }; | |
| 988 | ||
| 989 | return executeJson( | |
| 990 | "listPullRequests", | |
| 991 | HttpClientRequest.get( | |
| 992 | apiUrl( | |
| 993 | `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests`, | |
| 994 | ), | |
| 995 | { urlParams: query }, | |
| 996 | ), | |
| 997 | BitbucketPullRequestListSchema, | |
| 998 | ); | |
| 999 | }), | |
| 1000 | Effect.map((list) => list.values.map(normalizeBitbucketPullRequestRecord)), | |
| 1001 | ), | |
| 1002 | getPullRequest: (input) => | |
| 1003 | getRawPullRequest(input).pipe(Effect.map(normalizeBitbucketPullRequestRecord)), | |
| 1004 | getRepositoryCloneUrls: (input) => | |
| 1005 | getRepository(input).pipe(Effect.map(normalizeRepositoryCloneUrls)), | |
| 1006 | createRepository: (input) => | |
| 1007 | requireRepositoryLocator(input.repository).pipe( | |
| 1008 | Effect.flatMap((repository) => | |
| 1009 | executeJson( | |
| 1010 | "createRepository", | |
| 1011 | HttpClientRequest.post( | |
| 1012 | apiUrl( | |
| 1013 | `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}`, | |
| 1014 | ), | |
| 1015 | ).pipe( | |
| 1016 | HttpClientRequest.bodyJsonUnsafe({ | |
| 1017 | scm: "git", | |
| 1018 | is_private: input.visibility === "private", | |
| 1019 | }), | |
| 1020 | ), | |
| 1021 | RawBitbucketRepositorySchema, | |
| 1022 | ), | |
| 1023 | ), | |
| 1024 | Effect.map(normalizeRepositoryCloneUrls), | |
| 1025 | ), | |
| 1026 | createPullRequest: (input) => | |
| 1027 | Effect.gen(function* () { | |
| 1028 | const repository = yield* resolveRepository(input); | |
| 1029 | const description = yield* fileSystem.readFileString(input.bodyFile).pipe( | |
| 1030 | Effect.mapError( | |
| 1031 | (cause) => | |
| 1032 | new BitbucketPullRequestBodyReadError({ | |
| 1033 | cwd: input.cwd, | |
| 1034 | bodyFile: input.bodyFile, | |
| 1035 | cause, | |
| 1036 | }), | |
| 1037 | ), | |
| 1038 | ); | |
| 1039 | const sourceOwner = sourceWorkspace(input); | |
| 1040 | const body = { | |
| 1041 | title: input.title, | |
| 1042 | description, | |
| 1043 | source: { | |
| 1044 | branch: { | |
| 1045 | name: SourceControlProvider.sourceBranch(input), | |
| 1046 | }, | |
| 1047 | ...(sourceOwner | |
| 1048 | ? { | |
| 1049 | repository: { | |
| 1050 | full_name: `${sourceOwner}/${input.source?.repository ?? repository.repoSlug}`, | |
| 1051 | }, | |
| 1052 | } | |
| 1053 | : {}), | |
| 1054 | }, | |
| 1055 | destination: { | |
| 1056 | branch: { | |
| 1057 | name: input.target?.refName ?? input.baseBranch, | |
| 1058 | }, | |
| 1059 | }, | |
| 1060 | }; | |
| 1061 | ||
| 1062 | yield* executeJson( | |
| 1063 | "createPullRequest", | |
| 1064 | HttpClientRequest.post( | |
| 1065 | apiUrl( | |
| 1066 | `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests`, | |
| 1067 | ), | |
| 1068 | ).pipe(HttpClientRequest.bodyJsonUnsafe(body)), | |
| 1069 | BitbucketPullRequestSchema, | |
| 1070 | ); | |
| 1071 | }), | |
| 1072 | getDefaultBranch: (input) => | |
| 1073 | resolveRepository(input).pipe( | |
| 1074 | Effect.flatMap((locator) => | |
| 1075 | Effect.all( | |
| 1076 | { | |
| 1077 | repository: getRepositoryFromLocator(locator), | |
| 1078 | branchingModel: getBranchingModelFromLocator(locator).pipe( | |
| 1079 | Effect.orElseSucceed( | |
| 1080 | (): typeof RawBitbucketBranchingModelSchema.Type | null => null, | |
| 1081 | ), | |
| 1082 | ), | |
| 1083 | }, | |
| 1084 | { concurrency: "unbounded" }, | |
| 1085 | ), | |
| 1086 | ), | |
| 1087 | Effect.map(defaultChangeRequestTargetBranch), | |
| 1088 | ), | |
| 1089 | // Bitbucket Cloud pull requests are Git-backed and Bitbucket does not provide | |
| 1090 | // an official checkout CLI. This provider-local path uses GitVcsDriver as a | |
| 1091 | // narrow escape hatch to materialize Bitbucket PR refs. Do not generalize this | |
| 1092 | // as the source-control provider model: if we support non-Git-compatible | |
| 1093 | // hosting providers or native JJ/Sapling checkout flows, move this into a | |
| 1094 | // VCS-specific change-request checkout capability. | |
| 1095 | checkoutPullRequest: (input) => | |
| 1096 | Effect.gen(function* () { | |
| 1097 | const destinationRepository = yield* resolveRepository(input); | |
| 1098 | const pullRequest = yield* getRawPullRequestFromRepository( | |
| 1099 | destinationRepository, | |
| 1100 | input.reference, | |
| 1101 | ); | |
| 1102 | const destinationRepositoryName = | |
| 1103 | repositoryNameWithOwner(pullRequest.destination.repository) ?? | |
| 1104 | `${destinationRepository.workspace}/${destinationRepository.repoSlug}`; | |
| 1105 | const sourceRepositoryName = | |
| 1106 | repositoryNameWithOwner(pullRequest.source.repository) ?? destinationRepositoryName; | |
| 1107 | const isCrossRepository = sourceRepositoryName !== destinationRepositoryName; | |
| 1108 | const remoteName = yield* resolveCheckoutRemote({ | |
| 1109 | cwd: input.cwd, | |
| 1110 | destinationRepository, | |
| 1111 | sourceRepositoryName, | |
| 1112 | isCrossRepository, | |
| 1113 | ...(input.context ? { context: input.context } : {}), | |
| 1114 | }); | |
| 1115 | const remoteBranch = pullRequest.source.branch.name; | |
| 1116 | const localBranch = checkoutBranchName({ | |
| 1117 | pullRequestId: pullRequest.id, | |
| 1118 | headBranch: remoteBranch, | |
| 1119 | isCrossRepository, | |
| 1120 | }); | |
| 1121 | const localBranchNames = yield* git.listLocalBranchNames(input.cwd); | |
| 1122 | const localBranchExists = localBranchNames.includes(localBranch); | |
| 1123 | ||
| 1124 | if (input.force === true || !localBranchExists) { | |
| 1125 | yield* git.fetchRemoteBranch({ | |
| 1126 | cwd: input.cwd, | |
| 1127 | remoteName, | |
| 1128 | remoteBranch, | |
| 1129 | localBranch, | |
| 1130 | }); | |
| 1131 | } else { | |
| 1132 | yield* git.fetchRemoteTrackingBranch({ | |
| 1133 | cwd: input.cwd, | |
| 1134 | remoteName, | |
| 1135 | remoteBranch, | |
| 1136 | }); | |
| 1137 | } | |
| 1138 | ||
| 1139 | yield* git.setBranchUpstream({ | |
| 1140 | cwd: input.cwd, | |
| 1141 | branch: localBranch, | |
| 1142 | remoteName, | |
| 1143 | remoteBranch, | |
| 1144 | }); | |
| 1145 | yield* Effect.scoped(git.switchRef({ cwd: input.cwd, refName: localBranch })); | |
| 1146 | }).pipe( | |
| 1147 | Effect.mapError((cause) => | |
| 1148 | isBitbucketApiError(cause) | |
| 1149 | ? cause | |
| 1150 | : new BitbucketCheckoutError({ | |
| 1151 | cwd: input.cwd, | |
| 1152 | reference: input.reference, | |
| 1153 | cause, | |
| 1154 | }), | |
| 1155 | ), | |
| 1156 | ), | |
| 1157 | }); | |
| 1158 | }); | |
| 1159 | ||
| 1160 | export const layer = Layer.effect(BitbucketApi, make); |
packages/source-control-bitbucket/src/server/BitbucketPullRequestApi.test.ts created+1127| ... | ... | @@ -0,0 +1,1127 @@ |
| 1 | import { afterEach, assert, expect, it, vi } from "@effect/vitest"; | |
| 2 | import * as Duration from "effect/Duration"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Layer from "effect/Layer"; | |
| 5 | import * as TestClock from "effect/testing/TestClock"; | |
| 6 | ||
| 7 | import * as BitbucketApi from "./BitbucketApi.ts"; | |
| 8 | import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts"; | |
| 9 | ||
| 10 | const mockedRequest = vi.fn<BitbucketApi.BitbucketApi["Service"]["request"]>(); | |
| 11 | ||
| 12 | const layer = it.layer( | |
| 13 | BitbucketPullRequestApi.layer.pipe( | |
| 14 | Layer.provide( | |
| 15 | Layer.mock(BitbucketApi.BitbucketApi)({ | |
| 16 | request: mockedRequest, | |
| 17 | }), | |
| 18 | ), | |
| 19 | ), | |
| 20 | ); | |
| 21 | ||
| 22 | /** The shape `request` answers with: a body plus whether it had to be cut short. */ | |
| 23 | function response(body: string) { | |
| 24 | return { body, truncated: false }; | |
| 25 | } | |
| 26 | ||
| 27 | function page(count: number, firstNumber: number, next?: string): string { | |
| 28 | return JSON.stringify({ | |
| 29 | pagelen: 50, | |
| 30 | size: count, | |
| 31 | values: Array.from({ length: count }, (_, index) => ({ | |
| 32 | id: firstNumber + index, | |
| 33 | title: `Pull request ${firstNumber + index}`, | |
| 34 | state: "OPEN", | |
| 35 | created_on: "2026-06-16T05:04:32+00:00", | |
| 36 | updated_on: "2026-06-16T05:04:33+00:00", | |
| 37 | source: { branch: { name: "feat/page" } }, | |
| 38 | destination: { branch: { name: "master" } }, | |
| 39 | links: { html: { href: `https://bitbucket.org/acme/web/pull-requests/${firstNumber}` } }, | |
| 40 | })), | |
| 41 | ...(next === undefined ? {} : { next }), | |
| 42 | }); | |
| 43 | } | |
| 44 | ||
| 45 | function valuePage(values: ReadonlyArray<unknown>, next?: string): string { | |
| 46 | return JSON.stringify({ values, ...(next === undefined ? {} : { next }) }); | |
| 47 | } | |
| 48 | ||
| 49 | /** Who opened the pull request, and two accounts that could review it. */ | |
| 50 | const bilal = { uuid: "{bilal}", nickname: "bilal" }; | |
| 51 | const octocat = { uuid: "{octocat}", nickname: "octocat" }; | |
| 52 | const hubot = { uuid: "{hubot}", nickname: "hubot" }; | |
| 53 | ||
| 54 | /** One pull request as `/pullrequests/{id}` answers with it. */ | |
| 55 | function pullRequestJson(overrides: Record<string, unknown>): string { | |
| 56 | return JSON.stringify({ | |
| 57 | id: 7, | |
| 58 | title: "Pull request 7", | |
| 59 | state: "OPEN", | |
| 60 | author: bilal, | |
| 61 | created_on: "2026-06-16T05:04:32+00:00", | |
| 62 | updated_on: "2026-06-16T05:04:33+00:00", | |
| 63 | source: { branch: { name: "feat/page" } }, | |
| 64 | destination: { branch: { name: "master" } }, | |
| 65 | links: { html: { href: "https://bitbucket.org/acme/web/pull-requests/7" } }, | |
| 66 | ...overrides, | |
| 67 | }); | |
| 68 | } | |
| 69 | ||
| 70 | /** The request the nth call made. */ | |
| 71 | function callAt(index: number) { | |
| 72 | const call = mockedRequest.mock.calls[index]; | |
| 73 | assert.isDefined(call); | |
| 74 | return call[0]; | |
| 75 | } | |
| 76 | ||
| 77 | /** The filter expression of the nth request, read back out of its query string. */ | |
| 78 | function filterOfCall(index: number): string | null { | |
| 79 | const url = callAt(index).url; | |
| 80 | return new URLSearchParams(url.slice(url.indexOf("?") + 1)).get("q"); | |
| 81 | } | |
| 82 | ||
| 83 | afterEach(() => { | |
| 84 | mockedRequest.mockReset(); | |
| 85 | }); | |
| 86 | ||
| 87 | layer("BitbucketPullRequestApi.layer", (it) => { | |
| 88 | it.effect("asks for reviewers, newest first, at Bitbucket's page ceiling", () => | |
| 89 | Effect.gen(function* () { | |
| 90 | mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(3, 1)))); | |
| 91 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 92 | ||
| 93 | const batch = yield* api.listPullRequests({ | |
| 94 | repository: "acme/web", | |
| 95 | state: "open", | |
| 96 | limit: 50, | |
| 97 | }); | |
| 98 | ||
| 99 | assert.strictEqual(batch.items.length, 3); | |
| 100 | assert.isFalse(batch.truncated); | |
| 101 | const url = callAt(0).url; | |
| 102 | expect(url).toContain("/repositories/acme/web/pullrequests"); | |
| 103 | expect(url).toContain("state=OPEN"); | |
| 104 | // Over 50 Bitbucket answers with an empty page and no error, so it is never exceeded. | |
| 105 | expect(url).toContain("pagelen=50"); | |
| 106 | expect(url).toContain("sort=-updated_on"); | |
| 107 | expect(url).toContain("fields=%2Bvalues.reviewers"); | |
| 108 | }), | |
| 109 | ); | |
| 110 | ||
| 111 | it.effect("follows the cursor Bitbucket sends rather than counting offsets", () => | |
| 112 | Effect.gen(function* () { | |
| 113 | const next = "https://api.bitbucket.org/2.0/repositories/acme/web/pullrequests?page=2"; | |
| 114 | mockedRequest | |
| 115 | .mockReturnValueOnce(Effect.succeed(response(page(50, 1, next)))) | |
| 116 | .mockReturnValueOnce(Effect.succeed(response(page(50, 51)))); | |
| 117 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 118 | ||
| 119 | const batch = yield* api.listPullRequests({ | |
| 120 | repository: "acme/web", | |
| 121 | state: "open", | |
| 122 | limit: 100, | |
| 123 | }); | |
| 124 | ||
| 125 | assert.strictEqual(batch.items.length, 100); | |
| 126 | assert.isFalse(batch.truncated); | |
| 127 | assert.strictEqual(callAt(1).url, next); | |
| 128 | }), | |
| 129 | ); | |
| 130 | ||
| 131 | it.effect("stops at the caller's page and says more remain", () => | |
| 132 | Effect.gen(function* () { | |
| 133 | const next = "https://api.bitbucket.org/2.0/repositories/acme/web/pullrequests?page=2"; | |
| 134 | mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(50, 1, next)))); | |
| 135 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 136 | ||
| 137 | const batch = yield* api.listPullRequests({ | |
| 138 | repository: "acme/web", | |
| 139 | state: "open", | |
| 140 | limit: 50, | |
| 141 | }); | |
| 142 | ||
| 143 | assert.strictEqual(batch.items.length, 50); | |
| 144 | assert.isTrue(batch.truncated); | |
| 145 | assert.strictEqual(mockedRequest.mock.calls.length, 1); | |
| 146 | }), | |
| 147 | ); | |
| 148 | ||
| 149 | it.effect("counts the rows it walked past as more to come", () => | |
| 150 | Effect.gen(function* () { | |
| 151 | // Bitbucket pages in fifties whatever was asked for, so a request for ninety-nine reads a | |
| 152 | // hundred and drops one. That row is more results, and saying otherwise takes the "load | |
| 153 | // more" away from a listing that has not finished. | |
| 154 | const next = "https://api.bitbucket.org/2.0/repositories/acme/web/pullrequests?page=2"; | |
| 155 | mockedRequest | |
| 156 | .mockReturnValueOnce(Effect.succeed(response(page(50, 1, next)))) | |
| 157 | .mockReturnValueOnce(Effect.succeed(response(page(50, 51)))); | |
| 158 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 159 | ||
| 160 | const batch = yield* api.listPullRequests({ | |
| 161 | repository: "acme/web", | |
| 162 | state: "open", | |
| 163 | limit: 99, | |
| 164 | }); | |
| 165 | ||
| 166 | assert.strictEqual(batch.items.length, 99); | |
| 167 | assert.isTrue(batch.truncated); | |
| 168 | }), | |
| 169 | ); | |
| 170 | ||
| 171 | it.effect("searches with a filter expression, which is all Bitbucket offers", () => | |
| 172 | Effect.gen(function* () { | |
| 173 | mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); | |
| 174 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 175 | ||
| 176 | yield* api.listPullRequests({ | |
| 177 | repository: "acme/web", | |
| 178 | state: "open", | |
| 179 | limit: 50, | |
| 180 | query: "page", | |
| 181 | }); | |
| 182 | ||
| 183 | expect(filterOfCall(0)).toBe('(title ~ "page" OR description ~ "page")'); | |
| 184 | // The state filter beside it still stands, which the brackets are there to keep. | |
| 185 | expect(callAt(0).url).toContain("state=OPEN"); | |
| 186 | }), | |
| 187 | ); | |
| 188 | ||
| 189 | it.effect("escapes a quote and a backslash, so a search cannot reshape the filter", () => | |
| 190 | Effect.gen(function* () { | |
| 191 | mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); | |
| 192 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 193 | ||
| 194 | yield* api.listPullRequests({ | |
| 195 | repository: "acme/web", | |
| 196 | state: "open", | |
| 197 | limit: 50, | |
| 198 | query: String.raw`a\" OR state = "MERGED"`, | |
| 199 | }); | |
| 200 | ||
| 201 | const literal = String.raw`a\\\" OR state = \"MERGED\"`; | |
| 202 | expect(filterOfCall(0)).toBe(`(title ~ "${literal}" OR description ~ "${literal}")`); | |
| 203 | }), | |
| 204 | ); | |
| 205 | ||
| 206 | it.effect("asks for no filter at all when the reader typed only spaces", () => | |
| 207 | Effect.gen(function* () { | |
| 208 | mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); | |
| 209 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 210 | ||
| 211 | yield* api.listPullRequests({ | |
| 212 | repository: "acme/web", | |
| 213 | state: "open", | |
| 214 | limit: 50, | |
| 215 | query: " ", | |
| 216 | }); | |
| 217 | ||
| 218 | assert.isNull(filterOfCall(0)); | |
| 219 | }), | |
| 220 | ); | |
| 221 | ||
| 222 | it.effect("carries on from the instant the last slice ended on", () => | |
| 223 | Effect.gen(function* () { | |
| 224 | mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); | |
| 225 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 226 | ||
| 227 | yield* api.listPullRequests({ | |
| 228 | repository: "acme/web", | |
| 229 | state: "open", | |
| 230 | limit: 50, | |
| 231 | cursor: { updatedBefore: "2026-07-02T00:00:00.123456+00:00", delivered: 50 }, | |
| 232 | }); | |
| 233 | ||
| 234 | // Inclusive, so the rows already sent at that instant come back for the caller to drop. | |
| 235 | expect(filterOfCall(0)).toBe("updated_on <= 2026-07-02T00:00:00.123456+00:00"); | |
| 236 | expect(callAt(0).url).toContain("sort=-updated_on"); | |
| 237 | }), | |
| 238 | ); | |
| 239 | ||
| 240 | it.effect("narrows by the reader's words and by where it left off at once", () => | |
| 241 | Effect.gen(function* () { | |
| 242 | mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); | |
| 243 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 244 | ||
| 245 | yield* api.listPullRequests({ | |
| 246 | repository: "acme/web", | |
| 247 | state: "open", | |
| 248 | limit: 50, | |
| 249 | query: "page", | |
| 250 | cursor: { updatedBefore: "2026-07-02T00:00:00+00:00", delivered: 50 }, | |
| 251 | }); | |
| 252 | ||
| 253 | // Bitbucket takes one `q`, so the two narrowings are joined rather than one replacing the | |
| 254 | // other — and the search keeps its brackets, which is what keeps the AND out of its OR. | |
| 255 | expect(filterOfCall(0)).toBe( | |
| 256 | '(title ~ "page" OR description ~ "page") AND updated_on <= 2026-07-02T00:00:00+00:00', | |
| 257 | ); | |
| 258 | }), | |
| 259 | ); | |
| 260 | ||
| 261 | it.effect("asks for declined pull requests on the closed tab", () => | |
| 262 | Effect.gen(function* () { | |
| 263 | mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); | |
| 264 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 265 | ||
| 266 | yield* api.listPullRequests({ repository: "acme/web", state: "closed", limit: 50 }); | |
| 267 | ||
| 268 | expect(callAt(0).url).toContain("state=DECLINED"); | |
| 269 | }), | |
| 270 | ); | |
| 271 | ||
| 272 | it.effect("asks for every state at once on the All tab", () => | |
| 273 | Effect.gen(function* () { | |
| 274 | mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); | |
| 275 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 276 | ||
| 277 | yield* api.listPullRequests({ repository: "acme/web", state: "all", limit: 50 }); | |
| 278 | ||
| 279 | // Bitbucket unions repeated state parameters, which is the only way to span them. | |
| 280 | const url = callAt(0).url; | |
| 281 | for (const state of ["OPEN", "MERGED", "DECLINED", "SUPERSEDED"]) { | |
| 282 | expect(url).toContain(`state=${state}`); | |
| 283 | } | |
| 284 | }), | |
| 285 | ); | |
| 286 | ||
| 287 | it.effect("counts a superseded pull request as closed", () => | |
| 288 | Effect.gen(function* () { | |
| 289 | mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); | |
| 290 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 291 | ||
| 292 | yield* api.listPullRequests({ repository: "acme/web", state: "closed", limit: 50 }); | |
| 293 | ||
| 294 | expect(callAt(0).url).toContain("state=DECLINED"); | |
| 295 | expect(callAt(0).url).toContain("state=SUPERSEDED"); | |
| 296 | }), | |
| 297 | ); | |
| 298 | ||
| 299 | it.effect("refuses a repository that is not workspace and slug", () => | |
| 300 | Effect.gen(function* () { | |
| 301 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 302 | ||
| 303 | const error = yield* Effect.flip( | |
| 304 | api.listPullRequests({ repository: "acme/team/web", state: "open", limit: 50 }), | |
| 305 | ); | |
| 306 | ||
| 307 | assert.strictEqual(error._tag, "BitbucketRepositoryUnsupportedError"); | |
| 308 | assert.strictEqual(mockedRequest.mock.calls.length, 0); | |
| 309 | }), | |
| 310 | ); | |
| 311 | ||
| 312 | it.effect("returns the diff verbatim, because Bitbucket already sends a patch", () => | |
| 313 | Effect.gen(function* () { | |
| 314 | const patch = "diff --git a/a.ts b/a.ts\n--- a/a.ts\n+++ b/a.ts\n@@ -1 +1 @@\n-a\n+b\n"; | |
| 315 | mockedRequest.mockReturnValueOnce(Effect.succeed(response(patch))); | |
| 316 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 317 | ||
| 318 | const diff = yield* api.getPullRequestDiff({ repository: "acme/web", number: 7 }); | |
| 319 | ||
| 320 | assert.strictEqual(diff.patch, patch); | |
| 321 | assert.isFalse(diff.truncated); | |
| 322 | expect(callAt(0)).toMatchObject({ | |
| 323 | url: "/repositories/acme/web/pullrequests/7/diff", | |
| 324 | // A diff of any size would otherwise be read into memory whole. | |
| 325 | maxBytes: 8 * 1024 * 1024, | |
| 326 | }); | |
| 327 | }), | |
| 328 | ); | |
| 329 | ||
| 330 | it.effect("reads a named commit's own patch, which pages no further than the whole of it", () => | |
| 331 | Effect.gen(function* () { | |
| 332 | const patch = "diff --git a/a.ts b/a.ts\n--- a/a.ts\n+++ b/a.ts\n@@ -1 +1 @@\n-a\n+b\n"; | |
| 333 | mockedRequest.mockReturnValueOnce(Effect.succeed(response(patch))); | |
| 334 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 335 | ||
| 336 | const diff = yield* api.getPullRequestDiff({ | |
| 337 | repository: "acme/web", | |
| 338 | number: 7, | |
| 339 | commit: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", | |
| 340 | }); | |
| 341 | ||
| 342 | assert.strictEqual(diff.patch, patch); | |
| 343 | expect(callAt(0)).toMatchObject({ | |
| 344 | url: "/repositories/acme/web/diff/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", | |
| 345 | maxBytes: 8 * 1024 * 1024, | |
| 346 | }); | |
| 347 | }), | |
| 348 | ); | |
| 349 | ||
| 350 | it.effect("refuses a commit that is not a sha rather than reading it into a URL", () => | |
| 351 | Effect.gen(function* () { | |
| 352 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 353 | ||
| 354 | const error = yield* Effect.flip( | |
| 355 | api.getPullRequestDiff({ | |
| 356 | repository: "acme/web", | |
| 357 | number: 7, | |
| 358 | commit: "../../acme/other/diff/deadbeef", | |
| 359 | }), | |
| 360 | ); | |
| 361 | ||
| 362 | assert.strictEqual(error._tag, "BitbucketDiffCommitError"); | |
| 363 | assert.strictEqual(mockedRequest.mock.calls.length, 0); | |
| 364 | }), | |
| 365 | ); | |
| 366 | ||
| 367 | it.effect("reads every file version the patch states, not only the paths asked about", () => | |
| 368 | Effect.gen(function* () { | |
| 369 | mockedRequest.mockReturnValueOnce( | |
| 370 | Effect.succeed( | |
| 371 | response( | |
| 372 | [ | |
| 373 | "diff --git a/a.ts b/a.ts", | |
| 374 | "index 1111111..2222222 100644", | |
| 375 | "--- a/a.ts", | |
| 376 | "+++ b/a.ts", | |
| 377 | "@@ -1 +1 @@", | |
| 378 | "-a", | |
| 379 | "+b", | |
| 380 | "diff --git a/b.ts b/b.ts", | |
| 381 | "index 3333333..4444444 100644", | |
| 382 | "--- a/b.ts", | |
| 383 | "+++ b/b.ts", | |
| 384 | "@@ -1 +1 @@", | |
| 385 | "-c", | |
| 386 | "+d", | |
| 387 | "", | |
| 388 | ].join("\n"), | |
| 389 | ), | |
| 390 | ), | |
| 391 | ); | |
| 392 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 393 | ||
| 394 | const revisions = yield* api.getFileRevisions({ | |
| 395 | repository: "acme/web", | |
| 396 | number: 71, | |
| 397 | paths: ["a.ts", "missing.ts"], | |
| 398 | }); | |
| 399 | ||
| 400 | // `b.ts` was not asked about and is reported anyway: parsing the patch for `a.ts` read it | |
| 401 | // too, and the caller holding it is what stops the next tick paying for the patch again. | |
| 402 | // `missing.ts` was asked about and the whole patch was read without finding it, which is | |
| 403 | // what a file this pull request deletes looks like, so it is answered as the empty version. | |
| 404 | assert.deepStrictEqual( | |
| 405 | [...revisions.revisions], | |
| 406 | [ | |
| 407 | ["a.ts", "2222222"], | |
| 408 | ["b.ts", "4444444"], | |
| 409 | ["missing.ts", ""], | |
| 410 | ], | |
| 411 | ); | |
| 412 | assert.strictEqual(revisions.complete, true); | |
| 413 | expect(callAt(0)).toMatchObject({ url: "/repositories/acme/web/pullrequests/71/diff" }); | |
| 414 | }), | |
| 415 | ); | |
| 416 | ||
| 417 | it.effect("says nothing about the files past the end of a patch it could not read whole", () => | |
| 418 | Effect.gen(function* () { | |
| 419 | // Bitbucket's patch is read up to a byte ceiling, and a file past the cut was not looked at. | |
| 420 | // Answering for it as deleted would clear a mark on it once and for good. | |
| 421 | mockedRequest.mockReturnValueOnce( | |
| 422 | Effect.succeed({ | |
| 423 | body: "diff --git a/a.ts b/a.ts\nindex 1111111..2222222 100644\n@@ -1 +1 @@\n", | |
| 424 | truncated: true, | |
| 425 | }), | |
| 426 | ); | |
| 427 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 428 | ||
| 429 | const revisions = yield* api.getFileRevisions({ | |
| 430 | repository: "acme/web", | |
| 431 | number: 72, | |
| 432 | paths: ["a.ts", "past-the-cut.ts"], | |
| 433 | }); | |
| 434 | ||
| 435 | assert.deepStrictEqual([...revisions.revisions], [["a.ts", "2222222"]]); | |
| 436 | // `past-the-cut.ts` gets no empty version, and nothing here may be held as the whole story. | |
| 437 | assert.strictEqual(revisions.complete, false); | |
| 438 | }), | |
| 439 | ); | |
| 440 | ||
| 441 | it.effect("reads the patch once for a run of ticks, not once a tick", () => | |
| 442 | Effect.gen(function* () { | |
| 443 | mockedRequest.mockReturnValue( | |
| 444 | Effect.succeed( | |
| 445 | response( | |
| 446 | [ | |
| 447 | "diff --git a/a.ts b/a.ts", | |
| 448 | "index 1111111..2222222 100644", | |
| 449 | "@@ -1 +1 @@", | |
| 450 | "diff --git a/b.ts b/b.ts", | |
| 451 | "index 3333333..4444444 100644", | |
| 452 | "@@ -1 +1 @@", | |
| 453 | "", | |
| 454 | ].join("\n"), | |
| 455 | ), | |
| 456 | ), | |
| 457 | ); | |
| 458 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 459 | ||
| 460 | const first = yield* api.getFileRevisions({ | |
| 461 | repository: "acme/web", | |
| 462 | number: 74, | |
| 463 | paths: ["a.ts"], | |
| 464 | }); | |
| 465 | // A path nobody has asked about before, which is what every tick after the first names. | |
| 466 | const second = yield* api.getFileRevisions({ | |
| 467 | repository: "acme/web", | |
| 468 | number: 74, | |
| 469 | paths: ["b.ts"], | |
| 470 | }); | |
| 471 | ||
| 472 | const both = [ | |
| 473 | ["a.ts", "2222222"], | |
| 474 | ["b.ts", "4444444"], | |
| 475 | ]; | |
| 476 | assert.deepStrictEqual([...first.revisions], both); | |
| 477 | assert.deepStrictEqual([...second.revisions], both); | |
| 478 | assert.strictEqual(mockedRequest.mock.calls.length, 1); | |
| 479 | }), | |
| 480 | ); | |
| 481 | ||
| 482 | it.effect("reads the patch afresh once the one it held has aged out", () => | |
| 483 | Effect.gen(function* () { | |
| 484 | mockedRequest.mockReturnValue( | |
| 485 | Effect.succeed( | |
| 486 | response("diff --git a/a.ts b/a.ts\nindex 1111111..2222222 100644\n@@ -1 +1 @@\n"), | |
| 487 | ), | |
| 488 | ); | |
| 489 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 490 | const read = () => | |
| 491 | api.getFileRevisions({ repository: "acme/web", number: 75, paths: ["a.ts"] }); | |
| 492 | ||
| 493 | yield* read(); | |
| 494 | yield* read(); | |
| 495 | assert.strictEqual(mockedRequest.mock.calls.length, 1); | |
| 496 | ||
| 497 | // Well inside the window the caller holds versions for: a refresh drops what it holds so | |
| 498 | // that the read after it reaches Bitbucket, and this must not answer that read instead. | |
| 499 | yield* TestClock.adjust(Duration.seconds(30)); | |
| 500 | yield* read(); | |
| 501 | assert.strictEqual(mockedRequest.mock.calls.length, 2); | |
| 502 | }), | |
| 503 | ); | |
| 504 | ||
| 505 | it.effect("asks Bitbucket nothing when no file has been ticked off", () => | |
| 506 | Effect.gen(function* () { | |
| 507 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 508 | ||
| 509 | const revisions = yield* api.getFileRevisions({ | |
| 510 | repository: "acme/web", | |
| 511 | number: 73, | |
| 512 | paths: [], | |
| 513 | }); | |
| 514 | ||
| 515 | assert.strictEqual(revisions.revisions.size, 0); | |
| 516 | assert.strictEqual(mockedRequest.mock.calls.length, 0); | |
| 517 | }), | |
| 518 | ); | |
| 519 | ||
| 520 | it.effect("aggregates every diffstat page", () => | |
| 521 | Effect.gen(function* () { | |
| 522 | const next = "https://api.bitbucket.org/2.0/diffstat?page=2"; | |
| 523 | mockedRequest | |
| 524 | .mockReturnValueOnce( | |
| 525 | Effect.succeed( | |
| 526 | response( | |
| 527 | valuePage( | |
| 528 | [ | |
| 529 | { lines_added: 9, lines_removed: 2 }, | |
| 530 | { lines_added: 3, lines_removed: 1 }, | |
| 531 | ], | |
| 532 | next, | |
| 533 | ), | |
| 534 | ), | |
| 535 | ), | |
| 536 | ) | |
| 537 | .mockReturnValueOnce( | |
| 538 | Effect.succeed(response(valuePage([{ lines_added: 4, lines_removed: 7 }]))), | |
| 539 | ); | |
| 540 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 541 | ||
| 542 | const stat = yield* api.getDiffStat({ repository: "acme/web", number: 7 }); | |
| 543 | ||
| 544 | expect(stat).toEqual({ additions: 16, deletions: 10, changedFiles: 3 }); | |
| 545 | expect(callAt(1).url).toBe(next); | |
| 546 | }), | |
| 547 | ); | |
| 548 | ||
| 549 | it.effect("returns the complete commit timeline oldest first across pages", () => | |
| 550 | Effect.gen(function* () { | |
| 551 | const next = "https://api.bitbucket.org/2.0/commits?page=2"; | |
| 552 | mockedRequest | |
| 553 | .mockReturnValueOnce( | |
| 554 | Effect.succeed( | |
| 555 | response( | |
| 556 | valuePage( | |
| 557 | [ | |
| 558 | { hash: "ddd", message: "fourth", date: "2026-07-04T00:00:00Z" }, | |
| 559 | { hash: "ccc", message: "third", date: "2026-07-03T00:00:00Z" }, | |
| 560 | ], | |
| 561 | next, | |
| 562 | ), | |
| 563 | ), | |
| 564 | ), | |
| 565 | ) | |
| 566 | .mockReturnValueOnce( | |
| 567 | Effect.succeed( | |
| 568 | response( | |
| 569 | valuePage([ | |
| 570 | { hash: "bbb", message: "second", date: "2026-07-02T00:00:00Z" }, | |
| 571 | { hash: "aaa", message: "first", date: "2026-07-01T00:00:00Z" }, | |
| 572 | ]), | |
| 573 | ), | |
| 574 | ), | |
| 575 | ); | |
| 576 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 577 | ||
| 578 | const commits = yield* api.listCommits({ repository: "acme/web", number: 7 }); | |
| 579 | ||
| 580 | expect(commits.map((commit) => commit.oid)).toEqual(["aaa", "bbb", "ccc", "ddd"]); | |
| 581 | expect(callAt(1).url).toBe(next); | |
| 582 | }), | |
| 583 | ); | |
| 584 | ||
| 585 | it.effect("returns build statuses from every page", () => | |
| 586 | Effect.gen(function* () { | |
| 587 | const next = "https://api.bitbucket.org/2.0/statuses?page=2"; | |
| 588 | mockedRequest | |
| 589 | .mockReturnValueOnce( | |
| 590 | Effect.succeed(response(valuePage([{ name: "Build", state: "SUCCESSFUL" }], next))), | |
| 591 | ) | |
| 592 | .mockReturnValueOnce( | |
| 593 | Effect.succeed(response(valuePage([{ name: "Lint", state: "FAILED" }]))), | |
| 594 | ); | |
| 595 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 596 | ||
| 597 | const checks = yield* api.listChecks({ repository: "acme/web", number: 7 }); | |
| 598 | ||
| 599 | expect(checks.map((check) => [check.name, check.status])).toEqual([ | |
| 600 | ["Build", "success"], | |
| 601 | ["Lint", "failure"], | |
| 602 | ]); | |
| 603 | expect(callAt(1).url).toBe(next); | |
| 604 | }), | |
| 605 | ); | |
| 606 | ||
| 607 | it.effect("reads an empty conflict list as mergeable", () => | |
| 608 | Effect.gen(function* () { | |
| 609 | mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); | |
| 610 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 611 | ||
| 612 | const mergeability = yield* api.getMergeability({ repository: "acme/web", number: 7 }); | |
| 613 | ||
| 614 | assert.strictEqual(mergeability, "mergeable"); | |
| 615 | expect(callAt(0).url).toBe("/repositories/acme/web/pullrequests/7/conflicts"); | |
| 616 | }), | |
| 617 | ); | |
| 618 | ||
| 619 | it.effect("merges with Bitbucket's own name for the strategy", () => | |
| 620 | Effect.gen(function* () { | |
| 621 | mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); | |
| 622 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 623 | ||
| 624 | yield* api.runAction({ | |
| 625 | repository: "acme/web", | |
| 626 | number: 7, | |
| 627 | action: "merge", | |
| 628 | mergeMethod: "rebase", | |
| 629 | }); | |
| 630 | ||
| 631 | expect(callAt(0)).toMatchObject({ | |
| 632 | method: "POST", | |
| 633 | url: "/repositories/acme/web/pullrequests/7/merge", | |
| 634 | body: '{"merge_strategy":"rebase_fast_forward"}', | |
| 635 | }); | |
| 636 | }), | |
| 637 | ); | |
| 638 | ||
| 639 | it.effect("closes a pull request by declining it", () => | |
| 640 | Effect.gen(function* () { | |
| 641 | mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); | |
| 642 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 643 | ||
| 644 | yield* api.runAction({ repository: "acme/web", number: 7, action: "close" }); | |
| 645 | ||
| 646 | expect(callAt(0)).toMatchObject({ | |
| 647 | method: "POST", | |
| 648 | url: "/repositories/acme/web/pullrequests/7/decline", | |
| 649 | }); | |
| 650 | }), | |
| 651 | ); | |
| 652 | ||
| 653 | it.effect("posts a comment as a JSON document, so the body stays text", () => | |
| 654 | Effect.gen(function* () { | |
| 655 | mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); | |
| 656 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 657 | ||
| 658 | yield* api.comment({ repository: "acme/web", number: 7, body: "true" }); | |
| 659 | ||
| 660 | expect(callAt(0)).toMatchObject({ | |
| 661 | method: "POST", | |
| 662 | url: "/repositories/acme/web/pullrequests/7/comments", | |
| 663 | body: '{"content":{"raw":"true"}}', | |
| 664 | }); | |
| 665 | }), | |
| 666 | ); | |
| 667 | ||
| 668 | it.effect("rewrites a title alone, without touching anything else", () => | |
| 669 | Effect.gen(function* () { | |
| 670 | mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); | |
| 671 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 672 | ||
| 673 | yield* api.updateChangeRequest({ repository: "acme/web", number: 7, title: "A new title" }); | |
| 674 | ||
| 675 | const call = callAt(0); | |
| 676 | expect(call.method).toBe("PUT"); | |
| 677 | expect(call.url).toBe("/repositories/acme/web/pullrequests/7"); | |
| 678 | // Bitbucket's PUT is a partial update, so a field left out of the body is left as it was. | |
| 679 | expect(JSON.parse(call.body ?? "")).toEqual({ title: "A new title" }); | |
| 680 | }), | |
| 681 | ); | |
| 682 | ||
| 683 | it.effect("leaves out the half of the pull request it was not asked about", () => | |
| 684 | Effect.gen(function* () { | |
| 685 | mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); | |
| 686 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 687 | ||
| 688 | yield* api.updateChangeRequest({ repository: "acme/web", number: 7, body: "New body." }); | |
| 689 | ||
| 690 | expect(JSON.parse(callAt(0).body ?? "")).toEqual({ description: "New body." }); | |
| 691 | }), | |
| 692 | ); | |
| 693 | ||
| 694 | it.effect("writes both fields when both were rewritten", () => | |
| 695 | Effect.gen(function* () { | |
| 696 | mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); | |
| 697 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 698 | ||
| 699 | yield* api.updateChangeRequest({ | |
| 700 | repository: "acme/web", | |
| 701 | number: 7, | |
| 702 | title: "A new title", | |
| 703 | body: "New body.", | |
| 704 | }); | |
| 705 | ||
| 706 | expect(JSON.parse(callAt(0).body ?? "")).toEqual({ | |
| 707 | title: "A new title", | |
| 708 | description: "New body.", | |
| 709 | }); | |
| 710 | }), | |
| 711 | ); | |
| 712 | ||
| 713 | it.effect("rewrites a comment where it stands, whichever kind it is", () => | |
| 714 | Effect.gen(function* () { | |
| 715 | mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); | |
| 716 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 717 | ||
| 718 | yield* api.updateComment({ | |
| 719 | repository: "acme/web", | |
| 720 | number: 7, | |
| 721 | commentId: "10", | |
| 722 | body: "Edited.", | |
| 723 | }); | |
| 724 | ||
| 725 | expect(callAt(0)).toMatchObject({ | |
| 726 | method: "PUT", | |
| 727 | url: "/repositories/acme/web/pullrequests/7/comments/10", | |
| 728 | body: '{"content":{"raw":"Edited."}}', | |
| 729 | }); | |
| 730 | }), | |
| 731 | ); | |
| 732 | ||
| 733 | it.effect("fails the read when Bitbucket answers with something unreadable", () => | |
| 734 | Effect.gen(function* () { | |
| 735 | mockedRequest.mockReturnValueOnce( | |
| 736 | Effect.succeed(response(JSON.stringify({ error: "nope" }))), | |
| 737 | ); | |
| 738 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 739 | ||
| 740 | const error = yield* Effect.flip(api.getPullRequest({ repository: "acme/web", number: 7 })); | |
| 741 | ||
| 742 | assert.strictEqual(error._tag, "BitbucketPullRequestReadError"); | |
| 743 | }), | |
| 744 | ); | |
| 745 | ||
| 746 | it.effect("states a failure once, without stacking one message inside another", () => | |
| 747 | Effect.gen(function* () { | |
| 748 | mockedRequest.mockReturnValueOnce( | |
| 749 | Effect.fail( | |
| 750 | new BitbucketApi.BitbucketResponseError({ | |
| 751 | operation: "request", | |
| 752 | status: 500, | |
| 753 | responseBodyLength: 0, | |
| 754 | }), | |
| 755 | ), | |
| 756 | ); | |
| 757 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 758 | ||
| 759 | const error = yield* Effect.flip(api.getViewer()); | |
| 760 | ||
| 761 | // The fact only; the provider adds the operation around it. | |
| 762 | assert.strictEqual(error.detail, "Bitbucket returned HTTP 500."); | |
| 763 | }), | |
| 764 | ); | |
| 765 | ||
| 766 | it.effect("fails when the credentials belong to no named account", () => | |
| 767 | Effect.gen(function* () { | |
| 768 | mockedRequest.mockReturnValueOnce(Effect.succeed(response(JSON.stringify({})))); | |
| 769 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 770 | ||
| 771 | const error = yield* Effect.flip(api.getViewer()); | |
| 772 | ||
| 773 | assert.strictEqual(error._tag, "BitbucketViewerUnavailableError"); | |
| 774 | }), | |
| 775 | ); | |
| 776 | ||
| 777 | it.effect("follows Bitbucket's cursor and reassembles a thread that spans two pages", () => | |
| 778 | Effect.gen(function* () { | |
| 779 | mockedRequest.mockReturnValueOnce( | |
| 780 | Effect.succeed( | |
| 781 | response( | |
| 782 | JSON.stringify({ | |
| 783 | next: "https://api.bitbucket.org/2.0/comments?page=2", | |
| 784 | values: [ | |
| 785 | { | |
| 786 | id: 10, | |
| 787 | content: { raw: "rename this" }, | |
| 788 | user: { nickname: "bilal" }, | |
| 789 | created_on: "2026-06-16T05:04:32+00:00", | |
| 790 | inline: { path: "src/a.ts", to: 12 }, | |
| 791 | }, | |
| 792 | ], | |
| 793 | }), | |
| 794 | ), | |
| 795 | ), | |
| 796 | ); | |
| 797 | mockedRequest.mockReturnValueOnce( | |
| 798 | Effect.succeed( | |
| 799 | response( | |
| 800 | // The reply arrives a page after the remark it answers, which is why the threads | |
| 801 | // are only assembled once every page is in hand. | |
| 802 | JSON.stringify({ | |
| 803 | values: [ | |
| 804 | { | |
| 805 | id: 11, | |
| 806 | content: { raw: "done" }, | |
| 807 | user: { nickname: "julius" }, | |
| 808 | created_on: "2026-06-16T06:04:32+00:00", | |
| 809 | parent: { id: 10 }, | |
| 810 | }, | |
| 811 | ], | |
| 812 | }), | |
| 813 | ), | |
| 814 | ), | |
| 815 | ); | |
| 816 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 817 | ||
| 818 | const { comments, threads, truncated } = yield* api.listComments({ | |
| 819 | repository: "acme/web", | |
| 820 | number: 7, | |
| 821 | }); | |
| 822 | ||
| 823 | expect(callAt(1).url).toBe("https://api.bitbucket.org/2.0/comments?page=2"); | |
| 824 | expect(comments.map((comment) => comment.id)).toEqual(["10", "11"]); | |
| 825 | expect(threads[0]?.comments.map((comment) => comment.id)).toEqual(["10", "11"]); | |
| 826 | assert.isFalse(truncated); | |
| 827 | }), | |
| 828 | ); | |
| 829 | ||
| 830 | it.effect("stops the comment walk at its bound and says the conversation was cut short", () => | |
| 831 | Effect.gen(function* () { | |
| 832 | // Bitbucket that always names a next page: the walk has to end itself. | |
| 833 | mockedRequest.mockReturnValue( | |
| 834 | Effect.succeed( | |
| 835 | response( | |
| 836 | JSON.stringify({ | |
| 837 | next: "https://api.bitbucket.org/2.0/comments?page=2", | |
| 838 | values: [ | |
| 839 | { | |
| 840 | id: 10, | |
| 841 | content: { raw: "again" }, | |
| 842 | created_on: "2026-06-16T05:04:32+00:00", | |
| 843 | }, | |
| 844 | ], | |
| 845 | }), | |
| 846 | ), | |
| 847 | ), | |
| 848 | ); | |
| 849 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 850 | ||
| 851 | const { truncated } = yield* api.listComments({ repository: "acme/web", number: 7 }); | |
| 852 | ||
| 853 | assert.strictEqual(mockedRequest.mock.calls.length, 10); | |
| 854 | assert.isTrue(truncated); | |
| 855 | }), | |
| 856 | ); | |
| 857 | ||
| 858 | it.effect("reassembles a thread from the flat comment list, replies included", () => | |
| 859 | Effect.gen(function* () { | |
| 860 | mockedRequest.mockReturnValueOnce( | |
| 861 | Effect.succeed( | |
| 862 | response( | |
| 863 | JSON.stringify({ | |
| 864 | values: [ | |
| 865 | { | |
| 866 | id: 10, | |
| 867 | content: { raw: "rename this" }, | |
| 868 | user: { nickname: "bilal" }, | |
| 869 | created_on: "2026-06-16T05:04:32+00:00", | |
| 870 | inline: { path: "src/a.ts", to: 12, from: null }, | |
| 871 | resolution: { type: "pullrequest_comment_resolution" }, | |
| 872 | }, | |
| 873 | { | |
| 874 | id: 11, | |
| 875 | content: { raw: "done" }, | |
| 876 | user: { nickname: "julius" }, | |
| 877 | created_on: "2026-06-16T06:04:32+00:00", | |
| 878 | parent: { id: 10 }, | |
| 879 | }, | |
| 880 | // A reply to a reply still belongs to the thread its root opened. | |
| 881 | { | |
| 882 | id: 12, | |
| 883 | content: { raw: "thanks" }, | |
| 884 | user: { nickname: "bilal" }, | |
| 885 | created_on: "2026-06-16T07:04:32+00:00", | |
| 886 | parent: { id: 11 }, | |
| 887 | }, | |
| 888 | { | |
| 889 | id: 13, | |
| 890 | content: { raw: "ship it" }, | |
| 891 | user: { nickname: "bilal" }, | |
| 892 | created_on: "2026-06-16T08:04:32+00:00", | |
| 893 | }, | |
| 894 | ], | |
| 895 | }), | |
| 896 | ), | |
| 897 | ), | |
| 898 | ); | |
| 899 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 900 | ||
| 901 | const { threads } = yield* api.listComments({ repository: "acme/web", number: 7 }); | |
| 902 | ||
| 903 | assert.strictEqual(threads.length, 1); | |
| 904 | expect(threads[0]).toMatchObject({ | |
| 905 | id: "10", | |
| 906 | path: "src/a.ts", | |
| 907 | line: 12, | |
| 908 | side: "right", | |
| 909 | isResolved: true, | |
| 910 | }); | |
| 911 | expect(threads[0]?.comments.map((comment) => comment.id)).toEqual(["10", "11", "12"]); | |
| 912 | }), | |
| 913 | ); | |
| 914 | ||
| 915 | it.effect("writes a review's line comments, its summary, then its verdict", () => | |
| 916 | Effect.gen(function* () { | |
| 917 | mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); | |
| 918 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 919 | ||
| 920 | yield* api.submitReview({ | |
| 921 | repository: "acme/web", | |
| 922 | number: 7, | |
| 923 | verdict: "request-changes", | |
| 924 | body: "Two things.", | |
| 925 | comments: [ | |
| 926 | { | |
| 927 | path: "src/a.ts", | |
| 928 | position: { kind: "deleted", oldLine: 12 }, | |
| 929 | body: "why remove?", | |
| 930 | }, | |
| 931 | ], | |
| 932 | }); | |
| 933 | ||
| 934 | expect(callAt(0).url).toContain("/pullrequests/7/comments"); | |
| 935 | expect(JSON.parse(callAt(0).body ?? "")).toEqual({ | |
| 936 | content: { raw: "why remove?" }, | |
| 937 | inline: { path: "src/a.ts", from: 12 }, | |
| 938 | }); | |
| 939 | expect(callAt(1).url).toContain("/pullrequests/7/comments"); | |
| 940 | // The verdict goes last, so a review that failed part-way is never a rejection either. | |
| 941 | expect(callAt(2).url).toContain("/pullrequests/7/request-changes"); | |
| 942 | }), | |
| 943 | ); | |
| 944 | ||
| 945 | it.effect("resolves by creating the sub-resource and unresolves by deleting it", () => | |
| 946 | Effect.gen(function* () { | |
| 947 | mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); | |
| 948 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 949 | ||
| 950 | yield* api.setCommentResolution({ | |
| 951 | repository: "acme/web", | |
| 952 | number: 7, | |
| 953 | commentId: "10", | |
| 954 | resolved: true, | |
| 955 | }); | |
| 956 | yield* api.setCommentResolution({ | |
| 957 | repository: "acme/web", | |
| 958 | number: 7, | |
| 959 | commentId: "10", | |
| 960 | resolved: false, | |
| 961 | }); | |
| 962 | ||
| 963 | assert.strictEqual(callAt(0).method, "POST"); | |
| 964 | assert.strictEqual(callAt(1).method, "DELETE"); | |
| 965 | expect(callAt(0).url).toContain("/comments/10/resolve"); | |
| 966 | }), | |
| 967 | ); | |
| 968 | ||
| 969 | it.effect("replies by naming the comment it answers", () => | |
| 970 | Effect.gen(function* () { | |
| 971 | mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); | |
| 972 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 973 | ||
| 974 | yield* api.replyToComment({ | |
| 975 | repository: "acme/web", | |
| 976 | number: 7, | |
| 977 | commentId: "10", | |
| 978 | body: "Fixed.", | |
| 979 | }); | |
| 980 | ||
| 981 | expect(JSON.parse(callAt(0).body ?? "")).toEqual({ | |
| 982 | content: { raw: "Fixed." }, | |
| 983 | parent: { id: 10 }, | |
| 984 | }); | |
| 985 | }), | |
| 986 | ); | |
| 987 | ||
| 988 | it.effect("asks for the credentials' permission on this repository, and nobody else's", () => | |
| 989 | Effect.gen(function* () { | |
| 990 | mockedRequest.mockReturnValue( | |
| 991 | Effect.succeed( | |
| 992 | response( | |
| 993 | JSON.stringify({ values: [{ type: "repository_permission", permission: "read" }] }), | |
| 994 | ), | |
| 995 | ), | |
| 996 | ); | |
| 997 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 998 | ||
| 999 | assert.isFalse(yield* api.getRepositoryPermission({ repository: "acme/web" })); | |
| 1000 | ||
| 1001 | expect(callAt(0).url).toContain("/user/permissions/repositories"); | |
| 1002 | assert.strictEqual(filterOfCall(0), 'repository.full_name="acme/web"'); | |
| 1003 | }), | |
| 1004 | ); | |
| 1005 | ||
| 1006 | it.effect("escapes a repository name before it goes inside a filter literal", () => | |
| 1007 | Effect.gen(function* () { | |
| 1008 | mockedRequest.mockReturnValue(Effect.succeed(response(JSON.stringify({ values: [] })))); | |
| 1009 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 1010 | ||
| 1011 | yield* api.getRepositoryPermission({ repository: 'acme/we"b' }); | |
| 1012 | ||
| 1013 | // A quote would otherwise end the literal and leave the rest standing as filter syntax. | |
| 1014 | assert.strictEqual(filterOfCall(0), 'repository.full_name="acme/we\\"b"'); | |
| 1015 | }), | |
| 1016 | ); | |
| 1017 | ||
| 1018 | it.effect( | |
| 1019 | "reads a removed permissions endpoint as granted rather than failing the merge on it", | |
| 1020 | () => | |
| 1021 | Effect.gen(function* () { | |
| 1022 | // Bitbucket retired /user/permissions/repositories under CHANGE-2770: every account now | |
| 1023 | // gets HTTP 410 here, whatever it may do. | |
| 1024 | mockedRequest.mockReturnValue( | |
| 1025 | Effect.fail( | |
| 1026 | new BitbucketApi.BitbucketResponseError({ | |
| 1027 | operation: "request", | |
| 1028 | status: 410, | |
| 1029 | responseBodyLength: 0, | |
| 1030 | }), | |
| 1031 | ), | |
| 1032 | ); | |
| 1033 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 1034 | ||
| 1035 | assert.isTrue(yield* api.getRepositoryPermission({ repository: "acme/web" })); | |
| 1036 | }), | |
| 1037 | ); | |
| 1038 | ||
| 1039 | it.effect("still fails the permission read on a failure that is not the removed endpoint", () => | |
| 1040 | Effect.gen(function* () { | |
| 1041 | mockedRequest.mockReturnValue( | |
| 1042 | Effect.fail( | |
| 1043 | new BitbucketApi.BitbucketResponseError({ | |
| 1044 | operation: "request", | |
| 1045 | status: 401, | |
| 1046 | responseBodyLength: 0, | |
| 1047 | }), | |
| 1048 | ), | |
| 1049 | ); | |
| 1050 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 1051 | ||
| 1052 | const error = yield* Effect.flip(api.getRepositoryPermission({ repository: "acme/web" })); | |
| 1053 | ||
| 1054 | assert.strictEqual(error._tag, "BitbucketResponseError"); | |
| 1055 | }), | |
| 1056 | ); | |
| 1057 | ||
| 1058 | it.effect("reads the workspace's people and marks whoever is already a reviewer", () => | |
| 1059 | Effect.gen(function* () { | |
| 1060 | mockedRequest | |
| 1061 | .mockReturnValueOnce(Effect.succeed(response(pullRequestJson({ reviewers: [octocat] })))) | |
| 1062 | .mockReturnValueOnce( | |
| 1063 | Effect.succeed( | |
| 1064 | response( | |
| 1065 | JSON.stringify({ values: [{ user: bilal }, { user: octocat }, { user: hubot }] }), | |
| 1066 | ), | |
| 1067 | ), | |
| 1068 | ); | |
| 1069 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 1070 | ||
| 1071 | const list = yield* api.listReviewerCandidates({ repository: "acme/web", number: 7 }); | |
| 1072 | ||
| 1073 | // The people live on the workspace: nothing on a repository lists who may review it. | |
| 1074 | expect(callAt(1).url).toBe("/workspaces/acme/members?pagelen=50"); | |
| 1075 | expect(list.candidates.map((candidate) => [candidate.id, candidate.isRequested])).toEqual([ | |
| 1076 | ["{octocat}", true], | |
| 1077 | ["{hubot}", false], | |
| 1078 | ]); | |
| 1079 | assert.isFalse(list.truncated); | |
| 1080 | }), | |
| 1081 | ); | |
| 1082 | ||
| 1083 | it.effect("writes the reviewer set back with the one being asked added to it", () => | |
| 1084 | Effect.gen(function* () { | |
| 1085 | mockedRequest | |
| 1086 | .mockReturnValueOnce(Effect.succeed(response(pullRequestJson({ reviewers: [octocat] })))) | |
| 1087 | .mockReturnValueOnce(Effect.succeed(response("{}"))); | |
| 1088 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 1089 | ||
| 1090 | yield* api.setReviewerRequest({ | |
| 1091 | repository: "acme/web", | |
| 1092 | number: 7, | |
| 1093 | reviewers: [{ id: "{hubot}" }], | |
| 1094 | requested: true, | |
| 1095 | }); | |
| 1096 | ||
| 1097 | // Bitbucket writes `reviewers` whole, so the one already on the pull request travels with | |
| 1098 | // the new one or the request would take them off it. | |
| 1099 | const call = callAt(1); | |
| 1100 | expect(call.method).toBe("PUT"); | |
| 1101 | expect(call.url).toBe("/repositories/acme/web/pullrequests/7"); | |
| 1102 | expect(JSON.parse(call.body ?? "")).toEqual({ | |
| 1103 | reviewers: [{ uuid: "{octocat}" }, { uuid: "{hubot}" }], | |
| 1104 | }); | |
| 1105 | }), | |
| 1106 | ); | |
| 1107 | ||
| 1108 | it.effect("takes a reviewer out of the set rather than clearing it", () => | |
| 1109 | Effect.gen(function* () { | |
| 1110 | mockedRequest | |
| 1111 | .mockReturnValueOnce( | |
| 1112 | Effect.succeed(response(pullRequestJson({ reviewers: [octocat, hubot] }))), | |
| 1113 | ) | |
| 1114 | .mockReturnValueOnce(Effect.succeed(response("{}"))); | |
| 1115 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 1116 | ||
| 1117 | yield* api.setReviewerRequest({ | |
| 1118 | repository: "acme/web", | |
| 1119 | number: 7, | |
| 1120 | reviewers: [{ id: "{hubot}" }], | |
| 1121 | requested: false, | |
| 1122 | }); | |
| 1123 | ||
| 1124 | expect(JSON.parse(callAt(1).body ?? "")).toEqual({ reviewers: [{ uuid: "{octocat}" }] }); | |
| 1125 | }), | |
| 1126 | ); | |
| 1127 | }); |
packages/source-control-bitbucket/src/server/BitbucketPullRequestApi.ts created+943| ... | ... | @@ -0,0 +1,943 @@ |
| 1 | import * as Cache from "effect/Cache"; | |
| 2 | import * as Context from "effect/Context"; | |
| 3 | import * as Duration from "effect/Duration"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Exit from "effect/Exit"; | |
| 6 | import * as Layer from "effect/Layer"; | |
| 7 | import * as Result from "effect/Result"; | |
| 8 | import * as Schema from "effect/Schema"; | |
| 9 | import type { | |
| 10 | PullRequestAction, | |
| 11 | PullRequestCheck, | |
| 12 | PullRequestComment, | |
| 13 | PullRequestCommit, | |
| 14 | PullRequestListState, | |
| 15 | PullRequestMergeMethod, | |
| 16 | PullRequestMergeability, | |
| 17 | PullRequestReviewCommentDraft, | |
| 18 | PullRequestReviewPosition, | |
| 19 | PullRequestReviewThread, | |
| 20 | PullRequestReviewVerdict, | |
| 21 | PullRequestReviewerCandidateList, | |
| 22 | } from "@t3tools/contracts"; | |
| 23 | ||
| 24 | import * as BitbucketApi from "./BitbucketApi.ts"; | |
| 25 | import { parseDiffFileRevisions } from "@t3tools/source-control-core/server/diffFileRevisions"; | |
| 26 | import { | |
| 27 | buildReviewThreads, | |
| 28 | decodeCommentsJson, | |
| 29 | decodeCommitsJson, | |
| 30 | decodeConflictsJson, | |
| 31 | decodeDiffstatJson, | |
| 32 | decodePullRequestJson, | |
| 33 | decodePullRequestPageJson, | |
| 34 | decodeRepositoryPermissionJson, | |
| 35 | decodeStatusesJson, | |
| 36 | decodeViewerJson, | |
| 37 | decodeWorkspaceMembersJson, | |
| 38 | type BitbucketDiffStat, | |
| 39 | type BitbucketPullRequest, | |
| 40 | type BitbucketRawComment, | |
| 41 | } from "./bitbucketPullRequestJson.ts"; | |
| 42 | import type { ProviderListCursor } from "@t3tools/source-control-core/server/PullRequestProvider"; | |
| 43 | ||
| 44 | /** | |
| 45 | * Names the read that produced unusable output, so a failure reports the call it came from | |
| 46 | * rather than borrowing another operation's message. | |
| 47 | */ | |
| 48 | export class BitbucketPullRequestReadError extends Schema.TaggedError<BitbucketPullRequestReadError>()( | |
| 49 | "BitbucketPullRequestReadError", | |
| 50 | { | |
| 51 | operation: Schema.String, | |
| 52 | cause: Schema.Defect(), | |
| 53 | }, | |
| 54 | ) { | |
| 55 | get detail(): string { | |
| 56 | return `Bitbucket returned an unreadable ${this.operation} response.`; | |
| 57 | } | |
| 58 | ||
| 59 | override get message(): string { | |
| 60 | return `Bitbucket failed in ${this.operation}: ${this.detail}`; | |
| 61 | } | |
| 62 | } | |
| 63 | ||
| 64 | /** Not a decode failure: Bitbucket answered, the account it answered for just has no handle. */ | |
| 65 | export class BitbucketViewerUnavailableError extends Schema.TaggedError<BitbucketViewerUnavailableError>()( | |
| 66 | "BitbucketViewerUnavailableError", | |
| 67 | {}, | |
| 68 | ) { | |
| 69 | get detail(): string { | |
| 70 | return "Bitbucket returned no account name for the configured credentials."; | |
| 71 | } | |
| 72 | ||
| 73 | override get message(): string { | |
| 74 | return `Bitbucket failed in getViewer: ${this.detail}`; | |
| 75 | } | |
| 76 | } | |
| 77 | ||
| 78 | /** A repository that is not `workspace/slug`, which is the only form Bitbucket addresses. */ | |
| 79 | export class BitbucketRepositoryUnsupportedError extends Schema.TaggedError<BitbucketRepositoryUnsupportedError>()( | |
| 80 | "BitbucketRepositoryUnsupportedError", | |
| 81 | { | |
| 82 | repository: Schema.String, | |
| 83 | }, | |
| 84 | ) { | |
| 85 | get detail(): string { | |
| 86 | return "A Bitbucket repository is addressed as workspace/repository."; | |
| 87 | } | |
| 88 | ||
| 89 | override get message(): string { | |
| 90 | return `Bitbucket failed in resolveRepository: ${this.detail}`; | |
| 91 | } | |
| 92 | } | |
| 93 | ||
| 94 | /** Not a decode failure: the reader named a commit that is not a sha this repository could hold. */ | |
| 95 | export class BitbucketDiffCommitError extends Schema.TaggedError<BitbucketDiffCommitError>()( | |
| 96 | "BitbucketDiffCommitError", | |
| 97 | {}, | |
| 98 | ) { | |
| 99 | get detail(): string { | |
| 100 | return "The named commit was not a commit sha."; | |
| 101 | } | |
| 102 | ||
| 103 | override get message(): string { | |
| 104 | return `Bitbucket failed in getPullRequestDiff: ${this.detail}`; | |
| 105 | } | |
| 106 | } | |
| 107 | ||
| 108 | export type BitbucketPullRequestApiError = | |
| 109 | | BitbucketApi.BitbucketApiError | |
| 110 | | BitbucketPullRequestReadError | |
| 111 | | BitbucketViewerUnavailableError | |
| 112 | | BitbucketRepositoryUnsupportedError | |
| 113 | | BitbucketDiffCommitError; | |
| 114 | ||
| 115 | /** | |
| 116 | * `/user/permissions/repositories` answering CHANGE-2770's removal notice rather than a | |
| 117 | * permission — Bitbucket sends this for every account now, not only ones it would have refused. | |
| 118 | */ | |
| 119 | function isRepositoryPermissionRemovedError( | |
| 120 | error: BitbucketPullRequestApiError, | |
| 121 | ): error is BitbucketApi.BitbucketResponseError { | |
| 122 | return error._tag === "BitbucketResponseError" && error.status === 410; | |
| 123 | } | |
| 124 | ||
| 125 | /** | |
| 126 | * Bitbucket's own ceiling. Asking for more does not fail — it answers with an empty page and no | |
| 127 | * error at all, so this is a number to respect rather than to push against. | |
| 128 | */ | |
| 129 | const MAX_PAGE_SIZE = 50; | |
| 130 | /** Pages to walk before a listing is reported as truncated. */ | |
| 131 | const MAX_LIST_PAGES = 10; | |
| 132 | /** The page size for pull request conversations, commits, and checks. */ | |
| 133 | const CONVERSATION_PAGE_SIZE = 50; | |
| 134 | /** | |
| 135 | * Pages of the conversation to follow before it is reported as truncated. Bitbucket serves | |
| 136 | * fifty comments a page, so this is five hundred — beyond any pull request a person is reading, | |
| 137 | * and an end to a walk whose only other stop is Bitbucket running out. | |
| 138 | */ | |
| 139 | const CONVERSATION_PAGES = 10; | |
| 140 | /** The same ceiling the gh and glab diff reads use. */ | |
| 141 | const DIFF_MAX_BYTES = 8 * 1024 * 1024; | |
| 142 | /** | |
| 143 | * Deliberately far shorter than the window the caller holds versions for: a refresh drops what the | |
| 144 | * caller holds precisely so the next read reaches Bitbucket, and this must not be what answers it | |
| 145 | * instead. | |
| 146 | */ | |
| 147 | const REVISION_PATCH_TTL = Duration.seconds(5); | |
| 148 | const REVISION_PATCH_CAPACITY = 16; | |
| 149 | export interface BitbucketPullRequestBatch { | |
| 150 | readonly items: ReadonlyArray<BitbucketPullRequest>; | |
| 151 | readonly truncated: boolean; | |
| 152 | } | |
| 153 | ||
| 154 | export class BitbucketPullRequestApi extends Context.Service< | |
| 155 | BitbucketPullRequestApi, | |
| 156 | { | |
| 157 | /** A function rather than a value, so the request is built per call and not at layer time. */ | |
| 158 | readonly getViewer: () => Effect.Effect<string, BitbucketPullRequestApiError>; | |
| 159 | ||
| 160 | readonly listPullRequests: (input: { | |
| 161 | readonly repository: string; | |
| 162 | readonly state: PullRequestListState; | |
| 163 | readonly limit: number; | |
| 164 | /** Free text, matched against a pull request's title and description. */ | |
| 165 | readonly query?: string | undefined; | |
| 166 | /** Where to carry on from, as a predicate on `updated_on` beside any other. */ | |
| 167 | readonly cursor?: ProviderListCursor | undefined; | |
| 168 | }) => Effect.Effect<BitbucketPullRequestBatch, BitbucketPullRequestApiError>; | |
| 169 | ||
| 170 | readonly getPullRequest: (input: { | |
| 171 | readonly repository: string; | |
| 172 | readonly number: number; | |
| 173 | }) => Effect.Effect<BitbucketPullRequest, BitbucketPullRequestApiError>; | |
| 174 | ||
| 175 | /** True where the credentials can write to the repository, which is what merging needs. */ | |
| 176 | readonly getRepositoryPermission: (input: { | |
| 177 | readonly repository: string; | |
| 178 | }) => Effect.Effect<boolean, BitbucketPullRequestApiError>; | |
| 179 | ||
| 180 | readonly getPullRequestDiff: (input: { | |
| 181 | readonly repository: string; | |
| 182 | readonly number: number; | |
| 183 | /** One commit's own changes, rather than everything the pull request carries. */ | |
| 184 | readonly commit?: string | undefined; | |
| 185 | }) => Effect.Effect< | |
| 186 | { readonly patch: string; readonly truncated: boolean }, | |
| 187 | BitbucketPullRequestApiError | |
| 188 | >; | |
| 189 | ||
| 190 | readonly getDiffStat: (input: { | |
| 191 | readonly repository: string; | |
| 192 | readonly number: number; | |
| 193 | }) => Effect.Effect<BitbucketDiffStat, BitbucketPullRequestApiError>; | |
| 194 | ||
| 195 | /** | |
| 196 | * What the pull request's head has of each of these paths, as opaque ids, read off the pull | |
| 197 | * request's own patch, the only place Bitbucket states a file's version. A path the patch does | |
| 198 | * not carry is answered as the empty revision, and left out altogether when the patch was cut | |
| 199 | * short at the byte ceiling and so cannot be spoken for. | |
| 200 | * | |
| 201 | * Answers with every file the patch carries rather than only the paths asked about, since | |
| 202 | * reading one file's version here means parsing all of them. `complete` is false for a patch | |
| 203 | * cut short, which cannot speak for what came after the cut. | |
| 204 | */ | |
| 205 | readonly getFileRevisions: (input: { | |
| 206 | readonly repository: string; | |
| 207 | readonly number: number; | |
| 208 | readonly paths: ReadonlyArray<string>; | |
| 209 | }) => Effect.Effect< | |
| 210 | { readonly revisions: ReadonlyMap<string, string>; readonly complete: boolean }, | |
| 211 | BitbucketPullRequestApiError | |
| 212 | >; | |
| 213 | ||
| 214 | readonly getMergeability: (input: { | |
| 215 | readonly repository: string; | |
| 216 | readonly number: number; | |
| 217 | }) => Effect.Effect<PullRequestMergeability, BitbucketPullRequestApiError>; | |
| 218 | ||
| 219 | readonly listComments: (input: { | |
| 220 | readonly repository: string; | |
| 221 | readonly number: number; | |
| 222 | }) => Effect.Effect< | |
| 223 | { | |
| 224 | readonly comments: ReadonlyArray<PullRequestComment>; | |
| 225 | readonly threads: ReadonlyArray<PullRequestReviewThread>; | |
| 226 | readonly truncated: boolean; | |
| 227 | }, | |
| 228 | BitbucketPullRequestApiError | |
| 229 | >; | |
| 230 | ||
| 231 | readonly listCommits: (input: { | |
| 232 | readonly repository: string; | |
| 233 | readonly number: number; | |
| 234 | }) => Effect.Effect<ReadonlyArray<PullRequestCommit>, BitbucketPullRequestApiError>; | |
| 235 | ||
| 236 | readonly listChecks: (input: { | |
| 237 | readonly repository: string; | |
| 238 | readonly number: number; | |
| 239 | }) => Effect.Effect<ReadonlyArray<PullRequestCheck>, BitbucketPullRequestApiError>; | |
| 240 | ||
| 241 | /** | |
| 242 | * Who this pull request may be sent to, and who it has already been sent to. Two reads at | |
| 243 | * once, because Bitbucket keeps the people on the workspace and the reviewers on the pull | |
| 244 | * request, and neither answers for the other. | |
| 245 | */ | |
| 246 | readonly listReviewerCandidates: (input: { | |
| 247 | readonly repository: string; | |
| 248 | readonly number: number; | |
| 249 | }) => Effect.Effect<PullRequestReviewerCandidateList, BitbucketPullRequestApiError>; | |
| 250 | ||
| 251 | readonly setReviewerRequest: (input: { | |
| 252 | readonly repository: string; | |
| 253 | readonly number: number; | |
| 254 | readonly reviewers: ReadonlyArray<{ readonly id: string }>; | |
| 255 | readonly requested: boolean; | |
| 256 | }) => Effect.Effect<void, BitbucketPullRequestApiError>; | |
| 257 | ||
| 258 | readonly runAction: (input: { | |
| 259 | readonly repository: string; | |
| 260 | readonly number: number; | |
| 261 | readonly action: PullRequestAction; | |
| 262 | readonly mergeMethod?: PullRequestMergeMethod; | |
| 263 | }) => Effect.Effect<void, BitbucketPullRequestApiError>; | |
| 264 | ||
| 265 | readonly updateChangeRequest: (input: { | |
| 266 | readonly repository: string; | |
| 267 | readonly number: number; | |
| 268 | readonly title?: string | undefined; | |
| 269 | readonly body?: string | undefined; | |
| 270 | }) => Effect.Effect<void, BitbucketPullRequestApiError>; | |
| 271 | ||
| 272 | readonly comment: (input: { | |
| 273 | readonly repository: string; | |
| 274 | readonly number: number; | |
| 275 | readonly body: string; | |
| 276 | }) => Effect.Effect<void, BitbucketPullRequestApiError>; | |
| 277 | ||
| 278 | readonly updateComment: (input: { | |
| 279 | readonly repository: string; | |
| 280 | readonly number: number; | |
| 281 | readonly commentId: string; | |
| 282 | readonly body: string; | |
| 283 | }) => Effect.Effect<void, BitbucketPullRequestApiError>; | |
| 284 | ||
| 285 | readonly submitReview: (input: { | |
| 286 | readonly repository: string; | |
| 287 | readonly number: number; | |
| 288 | readonly verdict: PullRequestReviewVerdict; | |
| 289 | readonly body: string; | |
| 290 | readonly comments: ReadonlyArray<PullRequestReviewCommentDraft>; | |
| 291 | }) => Effect.Effect<void, BitbucketPullRequestApiError>; | |
| 292 | ||
| 293 | readonly replyToComment: (input: { | |
| 294 | readonly repository: string; | |
| 295 | readonly number: number; | |
| 296 | readonly commentId: string; | |
| 297 | readonly body: string; | |
| 298 | }) => Effect.Effect<void, BitbucketPullRequestApiError>; | |
| 299 | ||
| 300 | readonly setCommentResolution: (input: { | |
| 301 | readonly repository: string; | |
| 302 | readonly number: number; | |
| 303 | readonly commentId: string; | |
| 304 | readonly resolved: boolean; | |
| 305 | }) => Effect.Effect<void, BitbucketPullRequestApiError>; | |
| 306 | } | |
| 307 | >()("@t3tools/source-control-bitbucket/server/BitbucketPullRequestApi") {} | |
| 308 | ||
| 309 | /** `workspace/slug`; Bitbucket has no deeper nesting to address. */ | |
| 310 | function repositorySegments( | |
| 311 | repository: string, | |
| 312 | ): Result.Result< | |
| 313 | { readonly workspace: string; readonly slug: string }, | |
| 314 | BitbucketRepositoryUnsupportedError | |
| 315 | > { | |
| 316 | const segments = repository | |
| 317 | .split("/") | |
| 318 | .map((segment) => segment.trim()) | |
| 319 | .filter((segment) => segment.length > 0); | |
| 320 | const [workspace, slug] = segments; | |
| 321 | if (segments.length !== 2 || workspace === undefined || slug === undefined) { | |
| 322 | return Result.fail(new BitbucketRepositoryUnsupportedError({ repository })); | |
| 323 | } | |
| 324 | return Result.succeed({ workspace, slug }); | |
| 325 | } | |
| 326 | ||
| 327 | function repositoryPathOf(segments: { readonly workspace: string; readonly slug: string }): string { | |
| 328 | return `/repositories/${encodeURIComponent(segments.workspace)}/${encodeURIComponent( | |
| 329 | segments.slug, | |
| 330 | )}`; | |
| 331 | } | |
| 332 | ||
| 333 | /** | |
| 334 | * A commit sha arrives from the reader and goes straight into a request path, so it is checked | |
| 335 | * rather than trusted: hexadecimal only, from the shortest abbreviation a host prints up to a | |
| 336 | * whole sha. | |
| 337 | */ | |
| 338 | function isCommitSha(value: string): boolean { | |
| 339 | return /^[0-9a-f]{7,64}$/i.test(value); | |
| 340 | } | |
| 341 | ||
| 342 | /** | |
| 343 | * Bitbucket unions repeated `state` parameters, so a tab that spans several of its states asks | |
| 344 | * for each. It separates a declined pull request from one superseded by another, and both read | |
| 345 | * as closed here. | |
| 346 | */ | |
| 347 | function stateParams(state: PullRequestListState): ReadonlyArray<string> { | |
| 348 | switch (state) { | |
| 349 | case "open": | |
| 350 | return ["OPEN"]; | |
| 351 | case "merged": | |
| 352 | return ["MERGED"]; | |
| 353 | case "closed": | |
| 354 | return ["DECLINED", "SUPERSEDED"]; | |
| 355 | case "all": | |
| 356 | return ["OPEN", "MERGED", "DECLINED", "SUPERSEDED"]; | |
| 357 | } | |
| 358 | } | |
| 359 | ||
| 360 | /** | |
| 361 | * Bitbucket has no search term, only a filter expression, so free text becomes one: a | |
| 362 | * case-insensitive contains against the two fields a pull request carries words in. The | |
| 363 | * parentheses matter, because the expression is ANDed with the state filter beside it and an | |
| 364 | * unbracketed `OR` would swallow it. | |
| 365 | * | |
| 366 | * A string literal in that grammar is delimited by double quotes, so the reader's text is | |
| 367 | * escaped before it goes inside one — a quote would otherwise end the literal and leave the | |
| 368 | * rest of the text standing as filter syntax. The whole expression is then URL-encoded, so | |
| 369 | * nothing in it reaches the query string as a parameter of its own. | |
| 370 | */ | |
| 371 | function searchFilter(query: string): string { | |
| 372 | const literal = filterLiteral(query); | |
| 373 | return `(title ~ "${literal}" OR description ~ "${literal}")`; | |
| 374 | } | |
| 375 | ||
| 376 | /** | |
| 377 | * Text as a string literal of Bitbucket's filter grammar. The backslash is escaped first, or | |
| 378 | * escaping the quote would only produce a literal backslash followed by a live quote. | |
| 379 | */ | |
| 380 | function filterLiteral(value: string): string { | |
| 381 | return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); | |
| 382 | } | |
| 383 | ||
| 384 | /** Bitbucket's merge strategies, named differently from the three the contract carries. */ | |
| 385 | function mergeStrategy(method: PullRequestMergeMethod | undefined): string { | |
| 386 | switch (method) { | |
| 387 | case "squash": | |
| 388 | return "squash"; | |
| 389 | case "rebase": | |
| 390 | // The linear history GitHub calls "rebase and merge". | |
| 391 | return "rebase_fast_forward"; | |
| 392 | default: | |
| 393 | return "merge_commit"; | |
| 394 | } | |
| 395 | } | |
| 396 | ||
| 397 | function bitbucketReviewPosition( | |
| 398 | position: PullRequestReviewPosition, | |
| 399 | ): { readonly from: number } | { readonly to: number } { | |
| 400 | switch (position.kind) { | |
| 401 | case "added": | |
| 402 | return { to: position.newLine }; | |
| 403 | case "deleted": | |
| 404 | return { from: position.oldLine }; | |
| 405 | case "context": | |
| 406 | return position.side === "left" ? { from: position.oldLine } : { to: position.newLine }; | |
| 407 | } | |
| 408 | } | |
| 409 | ||
| 410 | /** @public Service construction is part of the canonical Effect module API. */ | |
| 411 | export const make = Effect.gen(function* () { | |
| 412 | const bitbucket = yield* BitbucketApi.BitbucketApi; | |
| 413 | ||
| 414 | /** | |
| 415 | * The repository's own path, and the workspace above it — which the people who may review are | |
| 416 | * kept on rather than on the repository, so both are handed over at once. | |
| 417 | */ | |
| 418 | const withRepository = <A>( | |
| 419 | repository: string, | |
| 420 | use: (path: string, workspace: string) => Effect.Effect<A, BitbucketPullRequestApiError>, | |
| 421 | ): Effect.Effect<A, BitbucketPullRequestApiError> => { | |
| 422 | const segments = repositorySegments(repository); | |
| 423 | return Result.isSuccess(segments) | |
| 424 | ? use(repositoryPathOf(segments.success), segments.success.workspace) | |
| 425 | : Effect.fail(segments.failure); | |
| 426 | }; | |
| 427 | ||
| 428 | /** | |
| 429 | * Bitbucket pages with a cursor rather than an offset, so the walk follows the `next` URL it | |
| 430 | * sends. It stops once the caller's page is filled, when Bitbucket reports no next page, or at | |
| 431 | * the page cap — and anything but running out of pages means there is more to be had. | |
| 432 | */ | |
| 433 | const listPage = (input: { | |
| 434 | readonly url: string; | |
| 435 | readonly limit: number; | |
| 436 | readonly page: number; | |
| 437 | readonly collected: ReadonlyArray<BitbucketPullRequest>; | |
| 438 | }): Effect.Effect<BitbucketPullRequestBatch, BitbucketPullRequestApiError> => | |
| 439 | bitbucket.request({ method: "GET", url: input.url }).pipe( | |
| 440 | Effect.flatMap((response) => { | |
| 441 | const decoded = decodePullRequestPageJson(response.body); | |
| 442 | if (!Result.isSuccess(decoded)) { | |
| 443 | return Effect.fail( | |
| 444 | new BitbucketPullRequestReadError({ | |
| 445 | operation: "listPullRequests", | |
| 446 | cause: decoded.failure, | |
| 447 | }), | |
| 448 | ); | |
| 449 | } | |
| 450 | const collected = [...input.collected, ...decoded.success.items]; | |
| 451 | const next = decoded.success.next; | |
| 452 | if (next === null || collected.length >= input.limit || input.page >= MAX_LIST_PAGES) { | |
| 453 | return Effect.succeed({ | |
| 454 | items: collected.slice(0, input.limit), | |
| 455 | // Bitbucket pages in fifties whatever was asked for, so a walk that stopped on the | |
| 456 | // count rather than on the last page is holding rows it is about to drop. Those are | |
| 457 | // more results just as surely as another page would be. | |
| 458 | truncated: next !== null || collected.length > input.limit, | |
| 459 | }); | |
| 460 | } | |
| 461 | return listPage({ ...input, url: next, page: input.page + 1, collected }); | |
| 462 | }), | |
| 463 | ); | |
| 464 | ||
| 465 | const readPage = <A>(input: { | |
| 466 | readonly operation: string; | |
| 467 | readonly url: string; | |
| 468 | readonly decode: (body: string) => Result.Result<A, unknown>; | |
| 469 | }): Effect.Effect<A, BitbucketPullRequestApiError> => | |
| 470 | bitbucket.request({ method: "GET", url: input.url }).pipe( | |
| 471 | Effect.flatMap((response) => { | |
| 472 | const decoded = input.decode(response.body); | |
| 473 | return Result.isSuccess(decoded) | |
| 474 | ? Effect.succeed(decoded.success) | |
| 475 | : Effect.fail( | |
| 476 | new BitbucketPullRequestReadError({ | |
| 477 | operation: input.operation, | |
| 478 | cause: decoded.failure, | |
| 479 | }), | |
| 480 | ); | |
| 481 | }), | |
| 482 | ); | |
| 483 | ||
| 484 | /** | |
| 485 | * The conversation, following the `next` Bitbucket sends until it sends none. Threads are | |
| 486 | * assembled once at the end rather than per page, because a reply and the remark it answers | |
| 487 | * can land either side of a page boundary. | |
| 488 | */ | |
| 489 | const commentsPage = (input: { | |
| 490 | readonly url: string; | |
| 491 | readonly page: number; | |
| 492 | readonly comments: ReadonlyArray<PullRequestComment>; | |
| 493 | readonly entries: ReadonlyArray<BitbucketRawComment>; | |
| 494 | }): Effect.Effect< | |
| 495 | { | |
| 496 | readonly comments: ReadonlyArray<PullRequestComment>; | |
| 497 | readonly threads: ReadonlyArray<PullRequestReviewThread>; | |
| 498 | readonly truncated: boolean; | |
| 499 | }, | |
| 500 | BitbucketPullRequestApiError | |
| 501 | > => | |
| 502 | readPage({ operation: "listComments", url: input.url, decode: decodeCommentsJson }).pipe( | |
| 503 | Effect.flatMap((page) => { | |
| 504 | const comments = [...input.comments, ...page.comments]; | |
| 505 | const entries = [...input.entries, ...page.entries]; | |
| 506 | if (page.next !== null && input.page < CONVERSATION_PAGES) { | |
| 507 | return commentsPage({ url: page.next, page: input.page + 1, comments, entries }); | |
| 508 | } | |
| 509 | return Effect.succeed({ | |
| 510 | comments, | |
| 511 | threads: buildReviewThreads(entries), | |
| 512 | truncated: page.next !== null, | |
| 513 | }); | |
| 514 | }), | |
| 515 | ); | |
| 516 | ||
| 517 | /** Walks a Bitbucket cursor to its end and combines every decoded item. */ | |
| 518 | const itemPages = <A>(input: { | |
| 519 | readonly operation: string; | |
| 520 | readonly url: string; | |
| 521 | readonly decode: ( | |
| 522 | body: string, | |
| 523 | ) => Result.Result<{ readonly items: ReadonlyArray<A>; readonly next: string | null }, unknown>; | |
| 524 | readonly items: ReadonlyArray<A>; | |
| 525 | /** Commit pages are individually oldest-first, so older pages are prepended. */ | |
| 526 | readonly prepend: boolean; | |
| 527 | }): Effect.Effect<ReadonlyArray<A>, BitbucketPullRequestApiError> => | |
| 528 | readPage({ operation: input.operation, url: input.url, decode: input.decode }).pipe( | |
| 529 | Effect.flatMap((page) => { | |
| 530 | const items = input.prepend | |
| 531 | ? [...page.items, ...input.items] | |
| 532 | : [...input.items, ...page.items]; | |
| 533 | return page.next === null | |
| 534 | ? Effect.succeed(items) | |
| 535 | : itemPages({ ...input, url: page.next, items }); | |
| 536 | }), | |
| 537 | ); | |
| 538 | ||
| 539 | /** Diffstat has one aggregate per page, so its totals are folded while following `next`. */ | |
| 540 | const diffStatPages = (input: { | |
| 541 | readonly url: string; | |
| 542 | readonly totals: BitbucketDiffStat; | |
| 543 | }): Effect.Effect<BitbucketDiffStat, BitbucketPullRequestApiError> => | |
| 544 | readPage({ operation: "getDiffStat", url: input.url, decode: decodeDiffstatJson }).pipe( | |
| 545 | Effect.flatMap((page) => { | |
| 546 | const totals = { | |
| 547 | additions: input.totals.additions + page.additions, | |
| 548 | deletions: input.totals.deletions + page.deletions, | |
| 549 | changedFiles: input.totals.changedFiles + page.changedFiles, | |
| 550 | }; | |
| 551 | return page.next === null | |
| 552 | ? Effect.succeed(totals) | |
| 553 | : diffStatPages({ url: page.next, totals }); | |
| 554 | }), | |
| 555 | ); | |
| 556 | ||
| 557 | const pullRequestDiff = (input: { | |
| 558 | readonly repository: string; | |
| 559 | readonly number: number; | |
| 560 | readonly commit?: string | undefined; | |
| 561 | }): Effect.Effect< | |
| 562 | { readonly patch: string; readonly truncated: boolean }, | |
| 563 | BitbucketPullRequestApiError | |
| 564 | > => | |
| 565 | input.commit !== undefined && !isCommitSha(input.commit) | |
| 566 | ? Effect.fail(new BitbucketDiffCommitError()) | |
| 567 | : withRepository(input.repository, (path) => | |
| 568 | // Already a unified patch, so it needs no decoding at all, only a bound, which a | |
| 569 | // diff of any size would otherwise ignore. A commit's own patch sits beside the pull | |
| 570 | // request's at `/diff/{sha}` and reads the same way. | |
| 571 | bitbucket | |
| 572 | .request({ | |
| 573 | method: "GET", | |
| 574 | url: | |
| 575 | input.commit === undefined | |
| 576 | ? `${path}/pullrequests/${input.number}/diff` | |
| 577 | : `${path}/diff/${input.commit}`, | |
| 578 | maxBytes: DIFF_MAX_BYTES, | |
| 579 | }) | |
| 580 | .pipe( | |
| 581 | Effect.map((response) => ({ patch: response.body, truncated: response.truncated })), | |
| 582 | ), | |
| 583 | ); | |
| 584 | ||
| 585 | /** | |
| 586 | * What the version reads that come one tick at a time want out of the pull request's whole | |
| 587 | * patch, shared between them. The parsed answer rather than the patch, which at this capacity | |
| 588 | * would hold sixteen bodies of up to the byte ceiling each resident, and saves walking a patch | |
| 589 | * of a hundred thousand lines again on every tick. | |
| 590 | */ | |
| 591 | const revisionPatches = yield* Cache.makeWith( | |
| 592 | (key: string) => { | |
| 593 | const [repository, number] = JSON.parse(key) as [string, number]; | |
| 594 | return pullRequestDiff({ repository, number }).pipe( | |
| 595 | Effect.map((diff) => ({ | |
| 596 | revisions: parseDiffFileRevisions(diff.patch), | |
| 597 | truncated: diff.truncated, | |
| 598 | })), | |
| 599 | ); | |
| 600 | }, | |
| 601 | { | |
| 602 | capacity: REVISION_PATCH_CAPACITY, | |
| 603 | // A failure is not held: the tick after it should reach Bitbucket rather than be handed the | |
| 604 | // same error for as long as a good patch would have lasted. | |
| 605 | timeToLive: (exit) => (Exit.isSuccess(exit) ? REVISION_PATCH_TTL : Duration.zero), | |
| 606 | }, | |
| 607 | ); | |
| 608 | ||
| 609 | return BitbucketPullRequestApi.of({ | |
| 610 | getViewer: () => | |
| 611 | bitbucket.request({ method: "GET", url: "/user" }).pipe( | |
| 612 | Effect.flatMap((response): Effect.Effect<string, BitbucketPullRequestApiError> => { | |
| 613 | const decoded = decodeViewerJson(response.body); | |
| 614 | if (!Result.isSuccess(decoded)) { | |
| 615 | return Effect.fail( | |
| 616 | new BitbucketPullRequestReadError({ operation: "getViewer", cause: decoded.failure }), | |
| 617 | ); | |
| 618 | } | |
| 619 | return decoded.success === null | |
| 620 | ? Effect.fail(new BitbucketViewerUnavailableError()) | |
| 621 | : Effect.succeed(decoded.success); | |
| 622 | }), | |
| 623 | ), | |
| 624 | ||
| 625 | listPullRequests: (input) => | |
| 626 | withRepository(input.repository, (path) => { | |
| 627 | const search = input.query?.trim() ?? ""; | |
| 628 | // Both narrowings share the one `q` Bitbucket takes, so they are ANDed rather than one | |
| 629 | // replacing the other. The boundary instant is read inclusively — the rows already sent | |
| 630 | // at it come back and the caller drops them, which is what keeps their neighbours at the | |
| 631 | // same instant from being skipped. A date is a bare literal in this grammar, and this one | |
| 632 | // was checked against a timestamp's shape before it got here. | |
| 633 | const predicates = [ | |
| 634 | ...(search.length === 0 ? [] : [searchFilter(search)]), | |
| 635 | ...(input.cursor === undefined ? [] : [`updated_on <= ${input.cursor.updatedBefore}`]), | |
| 636 | ]; | |
| 637 | return listPage({ | |
| 638 | // Reviewers are not on a listing by default, and `viewerReviewRequested` needs them. | |
| 639 | url: `${path}/pullrequests?${stateParams(input.state) | |
| 640 | .map((state) => `state=${state}`) | |
| 641 | .join("&")}&pagelen=${MAX_PAGE_SIZE}&sort=-updated_on&fields=%2Bvalues.reviewers${ | |
| 642 | predicates.length === 0 ? "" : `&q=${encodeURIComponent(predicates.join(" AND "))}` | |
| 643 | }`, | |
| 644 | limit: input.limit, | |
| 645 | page: 1, | |
| 646 | collected: [], | |
| 647 | }); | |
| 648 | }), | |
| 649 | ||
| 650 | getPullRequest: (input) => | |
| 651 | withRepository(input.repository, (path) => | |
| 652 | readPage({ | |
| 653 | operation: "getPullRequest", | |
| 654 | url: `${path}/pullrequests/${input.number}`, | |
| 655 | decode: decodePullRequestJson, | |
| 656 | }), | |
| 657 | ), | |
| 658 | ||
| 659 | // Nothing on the repository, the pull request or the workspace states what the credentials | |
| 660 | // may do, so this endpoint is the one request Bitbucket makes unavoidable. It is asked | |
| 661 | // alongside the reads the detail was already making, so it costs no round trip of its own. | |
| 662 | // | |
| 663 | // Bitbucket permanently removed this endpoint (CHANGE-2770): every account now gets HTTP 410 | |
| 664 | // in place of an answer, whatever it may do. That is the deprecated-endpoint signal, not a | |
| 665 | // permission being refused, so it is read the same way an unreachable read already is | |
| 666 | // elsewhere — as a permission that could not be learned, which grants rather than blocks, and | |
| 667 | // leaves the actual merge or write to say why if the account may not do it. Any other failure | |
| 668 | // (a bad token, a network fault, an unreadable body) still fails as it did before. | |
| 669 | getRepositoryPermission: (input) => | |
| 670 | withRepository(input.repository, () => | |
| 671 | readPage({ | |
| 672 | operation: "getRepositoryPermission", | |
| 673 | url: `/user/permissions/repositories?q=${encodeURIComponent( | |
| 674 | `repository.full_name="${filterLiteral(input.repository.trim())}"`, | |
| 675 | )}`, | |
| 676 | decode: decodeRepositoryPermissionJson, | |
| 677 | }), | |
| 678 | ).pipe(Effect.catchIf(isRepositoryPermissionRemovedError, () => Effect.succeed(true))), | |
| 679 | ||
| 680 | getPullRequestDiff: pullRequestDiff, | |
| 681 | ||
| 682 | getFileRevisions: (input) => | |
| 683 | input.paths.length === 0 | |
| 684 | ? Effect.succeed({ revisions: new Map(), complete: false }) | |
| 685 | : Cache.get(revisionPatches, JSON.stringify([input.repository, input.number])).pipe( | |
| 686 | Effect.map((diff) => { | |
| 687 | const revisions = new Map(diff.revisions); | |
| 688 | // A patch cut short at the byte ceiling says nothing about the files past the cut, | |
| 689 | // so those paths are left out rather than reported as removed. | |
| 690 | if (!diff.truncated) { | |
| 691 | for (const path of input.paths) { | |
| 692 | if (!revisions.has(path)) revisions.set(path, ""); | |
| 693 | } | |
| 694 | } | |
| 695 | return { revisions, complete: !diff.truncated }; | |
| 696 | }), | |
| 697 | ), | |
| 698 | ||
| 699 | getDiffStat: (input) => | |
| 700 | withRepository(input.repository, (path) => | |
| 701 | diffStatPages({ | |
| 702 | url: `${path}/pullrequests/${input.number}/diffstat?pagelen=${MAX_PAGE_SIZE}`, | |
| 703 | totals: { additions: 0, deletions: 0, changedFiles: 0 }, | |
| 704 | }), | |
| 705 | ), | |
| 706 | ||
| 707 | getMergeability: (input) => | |
| 708 | withRepository(input.repository, (path) => | |
| 709 | readPage({ | |
| 710 | operation: "getMergeability", | |
| 711 | url: `${path}/pullrequests/${input.number}/conflicts`, | |
| 712 | decode: decodeConflictsJson, | |
| 713 | }), | |
| 714 | ), | |
| 715 | ||
| 716 | listComments: (input) => | |
| 717 | withRepository(input.repository, (path) => | |
| 718 | commentsPage({ | |
| 719 | url: `${path}/pullrequests/${input.number}/comments?pagelen=${CONVERSATION_PAGE_SIZE}`, | |
| 720 | page: 1, | |
| 721 | comments: [], | |
| 722 | entries: [], | |
| 723 | }), | |
| 724 | ), | |
| 725 | ||
| 726 | listCommits: (input) => | |
| 727 | withRepository(input.repository, (path) => | |
| 728 | itemPages({ | |
| 729 | operation: "listCommits", | |
| 730 | url: `${path}/pullrequests/${input.number}/commits?pagelen=${CONVERSATION_PAGE_SIZE}`, | |
| 731 | decode: decodeCommitsJson, | |
| 732 | items: [], | |
| 733 | prepend: true, | |
| 734 | }), | |
| 735 | ), | |
| 736 | ||
| 737 | listChecks: (input) => | |
| 738 | withRepository(input.repository, (path) => | |
| 739 | itemPages({ | |
| 740 | operation: "listChecks", | |
| 741 | url: `${path}/pullrequests/${input.number}/statuses?pagelen=${CONVERSATION_PAGE_SIZE}`, | |
| 742 | decode: decodeStatusesJson, | |
| 743 | items: [], | |
| 744 | prepend: false, | |
| 745 | }), | |
| 746 | ), | |
| 747 | ||
| 748 | listReviewerCandidates: (input) => | |
| 749 | withRepository(input.repository, (path, workspace) => | |
| 750 | Effect.all( | |
| 751 | [ | |
| 752 | readPage({ | |
| 753 | operation: "getPullRequest", | |
| 754 | url: `${path}/pullrequests/${input.number}`, | |
| 755 | decode: decodePullRequestJson, | |
| 756 | }), | |
| 757 | readPage({ | |
| 758 | operation: "listReviewerCandidates", | |
| 759 | url: `/workspaces/${encodeURIComponent(workspace)}/members?pagelen=${MAX_PAGE_SIZE}`, | |
| 760 | decode: decodeWorkspaceMembersJson, | |
| 761 | }), | |
| 762 | ], | |
| 763 | { concurrency: 2 }, | |
| 764 | ).pipe( | |
| 765 | Effect.map(([pullRequest, members]) => { | |
| 766 | const requested = new Set(pullRequest.reviewerIds); | |
| 767 | const author = pullRequest.author?.login; | |
| 768 | return { | |
| 769 | // The author is dropped rather than shown unusable: Bitbucket refuses to make the | |
| 770 | // person who opened a pull request its reviewer. | |
| 771 | candidates: members.items.flatMap((candidate) => | |
| 772 | candidate.login === author | |
| 773 | ? [] | |
| 774 | : [{ ...candidate, isRequested: requested.has(candidate.id) }], | |
| 775 | ), | |
| 776 | truncated: members.next !== null, | |
| 777 | }; | |
| 778 | }), | |
| 779 | ), | |
| 780 | ), | |
| 781 | ||
| 782 | setReviewerRequest: (input) => | |
| 783 | withRepository(input.repository, (path) => { | |
| 784 | const pullRequest = `${path}/pullrequests/${input.number}`; | |
| 785 | return readPage({ | |
| 786 | operation: "getPullRequest", | |
| 787 | url: pullRequest, | |
| 788 | decode: decodePullRequestJson, | |
| 789 | }).pipe( | |
| 790 | Effect.flatMap((current) => { | |
| 791 | // Bitbucket has no endpoint that adds or removes one reviewer: the pull request's | |
| 792 | // `reviewers` is written whole, so the set that is already there is read first and | |
| 793 | // the change applied to it. Everything else about the pull request is left out of | |
| 794 | // the body, which leaves it as it was. | |
| 795 | const uuids = new Set(current.reviewerIds); | |
| 796 | for (const reviewer of input.reviewers) { | |
| 797 | if (input.requested) uuids.add(reviewer.id); | |
| 798 | else uuids.delete(reviewer.id); | |
| 799 | } | |
| 800 | return bitbucket.request({ | |
| 801 | method: "PUT", | |
| 802 | url: pullRequest, | |
| 803 | body: JSON.stringify({ reviewers: [...uuids].map((uuid) => ({ uuid })) }), | |
| 804 | }); | |
| 805 | }), | |
| 806 | Effect.asVoid, | |
| 807 | ); | |
| 808 | }), | |
| 809 | ||
| 810 | runAction: (input) => | |
| 811 | withRepository(input.repository, (path) => { | |
| 812 | const pullRequest = `${path}/pullrequests/${input.number}`; | |
| 813 | // Only merge and close reach here: the provider declares the others unsupported, so the | |
| 814 | // surface never offers them. | |
| 815 | if (input.action === "merge") { | |
| 816 | return bitbucket | |
| 817 | .request({ | |
| 818 | method: "POST", | |
| 819 | url: `${pullRequest}/merge`, | |
| 820 | body: JSON.stringify({ merge_strategy: mergeStrategy(input.mergeMethod) }), | |
| 821 | }) | |
| 822 | .pipe(Effect.asVoid); | |
| 823 | } | |
| 824 | return bitbucket | |
| 825 | .request({ method: "POST", url: `${pullRequest}/decline` }) | |
| 826 | .pipe(Effect.asVoid); | |
| 827 | }), | |
| 828 | ||
| 829 | updateChangeRequest: (input) => | |
| 830 | withRepository(input.repository, (path) => | |
| 831 | // Only the words this call rewrites travel in the body: as `setReviewerRequest` above | |
| 832 | // relies on, Bitbucket's PUT is a partial update, so any field left out is left as it | |
| 833 | // was — sending `reviewers` back here would overwrite a change another user made to it | |
| 834 | // between this call being issued and the request landing. | |
| 835 | bitbucket | |
| 836 | .request({ | |
| 837 | method: "PUT", | |
| 838 | url: `${path}/pullrequests/${input.number}`, | |
| 839 | body: JSON.stringify({ | |
| 840 | ...(input.title === undefined ? {} : { title: input.title }), | |
| 841 | ...(input.body === undefined ? {} : { description: input.body }), | |
| 842 | }), | |
| 843 | }) | |
| 844 | .pipe(Effect.asVoid), | |
| 845 | ), | |
| 846 | ||
| 847 | comment: (input) => | |
| 848 | withRepository(input.repository, (path) => | |
| 849 | bitbucket | |
| 850 | .request({ | |
| 851 | method: "POST", | |
| 852 | url: `${path}/pullrequests/${input.number}/comments`, | |
| 853 | // A JSON document rather than a form field, so the body stays text whatever it says. | |
| 854 | body: JSON.stringify({ content: { raw: input.body } }), | |
| 855 | }) | |
| 856 | .pipe(Effect.asVoid), | |
| 857 | ), | |
| 858 | ||
| 859 | updateComment: (input) => | |
| 860 | withRepository(input.repository, (path) => | |
| 861 | bitbucket | |
| 862 | .request({ | |
| 863 | // Bitbucket keeps a pull request's remarks and its line comments in the one | |
| 864 | // collection, so this endpoint rewrites either kind. | |
| 865 | method: "PUT", | |
| 866 | url: `${path}/pullrequests/${input.number}/comments/${encodeURIComponent( | |
| 867 | input.commentId, | |
| 868 | )}`, | |
| 869 | body: JSON.stringify({ content: { raw: input.body } }), | |
| 870 | }) | |
| 871 | .pipe(Effect.asVoid), | |
| 872 | ), | |
| 873 | ||
| 874 | submitReview: (input) => | |
| 875 | withRepository(input.repository, (path) => | |
| 876 | Effect.gen(function* () { | |
| 877 | const pullRequest = `${path}/pullrequests/${input.number}`; | |
| 878 | // Bitbucket has no pending review, so a review is replayed as the requests it is | |
| 879 | // made of: the line comments, then the summary, then the verdict. The verdict goes | |
| 880 | // last so a review that fails part-way is never left standing as an approval. | |
| 881 | yield* Effect.forEach( | |
| 882 | input.comments, | |
| 883 | (comment) => | |
| 884 | bitbucket.request({ | |
| 885 | method: "POST", | |
| 886 | url: `${pullRequest}/comments`, | |
| 887 | body: JSON.stringify({ | |
| 888 | content: { raw: comment.body }, | |
| 889 | inline: { | |
| 890 | path: comment.path, | |
| 891 | ...bitbucketReviewPosition(comment.position), | |
| 892 | }, | |
| 893 | }), | |
| 894 | }), | |
| 895 | { discard: true }, | |
| 896 | ); | |
| 897 | if (input.body.trim().length > 0) { | |
| 898 | yield* bitbucket.request({ | |
| 899 | method: "POST", | |
| 900 | url: `${pullRequest}/comments`, | |
| 901 | // @effect-diagnostics-next-line preferSchemaOverJson:off | |
| 902 | body: JSON.stringify({ content: { raw: input.body } }), | |
| 903 | }); | |
| 904 | } | |
| 905 | if (input.verdict === "approve") { | |
| 906 | yield* bitbucket.request({ method: "POST", url: `${pullRequest}/approve` }); | |
| 907 | } | |
| 908 | if (input.verdict === "request-changes") { | |
| 909 | yield* bitbucket.request({ method: "POST", url: `${pullRequest}/request-changes` }); | |
| 910 | } | |
| 911 | }), | |
| 912 | ), | |
| 913 | ||
| 914 | replyToComment: (input) => | |
| 915 | withRepository(input.repository, (path) => | |
| 916 | bitbucket | |
| 917 | .request({ | |
| 918 | method: "POST", | |
| 919 | url: `${path}/pullrequests/${input.number}/comments`, | |
| 920 | body: JSON.stringify({ | |
| 921 | content: { raw: input.body }, | |
| 922 | parent: { id: Number(input.commentId) }, | |
| 923 | }), | |
| 924 | }) | |
| 925 | .pipe(Effect.asVoid), | |
| 926 | ), | |
| 927 | ||
| 928 | setCommentResolution: (input) => | |
| 929 | withRepository(input.repository, (path) => | |
| 930 | bitbucket | |
| 931 | .request({ | |
| 932 | // Resolving is a sub-resource that is created and deleted, rather than a field. | |
| 933 | method: input.resolved ? "POST" : "DELETE", | |
| 934 | url: `${path}/pullrequests/${input.number}/comments/${encodeURIComponent( | |
| 935 | input.commentId, | |
| 936 | )}/resolve`, | |
| 937 | }) | |
| 938 | .pipe(Effect.asVoid), | |
| 939 | ), | |
| 940 | }); | |
| 941 | }); | |
| 942 | ||
| 943 | export const layer = Layer.effect(BitbucketPullRequestApi, make); |
packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.test.ts created+175| ... | ... | @@ -0,0 +1,175 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Layer from "effect/Layer"; | |
| 4 | import * as Result from "effect/Result"; | |
| 5 | ||
| 6 | import * as BitbucketApi from "./BitbucketApi.ts"; | |
| 7 | import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts"; | |
| 8 | import { decodePullRequestJson } from "./bitbucketPullRequestJson.ts"; | |
| 9 | import { | |
| 10 | bitbucketProviderFailure, | |
| 11 | bitbucketViewerPermissions, | |
| 12 | make, | |
| 13 | } from "./BitbucketPullRequestProvider.ts"; | |
| 14 | ||
| 15 | for (const operation of [ | |
| 16 | "getMergeability", | |
| 17 | "listChecks", | |
| 18 | "getRepositoryPermission", | |
| 19 | "listComments", | |
| 20 | "listCommits", | |
| 21 | ] as const) { | |
| 22 | it.effect.each(["response", "body read"])( | |
| 23 | `preserves rate limits from ${operation} on %s errors while recovering other optional-read failures`, | |
| 24 | (variant) => | |
| 25 | Effect.gen(function* () { | |
| 26 | const pullRequest = Result.getOrThrow( | |
| 27 | decodePullRequestJson(`{ | |
| 28 | "id": 1, "title": "Check polling", "state": "OPEN", | |
| 29 | "source": { "branch": { "name": "feature" } }, | |
| 30 | "destination": { "branch": { "name": "main" } }, | |
| 31 | "created_on": "2026-09-16T00:00:00Z", | |
| 32 | "updated_on": "2026-09-16T00:00:00Z", | |
| 33 | "links": { "html": { "href": "https://bitbucket.org/acme/web/pull-requests/1" } } | |
| 34 | }`), | |
| 35 | ); | |
| 36 | for (const status of [429, 403]) { | |
| 37 | const provider = yield* make.pipe( | |
| 38 | Effect.provide( | |
| 39 | Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({ | |
| 40 | getPullRequest: () => Effect.succeed(pullRequest), | |
| 41 | getDiffStat: () => Effect.succeed({ additions: 0, deletions: 0, changedFiles: 0 }), | |
| 42 | getMergeability: () => Effect.succeed("unknown" as const), | |
| 43 | listChecks: () => Effect.succeed([]), | |
| 44 | getRepositoryPermission: () => Effect.succeed(true), | |
| 45 | listComments: () => Effect.succeed({ comments: [], threads: [], truncated: false }), | |
| 46 | listCommits: () => Effect.succeed([]), | |
| 47 | [operation]: () => | |
| 48 | Effect.fail( | |
| 49 | variant === "response" | |
| 50 | ? new BitbucketApi.BitbucketResponseError({ | |
| 51 | operation: "request", | |
| 52 | status, | |
| 53 | responseBodyLength: 0, | |
| 54 | retryAt: 120_000, | |
| 55 | }) | |
| 56 | : new BitbucketApi.BitbucketResponseBodyReadError({ | |
| 57 | operation: "request", | |
| 58 | status, | |
| 59 | cause: new Error("response stream failed"), | |
| 60 | retryAt: 120_000, | |
| 61 | }), | |
| 62 | ), | |
| 63 | }), | |
| 64 | ), | |
| 65 | ); | |
| 66 | const reference = { | |
| 67 | cwd: "/repo", | |
| 68 | repository: "acme/web", | |
| 69 | number: 1, | |
| 70 | host: "bitbucket.org", | |
| 71 | }; | |
| 72 | const result = yield* operation === "listComments" || operation === "listCommits" | |
| 73 | ? Effect.result(provider.getChangeRequestActivity(reference)) | |
| 74 | : Effect.result(provider.getChangeRequest(reference)); | |
| 75 | if (status === 429) { | |
| 76 | expect(result).toMatchObject({ | |
| 77 | _tag: "Failure", | |
| 78 | failure: { reason: "rate-limited", retryAt: 120_000 }, | |
| 79 | }); | |
| 80 | } else { | |
| 81 | expect(result._tag).toBe("Success"); | |
| 82 | } | |
| 83 | } | |
| 84 | }), | |
| 85 | ); | |
| 86 | } | |
| 87 | ||
| 88 | it.effect("reads checks and PR state without diff, mergeability, or permission requests", () => | |
| 89 | Effect.gen(function* () { | |
| 90 | const pullRequest = Result.getOrThrow( | |
| 91 | decodePullRequestJson(`{ | |
| 92 | "id": 1, "title": "Checks", "state": "OPEN", | |
| 93 | "source": { "branch": { "name": "feature" } }, | |
| 94 | "destination": { "branch": { "name": "main" } }, | |
| 95 | "created_on": "2026-09-16T00:00:00Z", "updated_on": "2026-09-16T00:00:00Z", | |
| 96 | "links": { "html": { "href": "https://bitbucket.org/acme/web/pull-requests/1" } } | |
| 97 | }`), | |
| 98 | ); | |
| 99 | let limited = false; | |
| 100 | const provider = yield* make.pipe( | |
| 101 | Effect.provide( | |
| 102 | Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({ | |
| 103 | getPullRequest: () => Effect.succeed(pullRequest), | |
| 104 | listChecks: () => | |
| 105 | limited | |
| 106 | ? Effect.fail( | |
| 107 | new BitbucketApi.BitbucketResponseError({ | |
| 108 | operation: "request", | |
| 109 | status: 429, | |
| 110 | responseBodyLength: 0, | |
| 111 | retryAt: 120_000, | |
| 112 | }), | |
| 113 | ) | |
| 114 | : Effect.succeed([ | |
| 115 | { name: "build", status: "failure" as const, description: null, url: null }, | |
| 116 | ]), | |
| 117 | }), | |
| 118 | ), | |
| 119 | ); | |
| 120 | const read = provider.getChangeRequestChecks; | |
| 121 | if (read === undefined) return yield* Effect.die("checks read missing"); | |
| 122 | const input = { cwd: "/repo", repository: "acme/web", host: "bitbucket.org", number: 1 }; | |
| 123 | expect((yield* read(input)).checks[0]?.status).toBe("failure"); | |
| 124 | limited = true; | |
| 125 | expect(yield* Effect.flip(read(input))).toMatchObject({ | |
| 126 | reason: "rate-limited", | |
| 127 | retryAt: 120_000, | |
| 128 | }); | |
| 129 | }), | |
| 130 | ); | |
| 131 | ||
| 132 | describe("bitbucketProviderFailure", () => { | |
| 133 | it("treats only an HTTP 401 as unusable credentials", () => { | |
| 134 | const responseError = (status: number) => | |
| 135 | new BitbucketApi.BitbucketResponseError({ | |
| 136 | operation: "request", | |
| 137 | status, | |
| 138 | responseBodyLength: 0, | |
| 139 | }); | |
| 140 | ||
| 141 | expect(bitbucketProviderFailure(responseError(401)).reason).toBe("unauthenticated"); | |
| 142 | expect(bitbucketProviderFailure(responseError(403)).reason).toBe("failed"); | |
| 143 | }); | |
| 144 | }); | |
| 145 | ||
| 146 | describe("bitbucketViewerPermissions", () => { | |
| 147 | it("offers both actions to credentials with write access", () => { | |
| 148 | expect(bitbucketViewerPermissions({ canWrite: true })).toEqual({ | |
| 149 | actions: ["merge", "close"], | |
| 150 | comment: true, | |
| 151 | resolve: true, | |
| 152 | verdicts: ["comment", "approve", "request-changes"], | |
| 153 | // Bitbucket says nothing about who may set a reviewer, and an unreported permission is | |
| 154 | // granted. | |
| 155 | requestReviewers: true, | |
| 156 | }); | |
| 157 | }); | |
| 158 | ||
| 159 | it("keeps merge from credentials that can only read the repository", () => { | |
| 160 | expect(bitbucketViewerPermissions({ canWrite: false })).toEqual({ | |
| 161 | actions: ["close"], | |
| 162 | comment: true, | |
| 163 | resolve: true, | |
| 164 | verdicts: ["comment", "approve", "request-changes"], | |
| 165 | requestReviewers: true, | |
| 166 | }); | |
| 167 | }); | |
| 168 | ||
| 169 | it("treats an author with read access as any other reader, which is all Bitbucket says", () => { | |
| 170 | // The repository permission is the whole of what Bitbucket reports per account; it says | |
| 171 | // nothing about who opened this pull request, and its author may decline it with read access | |
| 172 | // alone — so declining stays offered rather than being taken from them. | |
| 173 | expect(bitbucketViewerPermissions({ canWrite: false }).actions).toEqual(["close"]); | |
| 174 | }); | |
| 175 | }); |
packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts created+385| ... | ... | @@ -0,0 +1,385 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts"; | |
| 3 | ||
| 4 | import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts"; | |
| 5 | import { | |
| 6 | PullRequestProviderError, | |
| 7 | type PullRequestProviderFailure, | |
| 8 | type ProviderChangeRequest, | |
| 9 | type ProviderChangeRequestActivity, | |
| 10 | type ProviderChangeRequestDetail, | |
| 11 | type PullRequestProviderApi, | |
| 12 | } from "@t3tools/source-control-core/server/PullRequestProvider"; | |
| 13 | import type { BitbucketPullRequest } from "./bitbucketPullRequestJson.ts"; | |
| 14 | ||
| 15 | const CAPABILITIES: PullRequestCapabilities = { | |
| 16 | diff: true, | |
| 17 | comment: true, | |
| 18 | // Bitbucket has no endpoint that reopens a declined pull request, and nothing documented that | |
| 19 | // moves one in or out of draft, so neither is offered rather than failing when pressed. | |
| 20 | actions: ["merge", "close"], | |
| 21 | mergeMethods: ["merge", "squash", "rebase"], | |
| 22 | search: true, | |
| 23 | // Bitbucket Cloud's API exposes no reaction on a pull request or on a comment, so none is | |
| 24 | // read and none is offered. | |
| 25 | reactions: false, | |
| 26 | review: { | |
| 27 | inlineComment: true, | |
| 28 | reply: true, | |
| 29 | resolve: true, | |
| 30 | verdicts: ["comment", "approve", "request-changes"], | |
| 31 | }, | |
| 32 | reviewers: { request: true, listCandidates: true }, | |
| 33 | edit: { changeRequest: true, comment: true }, | |
| 34 | // Bitbucket Cloud states nothing about what a reviewer has already read: no endpoint carries a | |
| 35 | // viewed file, and the per-pull-request properties it does offer are one value shared by | |
| 36 | // everyone rather than one per reader. So the marks are kept here, and the client says whose | |
| 37 | // they are rather than implying bitbucket.org will show them. | |
| 38 | viewedFiles: "environment", | |
| 39 | }; | |
| 40 | ||
| 41 | /** | |
| 42 | * What the configured account may do here, from the one thing Bitbucket states per viewer: the | |
| 43 | * repository permission. Merging needs `write` or `admin`, so that is what narrows. | |
| 44 | * | |
| 45 | * Declining stays offered whatever the permission. Bitbucket lets the author of a pull request | |
| 46 | * decline their own with no more than read access, and the permission response says nothing about | |
| 47 | * who opened this one — so withholding the control from the one person entitled to it is the | |
| 48 | * worse of the two mistakes. Commenting and reviewing are not narrowed either: read access is | |
| 49 | * enough to say something, to approve and to ask for changes. | |
| 50 | * | |
| 51 | * Asking for a review is left open for the same reason: Bitbucket takes a reviewer set from the | |
| 52 | * author of a pull request as well as from whoever can write, and says nothing here about which | |
| 53 | * of the two this account is. | |
| 54 | */ | |
| 55 | export function bitbucketViewerPermissions(input: { | |
| 56 | readonly canWrite: boolean; | |
| 57 | }): PullRequestViewerPermissions { | |
| 58 | return { | |
| 59 | actions: CAPABILITIES.actions.filter((action) => action !== "merge" || input.canWrite), | |
| 60 | comment: true, | |
| 61 | resolve: true, | |
| 62 | verdicts: CAPABILITIES.review.verdicts, | |
| 63 | requestReviewers: true, | |
| 64 | }; | |
| 65 | } | |
| 66 | ||
| 67 | /** The failures that mean the credentials are the problem, rather than one request. */ | |
| 68 | export function bitbucketProviderFailure( | |
| 69 | error: BitbucketPullRequestApi.BitbucketPullRequestApiError, | |
| 70 | ): PullRequestProviderFailure { | |
| 71 | // Bitbucket is read over HTTP with credentials from the environment, so there is no tool to be | |
| 72 | // missing: unusable always means the credentials are absent or refused. | |
| 73 | if (error._tag === "BitbucketResponseError" && error.status === 401) { | |
| 74 | return { reason: "unauthenticated" }; | |
| 75 | } | |
| 76 | if ( | |
| 77 | (error._tag === "BitbucketResponseError" || error._tag === "BitbucketResponseBodyReadError") && | |
| 78 | error.status === 429 | |
| 79 | ) { | |
| 80 | return { | |
| 81 | reason: "rate-limited", | |
| 82 | ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }), | |
| 83 | }; | |
| 84 | } | |
| 85 | if ( | |
| 86 | (error._tag === "BitbucketResponseError" || error._tag === "BitbucketResponseBodyReadError") && | |
| 87 | error.status === 404 | |
| 88 | ) { | |
| 89 | return { reason: "not-found" }; | |
| 90 | } | |
| 91 | return { reason: "failed" }; | |
| 92 | } | |
| 93 | ||
| 94 | function toChangeRequest(pullRequest: BitbucketPullRequest): ProviderChangeRequest { | |
| 95 | return { | |
| 96 | number: pullRequest.number, | |
| 97 | title: pullRequest.title, | |
| 98 | url: pullRequest.url, | |
| 99 | author: pullRequest.author, | |
| 100 | headBranch: pullRequest.headBranch, | |
| 101 | ...(pullRequest.headRepositoryNameWithOwner | |
| 102 | ? { headRepositoryNameWithOwner: pullRequest.headRepositoryNameWithOwner } | |
| 103 | : {}), | |
| 104 | baseBranch: pullRequest.baseBranch, | |
| 105 | state: pullRequest.state, | |
| 106 | isDraft: pullRequest.isDraft, | |
| 107 | mergeability: pullRequest.mergeability, | |
| 108 | // Line counts are a separate read, which only the detail is worth spending on. | |
| 109 | additions: 0, | |
| 110 | deletions: 0, | |
| 111 | createdAt: pullRequest.createdAt, | |
| 112 | updatedAt: pullRequest.updatedAt, | |
| 113 | reviewRequestLogins: pullRequest.reviewRequestLogins, | |
| 114 | // Bitbucket has no labels on a pull request. | |
| 115 | labels: [], | |
| 116 | }; | |
| 117 | } | |
| 118 | ||
| 119 | export const make = Effect.gen(function* () { | |
| 120 | const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; | |
| 121 | ||
| 122 | const fail = | |
| 123 | (operation: string) => (error: BitbucketPullRequestApi.BitbucketPullRequestApiError) => | |
| 124 | new PullRequestProviderError({ | |
| 125 | provider: "bitbucket", | |
| 126 | operation, | |
| 127 | ...bitbucketProviderFailure(error), | |
| 128 | // Every Bitbucket failure states its own fact; this names the operation around it, so | |
| 129 | // the two do not stack into "failed in x: failed in y: ...". | |
| 130 | detail: error.detail, | |
| 131 | cause: error, | |
| 132 | }); | |
| 133 | ||
| 134 | const recoverRead = <A>( | |
| 135 | read: Effect.Effect<A, BitbucketPullRequestApi.BitbucketPullRequestApiError>, | |
| 136 | fallback: A, | |
| 137 | ) => { | |
| 138 | const recover = () => Effect.succeed(fallback); | |
| 139 | return Effect.catchTags(read, { | |
| 140 | BitbucketResponseError: (error) => (error.status === 429 ? Effect.fail(error) : recover()), | |
| 141 | BitbucketUntrustedUrlError: recover, | |
| 142 | BitbucketRepositoryLocatorError: recover, | |
| 143 | BitbucketRequestError: recover, | |
| 144 | BitbucketResponseBodyReadError: (error) => | |
| 145 | error.status === 429 ? Effect.fail(error) : recover(), | |
| 146 | BitbucketResponseDecodeError: recover, | |
| 147 | BitbucketRepositoryVcsResolveError: recover, | |
| 148 | BitbucketRepositoryRemotesListError: recover, | |
| 149 | BitbucketRepositoryRemoteNotFoundError: recover, | |
| 150 | BitbucketPullRequestBodyReadError: recover, | |
| 151 | BitbucketCheckoutError: recover, | |
| 152 | BitbucketPullRequestReadError: recover, | |
| 153 | BitbucketViewerUnavailableError: recover, | |
| 154 | BitbucketRepositoryUnsupportedError: recover, | |
| 155 | BitbucketDiffCommitError: recover, | |
| 156 | }); | |
| 157 | }; | |
| 158 | ||
| 159 | const provider: PullRequestProviderApi = { | |
| 160 | kind: "bitbucket", | |
| 161 | capabilities: CAPABILITIES, | |
| 162 | ||
| 163 | // Bitbucket credentials come from the server's environment rather than a checkout, so the | |
| 164 | // account is the same whichever workspace asks. | |
| 165 | getViewer: () => api.getViewer().pipe(Effect.mapError(fail("getViewer"))), | |
| 166 | ||
| 167 | listChangeRequests: (input) => | |
| 168 | api | |
| 169 | .listPullRequests({ | |
| 170 | repository: input.repository, | |
| 171 | state: input.state, | |
| 172 | limit: input.limit, | |
| 173 | query: input.query, | |
| 174 | cursor: input.cursor, | |
| 175 | }) | |
| 176 | .pipe( | |
| 177 | Effect.mapError(fail("listChangeRequests")), | |
| 178 | Effect.map((batch) => ({ | |
| 179 | items: batch.items.map(toChangeRequest), | |
| 180 | truncated: batch.truncated, | |
| 181 | // Bitbucket is asked for `-updated_on` whether or not it is being carried on from, | |
| 182 | // so every page it answers is one a cursor can continue. | |
| 183 | continues: true, | |
| 184 | })), | |
| 185 | ), | |
| 186 | ||
| 187 | getChangeRequestChecks: (input) => | |
| 188 | Effect.all([api.getPullRequest(input), api.listChecks(input)], { concurrency: 2 }).pipe( | |
| 189 | Effect.map(([pullRequest, checks]) => ({ state: pullRequest.state, checks })), | |
| 190 | Effect.mapError(fail("getChangeRequestChecks")), | |
| 191 | ), | |
| 192 | ||
| 193 | getChangeRequest: (input) => { | |
| 194 | const target = { repository: input.repository, number: input.number }; | |
| 195 | return Effect.all( | |
| 196 | [ | |
| 197 | api.getPullRequest(target), | |
| 198 | api.getDiffStat(target), | |
| 199 | recoverRead(api.getMergeability(target), "unknown" as const), | |
| 200 | recoverRead(api.listChecks(target), []), | |
| 201 | // A permission that could not be read is an unknown one, which is granted: a hidden | |
| 202 | // Merge leaves someone entitled to it with no way through, and one Bitbucket refuses | |
| 203 | // at least says why. | |
| 204 | recoverRead(api.getRepositoryPermission(target), true), | |
| 205 | ], | |
| 206 | { concurrency: 5 }, | |
| 207 | ).pipe( | |
| 208 | Effect.mapError(fail("getChangeRequest")), | |
| 209 | Effect.map( | |
| 210 | ([ | |
| 211 | pullRequest, | |
| 212 | diffStat, | |
| 213 | mergeability, | |
| 214 | checks, | |
| 215 | canWrite, | |
| 216 | ]): ProviderChangeRequestDetail => ({ | |
| 217 | ...toChangeRequest(pullRequest), | |
| 218 | mergeability, | |
| 219 | additions: diffStat.additions, | |
| 220 | deletions: diffStat.deletions, | |
| 221 | changedFiles: diffStat.changedFiles, | |
| 222 | body: pullRequest.body, | |
| 223 | mergedAt: null, | |
| 224 | closedAt: null, | |
| 225 | reviewers: pullRequest.reviewers, | |
| 226 | checks, | |
| 227 | // Bitbucket publishes no per-repository list of allowed strategies, so the ones it | |
| 228 | // supports are all offered and a strategy the repository forbids fails on merge. | |
| 229 | mergeCapabilities: { merge: true, squash: true, rebase: true }, | |
| 230 | viewerPermissions: bitbucketViewerPermissions({ canWrite }), | |
| 231 | }), | |
| 232 | ), | |
| 233 | ); | |
| 234 | }, | |
| 235 | ||
| 236 | getChangeRequestActivity: (input) => { | |
| 237 | const target = { repository: input.repository, number: input.number }; | |
| 238 | return Effect.all( | |
| 239 | [ | |
| 240 | // Reviews ride on the pull request itself, so this inexpensive core read is repeated | |
| 241 | // here rather than making the core response wait for the conversation endpoints. | |
| 242 | api.getPullRequest(target), | |
| 243 | recoverRead(api.listComments(target), { comments: [], threads: [], truncated: true }), | |
| 244 | recoverRead(api.listCommits(target), []), | |
| 245 | ], | |
| 246 | { concurrency: 3 }, | |
| 247 | ).pipe( | |
| 248 | Effect.mapError(fail("getChangeRequestActivity")), | |
| 249 | Effect.map(([pullRequest, comments, commits]): ProviderChangeRequestActivity => ({ | |
| 250 | comments: [...comments.comments, ...pullRequest.reviews].toSorted((left, right) => | |
| 251 | left.createdAt.localeCompare(right.createdAt), | |
| 252 | ), | |
| 253 | commentCount: comments.comments.length + pullRequest.reviews.length, | |
| 254 | commentsTruncated: comments.truncated, | |
| 255 | reviewThreads: comments.threads, | |
| 256 | commits, | |
| 257 | })), | |
| 258 | ); | |
| 259 | }, | |
| 260 | ||
| 261 | getViewerPermissions: (input) => | |
| 262 | api.getRepositoryPermission({ repository: input.repository }).pipe( | |
| 263 | Effect.mapError(fail("getViewerPermissions")), | |
| 264 | Effect.map((canWrite) => bitbucketViewerPermissions({ canWrite })), | |
| 265 | ), | |
| 266 | ||
| 267 | // `/diff` answers with the whole patch and pages nothing, so the first slice is the last. | |
| 268 | getDiff: (input) => | |
| 269 | api | |
| 270 | .getPullRequestDiff({ | |
| 271 | repository: input.repository, | |
| 272 | number: input.number, | |
| 273 | ...(input.commit === undefined ? {} : { commit: input.commit }), | |
| 274 | }) | |
| 275 | .pipe( | |
| 276 | Effect.mapError(fail("getDiff")), | |
| 277 | Effect.map((diff) => ({ ...diff, nextCursor: null })), | |
| 278 | ), | |
| 279 | ||
| 280 | getFileRevisions: (input) => | |
| 281 | api | |
| 282 | .getFileRevisions({ | |
| 283 | repository: input.repository, | |
| 284 | number: input.number, | |
| 285 | paths: input.paths, | |
| 286 | }) | |
| 287 | .pipe(Effect.mapError(fail("getFileRevisions"))), | |
| 288 | ||
| 289 | // Users only: Bitbucket requests a review of an account, and has no group that stands in for | |
| 290 | // one on a pull request. | |
| 291 | listReviewerCandidates: (input) => | |
| 292 | api | |
| 293 | .listReviewerCandidates({ repository: input.repository, number: input.number }) | |
| 294 | .pipe(Effect.mapError(fail("listReviewerCandidates"))), | |
| 295 | ||
| 296 | setReviewerRequest: (input) => | |
| 297 | api | |
| 298 | .setReviewerRequest({ | |
| 299 | repository: input.repository, | |
| 300 | number: input.number, | |
| 301 | reviewers: input.reviewers, | |
| 302 | requested: input.requested, | |
| 303 | }) | |
| 304 | .pipe(Effect.mapError(fail("setReviewerRequest"))), | |
| 305 | ||
| 306 | runAction: (input) => | |
| 307 | api | |
| 308 | .runAction({ | |
| 309 | repository: input.repository, | |
| 310 | number: input.number, | |
| 311 | action: input.action, | |
| 312 | ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), | |
| 313 | }) | |
| 314 | .pipe(Effect.mapError(fail("runAction"))), | |
| 315 | ||
| 316 | updateChangeRequest: (input) => | |
| 317 | api | |
| 318 | .updateChangeRequest({ | |
| 319 | repository: input.repository, | |
| 320 | number: input.number, | |
| 321 | title: input.title, | |
| 322 | body: input.body, | |
| 323 | }) | |
| 324 | .pipe(Effect.mapError(fail("updateChangeRequest"))), | |
| 325 | ||
| 326 | comment: (input) => | |
| 327 | api | |
| 328 | .comment({ repository: input.repository, number: input.number, body: input.body }) | |
| 329 | .pipe(Effect.mapError(fail("comment"))), | |
| 330 | ||
| 331 | updateComment: (input) => | |
| 332 | api | |
| 333 | .updateComment({ | |
| 334 | repository: input.repository, | |
| 335 | number: input.number, | |
| 336 | commentId: input.commentId, | |
| 337 | body: input.body, | |
| 338 | }) | |
| 339 | .pipe(Effect.mapError(fail("updateComment"))), | |
| 340 | ||
| 341 | submitReview: (input) => | |
| 342 | api | |
| 343 | .submitReview({ | |
| 344 | repository: input.repository, | |
| 345 | number: input.number, | |
| 346 | verdict: input.verdict, | |
| 347 | body: input.body, | |
| 348 | comments: input.comments, | |
| 349 | }) | |
| 350 | .pipe(Effect.mapError(fail("submitReview"))), | |
| 351 | ||
| 352 | replyToThread: (input) => | |
| 353 | api | |
| 354 | .replyToComment({ | |
| 355 | repository: input.repository, | |
| 356 | number: input.number, | |
| 357 | commentId: input.threadId, | |
| 358 | body: input.body, | |
| 359 | }) | |
| 360 | .pipe(Effect.mapError(fail("replyToThread"))), | |
| 361 | ||
| 362 | // Never called: `capabilities.reactions` is false, and the service refuses without it. | |
| 363 | setReaction: () => | |
| 364 | Effect.fail( | |
| 365 | new PullRequestProviderError({ | |
| 366 | provider: "bitbucket", | |
| 367 | operation: "setReaction", | |
| 368 | reason: "failed", | |
| 369 | detail: "Bitbucket does not support reactions.", | |
| 370 | }), | |
| 371 | ), | |
| 372 | ||
| 373 | setThreadResolution: (input) => | |
| 374 | api | |
| 375 | .setCommentResolution({ | |
| 376 | repository: input.repository, | |
| 377 | number: input.number, | |
| 378 | commentId: input.threadId, | |
| 379 | resolved: input.resolved, | |
| 380 | }) | |
| 381 | .pipe(Effect.mapError(fail("setThreadResolution"))), | |
| 382 | }; | |
| 383 | ||
| 384 | return provider; | |
| 385 | }); |
packages/source-control-bitbucket/src/server/BitbucketSourceControlProvider.test.ts created+151| ... | ... | @@ -0,0 +1,151 @@ |
| 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 | ||
| 6 | import * as BitbucketApi from "./BitbucketApi.ts"; | |
| 7 | import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; | |
| 8 | ||
| 9 | function makeProvider(bitbucket: Partial<BitbucketApi.BitbucketApi["Service"]>) { | |
| 10 | return BitbucketSourceControlProvider.make.pipe( | |
| 11 | Effect.provide(Layer.mock(BitbucketApi.BitbucketApi)(bitbucket)), | |
| 12 | ); | |
| 13 | } | |
| 14 | ||
| 15 | it.effect("maps Bitbucket PR summaries into provider-neutral change requests", () => | |
| 16 | Effect.gen(function* () { | |
| 17 | const provider = yield* makeProvider({ | |
| 18 | getPullRequest: () => | |
| 19 | Effect.succeed({ | |
| 20 | number: 42, | |
| 21 | title: "Add Bitbucket provider", | |
| 22 | url: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42", | |
| 23 | baseRefName: "main", | |
| 24 | headRefName: "feature/source-control", | |
| 25 | state: "open", | |
| 26 | updatedAt: Option.none(), | |
| 27 | isCrossRepository: true, | |
| 28 | headRepositoryNameWithOwner: "fork/t3code", | |
| 29 | headRepositoryOwnerLogin: "fork", | |
| 30 | }), | |
| 31 | }); | |
| 32 | ||
| 33 | const changeRequest = yield* provider.getChangeRequest({ | |
| 34 | cwd: "/repo", | |
| 35 | reference: "42", | |
| 36 | }); | |
| 37 | ||
| 38 | assert.deepStrictEqual(changeRequest, { | |
| 39 | provider: "bitbucket", | |
| 40 | number: 42, | |
| 41 | title: "Add Bitbucket provider", | |
| 42 | url: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42", | |
| 43 | baseRefName: "main", | |
| 44 | headRefName: "feature/source-control", | |
| 45 | state: "open", | |
| 46 | updatedAt: Option.none(), | |
| 47 | isCrossRepository: true, | |
| 48 | headRepositoryNameWithOwner: "fork/t3code", | |
| 49 | headRepositoryOwnerLogin: "fork", | |
| 50 | }); | |
| 51 | }), | |
| 52 | ); | |
| 53 | ||
| 54 | it.effect("adds repository context while retaining Bitbucket API causes", () => | |
| 55 | Effect.gen(function* () { | |
| 56 | const upstreamCause = new Error("raw upstream failure"); | |
| 57 | const cause = new BitbucketApi.BitbucketRequestError({ | |
| 58 | operation: "getRepository", | |
| 59 | cause: upstreamCause, | |
| 60 | }); | |
| 61 | const provider = yield* makeProvider({ | |
| 62 | getRepositoryCloneUrls: () => Effect.fail(cause), | |
| 63 | }); | |
| 64 | ||
| 65 | const error = yield* provider | |
| 66 | .getRepositoryCloneUrls({ cwd: "/repo", repository: "owner/repo" }) | |
| 67 | .pipe(Effect.flip); | |
| 68 | ||
| 69 | assert.deepStrictEqual( | |
| 70 | { | |
| 71 | provider: error.provider, | |
| 72 | operation: error.operation, | |
| 73 | command: error.command, | |
| 74 | cwd: error.cwd, | |
| 75 | repository: error.repository, | |
| 76 | detail: error.detail, | |
| 77 | }, | |
| 78 | { | |
| 79 | provider: "bitbucket", | |
| 80 | operation: "getRepositoryCloneUrls", | |
| 81 | command: undefined, | |
| 82 | cwd: "/repo", | |
| 83 | repository: "owner/repo", | |
| 84 | detail: "Failed to get repository clone URLs.", | |
| 85 | }, | |
| 86 | ); | |
| 87 | assert.strictEqual(error.cause, cause); | |
| 88 | assert.equal(error.message.includes(upstreamCause.message), false); | |
| 89 | }), | |
| 90 | ); | |
| 91 | ||
| 92 | it.effect("lists Bitbucket PRs through provider-neutral input names", () => | |
| 93 | Effect.gen(function* () { | |
| 94 | let listInput: Parameters<BitbucketApi.BitbucketApi["Service"]["listPullRequests"]>[0] | null = | |
| 95 | null; | |
| 96 | const provider = yield* makeProvider({ | |
| 97 | listPullRequests: (input) => { | |
| 98 | listInput = input; | |
| 99 | return Effect.succeed([]); | |
| 100 | }, | |
| 101 | }); | |
| 102 | ||
| 103 | yield* provider.listChangeRequests({ | |
| 104 | cwd: "/repo", | |
| 105 | headSelector: "feature/provider", | |
| 106 | state: "all", | |
| 107 | limit: 10, | |
| 108 | }); | |
| 109 | ||
| 110 | assert.deepStrictEqual(listInput, { | |
| 111 | cwd: "/repo", | |
| 112 | headSelector: "feature/provider", | |
| 113 | state: "all", | |
| 114 | limit: 10, | |
| 115 | }); | |
| 116 | }), | |
| 117 | ); | |
| 118 | ||
| 119 | it.effect("creates Bitbucket PRs through provider-neutral input names", () => | |
| 120 | Effect.gen(function* () { | |
| 121 | let createInput: | |
| 122 | | Parameters<BitbucketApi.BitbucketApi["Service"]["createPullRequest"]>[0] | |
| 123 | | null = null; | |
| 124 | const provider = yield* makeProvider({ | |
| 125 | createPullRequest: (input) => { | |
| 126 | createInput = input; | |
| 127 | return Effect.void; | |
| 128 | }, | |
| 129 | }); | |
| 130 | ||
| 131 | yield* provider.createChangeRequest({ | |
| 132 | cwd: "/repo", | |
| 133 | baseRefName: "main", | |
| 134 | headSelector: "owner:feature/provider", | |
| 135 | title: "Provider PR", | |
| 136 | bodyFile: "/tmp/body.md", | |
| 137 | }); | |
| 138 | ||
| 139 | assert.deepStrictEqual(createInput, { | |
| 140 | cwd: "/repo", | |
| 141 | baseBranch: "main", | |
| 142 | headSelector: "owner:feature/provider", | |
| 143 | source: { | |
| 144 | owner: "owner", | |
| 145 | refName: "feature/provider", | |
| 146 | }, | |
| 147 | title: "Provider PR", | |
| 148 | bodyFile: "/tmp/body.md", | |
| 149 | }); | |
| 150 | }), | |
| 151 | ); |
packages/source-control-bitbucket/src/server/BitbucketSourceControlProvider.ts created+198| ... | ... | @@ -0,0 +1,198 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import * as Option from "effect/Option"; | |
| 3 | import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; | |
| 4 | ||
| 5 | import * as BitbucketApi from "./BitbucketApi.ts"; | |
| 6 | import type { NormalizedBitbucketPullRequestRecord } from "./bitbucketPullRequests.ts"; | |
| 7 | import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; | |
| 8 | import type { SourceControlApiDiscoverySpec } from "@t3tools/source-control-core/server/discovery"; | |
| 9 | ||
| 10 | function toChangeRequest(summary: NormalizedBitbucketPullRequestRecord): ChangeRequest { | |
| 11 | return { | |
| 12 | provider: "bitbucket", | |
| 13 | number: summary.number, | |
| 14 | title: summary.title, | |
| 15 | url: summary.url, | |
| 16 | baseRefName: summary.baseRefName, | |
| 17 | headRefName: summary.headRefName, | |
| 18 | state: summary.state, | |
| 19 | ...(summary.isDraft === true ? { isDraft: true } : {}), | |
| 20 | updatedAt: summary.updatedAt ?? Option.none(), | |
| 21 | ...(summary.isCrossRepository !== undefined | |
| 22 | ? { isCrossRepository: summary.isCrossRepository } | |
| 23 | : {}), | |
| 24 | ...(summary.headRepositoryNameWithOwner !== undefined | |
| 25 | ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner } | |
| 26 | : {}), | |
| 27 | ...(summary.headRepositoryOwnerLogin !== undefined | |
| 28 | ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin } | |
| 29 | : {}), | |
| 30 | }; | |
| 31 | } | |
| 32 | ||
| 33 | export const make = Effect.gen(function* () { | |
| 34 | const bitbucket = yield* BitbucketApi.BitbucketApi; | |
| 35 | ||
| 36 | return SourceControlProvider.SourceControlProvider.of({ | |
| 37 | kind: "bitbucket", | |
| 38 | listChangeRequests: (input) => { | |
| 39 | const source = SourceControlProvider.sourceControlRefFromInput(input); | |
| 40 | return bitbucket | |
| 41 | .listPullRequests({ | |
| 42 | cwd: input.cwd, | |
| 43 | ...(input.context ? { context: input.context } : {}), | |
| 44 | headSelector: input.headSelector, | |
| 45 | ...(source ? { source } : {}), | |
| 46 | state: input.state, | |
| 47 | ...(input.limit !== undefined ? { limit: input.limit } : {}), | |
| 48 | }) | |
| 49 | .pipe( | |
| 50 | Effect.map((items) => items.map(toChangeRequest)), | |
| 51 | Effect.mapError( | |
| 52 | (error) => | |
| 53 | new SourceControlProviderError({ | |
| 54 | provider: "bitbucket", | |
| 55 | operation: "listChangeRequests", | |
| 56 | cwd: input.cwd, | |
| 57 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | |
| 58 | input.headSelector, | |
| 59 | ), | |
| 60 | detail: "Failed to list change requests.", | |
| 61 | cause: error, | |
| 62 | }), | |
| 63 | ), | |
| 64 | ); | |
| 65 | }, | |
| 66 | getChangeRequest: (input) => | |
| 67 | bitbucket.getPullRequest(input).pipe( | |
| 68 | Effect.map(toChangeRequest), | |
| 69 | Effect.mapError( | |
| 70 | (error) => | |
| 71 | new SourceControlProviderError({ | |
| 72 | provider: "bitbucket", | |
| 73 | operation: "getChangeRequest", | |
| 74 | cwd: input.cwd, | |
| 75 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | |
| 76 | input.reference, | |
| 77 | ), | |
| 78 | detail: "Failed to get change request.", | |
| 79 | cause: error, | |
| 80 | }), | |
| 81 | ), | |
| 82 | ), | |
| 83 | createChangeRequest: (input) => { | |
| 84 | const source = SourceControlProvider.sourceControlRefFromInput(input); | |
| 85 | return bitbucket | |
| 86 | .createPullRequest({ | |
| 87 | cwd: input.cwd, | |
| 88 | ...(input.context ? { context: input.context } : {}), | |
| 89 | baseBranch: input.baseRefName, | |
| 90 | headSelector: input.headSelector, | |
| 91 | ...(source ? { source } : {}), | |
| 92 | ...(input.target ? { target: input.target } : {}), | |
| 93 | title: input.title, | |
| 94 | bodyFile: input.bodyFile, | |
| 95 | }) | |
| 96 | .pipe( | |
| 97 | Effect.mapError( | |
| 98 | (error) => | |
| 99 | new SourceControlProviderError({ | |
| 100 | provider: "bitbucket", | |
| 101 | operation: "createChangeRequest", | |
| 102 | cwd: input.cwd, | |
| 103 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | |
| 104 | input.headSelector, | |
| 105 | ), | |
| 106 | detail: "Failed to create change request.", | |
| 107 | cause: error, | |
| 108 | }), | |
| 109 | ), | |
| 110 | ); | |
| 111 | }, | |
| 112 | getRepositoryCloneUrls: (input) => | |
| 113 | bitbucket.getRepositoryCloneUrls(input).pipe( | |
| 114 | Effect.mapError( | |
| 115 | (error) => | |
| 116 | new SourceControlProviderError({ | |
| 117 | provider: "bitbucket", | |
| 118 | operation: "getRepositoryCloneUrls", | |
| 119 | cwd: input.cwd, | |
| 120 | repository: SourceControlProvider.transportSafeSourceControlErrorValue( | |
| 121 | input.repository, | |
| 122 | ), | |
| 123 | detail: "Failed to get repository clone URLs.", | |
| 124 | cause: error, | |
| 125 | }), | |
| 126 | ), | |
| 127 | ), | |
| 128 | createRepository: (input) => | |
| 129 | bitbucket.createRepository(input).pipe( | |
| 130 | Effect.mapError( | |
| 131 | (error) => | |
| 132 | new SourceControlProviderError({ | |
| 133 | provider: "bitbucket", | |
| 134 | operation: "createRepository", | |
| 135 | cwd: input.cwd, | |
| 136 | repository: SourceControlProvider.transportSafeSourceControlErrorValue( | |
| 137 | input.repository, | |
| 138 | ), | |
| 139 | detail: "Failed to create repository.", | |
| 140 | cause: error, | |
| 141 | }), | |
| 142 | ), | |
| 143 | ), | |
| 144 | getDefaultBranch: (input) => | |
| 145 | bitbucket | |
| 146 | .getDefaultBranch({ | |
| 147 | cwd: input.cwd, | |
| 148 | ...(input.context ? { context: input.context } : {}), | |
| 149 | }) | |
| 150 | .pipe( | |
| 151 | Effect.mapError( | |
| 152 | (error) => | |
| 153 | new SourceControlProviderError({ | |
| 154 | provider: "bitbucket", | |
| 155 | operation: "getDefaultBranch", | |
| 156 | cwd: input.cwd, | |
| 157 | detail: "Failed to get default branch.", | |
| 158 | cause: error, | |
| 159 | }), | |
| 160 | ), | |
| 161 | ), | |
| 162 | checkoutChangeRequest: (input) => | |
| 163 | bitbucket | |
| 164 | .checkoutPullRequest({ | |
| 165 | cwd: input.cwd, | |
| 166 | ...(input.context ? { context: input.context } : {}), | |
| 167 | reference: input.reference, | |
| 168 | ...(input.force !== undefined ? { force: input.force } : {}), | |
| 169 | }) | |
| 170 | .pipe( | |
| 171 | Effect.mapError( | |
| 172 | (error) => | |
| 173 | new SourceControlProviderError({ | |
| 174 | provider: "bitbucket", | |
| 175 | operation: "checkoutChangeRequest", | |
| 176 | cwd: input.cwd, | |
| 177 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | |
| 178 | input.reference, | |
| 179 | ), | |
| 180 | detail: "Failed to check out change request.", | |
| 181 | cause: error, | |
| 182 | }), | |
| 183 | ), | |
| 184 | ), | |
| 185 | }); | |
| 186 | }); | |
| 187 | ||
| 188 | export const makeDiscovery = Effect.gen(function* () { | |
| 189 | const bitbucket = yield* BitbucketApi.BitbucketApi; | |
| 190 | ||
| 191 | return { | |
| 192 | type: "api", | |
| 193 | kind: "bitbucket", | |
| 194 | label: "Bitbucket", | |
| 195 | installHint: "Add a Bitbucket token in Settings → Source Control.", | |
| 196 | probeAuth: bitbucket.probeAuth, | |
| 197 | } satisfies SourceControlApiDiscoverySpec; | |
| 198 | }); |
packages/source-control-bitbucket/src/server/bitbucketPullRequestJson.test.ts created+377| ... | ... | @@ -0,0 +1,377 @@ |
| 1 | import * as Result from "effect/Result"; | |
| 2 | import { describe, expect, it } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { | |
| 5 | decodeCommentsJson, | |
| 6 | decodeCommitsJson, | |
| 7 | decodeConflictsJson, | |
| 8 | decodeDiffstatJson, | |
| 9 | decodePullRequestJson, | |
| 10 | decodePullRequestPageJson, | |
| 11 | decodeRepositoryPermissionJson, | |
| 12 | decodeStatusesJson, | |
| 13 | decodeViewerJson, | |
| 14 | } from "./bitbucketPullRequestJson.ts"; | |
| 15 | ||
| 16 | /** Shaped after a real api.bitbucket.org pull request, trimmed to the fields that are read. */ | |
| 17 | function pullRequest(overrides: Record<string, unknown> = {}): Record<string, unknown> { | |
| 18 | return { | |
| 19 | id: 897, | |
| 20 | title: "Add trustabl-pipe", | |
| 21 | description: "# Add trustabl-pipe", | |
| 22 | state: "OPEN", | |
| 23 | draft: false, | |
| 24 | created_on: "2026-06-16T05:04:32.258456+00:00", | |
| 25 | updated_on: "2026-06-16T05:04:33.750542+00:00", | |
| 26 | author: { display_name: "Bilal Hassan", nickname: "bilal", type: "user" }, | |
| 27 | source: { branch: { name: "feat/page" }, repository: { full_name: "fork/web" } }, | |
| 28 | destination: { branch: { name: "master" } }, | |
| 29 | links: { html: { href: "https://bitbucket.org/acme/web/pull-requests/897" } }, | |
| 30 | ...overrides, | |
| 31 | }; | |
| 32 | } | |
| 33 | ||
| 34 | function page(values: ReadonlyArray<unknown>, extra: Record<string, unknown> = {}): string { | |
| 35 | return JSON.stringify({ pagelen: 50, page: 1, size: values.length, values, ...extra }); | |
| 36 | } | |
| 37 | ||
| 38 | function expectSuccess<A>(result: Result.Result<A, unknown>): A { | |
| 39 | expect(Result.isSuccess(result)).toBe(true); | |
| 40 | if (!Result.isSuccess(result)) throw new Error("expected a successful decode"); | |
| 41 | return result.success; | |
| 42 | } | |
| 43 | ||
| 44 | describe("decodePullRequestPageJson", () => { | |
| 45 | it("reads a pull request as a change request", () => { | |
| 46 | const decoded = expectSuccess(decodePullRequestPageJson(page([pullRequest()]))); | |
| 47 | ||
| 48 | expect(decoded.items).toHaveLength(1); | |
| 49 | expect(decoded.items[0]).toMatchObject({ | |
| 50 | number: 897, | |
| 51 | title: "Add trustabl-pipe", | |
| 52 | url: "https://bitbucket.org/acme/web/pull-requests/897", | |
| 53 | author: { login: "bilal", name: "Bilal Hassan" }, | |
| 54 | headBranch: "feat/page", | |
| 55 | headRepositoryNameWithOwner: "fork/web", | |
| 56 | baseBranch: "master", | |
| 57 | state: "open", | |
| 58 | isDraft: false, | |
| 59 | // Bitbucket says nothing about conflicts on the pull request itself. | |
| 60 | mergeability: "unknown", | |
| 61 | }); | |
| 62 | expect(decoded.next).toBeNull(); | |
| 63 | }); | |
| 64 | ||
| 65 | it("normalizes Bitbucket's offset timestamps, which the page sorts against other hosts", () => { | |
| 66 | const decoded = expectSuccess(decodePullRequestPageJson(page([pullRequest()]))); | |
| 67 | ||
| 68 | expect(decoded.items[0]).toMatchObject({ | |
| 69 | createdAt: "2026-06-16T05:04:32.258Z", | |
| 70 | updatedAt: "2026-06-16T05:04:33.750Z", | |
| 71 | }); | |
| 72 | }); | |
| 73 | ||
| 74 | it("reports the next page as the whole URL Bitbucket sends", () => { | |
| 75 | const next = "https://api.bitbucket.org/2.0/repositories/acme/web/pullrequests?page=2"; | |
| 76 | const decoded = expectSuccess(decodePullRequestPageJson(page([pullRequest()], { next }))); | |
| 77 | ||
| 78 | expect(decoded.next).toBe(next); | |
| 79 | }); | |
| 80 | ||
| 81 | it.each([ | |
| 82 | ["MERGED", "merged"], | |
| 83 | ["DECLINED", "closed"], | |
| 84 | ["SUPERSEDED", "closed"], | |
| 85 | ["OPEN", "open"], | |
| 86 | ["something new", "open"], | |
| 87 | ])("reads the %s state as %s", (state, expected) => { | |
| 88 | const decoded = expectSuccess(decodePullRequestPageJson(page([pullRequest({ state })]))); | |
| 89 | ||
| 90 | expect(decoded.items[0]?.state).toBe(expected); | |
| 91 | }); | |
| 92 | ||
| 93 | it("skips a malformed row rather than failing the page", () => { | |
| 94 | const decoded = expectSuccess( | |
| 95 | decodePullRequestPageJson(page([{ id: "not a number" }, pullRequest()])), | |
| 96 | ); | |
| 97 | ||
| 98 | expect(decoded.items).toHaveLength(1); | |
| 99 | }); | |
| 100 | ||
| 101 | it("fails when Bitbucket did not answer with a page", () => { | |
| 102 | expect(Result.isFailure(decodePullRequestPageJson(JSON.stringify({ error: "nope" })))).toBe( | |
| 103 | true, | |
| 104 | ); | |
| 105 | }); | |
| 106 | }); | |
| 107 | ||
| 108 | describe("decodePullRequestJson", () => { | |
| 109 | it("reads reviewers as review requests", () => { | |
| 110 | const decoded = expectSuccess( | |
| 111 | decodePullRequestJson( | |
| 112 | JSON.stringify( | |
| 113 | pullRequest({ | |
| 114 | reviewers: [{ nickname: "julius", display_name: "Julius" }], | |
| 115 | }), | |
| 116 | ), | |
| 117 | ), | |
| 118 | ); | |
| 119 | ||
| 120 | expect(decoded.reviewRequestLogins).toEqual(["julius"]); | |
| 121 | expect(decoded.reviewers).toEqual([{ login: "julius", name: "Julius", avatarUrl: null }]); | |
| 122 | }); | |
| 123 | ||
| 124 | it("reads a participant's vote as a review", () => { | |
| 125 | const decoded = expectSuccess( | |
| 126 | decodePullRequestJson( | |
| 127 | JSON.stringify( | |
| 128 | pullRequest({ | |
| 129 | participants: [ | |
| 130 | { | |
| 131 | user: { nickname: "julius", display_name: "Julius" }, | |
| 132 | role: "REVIEWER", | |
| 133 | approved: true, | |
| 134 | state: "approved", | |
| 135 | participated_on: "2026-06-17T09:00:00+00:00", | |
| 136 | }, | |
| 137 | // Added as a reviewer but has not voted, so there is no verdict to show. | |
| 138 | { | |
| 139 | user: { nickname: "sam", display_name: "Sam" }, | |
| 140 | role: "REVIEWER", | |
| 141 | approved: false, | |
| 142 | state: null, | |
| 143 | participated_on: null, | |
| 144 | }, | |
| 145 | ], | |
| 146 | }), | |
| 147 | ), | |
| 148 | ), | |
| 149 | ); | |
| 150 | ||
| 151 | expect(decoded.reviews).toHaveLength(1); | |
| 152 | expect(decoded.reviews[0]).toMatchObject({ | |
| 153 | kind: "review", | |
| 154 | author: { login: "julius" }, | |
| 155 | reviewState: "approved", | |
| 156 | createdAt: "2026-06-17T09:00:00.000Z", | |
| 157 | }); | |
| 158 | }); | |
| 159 | }); | |
| 160 | ||
| 161 | describe("decodeViewerJson", () => { | |
| 162 | it("reads the signed-in nickname", () => { | |
| 163 | const decoded = decodeViewerJson(JSON.stringify({ nickname: "bilal", display_name: "Bilal" })); | |
| 164 | ||
| 165 | expect(expectSuccess(decoded)).toBe("bilal"); | |
| 166 | }); | |
| 167 | ||
| 168 | it("falls back to the display name, which app accounts have instead", () => { | |
| 169 | const decoded = decodeViewerJson(JSON.stringify({ display_name: "Release Bot" })); | |
| 170 | ||
| 171 | expect(expectSuccess(decoded)).toBe("Release Bot"); | |
| 172 | }); | |
| 173 | ||
| 174 | it("returns nothing when the account has neither", () => { | |
| 175 | expect(expectSuccess(decodeViewerJson(JSON.stringify({})))).toBeNull(); | |
| 176 | }); | |
| 177 | }); | |
| 178 | ||
| 179 | describe("decodeCommentsJson", () => { | |
| 180 | it("keeps a posted comment and drops deleted and unposted ones", () => { | |
| 181 | const decoded = expectSuccess( | |
| 182 | decodeCommentsJson( | |
| 183 | page([ | |
| 184 | { | |
| 185 | id: 797230941, | |
| 186 | content: { raw: "The issue is ready for review." }, | |
| 187 | user: { display_name: "Release Bot", type: "app_user" }, | |
| 188 | created_on: "2026-05-15T01:58:38.220690+00:00", | |
| 189 | deleted: false, | |
| 190 | pending: false, | |
| 191 | links: { html: { href: "https://bitbucket.org/acme/web/pull-requests/892#c1" } }, | |
| 192 | }, | |
| 193 | { | |
| 194 | id: 2, | |
| 195 | content: { raw: "gone" }, | |
| 196 | created_on: "2026-05-15T02:00:00+00:00", | |
| 197 | deleted: true, | |
| 198 | }, | |
| 199 | { | |
| 200 | id: 3, | |
| 201 | content: { raw: "wip" }, | |
| 202 | created_on: "2026-05-15T02:00:00+00:00", | |
| 203 | pending: true, | |
| 204 | }, | |
| 205 | { id: 4, content: { raw: " " }, created_on: "2026-05-15T02:00:00+00:00" }, | |
| 206 | ]), | |
| 207 | ), | |
| 208 | ); | |
| 209 | ||
| 210 | expect(decoded.comments).toHaveLength(1); | |
| 211 | expect(decoded.comments[0]).toMatchObject({ | |
| 212 | id: "797230941", | |
| 213 | kind: "issue-comment", | |
| 214 | // An app account has no nickname, so its display name is the only handle it has. | |
| 215 | author: { login: "Release Bot" }, | |
| 216 | createdAt: "2026-05-15T01:58:38.220Z", | |
| 217 | }); | |
| 218 | }); | |
| 219 | ||
| 220 | it("reads a comment pinned to a file as a review comment", () => { | |
| 221 | const decoded = expectSuccess( | |
| 222 | decodeCommentsJson( | |
| 223 | page([ | |
| 224 | { | |
| 225 | id: 5, | |
| 226 | content: { raw: "Rename this." }, | |
| 227 | created_on: "2026-05-15T02:00:00+00:00", | |
| 228 | inline: { path: "src/app.ts" }, | |
| 229 | }, | |
| 230 | ]), | |
| 231 | ), | |
| 232 | ); | |
| 233 | ||
| 234 | expect(decoded.comments[0]).toMatchObject({ kind: "review-comment", path: "src/app.ts" }); | |
| 235 | }); | |
| 236 | }); | |
| 237 | ||
| 238 | describe("decodeCommitsJson", () => { | |
| 239 | it("returns commits oldest first with only the subject line", () => { | |
| 240 | const decoded = expectSuccess( | |
| 241 | decodeCommitsJson( | |
| 242 | page([ | |
| 243 | { hash: "bbb", message: "second\n\nbody text\n", date: "2026-06-16T04:51:00+00:00" }, | |
| 244 | { | |
| 245 | hash: "aaa", | |
| 246 | message: "first\n", | |
| 247 | date: "2026-06-16T04:50:49+00:00", | |
| 248 | author: { | |
| 249 | raw: "Ada Lovelace <ada@example.com>", | |
| 250 | user: { nickname: "ada", display_name: "Ada Lovelace" }, | |
| 251 | }, | |
| 252 | }, | |
| 253 | ]), | |
| 254 | ), | |
| 255 | ); | |
| 256 | ||
| 257 | expect(decoded.items.map((commit) => commit.oid)).toEqual(["aaa", "bbb"]); | |
| 258 | expect(decoded.items[0]?.authors).toEqual([ | |
| 259 | { login: "ada", name: "Ada Lovelace", avatarUrl: null }, | |
| 260 | ]); | |
| 261 | expect(decoded.items[1]?.messageHeadline).toBe("second"); | |
| 262 | expect(decoded.next).toBeNull(); | |
| 263 | }); | |
| 264 | ||
| 265 | it("skips commits whose hash is empty", () => { | |
| 266 | const decoded = expectSuccess( | |
| 267 | decodeCommitsJson( | |
| 268 | page([ | |
| 269 | { hash: " ", message: "invalid", date: "2026-06-16T04:51:00+00:00" }, | |
| 270 | { hash: "aaa", date: "2026-06-16T04:50:49+00:00" }, | |
| 271 | ]), | |
| 272 | ), | |
| 273 | ); | |
| 274 | ||
| 275 | expect(decoded.items.map((commit) => commit.oid)).toEqual(["aaa"]); | |
| 276 | }); | |
| 277 | }); | |
| 278 | ||
| 279 | describe("decodeStatusesJson", () => { | |
| 280 | it("reads a build status as a check", () => { | |
| 281 | const decoded = expectSuccess( | |
| 282 | decodeStatusesJson( | |
| 283 | page([ | |
| 284 | { | |
| 285 | key: "custom:check-version-and-pr", | |
| 286 | name: "Pipeline - custom: check-version-and-pr", | |
| 287 | state: "SUCCESSFUL", | |
| 288 | description: "", | |
| 289 | url: "https://bitbucket.org/acme/web/pipelines/results/8126", | |
| 290 | }, | |
| 291 | ]), | |
| 292 | ), | |
| 293 | ); | |
| 294 | ||
| 295 | expect(decoded).toEqual({ | |
| 296 | items: [ | |
| 297 | { | |
| 298 | name: "Pipeline - custom: check-version-and-pr", | |
| 299 | status: "success", | |
| 300 | description: null, | |
| 301 | url: "https://bitbucket.org/acme/web/pipelines/results/8126", | |
| 302 | }, | |
| 303 | ], | |
| 304 | next: null, | |
| 305 | }); | |
| 306 | }); | |
| 307 | ||
| 308 | it.each([ | |
| 309 | ["SUCCESSFUL", "success"], | |
| 310 | ["FAILED", "failure"], | |
| 311 | ["INPROGRESS", "pending"], | |
| 312 | ["STOPPED", "cancelled"], | |
| 313 | ["something new", "neutral"], | |
| 314 | ])("reads the %s build state as %s", (state, expected) => { | |
| 315 | const decoded = expectSuccess(decodeStatusesJson(page([{ name: "Pipeline", state }]))); | |
| 316 | ||
| 317 | expect(decoded.items[0]?.status).toBe(expected); | |
| 318 | }); | |
| 319 | ||
| 320 | it("keeps two statuses that share a display name but have different keys", () => { | |
| 321 | const decoded = expectSuccess( | |
| 322 | decodeStatusesJson( | |
| 323 | page([ | |
| 324 | { key: "build", name: "Pipeline", state: "SUCCESSFUL" }, | |
| 325 | { key: "deploy", name: "Pipeline", state: "FAILED" }, | |
| 326 | ]), | |
| 327 | ), | |
| 328 | ); | |
| 329 | ||
| 330 | expect(decoded.items.map((check) => [check.name, check.status])).toEqual([ | |
| 331 | ["build / Pipeline", "success"], | |
| 332 | ["deploy / Pipeline", "failure"], | |
| 333 | ]); | |
| 334 | }); | |
| 335 | }); | |
| 336 | ||
| 337 | describe("decodeDiffstatJson", () => { | |
| 338 | it("adds up the per-file counts", () => { | |
| 339 | const decoded = expectSuccess( | |
| 340 | decodeDiffstatJson( | |
| 341 | page([ | |
| 342 | { lines_added: 9, lines_removed: 2 }, | |
| 343 | { lines_added: 32, lines_removed: 14 }, | |
| 344 | ]), | |
| 345 | ), | |
| 346 | ); | |
| 347 | ||
| 348 | expect(decoded).toEqual({ additions: 41, deletions: 16, changedFiles: 2, next: null }); | |
| 349 | }); | |
| 350 | }); | |
| 351 | ||
| 352 | describe("decodeConflictsJson", () => { | |
| 353 | it("calls an empty conflict list mergeable", () => { | |
| 354 | expect(expectSuccess(decodeConflictsJson(page([])))).toBe("mergeable"); | |
| 355 | }); | |
| 356 | ||
| 357 | it("calls any reported conflict conflicting", () => { | |
| 358 | expect(expectSuccess(decodeConflictsJson(page([{ path: "src/app.ts" }])))).toBe("conflicting"); | |
| 359 | }); | |
| 360 | }); | |
| 361 | ||
| 362 | describe("repository permission decoding", () => { | |
| 363 | const permissionPage = (permission: string) => | |
| 364 | page([{ type: "repository_permission", permission }]); | |
| 365 | ||
| 366 | it("counts admin and write as write, and read as not", () => { | |
| 367 | expect(expectSuccess(decodeRepositoryPermissionJson(permissionPage("admin")))).toBe(true); | |
| 368 | expect(expectSuccess(decodeRepositoryPermissionJson(permissionPage("write")))).toBe(true); | |
| 369 | expect(expectSuccess(decodeRepositoryPermissionJson(permissionPage("read")))).toBe(false); | |
| 370 | }); | |
| 371 | ||
| 372 | it("grants write where Bitbucket named no permission at all", () => { | |
| 373 | // An empty page is Bitbucket declining to say, which is an unknown standing rather than a | |
| 374 | // refusal — and an unknown one is granted. | |
| 375 | expect(expectSuccess(decodeRepositoryPermissionJson(page([])))).toBe(true); | |
| 376 | }); | |
| 377 | }); |
packages/source-control-bitbucket/src/server/bitbucketPullRequestJson.ts created+631| ... | ... | @@ -0,0 +1,631 @@ |
| 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 type { | |
| 8 | PullRequestActor, | |
| 9 | PullRequestCheck, | |
| 10 | PullRequestCheckStatus, | |
| 11 | PullRequestComment, | |
| 12 | PullRequestCommit, | |
| 13 | PullRequestMergeability, | |
| 14 | PullRequestReviewThread, | |
| 15 | PullRequestReviewerCandidate, | |
| 16 | PullRequestState, | |
| 17 | } from "@t3tools/contracts"; | |
| 18 | import { TrimmedNonEmptyString } from "@t3tools/contracts"; | |
| 19 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; | |
| 20 | ||
| 21 | import { dedupeChecks } from "@t3tools/source-control-core/server/pullRequestChecks"; | |
| 22 | ||
| 23 | /** | |
| 24 | * Bitbucket's enums are decoded as plain strings and normalized here, in the same tolerant | |
| 25 | * style as the GitHub and GitLab decoders: a new pull request state or build status must not | |
| 26 | * fail a whole payload. | |
| 27 | */ | |
| 28 | const RawUserSchema = Schema.Struct({ | |
| 29 | /** | |
| 30 | * How Bitbucket addresses an account when a reviewer set is written; the handles it shows are | |
| 31 | * not accepted there. Braced, and sent back exactly as it arrived. | |
| 32 | */ | |
| 33 | uuid: Schema.optional(Schema.NullOr(Schema.String)), | |
| 34 | /** Absent on an app account, which is why `display_name` has to stand in for it. */ | |
| 35 | nickname: Schema.optional(Schema.NullOr(Schema.String)), | |
| 36 | display_name: Schema.optional(Schema.NullOr(Schema.String)), | |
| 37 | links: Schema.optional( | |
| 38 | Schema.NullOr( | |
| 39 | Schema.Struct({ | |
| 40 | avatar: Schema.optional( | |
| 41 | Schema.NullOr(Schema.Struct({ href: Schema.optional(Schema.String) })), | |
| 42 | ), | |
| 43 | }), | |
| 44 | ), | |
| 45 | ), | |
| 46 | }); | |
| 47 | ||
| 48 | /** | |
| 49 | * Required, and required to be non-empty: the wire contract will not carry a change request | |
| 50 | * without a branch or a link, so a row missing one is skipped rather than breaking the response | |
| 51 | * it travels in. | |
| 52 | */ | |
| 53 | const RawBranchSchema = Schema.Struct({ | |
| 54 | branch: Schema.Struct({ name: TrimmedNonEmptyString }), | |
| 55 | repository: Schema.optional(Schema.NullOr(Schema.Struct({ full_name: TrimmedNonEmptyString }))), | |
| 56 | }); | |
| 57 | ||
| 58 | const RawLinkSchema = Schema.Struct({ href: Schema.optional(Schema.String) }); | |
| 59 | ||
| 60 | const RawPullRequestSchema = Schema.Struct({ | |
| 61 | id: Schema.Int, | |
| 62 | title: Schema.String, | |
| 63 | description: Schema.optional(Schema.NullOr(Schema.String)), | |
| 64 | state: Schema.optional(Schema.NullOr(Schema.String)), | |
| 65 | draft: Schema.optional(Schema.Boolean), | |
| 66 | author: Schema.optional(Schema.NullOr(RawUserSchema)), | |
| 67 | source: RawBranchSchema, | |
| 68 | destination: RawBranchSchema, | |
| 69 | created_on: Schema.String, | |
| 70 | updated_on: Schema.String, | |
| 71 | reviewers: Schema.optional(Schema.NullOr(Schema.Array(RawUserSchema))), | |
| 72 | participants: Schema.optional( | |
| 73 | Schema.NullOr( | |
| 74 | Schema.Array( | |
| 75 | Schema.Struct({ | |
| 76 | user: Schema.optional(Schema.NullOr(RawUserSchema)), | |
| 77 | role: Schema.optional(Schema.NullOr(Schema.String)), | |
| 78 | approved: Schema.optional(Schema.Boolean), | |
| 79 | state: Schema.optional(Schema.NullOr(Schema.String)), | |
| 80 | participated_on: Schema.optional(Schema.NullOr(Schema.String)), | |
| 81 | }), | |
| 82 | ), | |
| 83 | ), | |
| 84 | ), | |
| 85 | links: Schema.Struct({ html: Schema.Struct({ href: TrimmedNonEmptyString }) }), | |
| 86 | }); | |
| 87 | ||
| 88 | const RawPageSchema = Schema.Struct({ | |
| 89 | values: Schema.Array(Schema.Unknown), | |
| 90 | /** A total count, which Bitbucket omits on some endpoints. */ | |
| 91 | size: Schema.optional(Schema.NullOr(Schema.Int)), | |
| 92 | /** Present only while a further page exists. */ | |
| 93 | next: Schema.optional(Schema.NullOr(Schema.String)), | |
| 94 | }); | |
| 95 | ||
| 96 | const RawCommentSchema = Schema.Struct({ | |
| 97 | id: Schema.Int, | |
| 98 | content: Schema.optional(Schema.NullOr(Schema.Struct({ raw: Schema.optional(Schema.String) }))), | |
| 99 | user: Schema.optional(Schema.NullOr(RawUserSchema)), | |
| 100 | created_on: Schema.String, | |
| 101 | deleted: Schema.optional(Schema.Boolean), | |
| 102 | /** A comment still being drafted by its author. */ | |
| 103 | pending: Schema.optional(Schema.Boolean), | |
| 104 | /** Set on a reply, to the comment it answers — which may itself be a reply. */ | |
| 105 | parent: Schema.optional(Schema.NullOr(Schema.Struct({ id: Schema.Int }))), | |
| 106 | inline: Schema.optional( | |
| 107 | Schema.NullOr( | |
| 108 | Schema.Struct({ | |
| 109 | path: Schema.optional(Schema.NullOr(Schema.String)), | |
| 110 | /** The line in the file as it was; set instead of `to` on a removed line. */ | |
| 111 | from: Schema.optional(Schema.NullOr(Schema.Int)), | |
| 112 | /** The line in the file as it is now. */ | |
| 113 | to: Schema.optional(Schema.NullOr(Schema.Int)), | |
| 114 | outdated: Schema.optional(Schema.NullOr(Schema.Boolean)), | |
| 115 | }), | |
| 116 | ), | |
| 117 | ), | |
| 118 | /** Non-null once someone has marked the thread resolved. */ | |
| 119 | resolution: Schema.optional(Schema.NullOr(Schema.Unknown)), | |
| 120 | links: Schema.optional( | |
| 121 | Schema.NullOr(Schema.Struct({ html: Schema.optional(Schema.NullOr(RawLinkSchema)) })), | |
| 122 | ), | |
| 123 | }); | |
| 124 | ||
| 125 | const RawCommitSchema = Schema.Struct({ | |
| 126 | hash: TrimmedNonEmptyString, | |
| 127 | message: Schema.optional(Schema.NullOr(Schema.String)), | |
| 128 | date: Schema.optional(Schema.NullOr(Schema.String)), | |
| 129 | author: Schema.optional( | |
| 130 | Schema.NullOr( | |
| 131 | Schema.Struct({ | |
| 132 | raw: Schema.optional(Schema.NullOr(Schema.String)), | |
| 133 | user: Schema.optional(Schema.NullOr(RawUserSchema)), | |
| 134 | }), | |
| 135 | ), | |
| 136 | ), | |
| 137 | }); | |
| 138 | ||
| 139 | const RawStatusSchema = Schema.Struct({ | |
| 140 | key: Schema.optional(Schema.NullOr(Schema.String)), | |
| 141 | name: Schema.optional(Schema.NullOr(Schema.String)), | |
| 142 | state: Schema.optional(Schema.NullOr(Schema.String)), | |
| 143 | description: Schema.optional(Schema.NullOr(Schema.String)), | |
| 144 | url: Schema.optional(Schema.NullOr(Schema.String)), | |
| 145 | }); | |
| 146 | ||
| 147 | const RawDiffstatSchema = Schema.Struct({ | |
| 148 | lines_added: Schema.optional(Schema.NullOr(Schema.Int)), | |
| 149 | lines_removed: Schema.optional(Schema.NullOr(Schema.Int)), | |
| 150 | }); | |
| 151 | ||
| 152 | /** One row of `/workspaces/{workspace}/members`, which wraps the account it is about. */ | |
| 153 | const RawMemberSchema = Schema.Struct({ | |
| 154 | user: Schema.optional(Schema.NullOr(RawUserSchema)), | |
| 155 | }); | |
| 156 | ||
| 157 | const RawViewerSchema = Schema.Struct({ | |
| 158 | nickname: Schema.optional(Schema.NullOr(Schema.String)), | |
| 159 | display_name: Schema.optional(Schema.NullOr(Schema.String)), | |
| 160 | }); | |
| 161 | ||
| 162 | /** | |
| 163 | * `/user/permissions/repositories` filtered to one repository, which is the only place Bitbucket | |
| 164 | * states what the credentials may do with it: nothing on the repository, the pull request or the | |
| 165 | * workspace carries it. One row, or none where Bitbucket names no permission for this account. | |
| 166 | */ | |
| 167 | const RawRepositoryPermissionsSchema = Schema.Struct({ | |
| 168 | values: Schema.optional( | |
| 169 | Schema.NullOr( | |
| 170 | Schema.Array(Schema.Struct({ permission: Schema.optional(Schema.NullOr(Schema.String)) })), | |
| 171 | ), | |
| 172 | ), | |
| 173 | }); | |
| 174 | ||
| 175 | export interface BitbucketPullRequest { | |
| 176 | readonly number: number; | |
| 177 | readonly title: string; | |
| 178 | readonly url: string; | |
| 179 | readonly author: PullRequestActor | null; | |
| 180 | readonly headBranch: string; | |
| 181 | readonly headRepositoryNameWithOwner: string | null; | |
| 182 | readonly baseBranch: string; | |
| 183 | readonly state: PullRequestState; | |
| 184 | readonly isDraft: boolean; | |
| 185 | /** | |
| 186 | * Bitbucket reports no conflict state on a pull request, so the list leaves it unknown. The | |
| 187 | * detail read asks the conflicts endpoint, which does answer. | |
| 188 | */ | |
| 189 | readonly mergeability: PullRequestMergeability; | |
| 190 | readonly createdAt: string; | |
| 191 | readonly updatedAt: string; | |
| 192 | readonly body: string; | |
| 193 | readonly reviewRequestLogins: ReadonlyArray<string>; | |
| 194 | readonly reviewers: ReadonlyArray<PullRequestActor>; | |
| 195 | /** The reviewers as Bitbucket addresses them, which is what writing the set back takes. */ | |
| 196 | readonly reviewerIds: ReadonlyArray<string>; | |
| 197 | /** Approvals and change requests, which Bitbucket keeps on its participants. */ | |
| 198 | readonly reviews: ReadonlyArray<PullRequestComment>; | |
| 199 | } | |
| 200 | ||
| 201 | function trimmed(value: string | null | undefined): string | null { | |
| 202 | const text = value?.trim() ?? ""; | |
| 203 | return text.length > 0 ? text : null; | |
| 204 | } | |
| 205 | ||
| 206 | /** | |
| 207 | * Bitbucket stamps times as `+00:00` with microseconds. The page sorts change requests from | |
| 208 | * every host against each other as plain strings, so they are normalized to the same `Z` form | |
| 209 | * the other hosts already use. | |
| 210 | */ | |
| 211 | function toIsoUtc(value: string): string { | |
| 212 | return Option.match(DateTime.make(value), { | |
| 213 | onNone: () => value, | |
| 214 | onSome: DateTime.formatIso, | |
| 215 | }); | |
| 216 | } | |
| 217 | ||
| 218 | /** An app account has no nickname, so the display name is the only handle it has. */ | |
| 219 | function toActor(raw: Schema.Schema.Type<typeof RawUserSchema> | null | undefined) { | |
| 220 | const login = trimmed(raw?.nickname) ?? trimmed(raw?.display_name); | |
| 221 | return login === null | |
| 222 | ? null | |
| 223 | : { | |
| 224 | login, | |
| 225 | name: trimmed(raw?.display_name), | |
| 226 | avatarUrl: trimmed(raw?.links?.avatar?.href), | |
| 227 | }; | |
| 228 | } | |
| 229 | ||
| 230 | function toState(raw: Schema.Schema.Type<typeof RawPullRequestSchema>): PullRequestState { | |
| 231 | switch (raw.state?.trim().toUpperCase()) { | |
| 232 | case "MERGED": | |
| 233 | return "merged"; | |
| 234 | case "DECLINED": | |
| 235 | case "SUPERSEDED": | |
| 236 | return "closed"; | |
| 237 | default: | |
| 238 | return "open"; | |
| 239 | } | |
| 240 | } | |
| 241 | ||
| 242 | function toBuildStatus(value: string | null | undefined): PullRequestCheckStatus { | |
| 243 | switch (value?.trim().toUpperCase()) { | |
| 244 | case "SUCCESSFUL": | |
| 245 | return "success"; | |
| 246 | case "FAILED": | |
| 247 | return "failure"; | |
| 248 | case "STOPPED": | |
| 249 | return "cancelled"; | |
| 250 | case "INPROGRESS": | |
| 251 | return "pending"; | |
| 252 | default: | |
| 253 | return "neutral"; | |
| 254 | } | |
| 255 | } | |
| 256 | ||
| 257 | /** | |
| 258 | * A participant who has voted is the closest Bitbucket has to a review, so it reads as one in | |
| 259 | * the conversation. Participants who have only been added carry no verdict and are skipped. | |
| 260 | */ | |
| 261 | function toReviews( | |
| 262 | raw: Schema.Schema.Type<typeof RawPullRequestSchema>, | |
| 263 | ): ReadonlyArray<PullRequestComment> { | |
| 264 | return (raw.participants ?? []).flatMap((participant): ReadonlyArray<PullRequestComment> => { | |
| 265 | const author = toActor(participant.user); | |
| 266 | const votedAt = trimmed(participant.participated_on); | |
| 267 | const reviewState = | |
| 268 | trimmed(participant.state) ?? (participant.approved === true ? "approved" : null); | |
| 269 | if (author === null || votedAt === null || reviewState === null) return []; | |
| 270 | return [ | |
| 271 | { | |
| 272 | id: `${raw.id}:${author.login}`, | |
| 273 | kind: "review", | |
| 274 | author, | |
| 275 | body: "", | |
| 276 | createdAt: toIsoUtc(votedAt), | |
| 277 | url: null, | |
| 278 | path: null, | |
| 279 | reviewState, | |
| 280 | }, | |
| 281 | ]; | |
| 282 | }); | |
| 283 | } | |
| 284 | ||
| 285 | function toPullRequest(raw: Schema.Schema.Type<typeof RawPullRequestSchema>): BitbucketPullRequest { | |
| 286 | const reviewers = (raw.reviewers ?? []).flatMap((reviewer) => { | |
| 287 | const actor = toActor(reviewer); | |
| 288 | return actor === null ? [] : [actor]; | |
| 289 | }); | |
| 290 | return { | |
| 291 | number: raw.id, | |
| 292 | title: raw.title, | |
| 293 | url: raw.links.html.href, | |
| 294 | author: toActor(raw.author), | |
| 295 | headBranch: raw.source.branch.name, | |
| 296 | headRepositoryNameWithOwner: raw.source.repository?.full_name ?? null, | |
| 297 | baseBranch: raw.destination.branch.name, | |
| 298 | state: toState(raw), | |
| 299 | isDraft: raw.draft ?? false, | |
| 300 | mergeability: "unknown", | |
| 301 | createdAt: toIsoUtc(raw.created_on), | |
| 302 | updatedAt: toIsoUtc(raw.updated_on), | |
| 303 | body: raw.description ?? "", | |
| 304 | reviewRequestLogins: reviewers.map((reviewer) => reviewer.login), | |
| 305 | reviewers, | |
| 306 | reviewerIds: (raw.reviewers ?? []).flatMap((reviewer) => trimmed(reviewer.uuid) ?? []), | |
| 307 | reviews: toReviews(raw), | |
| 308 | }; | |
| 309 | } | |
| 310 | ||
| 311 | const decodePage = decodeJsonResult(RawPageSchema); | |
| 312 | const decodePullRequestEntry = Schema.decodeUnknownExit(RawPullRequestSchema); | |
| 313 | const decodePullRequest = decodeJsonResult(RawPullRequestSchema); | |
| 314 | const decodeCommentEntry = Schema.decodeUnknownExit(RawCommentSchema); | |
| 315 | const decodeCommitEntry = Schema.decodeUnknownExit(RawCommitSchema); | |
| 316 | const decodeStatusEntry = Schema.decodeUnknownExit(RawStatusSchema); | |
| 317 | const decodeDiffstatEntry = Schema.decodeUnknownExit(RawDiffstatSchema); | |
| 318 | const decodeMemberEntry = Schema.decodeUnknownExit(RawMemberSchema); | |
| 319 | const decodeViewer = decodeJsonResult(RawViewerSchema); | |
| 320 | const decodeConflicts = decodeJsonResult(RawPageSchema); | |
| 321 | const decodeRepositoryPermissions = decodeJsonResult(RawRepositoryPermissionsSchema); | |
| 322 | ||
| 323 | type DecodeFailure = Cause.Cause<Schema.SchemaError>; | |
| 324 | ||
| 325 | export interface BitbucketPage<A> { | |
| 326 | readonly items: ReadonlyArray<A>; | |
| 327 | /** The whole URL of the next page, which Bitbucket sends rather than an offset. */ | |
| 328 | readonly next: string | null; | |
| 329 | } | |
| 330 | ||
| 331 | /** Malformed entries are skipped rather than failing the page, as on the other hosts. */ | |
| 332 | export function decodePullRequestPageJson( | |
| 333 | raw: string, | |
| 334 | ): Result.Result<BitbucketPage<BitbucketPullRequest>, DecodeFailure> { | |
| 335 | const decoded = decodePage(raw); | |
| 336 | if (!Result.isSuccess(decoded)) { | |
| 337 | return Result.fail(decoded.failure); | |
| 338 | } | |
| 339 | const items: BitbucketPullRequest[] = []; | |
| 340 | for (const entry of decoded.success.values) { | |
| 341 | const item = decodePullRequestEntry(entry); | |
| 342 | if (Exit.isSuccess(item)) { | |
| 343 | items.push(toPullRequest(item.value)); | |
| 344 | } | |
| 345 | } | |
| 346 | return Result.succeed({ items, next: trimmed(decoded.success.next) }); | |
| 347 | } | |
| 348 | ||
| 349 | export function decodePullRequestJson( | |
| 350 | raw: string, | |
| 351 | ): Result.Result<BitbucketPullRequest, DecodeFailure> { | |
| 352 | const decoded = decodePullRequest(raw); | |
| 353 | return Result.isSuccess(decoded) | |
| 354 | ? Result.succeed(toPullRequest(decoded.success)) | |
| 355 | : Result.fail(decoded.failure); | |
| 356 | } | |
| 357 | ||
| 358 | export function decodeViewerJson(raw: string): Result.Result<string | null, DecodeFailure> { | |
| 359 | const decoded = decodeViewer(raw); | |
| 360 | return Result.isSuccess(decoded) | |
| 361 | ? Result.succeed(trimmed(decoded.success.nickname) ?? trimmed(decoded.success.display_name)) | |
| 362 | : Result.fail(decoded.failure); | |
| 363 | } | |
| 364 | ||
| 365 | /** | |
| 366 | * Whether the configured credentials can write to the repository, which is what merging needs. | |
| 367 | * Bitbucket answers `admin`, `write` or `read`, and an empty page means it named no permission at | |
| 368 | * all for this account — an unknown standing, which is granted rather than guessed away. | |
| 369 | */ | |
| 370 | export function decodeRepositoryPermissionJson(raw: string): Result.Result<boolean, DecodeFailure> { | |
| 371 | const decoded = decodeRepositoryPermissions(raw); | |
| 372 | if (!Result.isSuccess(decoded)) { | |
| 373 | return Result.fail(decoded.failure); | |
| 374 | } | |
| 375 | const permission = trimmed(decoded.success.values?.[0]?.permission)?.toLowerCase() ?? null; | |
| 376 | return Result.succeed(permission === null || permission === "admin" || permission === "write"); | |
| 377 | } | |
| 378 | ||
| 379 | /** | |
| 380 | * The workspace's members, which is the nearest thing Bitbucket has to "who may review this". | |
| 381 | * Nothing on a repository lists the people with access to it — `permissions-config/users` is for | |
| 382 | * administrators only — and a pull request can be sent to anyone in the workspace, so this is the | |
| 383 | * list Bitbucket's own reviewer field is filled from too. | |
| 384 | * | |
| 385 | * Nobody is marked requested here: who has been asked lives on the pull request, and only the | |
| 386 | * caller holds both. | |
| 387 | */ | |
| 388 | export function decodeWorkspaceMembersJson( | |
| 389 | raw: string, | |
| 390 | ): Result.Result<BitbucketPage<PullRequestReviewerCandidate>, DecodeFailure> { | |
| 391 | const decoded = decodePage(raw); | |
| 392 | if (!Result.isSuccess(decoded)) { | |
| 393 | return Result.fail(decoded.failure); | |
| 394 | } | |
| 395 | const items: PullRequestReviewerCandidate[] = []; | |
| 396 | for (const entry of decoded.success.values) { | |
| 397 | const member = decodeMemberEntry(entry); | |
| 398 | if (Exit.isFailure(member)) continue; | |
| 399 | const uuid = trimmed(member.value.user?.uuid); | |
| 400 | const actor = toActor(member.value.user); | |
| 401 | if (uuid === null || actor === null) continue; | |
| 402 | items.push({ ...actor, id: uuid, kind: "user", isRequested: false }); | |
| 403 | } | |
| 404 | return Result.succeed({ items, next: trimmed(decoded.success.next) }); | |
| 405 | } | |
| 406 | ||
| 407 | /** One comment as Bitbucket sent it, kept so threads can be assembled across pages. */ | |
| 408 | export type BitbucketRawComment = Schema.Schema.Type<typeof RawCommentSchema>; | |
| 409 | ||
| 410 | export interface BitbucketComments { | |
| 411 | readonly comments: ReadonlyArray<PullRequestComment>; | |
| 412 | /** | |
| 413 | * The same comments unread, for `buildReviewThreads`. A reply and the remark it answers can | |
| 414 | * land on different pages, and only the caller holding every page can put them together. | |
| 415 | */ | |
| 416 | readonly entries: ReadonlyArray<BitbucketRawComment>; | |
| 417 | readonly next: string | null; | |
| 418 | } | |
| 419 | ||
| 420 | /** | |
| 421 | * Bitbucket returns one flat list, so a thread is reassembled from it: a comment pinned to a | |
| 422 | * line opens a thread, and every reply that leads back to it belongs in it. A reply whose | |
| 423 | * parent is on a page that was not read has nowhere to go, and is left out rather than shown | |
| 424 | * as a thread of its own — it still stands in the flat conversation, which needs no parent. | |
| 425 | */ | |
| 426 | export function buildReviewThreads( | |
| 427 | comments: ReadonlyArray<BitbucketRawComment>, | |
| 428 | ): ReadonlyArray<PullRequestReviewThread> { | |
| 429 | const byId = new Map(comments.map((comment) => [comment.id, comment])); | |
| 430 | const rootOf = (comment: Schema.Schema.Type<typeof RawCommentSchema>) => { | |
| 431 | // Bounded by the number of comments read, so a parent cycle cannot spin here. | |
| 432 | let current = comment; | |
| 433 | for (let step = 0; step < byId.size; step += 1) { | |
| 434 | const parent = current.parent === null ? undefined : byId.get(current.parent?.id ?? -1); | |
| 435 | if (parent === undefined) return current; | |
| 436 | current = parent; | |
| 437 | } | |
| 438 | return current; | |
| 439 | }; | |
| 440 | ||
| 441 | const threads = new Map<number, PullRequestReviewThread>(); | |
| 442 | const replies = new Map<number, Array<Schema.Schema.Type<typeof RawCommentSchema>>>(); | |
| 443 | for (const comment of comments) { | |
| 444 | const root = rootOf(comment); | |
| 445 | const inline = root.inline; | |
| 446 | const path = trimmed(inline?.path); | |
| 447 | if (path === null) continue; | |
| 448 | if (root.id === comment.id) { | |
| 449 | // `to` is the line as the file stands now, `from` the line it replaced; a comment that | |
| 450 | // carries only `from` was written against the removed side. | |
| 451 | const side = inline?.to === null || inline?.to === undefined ? "left" : "right"; | |
| 452 | const line = side === "left" ? inline?.from : inline?.to; | |
| 453 | threads.set(root.id, { | |
| 454 | id: String(root.id), | |
| 455 | path, | |
| 456 | line: typeof line === "number" && line > 0 ? line : null, | |
| 457 | side, | |
| 458 | isResolved: root.resolution !== null && root.resolution !== undefined, | |
| 459 | isOutdated: inline?.outdated === true, | |
| 460 | comments: [], | |
| 461 | }); | |
| 462 | } | |
| 463 | const bucket = replies.get(root.id); | |
| 464 | if (bucket === undefined) replies.set(root.id, [comment]); | |
| 465 | else bucket.push(comment); | |
| 466 | } | |
| 467 | ||
| 468 | return [...threads.values()].flatMap((thread) => { | |
| 469 | const entries = (replies.get(Number(thread.id)) ?? []) | |
| 470 | .toSorted((left, right) => left.created_on.localeCompare(right.created_on)) | |
| 471 | .map((comment) => ({ | |
| 472 | id: String(comment.id), | |
| 473 | author: toActor(comment.user), | |
| 474 | body: comment.content?.raw ?? "", | |
| 475 | createdAt: toIsoUtc(comment.created_on), | |
| 476 | url: trimmed(comment.links?.html?.href), | |
| 477 | })); | |
| 478 | return entries.length === 0 ? [] : [{ ...thread, comments: entries }]; | |
| 479 | }); | |
| 480 | } | |
| 481 | ||
| 482 | /** | |
| 483 | * Deleted comments and ones their author has not posted yet carry nothing to show. A comment | |
| 484 | * pinned to a file is a line-level review comment, which is what that kind means. | |
| 485 | */ | |
| 486 | export function decodeCommentsJson(raw: string): Result.Result<BitbucketComments, DecodeFailure> { | |
| 487 | const decoded = decodePage(raw); | |
| 488 | if (!Result.isSuccess(decoded)) { | |
| 489 | return Result.fail(decoded.failure); | |
| 490 | } | |
| 491 | const comments: PullRequestComment[] = []; | |
| 492 | const kept: Array<BitbucketRawComment> = []; | |
| 493 | for (const entry of decoded.success.values) { | |
| 494 | const decodedComment = decodeCommentEntry(entry); | |
| 495 | if (Exit.isFailure(decodedComment)) continue; | |
| 496 | const comment = decodedComment.value; | |
| 497 | if (comment.deleted === true || comment.pending === true) continue; | |
| 498 | const body = comment.content?.raw ?? ""; | |
| 499 | if (body.trim().length === 0) continue; | |
| 500 | kept.push(comment); | |
| 501 | const path = trimmed(comment.inline?.path); | |
| 502 | comments.push({ | |
| 503 | id: String(comment.id), | |
| 504 | kind: path === null ? "issue-comment" : "review-comment", | |
| 505 | author: toActor(comment.user), | |
| 506 | body, | |
| 507 | createdAt: toIsoUtc(comment.created_on), | |
| 508 | url: trimmed(comment.links?.html?.href), | |
| 509 | path, | |
| 510 | reviewState: null, | |
| 511 | }); | |
| 512 | } | |
| 513 | return Result.succeed({ comments, entries: kept, next: trimmed(decoded.success.next) }); | |
| 514 | } | |
| 515 | ||
| 516 | export function decodeCommitsJson( | |
| 517 | raw: string, | |
| 518 | ): Result.Result<BitbucketPage<PullRequestCommit>, DecodeFailure> { | |
| 519 | const decoded = decodePage(raw); | |
| 520 | if (!Result.isSuccess(decoded)) { | |
| 521 | return Result.fail(decoded.failure); | |
| 522 | } | |
| 523 | const commits: PullRequestCommit[] = []; | |
| 524 | for (const entry of decoded.success.values) { | |
| 525 | const decodedCommit = decodeCommitEntry(entry); | |
| 526 | if (Exit.isFailure(decodedCommit)) continue; | |
| 527 | const commit = decodedCommit.value; | |
| 528 | const committedDate = trimmed(commit.date); | |
| 529 | if (committedDate === null) continue; | |
| 530 | const linkedAuthor = toActor(commit.author?.user); | |
| 531 | const rawAuthor = trimmed(commit.author?.raw); | |
| 532 | commits.push({ | |
| 533 | oid: commit.hash, | |
| 534 | messageHeadline: (commit.message ?? "").split("\n")[0] ?? "", | |
| 535 | committedDate: toIsoUtc(committedDate), | |
| 536 | authors: | |
| 537 | linkedAuthor !== null | |
| 538 | ? [linkedAuthor] | |
| 539 | : rawAuthor === null | |
| 540 | ? [] | |
| 541 | : [{ login: rawAuthor, name: rawAuthor, avatarUrl: null }], | |
| 542 | }); | |
| 543 | } | |
| 544 | // Bitbucket lists a pull request's commits newest first; the timeline reads oldest first. | |
| 545 | return Result.succeed({ items: commits.toReversed(), next: trimmed(decoded.success.next) }); | |
| 546 | } | |
| 547 | ||
| 548 | export function decodeStatusesJson( | |
| 549 | raw: string, | |
| 550 | ): Result.Result<BitbucketPage<PullRequestCheck>, DecodeFailure> { | |
| 551 | const decoded = decodePage(raw); | |
| 552 | if (!Result.isSuccess(decoded)) { | |
| 553 | return Result.fail(decoded.failure); | |
| 554 | } | |
| 555 | const checks: Array<{ | |
| 556 | readonly check: PullRequestCheck; | |
| 557 | readonly workflowName: string | null; | |
| 558 | readonly at: string | null; | |
| 559 | }> = []; | |
| 560 | for (const entry of decoded.success.values) { | |
| 561 | const decodedStatus = decodeStatusEntry(entry); | |
| 562 | if (Exit.isFailure(decodedStatus)) continue; | |
| 563 | const status = decodedStatus.value; | |
| 564 | const name = trimmed(status.name) ?? trimmed(status.key); | |
| 565 | if (name === null) continue; | |
| 566 | // Bitbucket re-uses a status key when a pipeline is run again, so the same check can appear | |
| 567 | // twice on one page. Nothing decoded here says which copy is newer, so the later one wins, | |
| 568 | // which is the order Bitbucket writes an update in. The key is kept as the workflow name so | |
| 569 | // two different pipelines that display the same name are not folded into one. | |
| 570 | checks.push({ | |
| 571 | check: { | |
| 572 | name, | |
| 573 | status: toBuildStatus(status.state), | |
| 574 | description: trimmed(status.description), | |
| 575 | url: trimmed(status.url), | |
| 576 | }, | |
| 577 | workflowName: trimmed(status.key), | |
| 578 | at: null, | |
| 579 | }); | |
| 580 | } | |
| 581 | return Result.succeed({ items: dedupeChecks(checks), next: trimmed(decoded.success.next) }); | |
| 582 | } | |
| 583 | ||
| 584 | export interface BitbucketDiffStat { | |
| 585 | readonly additions: number; | |
| 586 | readonly deletions: number; | |
| 587 | readonly changedFiles: number; | |
| 588 | } | |
| 589 | ||
| 590 | export interface BitbucketDiffStatPage extends BitbucketDiffStat { | |
| 591 | readonly next: string | null; | |
| 592 | } | |
| 593 | ||
| 594 | /** One entry per changed file, each carrying that file's line counts. */ | |
| 595 | export function decodeDiffstatJson( | |
| 596 | raw: string, | |
| 597 | ): Result.Result<BitbucketDiffStatPage, DecodeFailure> { | |
| 598 | const decoded = decodePage(raw); | |
| 599 | if (!Result.isSuccess(decoded)) { | |
| 600 | return Result.fail(decoded.failure); | |
| 601 | } | |
| 602 | let additions = 0; | |
| 603 | let deletions = 0; | |
| 604 | let changedFiles = 0; | |
| 605 | for (const entry of decoded.success.values) { | |
| 606 | const decodedStat = decodeDiffstatEntry(entry); | |
| 607 | if (Exit.isFailure(decodedStat)) continue; | |
| 608 | additions += decodedStat.value.lines_added ?? 0; | |
| 609 | deletions += decodedStat.value.lines_removed ?? 0; | |
| 610 | changedFiles += 1; | |
| 611 | } | |
| 612 | return Result.succeed({ | |
| 613 | additions, | |
| 614 | deletions, | |
| 615 | changedFiles, | |
| 616 | next: trimmed(decoded.success.next), | |
| 617 | }); | |
| 618 | } | |
| 619 | ||
| 620 | /** | |
| 621 | * The conflicts endpoint answers with one entry per conflicting path, so an empty page is the | |
| 622 | * only statement Bitbucket makes that a pull request merges cleanly. | |
| 623 | */ | |
| 624 | export function decodeConflictsJson( | |
| 625 | raw: string, | |
| 626 | ): Result.Result<PullRequestMergeability, DecodeFailure> { | |
| 627 | const decoded = decodeConflicts(raw); | |
| 628 | return Result.isSuccess(decoded) | |
| 629 | ? Result.succeed(decoded.success.values.length === 0 ? "mergeable" : "conflicting") | |
| 630 | : Result.fail(decoded.failure); | |
| 631 | } |
packages/source-control-bitbucket/src/server/bitbucketPullRequests.ts created+109| ... | ... | @@ -0,0 +1,109 @@ |
| 1 | import * as DateTime from "effect/DateTime"; | |
| 2 | import * as Option from "effect/Option"; | |
| 3 | import * as Schema from "effect/Schema"; | |
| 4 | import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; | |
| 5 | ||
| 6 | export interface NormalizedBitbucketPullRequestRecord { | |
| 7 | readonly number: number; | |
| 8 | readonly title: string; | |
| 9 | readonly url: string; | |
| 10 | readonly baseRefName: string; | |
| 11 | readonly headRefName: string; | |
| 12 | readonly state: "open" | "closed" | "merged"; | |
| 13 | readonly isDraft?: boolean; | |
| 14 | readonly updatedAt: Option.Option<DateTime.Utc>; | |
| 15 | readonly isCrossRepository?: boolean; | |
| 16 | readonly headRepositoryNameWithOwner?: string | null; | |
| 17 | readonly headRepositoryOwnerLogin?: string | null; | |
| 18 | } | |
| 19 | ||
| 20 | export const BitbucketRepositoryRefSchema = Schema.Struct({ | |
| 21 | full_name: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), | |
| 22 | workspace: Schema.optional( | |
| 23 | Schema.NullOr( | |
| 24 | Schema.Struct({ | |
| 25 | slug: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), | |
| 26 | }), | |
| 27 | ), | |
| 28 | ), | |
| 29 | }); | |
| 30 | ||
| 31 | export const BitbucketPullRequestBranchSchema = Schema.Struct({ | |
| 32 | repository: Schema.optional(Schema.NullOr(BitbucketRepositoryRefSchema)), | |
| 33 | branch: Schema.Struct({ | |
| 34 | name: TrimmedNonEmptyString, | |
| 35 | }), | |
| 36 | }); | |
| 37 | ||
| 38 | export const BitbucketPullRequestSchema = Schema.Struct({ | |
| 39 | id: PositiveInt, | |
| 40 | title: TrimmedNonEmptyString, | |
| 41 | state: Schema.optional(Schema.NullOr(Schema.String)), | |
| 42 | draft: Schema.optional(Schema.Boolean), | |
| 43 | updated_on: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), | |
| 44 | links: Schema.Struct({ | |
| 45 | html: Schema.Struct({ | |
| 46 | href: TrimmedNonEmptyString, | |
| 47 | }), | |
| 48 | }), | |
| 49 | source: BitbucketPullRequestBranchSchema, | |
| 50 | destination: BitbucketPullRequestBranchSchema, | |
| 51 | }); | |
| 52 | ||
| 53 | export const BitbucketPullRequestListSchema = Schema.Struct({ | |
| 54 | values: Schema.Array(BitbucketPullRequestSchema), | |
| 55 | next: Schema.optional(TrimmedNonEmptyString), | |
| 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 repositoryOwner(repository: Schema.Schema.Type<typeof BitbucketRepositoryRefSchema>) { | |
| 64 | return ( | |
| 65 | trimOptionalString(repository.workspace?.slug) ?? | |
| 66 | (repository.full_name?.includes("/") ? (repository.full_name.split("/")[0] ?? null) : null) | |
| 67 | ); | |
| 68 | } | |
| 69 | ||
| 70 | function normalizeBitbucketPullRequestState(state: string | null | undefined) { | |
| 71 | switch (state?.trim().toUpperCase()) { | |
| 72 | case "MERGED": | |
| 73 | return "merged" as const; | |
| 74 | case "DECLINED": | |
| 75 | case "SUPERSEDED": | |
| 76 | return "closed" as const; | |
| 77 | case "OPEN": | |
| 78 | default: | |
| 79 | return "open" as const; | |
| 80 | } | |
| 81 | } | |
| 82 | ||
| 83 | export function normalizeBitbucketPullRequestRecord( | |
| 84 | raw: Schema.Schema.Type<typeof BitbucketPullRequestSchema>, | |
| 85 | ): NormalizedBitbucketPullRequestRecord { | |
| 86 | const headRepositoryNameWithOwner = trimOptionalString(raw.source.repository?.full_name); | |
| 87 | const baseRepositoryNameWithOwner = trimOptionalString(raw.destination.repository?.full_name); | |
| 88 | const headRepositoryOwnerLogin = raw.source.repository | |
| 89 | ? repositoryOwner(raw.source.repository) | |
| 90 | : null; | |
| 91 | const isCrossRepository = | |
| 92 | headRepositoryNameWithOwner !== null && | |
| 93 | baseRepositoryNameWithOwner !== null && | |
| 94 | headRepositoryNameWithOwner !== baseRepositoryNameWithOwner; | |
| 95 | ||
| 96 | return { | |
| 97 | number: raw.id, | |
| 98 | title: raw.title, | |
| 99 | url: raw.links.html.href, | |
| 100 | baseRefName: raw.destination.branch.name, | |
| 101 | headRefName: raw.source.branch.name, | |
| 102 | state: normalizeBitbucketPullRequestState(raw.state), | |
| 103 | ...(raw.draft === true ? { isDraft: true } : {}), | |
| 104 | updatedAt: raw.updated_on ?? Option.none(), | |
| 105 | ...(isCrossRepository ? { isCrossRepository: true } : {}), | |
| 106 | ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}), | |
| 107 | ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}), | |
| 108 | }; | |
| 109 | } |
packages/source-control-bitbucket/src/server/driver.ts created+20| ... | ... | @@ -0,0 +1,20 @@ |
| 1 | /** | |
| 2 | * The Bitbucket Cloud driver: repository operations, discovery, and pull requests through the | |
| 3 | * Bitbucket REST API. | |
| 4 | * | |
| 5 | * @module source-control-bitbucket/server/driver | |
| 6 | */ | |
| 7 | import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; | |
| 8 | import * as Effect from "effect/Effect"; | |
| 9 | ||
| 10 | import * as BitbucketPullRequestProvider from "./BitbucketPullRequestProvider.ts"; | |
| 11 | import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; | |
| 12 | ||
| 13 | export const driver = defineSourceControlDriver({ | |
| 14 | kind: "bitbucket", | |
| 15 | make: Effect.all({ | |
| 16 | sourceControl: BitbucketSourceControlProvider.make, | |
| 17 | discovery: BitbucketSourceControlProvider.makeDiscovery, | |
| 18 | pullRequests: BitbucketPullRequestProvider.make, | |
| 19 | }), | |
| 20 | }); |
packages/source-control-bitbucket/tsconfig.json created+7| ... | ... | @@ -0,0 +1,7 @@ |
| 1 | { | |
| 2 | "extends": "../../tsconfig.base.json", | |
| 3 | "compilerOptions": { | |
| 4 | "types": ["node"] | |
| 5 | }, | |
| 6 | "include": ["src"] | |
| 7 | } |
packages/source-control-core/src/server/SourceControlHost.ts+51-1| ... | ... | @@ -7,9 +7,20 @@ |
| 7 | 7 | * its own API and CLI code. HTTP, the filesystem, and paths come from Effect's platform |
| 8 | 8 | * services directly. |
| 9 | 9 | * |
| 10 | * `git` is the subset of the server's git driver that checking out a change request needs; it | |
| 11 | * grows only when a provider needs another operation. | |
| 12 | * | |
| 10 | 13 | * @module source-control-core/server/SourceControlHost |
| 11 | 14 | */ |
| 12 | import type { ServerSettings, ServerSettingsError, VcsError } from "@t3tools/contracts"; | |
| 15 | import type { | |
| 16 | GitCommandError, | |
| 17 | ServerSettings, | |
| 18 | ServerSettingsError, | |
| 19 | VcsError, | |
| 20 | VcsListRemotesResult, | |
| 21 | VcsSwitchRefInput, | |
| 22 | VcsSwitchRefResult, | |
| 23 | } from "@t3tools/contracts"; | |
| 13 | 24 | import * as Context from "effect/Context"; |
| 14 | 25 | import type * as Effect from "effect/Effect"; |
| 15 | 26 | import type * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| ... | ... | @@ -43,6 +54,12 @@ export interface SourceControlProcessOutput { |
| 43 | 54 | readonly stderrInvalidUtf8?: boolean; |
| 44 | 55 | } |
| 45 | 56 | |
| 57 | export interface SourceControlGitRemoteBranch { | |
| 58 | readonly cwd: string; | |
| 59 | readonly remoteName: string; | |
| 60 | readonly remoteBranch: string; | |
| 61 | } | |
| 62 | ||
| 46 | 63 | export class SourceControlHost extends Context.Service< |
| 47 | 64 | SourceControlHost, |
| 48 | 65 | { |
| ... | ... | @@ -55,5 +72,38 @@ export class SourceControlHost extends Context.Service< |
| 55 | 72 | input: SourceControlProcessInput, |
| 56 | 73 | ) => Effect.Effect<SourceControlProcessOutput, VcsError>; |
| 57 | 74 | }; |
| 75 | readonly git: { | |
| 76 | /** | |
| 77 | * Lists the remotes of the repository at `cwd`. The outer effect resolves the repository | |
| 78 | * and fails when the server cannot drive one there; the inner one lists its remotes. | |
| 79 | */ | |
| 80 | readonly remotes: ( | |
| 81 | cwd: string, | |
| 82 | ) => Effect.Effect<Effect.Effect<VcsListRemotesResult, VcsError>, VcsError>; | |
| 83 | readonly readConfigValue: ( | |
| 84 | cwd: string, | |
| 85 | key: string, | |
| 86 | ) => Effect.Effect<string | null, GitCommandError>; | |
| 87 | readonly resolvePrimaryRemoteName: (cwd: string) => Effect.Effect<string, GitCommandError>; | |
| 88 | /** Adds a remote for `url` unless one exists, and returns the name it is under. */ | |
| 89 | readonly ensureRemote: (input: { | |
| 90 | readonly cwd: string; | |
| 91 | readonly preferredName: string; | |
| 92 | readonly url: string; | |
| 93 | }) => Effect.Effect<string, GitCommandError>; | |
| 94 | readonly listLocalBranchNames: (cwd: string) => Effect.Effect<string[], GitCommandError>; | |
| 95 | readonly fetchRemoteBranch: ( | |
| 96 | input: SourceControlGitRemoteBranch & { readonly localBranch: string }, | |
| 97 | ) => Effect.Effect<void, GitCommandError>; | |
| 98 | readonly fetchRemoteTrackingBranch: ( | |
| 99 | input: SourceControlGitRemoteBranch, | |
| 100 | ) => Effect.Effect<void, GitCommandError>; | |
| 101 | readonly setBranchUpstream: ( | |
| 102 | input: SourceControlGitRemoteBranch & { readonly branch: string }, | |
| 103 | ) => Effect.Effect<void, GitCommandError>; | |
| 104 | readonly switchRef: ( | |
| 105 | input: VcsSwitchRefInput, | |
| 106 | ) => Effect.Effect<VcsSwitchRefResult, GitCommandError>; | |
| 107 | }; | |
| 58 | 108 | } |
| 59 | 109 | >()("@t3tools/source-control-core/server/SourceControlHost") {} |
packages/source-control-testing/src/TestSourceControlHost.ts+21-2| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 | /** |
| 2 | 2 | * A `SourceControlHost.SourceControlHost` for provider tests. Settings are fixed unless the test |
| 3 | * supplies its own, and every process run goes to the test's `run`, which fails by default so an | |
| 4 | * unexpected CLI call is visible. | |
| 3 | * supplies its own. Process runs and git operations go to what the test supplies; anything else | |
| 4 | * dies, so an unexpected CLI or git call is visible. | |
| 5 | 5 | * |
| 6 | 6 | * @module source-control-testing/TestSourceControlHost |
| 7 | 7 | */ |
| ... | ... | @@ -14,8 +14,26 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 14 | 14 | export interface TestSourceControlHostOptions { |
| 15 | 15 | readonly settings?: ServerSettings; |
| 16 | 16 | readonly process?: Partial<SourceControlHost.SourceControlHost["Service"]["process"]>; |
| 17 | readonly git?: Partial<SourceControlHost.SourceControlHost["Service"]["git"]>; | |
| 17 | 18 | } |
| 18 | 19 | |
| 20 | type Git = SourceControlHost.SourceControlHost["Service"]["git"]; | |
| 21 | ||
| 22 | const unexpectedGit = (operation: string) => () => | |
| 23 | Effect.die(`Unexpected git ${operation} in a test that supplied none.`); | |
| 24 | ||
| 25 | const failingGit: Git = { | |
| 26 | remotes: unexpectedGit("remotes"), | |
| 27 | readConfigValue: unexpectedGit("readConfigValue"), | |
| 28 | resolvePrimaryRemoteName: unexpectedGit("resolvePrimaryRemoteName"), | |
| 29 | ensureRemote: unexpectedGit("ensureRemote"), | |
| 30 | listLocalBranchNames: unexpectedGit("listLocalBranchNames"), | |
| 31 | fetchRemoteBranch: unexpectedGit("fetchRemoteBranch"), | |
| 32 | fetchRemoteTrackingBranch: unexpectedGit("fetchRemoteTrackingBranch"), | |
| 33 | setBranchUpstream: unexpectedGit("setBranchUpstream"), | |
| 34 | switchRef: unexpectedGit("switchRef"), | |
| 35 | }; | |
| 36 | ||
| 19 | 37 | export const layer = ( |
| 20 | 38 | options: TestSourceControlHostOptions = {}, |
| 21 | 39 | ): Layer.Layer<SourceControlHost.SourceControlHost> => |
| ... | ... | @@ -28,6 +46,7 @@ export const layer = ( |
| 28 | 46 | options.process?.run ?? |
| 29 | 47 | ((input) => Effect.die(`Unexpected ${input.command} run in ${input.operation}.`)), |
| 30 | 48 | }, |
| 49 | git: { ...failingGit, ...options.git }, | |
| 31 | 50 | }), |
| 32 | 51 | ); |
| 33 | 52 |
pnpm-lock.yaml+31| ... | ... | @@ -651,6 +651,9 @@ importers: |
| 651 | 651 | '@t3tools/source-control-azure-devops': |
| 652 | 652 | specifier: workspace:* |
| 653 | 653 | version: link:../../packages/source-control-azure-devops |
| 654 | '@t3tools/source-control-bitbucket': | |
| 655 | specifier: workspace:* | |
| 656 | version: link:../../packages/source-control-bitbucket | |
| 654 | 657 | '@t3tools/source-control-core': |
| 655 | 658 | specifier: workspace:* |
| 656 | 659 | version: link:../../packages/source-control-core |
| ... | ... | @@ -1556,6 +1559,34 @@ importers: |
| 1556 | 1559 | specifier: 1.0.0 |
| 1557 | 1560 | 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) |
| 1558 | 1561 | |
| 1562 | packages/source-control-bitbucket: | |
| 1563 | dependencies: | |
| 1564 | '@t3tools/contracts': | |
| 1565 | specifier: workspace:* | |
| 1566 | version: link:../contracts | |
| 1567 | '@t3tools/provider-core': | |
| 1568 | specifier: workspace:* | |
| 1569 | version: link:../provider-core | |
| 1570 | '@t3tools/shared': | |
| 1571 | specifier: workspace:* | |
| 1572 | version: link:../shared | |
| 1573 | '@t3tools/source-control-core': | |
| 1574 | specifier: workspace:* | |
| 1575 | version: link:../source-control-core | |
| 1576 | effect: | |
| 1577 | specifier: 4.0.2 | |
| 1578 | version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688) | |
| 1579 | devDependencies: | |
| 1580 | '@effect/vitest': | |
| 1581 | specifier: 4.0.2 | |
| 1582 | 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) | |
| 1583 | '@types/node': | |
| 1584 | specifier: 24.12.4 | |
| 1585 | version: 24.12.4 | |
| 1586 | vite-plus: | |
| 1587 | specifier: 1.0.0 | |
| 1588 | 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) | |
| 1589 | ||
| 1559 | 1590 | packages/source-control-core: |
| 1560 | 1591 | dependencies: |
| 1561 | 1592 | '@t3tools/contracts': |
scripts/release-smoke.ts+1| ... | ... | @@ -38,6 +38,7 @@ const workspaceFiles = [ |
| 38 | 38 | "packages/provider-muse/package.json", |
| 39 | 39 | "packages/provider-opencode/package.json", |
| 40 | 40 | "packages/source-control-azure-devops/package.json", |
| 41 | "packages/source-control-bitbucket/package.json", | |
| 41 | 42 | "packages/source-control-core/package.json", |
| 42 | 43 | "packages/source-control-forgejo/package.json", |
| 43 | 44 | "packages/source-control-gitlab/package.json", |