diff --git a/apps/server/package.json b/apps/server/package.json index a246302618a3ace14fb157ebfb4d232a64758321..ab9ad49d4e621efa2649476205fcd7b2123524c8 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -46,6 +46,7 @@ "@t3tools/provider-opencode": "workspace:*", "@t3tools/provider-pi": "workspace:*", "@t3tools/source-control-azure-devops": "workspace:*", + "@t3tools/source-control-bitbucket": "workspace:*", "@t3tools/source-control-core": "workspace:*", "@t3tools/source-control-forgejo": "workspace:*", "@t3tools/source-control-gitlab": "workspace:*", diff --git a/apps/server/scripts/evaluate-thread-titles.ts b/apps/server/scripts/evaluate-thread-titles.ts index 0b3fb1393239b5b72b09be9e6866ae2399331cb0..598f6cc2f930784c862b43eee4d0867263605e14 100644 --- a/apps/server/scripts/evaluate-thread-titles.ts +++ b/apps/server/scripts/evaluate-thread-titles.ts @@ -25,7 +25,6 @@ import * as ThreadTitleLinks from "../src/textGeneration/ThreadTitleLinks.ts"; import * as SourceControlProviderRegistry from "../src/sourceControl/SourceControlProviderRegistry.ts"; import * as GitHubApi from "../src/sourceControl/GitHubApi.ts"; import * as SourceControlBuiltInDrivers from "../src/sourceControl/builtInDrivers.ts"; -import * as BitbucketApi from "../src/sourceControl/BitbucketApi.ts"; import * as ServerSettings from "../src/serverSettings.ts"; import * as VcsProcess from "../src/vcs/VcsProcess.ts"; import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts"; @@ -149,12 +148,7 @@ await Effect.runPromise( ProcessRunner.layer, SourceControlProviderRegistry.layer.pipe( Layer.provide( - Layer.mergeAll( - GitHubApi.layerWithDependencies, - SourceControlBuiltInDrivers.layer, - // No saved credentials here; Bitbucket falls back to T3CODE_BITBUCKET_* variables. - BitbucketApi.layer, - ), + Layer.mergeAll(GitHubApi.layerWithDependencies, SourceControlBuiltInDrivers.layer), ), // Default settings: no saved Bitbucket token, gh's own GitHub account choice. Layer.provide(ServerSettings.layerTest()), diff --git a/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts b/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts deleted file mode 100644 index d614dd90a7e7849b99e5bf5e9ffa047802f8395a..0000000000000000000000000000000000000000 --- a/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts +++ /dev/null @@ -1,1127 +0,0 @@ -import { afterEach, assert, expect, it, vi } from "@effect/vitest"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as TestClock from "effect/testing/TestClock"; - -import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; -import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts"; - -const mockedRequest = vi.fn(); - -const layer = it.layer( - BitbucketPullRequestApi.layer.pipe( - Layer.provide( - Layer.mock(BitbucketApi.BitbucketApi)({ - request: mockedRequest, - }), - ), - ), -); - -/** The shape `request` answers with: a body plus whether it had to be cut short. */ -function response(body: string) { - return { body, truncated: false }; -} - -function page(count: number, firstNumber: number, next?: string): string { - return JSON.stringify({ - pagelen: 50, - size: count, - values: Array.from({ length: count }, (_, index) => ({ - id: firstNumber + index, - title: `Pull request ${firstNumber + index}`, - state: "OPEN", - created_on: "2026-06-16T05:04:32+00:00", - updated_on: "2026-06-16T05:04:33+00:00", - source: { branch: { name: "feat/page" } }, - destination: { branch: { name: "master" } }, - links: { html: { href: `https://bitbucket.org/acme/web/pull-requests/${firstNumber}` } }, - })), - ...(next === undefined ? {} : { next }), - }); -} - -function valuePage(values: ReadonlyArray, next?: string): string { - return JSON.stringify({ values, ...(next === undefined ? {} : { next }) }); -} - -/** Who opened the pull request, and two accounts that could review it. */ -const bilal = { uuid: "{bilal}", nickname: "bilal" }; -const octocat = { uuid: "{octocat}", nickname: "octocat" }; -const hubot = { uuid: "{hubot}", nickname: "hubot" }; - -/** One pull request as `/pullrequests/{id}` answers with it. */ -function pullRequestJson(overrides: Record): string { - return JSON.stringify({ - id: 7, - title: "Pull request 7", - state: "OPEN", - author: bilal, - created_on: "2026-06-16T05:04:32+00:00", - updated_on: "2026-06-16T05:04:33+00:00", - source: { branch: { name: "feat/page" } }, - destination: { branch: { name: "master" } }, - links: { html: { href: "https://bitbucket.org/acme/web/pull-requests/7" } }, - ...overrides, - }); -} - -/** The request the nth call made. */ -function callAt(index: number) { - const call = mockedRequest.mock.calls[index]; - assert.isDefined(call); - return call[0]; -} - -/** The filter expression of the nth request, read back out of its query string. */ -function filterOfCall(index: number): string | null { - const url = callAt(index).url; - return new URLSearchParams(url.slice(url.indexOf("?") + 1)).get("q"); -} - -afterEach(() => { - mockedRequest.mockReset(); -}); - -layer("BitbucketPullRequestApi.layer", (it) => { - it.effect("asks for reviewers, newest first, at Bitbucket's page ceiling", () => - Effect.gen(function* () { - mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(3, 1)))); - const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - - const batch = yield* api.listPullRequests({ - repository: "acme/web", - state: "open", - limit: 50, - }); - - assert.strictEqual(batch.items.length, 3); - assert.isFalse(batch.truncated); - const url = callAt(0).url; - expect(url).toContain("/repositories/acme/web/pullrequests"); - expect(url).toContain("state=OPEN"); - // Over 50 Bitbucket answers with an empty page and no error, so it is never exceeded. - expect(url).toContain("pagelen=50"); - expect(url).toContain("sort=-updated_on"); - expect(url).toContain("fields=%2Bvalues.reviewers"); - }), - ); - - it.effect("follows the cursor Bitbucket sends rather than counting offsets", () => - Effect.gen(function* () { - const next = "https://api.bitbucket.org/2.0/repositories/acme/web/pullrequests?page=2"; - mockedRequest - .mockReturnValueOnce(Effect.succeed(response(page(50, 1, next)))) - .mockReturnValueOnce(Effect.succeed(response(page(50, 51)))); - const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - - const batch = yield* api.listPullRequests({ - repository: "acme/web", - state: "open", - limit: 100, - }); - - assert.strictEqual(batch.items.length, 100); - assert.isFalse(batch.truncated); - assert.strictEqual(callAt(1).url, next); - }), - ); - - it.effect("stops at the caller's page and says more remain", () => - Effect.gen(function* () { - const next = "https://api.bitbucket.org/2.0/repositories/acme/web/pullrequests?page=2"; - mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(50, 1, next)))); - const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - - const batch = yield* api.listPullRequests({ - repository: "acme/web", - state: "open", - limit: 50, - }); - - assert.strictEqual(batch.items.length, 50); - assert.isTrue(batch.truncated); - assert.strictEqual(mockedRequest.mock.calls.length, 1); - }), - ); - - it.effect("counts the rows it walked past as more to come", () => - Effect.gen(function* () { - // Bitbucket pages in fifties whatever was asked for, so a request for ninety-nine reads a - // hundred and drops one. That row is more results, and saying otherwise takes the "load - // more" away from a listing that has not finished. - const next = "https://api.bitbucket.org/2.0/repositories/acme/web/pullrequests?page=2"; - mockedRequest - .mockReturnValueOnce(Effect.succeed(response(page(50, 1, next)))) - .mockReturnValueOnce(Effect.succeed(response(page(50, 51)))); - const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - - const batch = yield* api.listPullRequests({ - repository: "acme/web", - state: "open", - limit: 99, - }); - - assert.strictEqual(batch.items.length, 99); - assert.isTrue(batch.truncated); - }), - ); - - it.effect("searches with a filter expression, which is all Bitbucket offers", () => - Effect.gen(function* () { - mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); - const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - - yield* api.listPullRequests({ - repository: "acme/web", - state: "open", - limit: 50, - query: "page", - }); - - expect(filterOfCall(0)).toBe('(title ~ "page" OR description ~ "page")'); - // The state filter beside it still stands, which the brackets are there to keep. - expect(callAt(0).url).toContain("state=OPEN"); - }), - ); - - it.effect("escapes a quote and a backslash, so a search cannot reshape the filter", () => - Effect.gen(function* () { - mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); - const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - - yield* api.listPullRequests({ - repository: "acme/web", - state: "open", - limit: 50, - query: String.raw`a\" OR state = "MERGED"`, - }); - - const literal = String.raw`a\\\" OR state = \"MERGED\"`; - expect(filterOfCall(0)).toBe(`(title ~ "${literal}" OR description ~ "${literal}")`); - }), - ); - - it.effect("asks for no filter at all when the reader typed only spaces", () => - Effect.gen(function* () { - mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); - const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - - yield* api.listPullRequests({ - repository: "acme/web", - state: "open", - limit: 50, - query: " ", - }); - - assert.isNull(filterOfCall(0)); - }), - ); - - it.effect("carries on from the instant the last slice ended on", () => - Effect.gen(function* () { - mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); - const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - - yield* api.listPullRequests({ - repository: "acme/web", - state: "open", - limit: 50, - cursor: { updatedBefore: "2026-07-02T00:00:00.123456+00:00", delivered: 50 }, - }); - - // Inclusive, so the rows already sent at that instant come back for the caller to drop. - expect(filterOfCall(0)).toBe("updated_on <= 2026-07-02T00:00:00.123456+00:00"); - expect(callAt(0).url).toContain("sort=-updated_on"); - }), - ); - - it.effect("narrows by the reader's words and by where it left off at once", () => - Effect.gen(function* () { - mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); - const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - - yield* api.listPullRequests({ - repository: "acme/web", - state: "open", - limit: 50, - query: "page", - cursor: { updatedBefore: "2026-07-02T00:00:00+00:00", delivered: 50 }, - }); - - // Bitbucket takes one `q`, so the two narrowings are joined rather than one replacing the - // other — and the search keeps its brackets, which is what keeps the AND out of its OR. - expect(filterOfCall(0)).toBe( - '(title ~ "page" OR description ~ "page") AND updated_on <= 2026-07-02T00:00:00+00:00', - ); - }), - ); - - it.effect("asks for declined pull requests on the closed tab", () => - Effect.gen(function* () { - mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); - const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - - yield* api.listPullRequests({ repository: "acme/web", state: "closed", limit: 50 }); - - expect(callAt(0).url).toContain("state=DECLINED"); - }), - ); - - it.effect("asks for every state at once on the All tab", () => - Effect.gen(function* () { - mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); - const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - - yield* api.listPullRequests({ repository: "acme/web", state: "all", limit: 50 }); - - // Bitbucket unions repeated state parameters, which is the only way to span them. - const url = callAt(0).url; - for (const state of ["OPEN", "MERGED", "DECLINED", "SUPERSEDED"]) { - expect(url).toContain(`state=${state}`); - } - }), - ); - - it.effect("counts a superseded pull request as closed", () => - Effect.gen(function* () { - mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); - const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - - yield* api.listPullRequests({ repository: "acme/web", state: "closed", limit: 50 }); - - expect(callAt(0).url).toContain("state=DECLINED"); - expect(callAt(0).url).toContain("state=SUPERSEDED"); - }), - ); - - it.effect("refuses a repository that is not workspace and slug", () => - Effect.gen(function* () { - const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - - const error = yield* Effect.flip( - api.listPullRequests({ repository: "acme/team/web", state: "open", limit: 50 }), - ); - - assert.strictEqual(error._tag, "BitbucketRepositoryUnsupportedError"); - assert.strictEqual(mockedRequest.mock.calls.length, 0); - }), - ); - - it.effect("returns the diff verbatim, because Bitbucket already sends a patch", () => - Effect.gen(function* () { - 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"; - mockedRequest.mockReturnValueOnce(Effect.succeed(response(patch))); - const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - - const diff = yield* api.getPullRequestDiff({ repository: "acme/web", number: 7 }); - - assert.strictEqual(diff.patch, patch); - assert.isFalse(diff.truncated); - expect(callAt(0)).toMatchObject({ - url: "/repositories/acme/web/pullrequests/7/diff", - // A diff of any size would otherwise be read into memory whole. - maxBytes: 8 * 1024 * 1024, - }); - }), - ); - - it.effect("reads a named commit's own patch, which pages no further than the whole of it", () => - Effect.gen(function* () { - 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"; - mockedRequest.mockReturnValueOnce(Effect.succeed(response(patch))); - const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - - const diff = yield* api.getPullRequestDiff({ - repository: "acme/web", - number: 7, - commit: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", - }); - - assert.strictEqual(diff.patch, patch); - expect(callAt(0)).toMatchObject({ - url: "/repositories/acme/web/diff/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", - maxBytes: 8 * 1024 * 1024, - }); - }), - ); - - it.effect("refuses a commit that is not a sha rather than reading it into a URL", () => - Effect.gen(function* () { - const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - - const error = yield* Effect.flip( - api.getPullRequestDiff({ - repository: "acme/web", - number: 7, - commit: "../../acme/other/diff/deadbeef", - }), - ); - - assert.strictEqual(error._tag, "BitbucketDiffCommitError"); - assert.strictEqual(mockedRequest.mock.calls.length, 0); - }), - ); - - it.effect("reads every file version the patch states, not only the paths asked about", () => - Effect.gen(function* () { - mockedRequest.mockReturnValueOnce( - Effect.succeed( - response( - [ - "diff --git a/a.ts b/a.ts", - "index 1111111..2222222 100644", - "--- a/a.ts", - "+++ b/a.ts", - "@@ -1 +1 @@", - "-a", - "+b", - "diff --git a/b.ts b/b.ts", - "index 3333333..4444444 100644", - "--- a/b.ts", - "+++ b/b.ts", - "@@ -1 +1 @@", - "-c", - "+d", - "", - ].join("\n"), - ), - ), - ); - const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - - const revisions = yield* api.getFileRevisions({ - repository: "acme/web", - number: 71, - paths: ["a.ts", "missing.ts"], - }); - - // `b.ts` was not asked about and is reported anyway: parsing the patch for `a.ts` read it - // too, and the caller holding it is what stops the next tick paying for the patch again. - // `missing.ts` was asked about and the whole patch was read without finding it, which is - // what a file this pull request deletes looks like, so it is answered as the empty version. - assert.deepStrictEqual( - [...revisions.revisions], - [ - ["a.ts", "2222222"], - ["b.ts", "4444444"], - ["missing.ts", ""], - ], - ); - assert.strictEqual(revisions.complete, true); - expect(callAt(0)).toMatchObject({ url: "/repositories/acme/web/pullrequests/71/diff" }); - }), - ); - - it.effect("says nothing about the files past the end of a patch it could not read whole", () => - Effect.gen(function* () { - // Bitbucket's patch is read up to a byte ceiling, and a file past the cut was not looked at. - // Answering for it as deleted would clear a mark on it once and for good. - mockedRequest.mockReturnValueOnce( - Effect.succeed({ - body: "diff --git a/a.ts b/a.ts\nindex 1111111..2222222 100644\n@@ -1 +1 @@\n", - truncated: true, - }), - ); - const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - - const revisions = yield* api.getFileRevisions({ - repository: "acme/web", - number: 72, - paths: ["a.ts", "past-the-cut.ts"], - }); - - assert.deepStrictEqual([...revisions.revisions], [["a.ts", "2222222"]]); - // `past-the-cut.ts` gets no empty version, and nothing here may be held as the whole story. - assert.strictEqual(revisions.complete, false); - }), - ); - - it.effect("reads the patch once for a run of ticks, not once a tick", () => - Effect.gen(function* () { - mockedRequest.mockReturnValue( - Effect.succeed( - response( - [ - "diff --git a/a.ts b/a.ts", - "index 1111111..2222222 100644", - "@@ -1 +1 @@", - "diff --git a/b.ts b/b.ts", - "index 3333333..4444444 100644", - "@@ -1 +1 @@", - "", - ].join("\n"), - ), - ), - ); - const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - - const first = yield* api.getFileRevisions({ - repository: "acme/web", - number: 74, - paths: ["a.ts"], - }); - // A path nobody has asked about before, which is what every tick after the first names. - const second = yield* api.getFileRevisions({ - repository: "acme/web", - number: 74, - paths: ["b.ts"], - }); - - const both = [ - ["a.ts", "2222222"], - ["b.ts", "4444444"], - ]; - assert.deepStrictEqual([...first.revisions], both); - assert.deepStrictEqual([...second.revisions], both); - assert.strictEqual(mockedRequest.mock.calls.length, 1); - }), - ); - - it.effect("reads the patch afresh once the one it held has aged out", () => - Effect.gen(function* () { - mockedRequest.mockReturnValue( - Effect.succeed( - response("diff --git a/a.ts b/a.ts\nindex 1111111..2222222 100644\n@@ -1 +1 @@\n"), - ), - ); - const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - const read = () => - api.getFileRevisions({ repository: "acme/web", number: 75, paths: ["a.ts"] }); - - yield* read(); - yield* read(); - assert.strictEqual(mockedRequest.mock.calls.length, 1); - - // Well inside the window the caller holds versions for: a refresh drops what it holds so - // that the read after it reaches Bitbucket, and this must not answer that read instead. - yield* TestClock.adjust(Duration.seconds(30)); - yield* read(); - assert.strictEqual(mockedRequest.mock.calls.length, 2); - }), - ); - - it.effect("asks Bitbucket nothing when no file has been ticked off", () => - Effect.gen(function* () { - const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - - const revisions = yield* api.getFileRevisions({ - repository: "acme/web", - number: 73, - paths: [], - }); - - assert.strictEqual(revisions.revisions.size, 0); - assert.strictEqual(mockedRequest.mock.calls.length, 0); - }), - ); - - it.effect("aggregates every diffstat page", () => - Effect.gen(function* () { - const next = "https://api.bitbucket.org/2.0/diffstat?page=2"; - mockedRequest - .mockReturnValueOnce( - Effect.succeed( - response( - valuePage( - [ - { lines_added: 9, lines_removed: 2 }, - { lines_added: 3, lines_removed: 1 }, - ], - next, - ), - ), - ), - ) - .mockReturnValueOnce( - Effect.succeed(response(valuePage([{ lines_added: 4, lines_removed: 7 }]))), - ); - const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - - const stat = yield* api.getDiffStat({ repository: "acme/web", number: 7 }); - - expect(stat).toEqual({ additions: 16, deletions: 10, changedFiles: 3 }); - expect(callAt(1).url).toBe(next); - }), - ); - - it.effect("returns the complete commit timeline oldest first across pages", () => - Effect.gen(function* () { - const next = "https://api.bitbucket.org/2.0/commits?page=2"; - mockedRequest - .mockReturnValueOnce( - Effect.succeed( - response( - valuePage( - [ - { hash: "ddd", message: "fourth", date: "2026-07-04T00:00:00Z" }, - { hash: "ccc", message: "third", date: "2026-07-03T00:00:00Z" }, - ], - next, - ), - ), - ), - ) - .mockReturnValueOnce( - Effect.succeed( - response( - valuePage([ - { hash: "bbb", message: "second", date: "2026-07-02T00:00:00Z" }, - { hash: "aaa", message: "first", date: "2026-07-01T00:00:00Z" }, - ]), - ), - ), - ); - const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - - const commits = yield* api.listCommits({ repository: "acme/web", number: 7 }); - - expect(commits.map((commit) => commit.oid)).toEqual(["aaa", "bbb", "ccc", "ddd"]); - expect(callAt(1).url).toBe(next); - }), - ); - - it.effect("returns build statuses from every page", () => - Effect.gen(function* () { - const next = "https://api.bitbucket.org/2.0/statuses?page=2"; - mockedRequest - .mockReturnValueOnce( - Effect.succeed(response(valuePage([{ name: "Build", state: "SUCCESSFUL" }], next))), - ) - .mockReturnValueOnce( - Effect.succeed(response(valuePage([{ name: "Lint", state: "FAILED" }]))), - ); - const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - - const checks = yield* api.listChecks({ repository: "acme/web", number: 7 }); - - expect(checks.map((check) => [check.name, check.status])).toEqual([ - ["Build", "success"], - ["Lint", "failure"], - ]); - expect(callAt(1).url).toBe(next); - }), - ); - - it.effect("reads an empty conflict list as mergeable", () => - Effect.gen(function* () { - mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); - const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - - const mergeability = yield* api.getMergeability({ repository: "acme/web", number: 7 }); - - assert.strictEqual(mergeability, "mergeable"); - expect(callAt(0).url).toBe("/repositories/acme/web/pullrequests/7/conflicts"); - }), - ); - - it.effect("merges with Bitbucket's own name for the strategy", () => - Effect.gen(function* () { - mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); - const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - - yield* api.runAction({ - repository: "acme/web", - number: 7, - action: "merge", - mergeMethod: "rebase", - }); - - expect(callAt(0)).toMatchObject({ - method: "POST", - url: "/repositories/acme/web/pullrequests/7/merge", - body: '{"merge_strategy":"rebase_fast_forward"}', - }); - }), - ); - - it.effect("closes a pull request by declining it", () => - Effect.gen(function* () { - mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); - const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - - yield* api.runAction({ repository: "acme/web", number: 7, action: "close" }); - - expect(callAt(0)).toMatchObject({ - method: "POST", - url: "/repositories/acme/web/pullrequests/7/decline", - }); - }), - ); - - it.effect("posts a comment as a JSON document, so the body stays text", () => - Effect.gen(function* () { - mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); - const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - - yield* api.comment({ repository: "acme/web", number: 7, body: "true" }); - - expect(callAt(0)).toMatchObject({ - method: "POST", - url: "/repositories/acme/web/pullrequests/7/comments", - body: '{"content":{"raw":"true"}}', - }); - }), - ); - - it.effect("rewrites a title alone, without touching anything else", () => - Effect.gen(function* () { - mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); - const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - - yield* api.updateChangeRequest({ repository: "acme/web", number: 7, title: "A new title" }); - - const call = callAt(0); - expect(call.method).toBe("PUT"); - expect(call.url).toBe("/repositories/acme/web/pullrequests/7"); - // Bitbucket's PUT is a partial update, so a field left out of the body is left as it was. - expect(JSON.parse(call.body ?? "")).toEqual({ title: "A new title" }); - }), - ); - - it.effect("leaves out the half of the pull request it was not asked about", () => - Effect.gen(function* () { - mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); - const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - - yield* api.updateChangeRequest({ repository: "acme/web", number: 7, body: "New body." }); - - expect(JSON.parse(callAt(0).body ?? "")).toEqual({ description: "New body." }); - }), - ); - - it.effect("writes both fields when both were rewritten", () => - Effect.gen(function* () { - mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); - const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - - yield* api.updateChangeRequest({ - repository: "acme/web", - number: 7, - title: "A new title", - body: "New body.", - }); - - expect(JSON.parse(callAt(0).body ?? "")).toEqual({ - title: "A new title", - description: "New body.", - }); - }), - ); - - it.effect("rewrites a comment where it stands, whichever kind it is", () => - Effect.gen(function* () { - mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); - const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - - yield* api.updateComment({ - repository: "acme/web", - number: 7, - commentId: "10", - body: "Edited.", - }); - - expect(callAt(0)).toMatchObject({ - method: "PUT", - url: "/repositories/acme/web/pullrequests/7/comments/10", - body: '{"content":{"raw":"Edited."}}', - }); - }), - ); - - it.effect("fails the read when Bitbucket answers with something unreadable", () => - Effect.gen(function* () { - mockedRequest.mockReturnValueOnce( - Effect.succeed(response(JSON.stringify({ error: "nope" }))), - ); - const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - - const error = yield* Effect.flip(api.getPullRequest({ repository: "acme/web", number: 7 })); - - assert.strictEqual(error._tag, "BitbucketPullRequestReadError"); - }), - ); - - it.effect("states a failure once, without stacking one message inside another", () => - Effect.gen(function* () { - mockedRequest.mockReturnValueOnce( - Effect.fail( - new BitbucketApi.BitbucketResponseError({ - operation: "request", - status: 500, - responseBodyLength: 0, - }), - ), - ); - const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - - const error = yield* Effect.flip(api.getViewer()); - - // The fact only; the provider adds the operation around it. - assert.strictEqual(error.detail, "Bitbucket returned HTTP 500."); - }), - ); - - it.effect("fails when the credentials belong to no named account", () => - Effect.gen(function* () { - mockedRequest.mockReturnValueOnce(Effect.succeed(response(JSON.stringify({})))); - const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - - const error = yield* Effect.flip(api.getViewer()); - - assert.strictEqual(error._tag, "BitbucketViewerUnavailableError"); - }), - ); - - it.effect("follows Bitbucket's cursor and reassembles a thread that spans two pages", () => - Effect.gen(function* () { - mockedRequest.mockReturnValueOnce( - Effect.succeed( - response( - JSON.stringify({ - next: "https://api.bitbucket.org/2.0/comments?page=2", - values: [ - { - id: 10, - content: { raw: "rename this" }, - user: { nickname: "bilal" }, - created_on: "2026-06-16T05:04:32+00:00", - inline: { path: "src/a.ts", to: 12 }, - }, - ], - }), - ), - ), - ); - mockedRequest.mockReturnValueOnce( - Effect.succeed( - response( - // The reply arrives a page after the remark it answers, which is why the threads - // are only assembled once every page is in hand. - JSON.stringify({ - values: [ - { - id: 11, - content: { raw: "done" }, - user: { nickname: "julius" }, - created_on: "2026-06-16T06:04:32+00:00", - parent: { id: 10 }, - }, - ], - }), - ), - ), - ); - const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - - const { comments, threads, truncated } = yield* api.listComments({ - repository: "acme/web", - number: 7, - }); - - expect(callAt(1).url).toBe("https://api.bitbucket.org/2.0/comments?page=2"); - expect(comments.map((comment) => comment.id)).toEqual(["10", "11"]); - expect(threads[0]?.comments.map((comment) => comment.id)).toEqual(["10", "11"]); - assert.isFalse(truncated); - }), - ); - - it.effect("stops the comment walk at its bound and says the conversation was cut short", () => - Effect.gen(function* () { - // Bitbucket that always names a next page: the walk has to end itself. - mockedRequest.mockReturnValue( - Effect.succeed( - response( - JSON.stringify({ - next: "https://api.bitbucket.org/2.0/comments?page=2", - values: [ - { - id: 10, - content: { raw: "again" }, - created_on: "2026-06-16T05:04:32+00:00", - }, - ], - }), - ), - ), - ); - const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - - const { truncated } = yield* api.listComments({ repository: "acme/web", number: 7 }); - - assert.strictEqual(mockedRequest.mock.calls.length, 10); - assert.isTrue(truncated); - }), - ); - - it.effect("reassembles a thread from the flat comment list, replies included", () => - Effect.gen(function* () { - mockedRequest.mockReturnValueOnce( - Effect.succeed( - response( - JSON.stringify({ - values: [ - { - id: 10, - content: { raw: "rename this" }, - user: { nickname: "bilal" }, - created_on: "2026-06-16T05:04:32+00:00", - inline: { path: "src/a.ts", to: 12, from: null }, - resolution: { type: "pullrequest_comment_resolution" }, - }, - { - id: 11, - content: { raw: "done" }, - user: { nickname: "julius" }, - created_on: "2026-06-16T06:04:32+00:00", - parent: { id: 10 }, - }, - // A reply to a reply still belongs to the thread its root opened. - { - id: 12, - content: { raw: "thanks" }, - user: { nickname: "bilal" }, - created_on: "2026-06-16T07:04:32+00:00", - parent: { id: 11 }, - }, - { - id: 13, - content: { raw: "ship it" }, - user: { nickname: "bilal" }, - created_on: "2026-06-16T08:04:32+00:00", - }, - ], - }), - ), - ), - ); - const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - - const { threads } = yield* api.listComments({ repository: "acme/web", number: 7 }); - - assert.strictEqual(threads.length, 1); - expect(threads[0]).toMatchObject({ - id: "10", - path: "src/a.ts", - line: 12, - side: "right", - isResolved: true, - }); - expect(threads[0]?.comments.map((comment) => comment.id)).toEqual(["10", "11", "12"]); - }), - ); - - it.effect("writes a review's line comments, its summary, then its verdict", () => - Effect.gen(function* () { - mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); - const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - - yield* api.submitReview({ - repository: "acme/web", - number: 7, - verdict: "request-changes", - body: "Two things.", - comments: [ - { - path: "src/a.ts", - position: { kind: "deleted", oldLine: 12 }, - body: "why remove?", - }, - ], - }); - - expect(callAt(0).url).toContain("/pullrequests/7/comments"); - expect(JSON.parse(callAt(0).body ?? "")).toEqual({ - content: { raw: "why remove?" }, - inline: { path: "src/a.ts", from: 12 }, - }); - expect(callAt(1).url).toContain("/pullrequests/7/comments"); - // The verdict goes last, so a review that failed part-way is never a rejection either. - expect(callAt(2).url).toContain("/pullrequests/7/request-changes"); - }), - ); - - it.effect("resolves by creating the sub-resource and unresolves by deleting it", () => - Effect.gen(function* () { - mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); - const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - - yield* api.setCommentResolution({ - repository: "acme/web", - number: 7, - commentId: "10", - resolved: true, - }); - yield* api.setCommentResolution({ - repository: "acme/web", - number: 7, - commentId: "10", - resolved: false, - }); - - assert.strictEqual(callAt(0).method, "POST"); - assert.strictEqual(callAt(1).method, "DELETE"); - expect(callAt(0).url).toContain("/comments/10/resolve"); - }), - ); - - it.effect("replies by naming the comment it answers", () => - Effect.gen(function* () { - mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); - const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - - yield* api.replyToComment({ - repository: "acme/web", - number: 7, - commentId: "10", - body: "Fixed.", - }); - - expect(JSON.parse(callAt(0).body ?? "")).toEqual({ - content: { raw: "Fixed." }, - parent: { id: 10 }, - }); - }), - ); - - it.effect("asks for the credentials' permission on this repository, and nobody else's", () => - Effect.gen(function* () { - mockedRequest.mockReturnValue( - Effect.succeed( - response( - JSON.stringify({ values: [{ type: "repository_permission", permission: "read" }] }), - ), - ), - ); - const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - - assert.isFalse(yield* api.getRepositoryPermission({ repository: "acme/web" })); - - expect(callAt(0).url).toContain("/user/permissions/repositories"); - assert.strictEqual(filterOfCall(0), 'repository.full_name="acme/web"'); - }), - ); - - it.effect("escapes a repository name before it goes inside a filter literal", () => - Effect.gen(function* () { - mockedRequest.mockReturnValue(Effect.succeed(response(JSON.stringify({ values: [] })))); - const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - - yield* api.getRepositoryPermission({ repository: 'acme/we"b' }); - - // A quote would otherwise end the literal and leave the rest standing as filter syntax. - assert.strictEqual(filterOfCall(0), 'repository.full_name="acme/we\\"b"'); - }), - ); - - it.effect( - "reads a removed permissions endpoint as granted rather than failing the merge on it", - () => - Effect.gen(function* () { - // Bitbucket retired /user/permissions/repositories under CHANGE-2770: every account now - // gets HTTP 410 here, whatever it may do. - mockedRequest.mockReturnValue( - Effect.fail( - new BitbucketApi.BitbucketResponseError({ - operation: "request", - status: 410, - responseBodyLength: 0, - }), - ), - ); - const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - - assert.isTrue(yield* api.getRepositoryPermission({ repository: "acme/web" })); - }), - ); - - it.effect("still fails the permission read on a failure that is not the removed endpoint", () => - Effect.gen(function* () { - mockedRequest.mockReturnValue( - Effect.fail( - new BitbucketApi.BitbucketResponseError({ - operation: "request", - status: 401, - responseBodyLength: 0, - }), - ), - ); - const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - - const error = yield* Effect.flip(api.getRepositoryPermission({ repository: "acme/web" })); - - assert.strictEqual(error._tag, "BitbucketResponseError"); - }), - ); - - it.effect("reads the workspace's people and marks whoever is already a reviewer", () => - Effect.gen(function* () { - mockedRequest - .mockReturnValueOnce(Effect.succeed(response(pullRequestJson({ reviewers: [octocat] })))) - .mockReturnValueOnce( - Effect.succeed( - response( - JSON.stringify({ values: [{ user: bilal }, { user: octocat }, { user: hubot }] }), - ), - ), - ); - const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - - const list = yield* api.listReviewerCandidates({ repository: "acme/web", number: 7 }); - - // The people live on the workspace: nothing on a repository lists who may review it. - expect(callAt(1).url).toBe("/workspaces/acme/members?pagelen=50"); - expect(list.candidates.map((candidate) => [candidate.id, candidate.isRequested])).toEqual([ - ["{octocat}", true], - ["{hubot}", false], - ]); - assert.isFalse(list.truncated); - }), - ); - - it.effect("writes the reviewer set back with the one being asked added to it", () => - Effect.gen(function* () { - mockedRequest - .mockReturnValueOnce(Effect.succeed(response(pullRequestJson({ reviewers: [octocat] })))) - .mockReturnValueOnce(Effect.succeed(response("{}"))); - const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - - yield* api.setReviewerRequest({ - repository: "acme/web", - number: 7, - reviewers: [{ id: "{hubot}" }], - requested: true, - }); - - // Bitbucket writes `reviewers` whole, so the one already on the pull request travels with - // the new one or the request would take them off it. - const call = callAt(1); - expect(call.method).toBe("PUT"); - expect(call.url).toBe("/repositories/acme/web/pullrequests/7"); - expect(JSON.parse(call.body ?? "")).toEqual({ - reviewers: [{ uuid: "{octocat}" }, { uuid: "{hubot}" }], - }); - }), - ); - - it.effect("takes a reviewer out of the set rather than clearing it", () => - Effect.gen(function* () { - mockedRequest - .mockReturnValueOnce( - Effect.succeed(response(pullRequestJson({ reviewers: [octocat, hubot] }))), - ) - .mockReturnValueOnce(Effect.succeed(response("{}"))); - const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - - yield* api.setReviewerRequest({ - repository: "acme/web", - number: 7, - reviewers: [{ id: "{hubot}" }], - requested: false, - }); - - expect(JSON.parse(callAt(1).body ?? "")).toEqual({ reviewers: [{ uuid: "{octocat}" }] }); - }), - ); -}); diff --git a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts deleted file mode 100644 index b62b86ffe48f9c11e9688b00b0a15616b4f5e94a..0000000000000000000000000000000000000000 --- a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts +++ /dev/null @@ -1,943 +0,0 @@ -import * as Cache from "effect/Cache"; -import * as Context from "effect/Context"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Layer from "effect/Layer"; -import * as Result from "effect/Result"; -import * as Schema from "effect/Schema"; -import type { - PullRequestAction, - PullRequestCheck, - PullRequestComment, - PullRequestCommit, - PullRequestListState, - PullRequestMergeMethod, - PullRequestMergeability, - PullRequestReviewCommentDraft, - PullRequestReviewPosition, - PullRequestReviewThread, - PullRequestReviewVerdict, - PullRequestReviewerCandidateList, -} from "@t3tools/contracts"; - -import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; -import { parseDiffFileRevisions } from "@t3tools/source-control-core/server/diffFileRevisions"; -import { - buildReviewThreads, - decodeCommentsJson, - decodeCommitsJson, - decodeConflictsJson, - decodeDiffstatJson, - decodePullRequestJson, - decodePullRequestPageJson, - decodeRepositoryPermissionJson, - decodeStatusesJson, - decodeViewerJson, - decodeWorkspaceMembersJson, - type BitbucketDiffStat, - type BitbucketPullRequest, - type BitbucketRawComment, -} from "./bitbucketPullRequestJson.ts"; -import type { ProviderListCursor } from "@t3tools/source-control-core/server/PullRequestProvider"; - -/** - * Names the read that produced unusable output, so a failure reports the call it came from - * rather than borrowing another operation's message. - */ -export class BitbucketPullRequestReadError extends Schema.TaggedError()( - "BitbucketPullRequestReadError", - { - operation: Schema.String, - cause: Schema.Defect(), - }, -) { - get detail(): string { - return `Bitbucket returned an unreadable ${this.operation} response.`; - } - - override get message(): string { - return `Bitbucket failed in ${this.operation}: ${this.detail}`; - } -} - -/** Not a decode failure: Bitbucket answered, the account it answered for just has no handle. */ -export class BitbucketViewerUnavailableError extends Schema.TaggedError()( - "BitbucketViewerUnavailableError", - {}, -) { - get detail(): string { - return "Bitbucket returned no account name for the configured credentials."; - } - - override get message(): string { - return `Bitbucket failed in getViewer: ${this.detail}`; - } -} - -/** A repository that is not `workspace/slug`, which is the only form Bitbucket addresses. */ -export class BitbucketRepositoryUnsupportedError extends Schema.TaggedError()( - "BitbucketRepositoryUnsupportedError", - { - repository: Schema.String, - }, -) { - get detail(): string { - return "A Bitbucket repository is addressed as workspace/repository."; - } - - override get message(): string { - return `Bitbucket failed in resolveRepository: ${this.detail}`; - } -} - -/** Not a decode failure: the reader named a commit that is not a sha this repository could hold. */ -export class BitbucketDiffCommitError extends Schema.TaggedError()( - "BitbucketDiffCommitError", - {}, -) { - get detail(): string { - return "The named commit was not a commit sha."; - } - - override get message(): string { - return `Bitbucket failed in getPullRequestDiff: ${this.detail}`; - } -} - -export type BitbucketPullRequestApiError = - | BitbucketApi.BitbucketApiError - | BitbucketPullRequestReadError - | BitbucketViewerUnavailableError - | BitbucketRepositoryUnsupportedError - | BitbucketDiffCommitError; - -/** - * `/user/permissions/repositories` answering CHANGE-2770's removal notice rather than a - * permission — Bitbucket sends this for every account now, not only ones it would have refused. - */ -function isRepositoryPermissionRemovedError( - error: BitbucketPullRequestApiError, -): error is BitbucketApi.BitbucketResponseError { - return error._tag === "BitbucketResponseError" && error.status === 410; -} - -/** - * Bitbucket's own ceiling. Asking for more does not fail — it answers with an empty page and no - * error at all, so this is a number to respect rather than to push against. - */ -const MAX_PAGE_SIZE = 50; -/** Pages to walk before a listing is reported as truncated. */ -const MAX_LIST_PAGES = 10; -/** The page size for pull request conversations, commits, and checks. */ -const CONVERSATION_PAGE_SIZE = 50; -/** - * Pages of the conversation to follow before it is reported as truncated. Bitbucket serves - * fifty comments a page, so this is five hundred — beyond any pull request a person is reading, - * and an end to a walk whose only other stop is Bitbucket running out. - */ -const CONVERSATION_PAGES = 10; -/** The same ceiling the gh and glab diff reads use. */ -const DIFF_MAX_BYTES = 8 * 1024 * 1024; -/** - * Deliberately far shorter than the window the caller holds versions for: a refresh drops what the - * caller holds precisely so the next read reaches Bitbucket, and this must not be what answers it - * instead. - */ -const REVISION_PATCH_TTL = Duration.seconds(5); -const REVISION_PATCH_CAPACITY = 16; -export interface BitbucketPullRequestBatch { - readonly items: ReadonlyArray; - readonly truncated: boolean; -} - -export class BitbucketPullRequestApi extends Context.Service< - BitbucketPullRequestApi, - { - /** A function rather than a value, so the request is built per call and not at layer time. */ - readonly getViewer: () => Effect.Effect; - - readonly listPullRequests: (input: { - readonly repository: string; - readonly state: PullRequestListState; - readonly limit: number; - /** Free text, matched against a pull request's title and description. */ - readonly query?: string | undefined; - /** Where to carry on from, as a predicate on `updated_on` beside any other. */ - readonly cursor?: ProviderListCursor | undefined; - }) => Effect.Effect; - - readonly getPullRequest: (input: { - readonly repository: string; - readonly number: number; - }) => Effect.Effect; - - /** True where the credentials can write to the repository, which is what merging needs. */ - readonly getRepositoryPermission: (input: { - readonly repository: string; - }) => Effect.Effect; - - readonly getPullRequestDiff: (input: { - readonly repository: string; - readonly number: number; - /** One commit's own changes, rather than everything the pull request carries. */ - readonly commit?: string | undefined; - }) => Effect.Effect< - { readonly patch: string; readonly truncated: boolean }, - BitbucketPullRequestApiError - >; - - readonly getDiffStat: (input: { - readonly repository: string; - readonly number: number; - }) => Effect.Effect; - - /** - * What the pull request's head has of each of these paths, as opaque ids, read off the pull - * request's own patch, the only place Bitbucket states a file's version. A path the patch does - * not carry is answered as the empty revision, and left out altogether when the patch was cut - * short at the byte ceiling and so cannot be spoken for. - * - * Answers with every file the patch carries rather than only the paths asked about, since - * reading one file's version here means parsing all of them. `complete` is false for a patch - * cut short, which cannot speak for what came after the cut. - */ - readonly getFileRevisions: (input: { - readonly repository: string; - readonly number: number; - readonly paths: ReadonlyArray; - }) => Effect.Effect< - { readonly revisions: ReadonlyMap; readonly complete: boolean }, - BitbucketPullRequestApiError - >; - - readonly getMergeability: (input: { - readonly repository: string; - readonly number: number; - }) => Effect.Effect; - - readonly listComments: (input: { - readonly repository: string; - readonly number: number; - }) => Effect.Effect< - { - readonly comments: ReadonlyArray; - readonly threads: ReadonlyArray; - readonly truncated: boolean; - }, - BitbucketPullRequestApiError - >; - - readonly listCommits: (input: { - readonly repository: string; - readonly number: number; - }) => Effect.Effect, BitbucketPullRequestApiError>; - - readonly listChecks: (input: { - readonly repository: string; - readonly number: number; - }) => Effect.Effect, BitbucketPullRequestApiError>; - - /** - * Who this pull request may be sent to, and who it has already been sent to. Two reads at - * once, because Bitbucket keeps the people on the workspace and the reviewers on the pull - * request, and neither answers for the other. - */ - readonly listReviewerCandidates: (input: { - readonly repository: string; - readonly number: number; - }) => Effect.Effect; - - readonly setReviewerRequest: (input: { - readonly repository: string; - readonly number: number; - readonly reviewers: ReadonlyArray<{ readonly id: string }>; - readonly requested: boolean; - }) => Effect.Effect; - - readonly runAction: (input: { - readonly repository: string; - readonly number: number; - readonly action: PullRequestAction; - readonly mergeMethod?: PullRequestMergeMethod; - }) => Effect.Effect; - - readonly updateChangeRequest: (input: { - readonly repository: string; - readonly number: number; - readonly title?: string | undefined; - readonly body?: string | undefined; - }) => Effect.Effect; - - readonly comment: (input: { - readonly repository: string; - readonly number: number; - readonly body: string; - }) => Effect.Effect; - - readonly updateComment: (input: { - readonly repository: string; - readonly number: number; - readonly commentId: string; - readonly body: string; - }) => Effect.Effect; - - readonly submitReview: (input: { - readonly repository: string; - readonly number: number; - readonly verdict: PullRequestReviewVerdict; - readonly body: string; - readonly comments: ReadonlyArray; - }) => Effect.Effect; - - readonly replyToComment: (input: { - readonly repository: string; - readonly number: number; - readonly commentId: string; - readonly body: string; - }) => Effect.Effect; - - readonly setCommentResolution: (input: { - readonly repository: string; - readonly number: number; - readonly commentId: string; - readonly resolved: boolean; - }) => Effect.Effect; - } ->()("t3/pullRequest/BitbucketPullRequestApi") {} - -/** `workspace/slug`; Bitbucket has no deeper nesting to address. */ -function repositorySegments( - repository: string, -): Result.Result< - { readonly workspace: string; readonly slug: string }, - BitbucketRepositoryUnsupportedError -> { - const segments = repository - .split("/") - .map((segment) => segment.trim()) - .filter((segment) => segment.length > 0); - const [workspace, slug] = segments; - if (segments.length !== 2 || workspace === undefined || slug === undefined) { - return Result.fail(new BitbucketRepositoryUnsupportedError({ repository })); - } - return Result.succeed({ workspace, slug }); -} - -function repositoryPathOf(segments: { readonly workspace: string; readonly slug: string }): string { - return `/repositories/${encodeURIComponent(segments.workspace)}/${encodeURIComponent( - segments.slug, - )}`; -} - -/** - * A commit sha arrives from the reader and goes straight into a request path, so it is checked - * rather than trusted: hexadecimal only, from the shortest abbreviation a host prints up to a - * whole sha. - */ -function isCommitSha(value: string): boolean { - return /^[0-9a-f]{7,64}$/i.test(value); -} - -/** - * Bitbucket unions repeated `state` parameters, so a tab that spans several of its states asks - * for each. It separates a declined pull request from one superseded by another, and both read - * as closed here. - */ -function stateParams(state: PullRequestListState): ReadonlyArray { - switch (state) { - case "open": - return ["OPEN"]; - case "merged": - return ["MERGED"]; - case "closed": - return ["DECLINED", "SUPERSEDED"]; - case "all": - return ["OPEN", "MERGED", "DECLINED", "SUPERSEDED"]; - } -} - -/** - * Bitbucket has no search term, only a filter expression, so free text becomes one: a - * case-insensitive contains against the two fields a pull request carries words in. The - * parentheses matter, because the expression is ANDed with the state filter beside it and an - * unbracketed `OR` would swallow it. - * - * A string literal in that grammar is delimited by double quotes, so the reader's text is - * escaped before it goes inside one — a quote would otherwise end the literal and leave the - * rest of the text standing as filter syntax. The whole expression is then URL-encoded, so - * nothing in it reaches the query string as a parameter of its own. - */ -function searchFilter(query: string): string { - const literal = filterLiteral(query); - return `(title ~ "${literal}" OR description ~ "${literal}")`; -} - -/** - * Text as a string literal of Bitbucket's filter grammar. The backslash is escaped first, or - * escaping the quote would only produce a literal backslash followed by a live quote. - */ -function filterLiteral(value: string): string { - return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); -} - -/** Bitbucket's merge strategies, named differently from the three the contract carries. */ -function mergeStrategy(method: PullRequestMergeMethod | undefined): string { - switch (method) { - case "squash": - return "squash"; - case "rebase": - // The linear history GitHub calls "rebase and merge". - return "rebase_fast_forward"; - default: - return "merge_commit"; - } -} - -function bitbucketReviewPosition( - position: PullRequestReviewPosition, -): { readonly from: number } | { readonly to: number } { - switch (position.kind) { - case "added": - return { to: position.newLine }; - case "deleted": - return { from: position.oldLine }; - case "context": - return position.side === "left" ? { from: position.oldLine } : { to: position.newLine }; - } -} - -/** @public Service construction is part of the canonical Effect module API. */ -export const make = Effect.gen(function* () { - const bitbucket = yield* BitbucketApi.BitbucketApi; - - /** - * The repository's own path, and the workspace above it — which the people who may review are - * kept on rather than on the repository, so both are handed over at once. - */ - const withRepository = ( - repository: string, - use: (path: string, workspace: string) => Effect.Effect, - ): Effect.Effect => { - const segments = repositorySegments(repository); - return Result.isSuccess(segments) - ? use(repositoryPathOf(segments.success), segments.success.workspace) - : Effect.fail(segments.failure); - }; - - /** - * Bitbucket pages with a cursor rather than an offset, so the walk follows the `next` URL it - * sends. It stops once the caller's page is filled, when Bitbucket reports no next page, or at - * the page cap — and anything but running out of pages means there is more to be had. - */ - const listPage = (input: { - readonly url: string; - readonly limit: number; - readonly page: number; - readonly collected: ReadonlyArray; - }): Effect.Effect => - bitbucket.request({ method: "GET", url: input.url }).pipe( - Effect.flatMap((response) => { - const decoded = decodePullRequestPageJson(response.body); - if (!Result.isSuccess(decoded)) { - return Effect.fail( - new BitbucketPullRequestReadError({ - operation: "listPullRequests", - cause: decoded.failure, - }), - ); - } - const collected = [...input.collected, ...decoded.success.items]; - const next = decoded.success.next; - if (next === null || collected.length >= input.limit || input.page >= MAX_LIST_PAGES) { - return Effect.succeed({ - items: collected.slice(0, input.limit), - // Bitbucket pages in fifties whatever was asked for, so a walk that stopped on the - // count rather than on the last page is holding rows it is about to drop. Those are - // more results just as surely as another page would be. - truncated: next !== null || collected.length > input.limit, - }); - } - return listPage({ ...input, url: next, page: input.page + 1, collected }); - }), - ); - - const readPage = (input: { - readonly operation: string; - readonly url: string; - readonly decode: (body: string) => Result.Result; - }): Effect.Effect => - bitbucket.request({ method: "GET", url: input.url }).pipe( - Effect.flatMap((response) => { - const decoded = input.decode(response.body); - return Result.isSuccess(decoded) - ? Effect.succeed(decoded.success) - : Effect.fail( - new BitbucketPullRequestReadError({ - operation: input.operation, - cause: decoded.failure, - }), - ); - }), - ); - - /** - * The conversation, following the `next` Bitbucket sends until it sends none. Threads are - * assembled once at the end rather than per page, because a reply and the remark it answers - * can land either side of a page boundary. - */ - const commentsPage = (input: { - readonly url: string; - readonly page: number; - readonly comments: ReadonlyArray; - readonly entries: ReadonlyArray; - }): Effect.Effect< - { - readonly comments: ReadonlyArray; - readonly threads: ReadonlyArray; - readonly truncated: boolean; - }, - BitbucketPullRequestApiError - > => - readPage({ operation: "listComments", url: input.url, decode: decodeCommentsJson }).pipe( - Effect.flatMap((page) => { - const comments = [...input.comments, ...page.comments]; - const entries = [...input.entries, ...page.entries]; - if (page.next !== null && input.page < CONVERSATION_PAGES) { - return commentsPage({ url: page.next, page: input.page + 1, comments, entries }); - } - return Effect.succeed({ - comments, - threads: buildReviewThreads(entries), - truncated: page.next !== null, - }); - }), - ); - - /** Walks a Bitbucket cursor to its end and combines every decoded item. */ - const itemPages = (input: { - readonly operation: string; - readonly url: string; - readonly decode: ( - body: string, - ) => Result.Result<{ readonly items: ReadonlyArray; readonly next: string | null }, unknown>; - readonly items: ReadonlyArray; - /** Commit pages are individually oldest-first, so older pages are prepended. */ - readonly prepend: boolean; - }): Effect.Effect, BitbucketPullRequestApiError> => - readPage({ operation: input.operation, url: input.url, decode: input.decode }).pipe( - Effect.flatMap((page) => { - const items = input.prepend - ? [...page.items, ...input.items] - : [...input.items, ...page.items]; - return page.next === null - ? Effect.succeed(items) - : itemPages({ ...input, url: page.next, items }); - }), - ); - - /** Diffstat has one aggregate per page, so its totals are folded while following `next`. */ - const diffStatPages = (input: { - readonly url: string; - readonly totals: BitbucketDiffStat; - }): Effect.Effect => - readPage({ operation: "getDiffStat", url: input.url, decode: decodeDiffstatJson }).pipe( - Effect.flatMap((page) => { - const totals = { - additions: input.totals.additions + page.additions, - deletions: input.totals.deletions + page.deletions, - changedFiles: input.totals.changedFiles + page.changedFiles, - }; - return page.next === null - ? Effect.succeed(totals) - : diffStatPages({ url: page.next, totals }); - }), - ); - - const pullRequestDiff = (input: { - readonly repository: string; - readonly number: number; - readonly commit?: string | undefined; - }): Effect.Effect< - { readonly patch: string; readonly truncated: boolean }, - BitbucketPullRequestApiError - > => - input.commit !== undefined && !isCommitSha(input.commit) - ? Effect.fail(new BitbucketDiffCommitError()) - : withRepository(input.repository, (path) => - // Already a unified patch, so it needs no decoding at all, only a bound, which a - // diff of any size would otherwise ignore. A commit's own patch sits beside the pull - // request's at `/diff/{sha}` and reads the same way. - bitbucket - .request({ - method: "GET", - url: - input.commit === undefined - ? `${path}/pullrequests/${input.number}/diff` - : `${path}/diff/${input.commit}`, - maxBytes: DIFF_MAX_BYTES, - }) - .pipe( - Effect.map((response) => ({ patch: response.body, truncated: response.truncated })), - ), - ); - - /** - * What the version reads that come one tick at a time want out of the pull request's whole - * patch, shared between them. The parsed answer rather than the patch, which at this capacity - * would hold sixteen bodies of up to the byte ceiling each resident, and saves walking a patch - * of a hundred thousand lines again on every tick. - */ - const revisionPatches = yield* Cache.makeWith( - (key: string) => { - const [repository, number] = JSON.parse(key) as [string, number]; - return pullRequestDiff({ repository, number }).pipe( - Effect.map((diff) => ({ - revisions: parseDiffFileRevisions(diff.patch), - truncated: diff.truncated, - })), - ); - }, - { - capacity: REVISION_PATCH_CAPACITY, - // A failure is not held: the tick after it should reach Bitbucket rather than be handed the - // same error for as long as a good patch would have lasted. - timeToLive: (exit) => (Exit.isSuccess(exit) ? REVISION_PATCH_TTL : Duration.zero), - }, - ); - - return BitbucketPullRequestApi.of({ - getViewer: () => - bitbucket.request({ method: "GET", url: "/user" }).pipe( - Effect.flatMap((response): Effect.Effect => { - const decoded = decodeViewerJson(response.body); - if (!Result.isSuccess(decoded)) { - return Effect.fail( - new BitbucketPullRequestReadError({ operation: "getViewer", cause: decoded.failure }), - ); - } - return decoded.success === null - ? Effect.fail(new BitbucketViewerUnavailableError()) - : Effect.succeed(decoded.success); - }), - ), - - listPullRequests: (input) => - withRepository(input.repository, (path) => { - const search = input.query?.trim() ?? ""; - // Both narrowings share the one `q` Bitbucket takes, so they are ANDed rather than one - // replacing the other. The boundary instant is read inclusively — the rows already sent - // at it come back and the caller drops them, which is what keeps their neighbours at the - // same instant from being skipped. A date is a bare literal in this grammar, and this one - // was checked against a timestamp's shape before it got here. - const predicates = [ - ...(search.length === 0 ? [] : [searchFilter(search)]), - ...(input.cursor === undefined ? [] : [`updated_on <= ${input.cursor.updatedBefore}`]), - ]; - return listPage({ - // Reviewers are not on a listing by default, and `viewerReviewRequested` needs them. - url: `${path}/pullrequests?${stateParams(input.state) - .map((state) => `state=${state}`) - .join("&")}&pagelen=${MAX_PAGE_SIZE}&sort=-updated_on&fields=%2Bvalues.reviewers${ - predicates.length === 0 ? "" : `&q=${encodeURIComponent(predicates.join(" AND "))}` - }`, - limit: input.limit, - page: 1, - collected: [], - }); - }), - - getPullRequest: (input) => - withRepository(input.repository, (path) => - readPage({ - operation: "getPullRequest", - url: `${path}/pullrequests/${input.number}`, - decode: decodePullRequestJson, - }), - ), - - // Nothing on the repository, the pull request or the workspace states what the credentials - // may do, so this endpoint is the one request Bitbucket makes unavoidable. It is asked - // alongside the reads the detail was already making, so it costs no round trip of its own. - // - // Bitbucket permanently removed this endpoint (CHANGE-2770): every account now gets HTTP 410 - // in place of an answer, whatever it may do. That is the deprecated-endpoint signal, not a - // permission being refused, so it is read the same way an unreachable read already is - // elsewhere — as a permission that could not be learned, which grants rather than blocks, and - // leaves the actual merge or write to say why if the account may not do it. Any other failure - // (a bad token, a network fault, an unreadable body) still fails as it did before. - getRepositoryPermission: (input) => - withRepository(input.repository, () => - readPage({ - operation: "getRepositoryPermission", - url: `/user/permissions/repositories?q=${encodeURIComponent( - `repository.full_name="${filterLiteral(input.repository.trim())}"`, - )}`, - decode: decodeRepositoryPermissionJson, - }), - ).pipe(Effect.catchIf(isRepositoryPermissionRemovedError, () => Effect.succeed(true))), - - getPullRequestDiff: pullRequestDiff, - - getFileRevisions: (input) => - input.paths.length === 0 - ? Effect.succeed({ revisions: new Map(), complete: false }) - : Cache.get(revisionPatches, JSON.stringify([input.repository, input.number])).pipe( - Effect.map((diff) => { - const revisions = new Map(diff.revisions); - // A patch cut short at the byte ceiling says nothing about the files past the cut, - // so those paths are left out rather than reported as removed. - if (!diff.truncated) { - for (const path of input.paths) { - if (!revisions.has(path)) revisions.set(path, ""); - } - } - return { revisions, complete: !diff.truncated }; - }), - ), - - getDiffStat: (input) => - withRepository(input.repository, (path) => - diffStatPages({ - url: `${path}/pullrequests/${input.number}/diffstat?pagelen=${MAX_PAGE_SIZE}`, - totals: { additions: 0, deletions: 0, changedFiles: 0 }, - }), - ), - - getMergeability: (input) => - withRepository(input.repository, (path) => - readPage({ - operation: "getMergeability", - url: `${path}/pullrequests/${input.number}/conflicts`, - decode: decodeConflictsJson, - }), - ), - - listComments: (input) => - withRepository(input.repository, (path) => - commentsPage({ - url: `${path}/pullrequests/${input.number}/comments?pagelen=${CONVERSATION_PAGE_SIZE}`, - page: 1, - comments: [], - entries: [], - }), - ), - - listCommits: (input) => - withRepository(input.repository, (path) => - itemPages({ - operation: "listCommits", - url: `${path}/pullrequests/${input.number}/commits?pagelen=${CONVERSATION_PAGE_SIZE}`, - decode: decodeCommitsJson, - items: [], - prepend: true, - }), - ), - - listChecks: (input) => - withRepository(input.repository, (path) => - itemPages({ - operation: "listChecks", - url: `${path}/pullrequests/${input.number}/statuses?pagelen=${CONVERSATION_PAGE_SIZE}`, - decode: decodeStatusesJson, - items: [], - prepend: false, - }), - ), - - listReviewerCandidates: (input) => - withRepository(input.repository, (path, workspace) => - Effect.all( - [ - readPage({ - operation: "getPullRequest", - url: `${path}/pullrequests/${input.number}`, - decode: decodePullRequestJson, - }), - readPage({ - operation: "listReviewerCandidates", - url: `/workspaces/${encodeURIComponent(workspace)}/members?pagelen=${MAX_PAGE_SIZE}`, - decode: decodeWorkspaceMembersJson, - }), - ], - { concurrency: 2 }, - ).pipe( - Effect.map(([pullRequest, members]) => { - const requested = new Set(pullRequest.reviewerIds); - const author = pullRequest.author?.login; - return { - // The author is dropped rather than shown unusable: Bitbucket refuses to make the - // person who opened a pull request its reviewer. - candidates: members.items.flatMap((candidate) => - candidate.login === author - ? [] - : [{ ...candidate, isRequested: requested.has(candidate.id) }], - ), - truncated: members.next !== null, - }; - }), - ), - ), - - setReviewerRequest: (input) => - withRepository(input.repository, (path) => { - const pullRequest = `${path}/pullrequests/${input.number}`; - return readPage({ - operation: "getPullRequest", - url: pullRequest, - decode: decodePullRequestJson, - }).pipe( - Effect.flatMap((current) => { - // Bitbucket has no endpoint that adds or removes one reviewer: the pull request's - // `reviewers` is written whole, so the set that is already there is read first and - // the change applied to it. Everything else about the pull request is left out of - // the body, which leaves it as it was. - const uuids = new Set(current.reviewerIds); - for (const reviewer of input.reviewers) { - if (input.requested) uuids.add(reviewer.id); - else uuids.delete(reviewer.id); - } - return bitbucket.request({ - method: "PUT", - url: pullRequest, - body: JSON.stringify({ reviewers: [...uuids].map((uuid) => ({ uuid })) }), - }); - }), - Effect.asVoid, - ); - }), - - runAction: (input) => - withRepository(input.repository, (path) => { - const pullRequest = `${path}/pullrequests/${input.number}`; - // Only merge and close reach here: the provider declares the others unsupported, so the - // surface never offers them. - if (input.action === "merge") { - return bitbucket - .request({ - method: "POST", - url: `${pullRequest}/merge`, - body: JSON.stringify({ merge_strategy: mergeStrategy(input.mergeMethod) }), - }) - .pipe(Effect.asVoid); - } - return bitbucket - .request({ method: "POST", url: `${pullRequest}/decline` }) - .pipe(Effect.asVoid); - }), - - updateChangeRequest: (input) => - withRepository(input.repository, (path) => - // Only the words this call rewrites travel in the body: as `setReviewerRequest` above - // relies on, Bitbucket's PUT is a partial update, so any field left out is left as it - // was — sending `reviewers` back here would overwrite a change another user made to it - // between this call being issued and the request landing. - bitbucket - .request({ - method: "PUT", - url: `${path}/pullrequests/${input.number}`, - body: JSON.stringify({ - ...(input.title === undefined ? {} : { title: input.title }), - ...(input.body === undefined ? {} : { description: input.body }), - }), - }) - .pipe(Effect.asVoid), - ), - - comment: (input) => - withRepository(input.repository, (path) => - bitbucket - .request({ - method: "POST", - url: `${path}/pullrequests/${input.number}/comments`, - // A JSON document rather than a form field, so the body stays text whatever it says. - body: JSON.stringify({ content: { raw: input.body } }), - }) - .pipe(Effect.asVoid), - ), - - updateComment: (input) => - withRepository(input.repository, (path) => - bitbucket - .request({ - // Bitbucket keeps a pull request's remarks and its line comments in the one - // collection, so this endpoint rewrites either kind. - method: "PUT", - url: `${path}/pullrequests/${input.number}/comments/${encodeURIComponent( - input.commentId, - )}`, - body: JSON.stringify({ content: { raw: input.body } }), - }) - .pipe(Effect.asVoid), - ), - - submitReview: (input) => - withRepository(input.repository, (path) => - Effect.gen(function* () { - const pullRequest = `${path}/pullrequests/${input.number}`; - // Bitbucket has no pending review, so a review is replayed as the requests it is - // made of: the line comments, then the summary, then the verdict. The verdict goes - // last so a review that fails part-way is never left standing as an approval. - yield* Effect.forEach( - input.comments, - (comment) => - bitbucket.request({ - method: "POST", - url: `${pullRequest}/comments`, - body: JSON.stringify({ - content: { raw: comment.body }, - inline: { - path: comment.path, - ...bitbucketReviewPosition(comment.position), - }, - }), - }), - { discard: true }, - ); - if (input.body.trim().length > 0) { - yield* bitbucket.request({ - method: "POST", - url: `${pullRequest}/comments`, - // @effect-diagnostics-next-line preferSchemaOverJson:off - body: JSON.stringify({ content: { raw: input.body } }), - }); - } - if (input.verdict === "approve") { - yield* bitbucket.request({ method: "POST", url: `${pullRequest}/approve` }); - } - if (input.verdict === "request-changes") { - yield* bitbucket.request({ method: "POST", url: `${pullRequest}/request-changes` }); - } - }), - ), - - replyToComment: (input) => - withRepository(input.repository, (path) => - bitbucket - .request({ - method: "POST", - url: `${path}/pullrequests/${input.number}/comments`, - body: JSON.stringify({ - content: { raw: input.body }, - parent: { id: Number(input.commentId) }, - }), - }) - .pipe(Effect.asVoid), - ), - - setCommentResolution: (input) => - withRepository(input.repository, (path) => - bitbucket - .request({ - // Resolving is a sub-resource that is created and deleted, rather than a field. - method: input.resolved ? "POST" : "DELETE", - url: `${path}/pullrequests/${input.number}/comments/${encodeURIComponent( - input.commentId, - )}/resolve`, - }) - .pipe(Effect.asVoid), - ), - }); -}); - -export const layer = Layer.effect(BitbucketPullRequestApi, make); diff --git a/apps/server/src/pullRequest/BitbucketPullRequestProvider.test.ts b/apps/server/src/pullRequest/BitbucketPullRequestProvider.test.ts deleted file mode 100644 index 7c2d58af950e186d80633788c098e56581f111ed..0000000000000000000000000000000000000000 --- a/apps/server/src/pullRequest/BitbucketPullRequestProvider.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Result from "effect/Result"; - -import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; -import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts"; -import { decodePullRequestJson } from "./bitbucketPullRequestJson.ts"; -import { - bitbucketProviderFailure, - bitbucketViewerPermissions, - make, -} from "./BitbucketPullRequestProvider.ts"; - -for (const operation of [ - "getMergeability", - "listChecks", - "getRepositoryPermission", - "listComments", - "listCommits", -] as const) { - it.effect.each(["response", "body read"])( - `preserves rate limits from ${operation} on %s errors while recovering other optional-read failures`, - (variant) => - Effect.gen(function* () { - const pullRequest = Result.getOrThrow( - decodePullRequestJson(`{ - "id": 1, "title": "Check polling", "state": "OPEN", - "source": { "branch": { "name": "feature" } }, - "destination": { "branch": { "name": "main" } }, - "created_on": "2026-09-16T00:00:00Z", - "updated_on": "2026-09-16T00:00:00Z", - "links": { "html": { "href": "https://bitbucket.org/acme/web/pull-requests/1" } } - }`), - ); - for (const status of [429, 403]) { - const provider = yield* make.pipe( - Effect.provide( - Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({ - getPullRequest: () => Effect.succeed(pullRequest), - getDiffStat: () => Effect.succeed({ additions: 0, deletions: 0, changedFiles: 0 }), - getMergeability: () => Effect.succeed("unknown" as const), - listChecks: () => Effect.succeed([]), - getRepositoryPermission: () => Effect.succeed(true), - listComments: () => Effect.succeed({ comments: [], threads: [], truncated: false }), - listCommits: () => Effect.succeed([]), - [operation]: () => - Effect.fail( - variant === "response" - ? new BitbucketApi.BitbucketResponseError({ - operation: "request", - status, - responseBodyLength: 0, - retryAt: 120_000, - }) - : new BitbucketApi.BitbucketResponseBodyReadError({ - operation: "request", - status, - cause: new Error("response stream failed"), - retryAt: 120_000, - }), - ), - }), - ), - ); - const reference = { - cwd: "/repo", - repository: "acme/web", - number: 1, - host: "bitbucket.org", - }; - const result = yield* operation === "listComments" || operation === "listCommits" - ? Effect.result(provider.getChangeRequestActivity(reference)) - : Effect.result(provider.getChangeRequest(reference)); - if (status === 429) { - expect(result).toMatchObject({ - _tag: "Failure", - failure: { reason: "rate-limited", retryAt: 120_000 }, - }); - } else { - expect(result._tag).toBe("Success"); - } - } - }), - ); -} - -it.effect("reads checks and PR state without diff, mergeability, or permission requests", () => - Effect.gen(function* () { - const pullRequest = Result.getOrThrow( - decodePullRequestJson(`{ - "id": 1, "title": "Checks", "state": "OPEN", - "source": { "branch": { "name": "feature" } }, - "destination": { "branch": { "name": "main" } }, - "created_on": "2026-09-16T00:00:00Z", "updated_on": "2026-09-16T00:00:00Z", - "links": { "html": { "href": "https://bitbucket.org/acme/web/pull-requests/1" } } - }`), - ); - let limited = false; - const provider = yield* make.pipe( - Effect.provide( - Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({ - getPullRequest: () => Effect.succeed(pullRequest), - listChecks: () => - limited - ? Effect.fail( - new BitbucketApi.BitbucketResponseError({ - operation: "request", - status: 429, - responseBodyLength: 0, - retryAt: 120_000, - }), - ) - : Effect.succeed([ - { name: "build", status: "failure" as const, description: null, url: null }, - ]), - }), - ), - ); - const read = provider.getChangeRequestChecks; - if (read === undefined) return yield* Effect.die("checks read missing"); - const input = { cwd: "/repo", repository: "acme/web", host: "bitbucket.org", number: 1 }; - expect((yield* read(input)).checks[0]?.status).toBe("failure"); - limited = true; - expect(yield* Effect.flip(read(input))).toMatchObject({ - reason: "rate-limited", - retryAt: 120_000, - }); - }), -); - -describe("bitbucketProviderFailure", () => { - it("treats only an HTTP 401 as unusable credentials", () => { - const responseError = (status: number) => - new BitbucketApi.BitbucketResponseError({ - operation: "request", - status, - responseBodyLength: 0, - }); - - expect(bitbucketProviderFailure(responseError(401)).reason).toBe("unauthenticated"); - expect(bitbucketProviderFailure(responseError(403)).reason).toBe("failed"); - }); -}); - -describe("bitbucketViewerPermissions", () => { - it("offers both actions to credentials with write access", () => { - expect(bitbucketViewerPermissions({ canWrite: true })).toEqual({ - actions: ["merge", "close"], - comment: true, - resolve: true, - verdicts: ["comment", "approve", "request-changes"], - // Bitbucket says nothing about who may set a reviewer, and an unreported permission is - // granted. - requestReviewers: true, - }); - }); - - it("keeps merge from credentials that can only read the repository", () => { - expect(bitbucketViewerPermissions({ canWrite: false })).toEqual({ - actions: ["close"], - comment: true, - resolve: true, - verdicts: ["comment", "approve", "request-changes"], - requestReviewers: true, - }); - }); - - it("treats an author with read access as any other reader, which is all Bitbucket says", () => { - // The repository permission is the whole of what Bitbucket reports per account; it says - // nothing about who opened this pull request, and its author may decline it with read access - // alone — so declining stays offered rather than being taken from them. - expect(bitbucketViewerPermissions({ canWrite: false }).actions).toEqual(["close"]); - }); -}); diff --git a/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts b/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts deleted file mode 100644 index 0abcb7d95e7e745350044265fea70c145aae5fba..0000000000000000000000000000000000000000 --- a/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts +++ /dev/null @@ -1,385 +0,0 @@ -import * as Effect from "effect/Effect"; -import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts"; - -import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts"; -import { - PullRequestProviderError, - type PullRequestProviderFailure, - type ProviderChangeRequest, - type ProviderChangeRequestActivity, - type ProviderChangeRequestDetail, - type PullRequestProviderApi, -} from "@t3tools/source-control-core/server/PullRequestProvider"; -import type { BitbucketPullRequest } from "./bitbucketPullRequestJson.ts"; - -const CAPABILITIES: PullRequestCapabilities = { - diff: true, - comment: true, - // Bitbucket has no endpoint that reopens a declined pull request, and nothing documented that - // moves one in or out of draft, so neither is offered rather than failing when pressed. - actions: ["merge", "close"], - mergeMethods: ["merge", "squash", "rebase"], - search: true, - // Bitbucket Cloud's API exposes no reaction on a pull request or on a comment, so none is - // read and none is offered. - reactions: false, - review: { - inlineComment: true, - reply: true, - resolve: true, - verdicts: ["comment", "approve", "request-changes"], - }, - reviewers: { request: true, listCandidates: true }, - edit: { changeRequest: true, comment: true }, - // Bitbucket Cloud states nothing about what a reviewer has already read: no endpoint carries a - // viewed file, and the per-pull-request properties it does offer are one value shared by - // everyone rather than one per reader. So the marks are kept here, and the client says whose - // they are rather than implying bitbucket.org will show them. - viewedFiles: "environment", -}; - -/** - * What the configured account may do here, from the one thing Bitbucket states per viewer: the - * repository permission. Merging needs `write` or `admin`, so that is what narrows. - * - * Declining stays offered whatever the permission. Bitbucket lets the author of a pull request - * decline their own with no more than read access, and the permission response says nothing about - * who opened this one — so withholding the control from the one person entitled to it is the - * worse of the two mistakes. Commenting and reviewing are not narrowed either: read access is - * enough to say something, to approve and to ask for changes. - * - * Asking for a review is left open for the same reason: Bitbucket takes a reviewer set from the - * author of a pull request as well as from whoever can write, and says nothing here about which - * of the two this account is. - */ -export function bitbucketViewerPermissions(input: { - readonly canWrite: boolean; -}): PullRequestViewerPermissions { - return { - actions: CAPABILITIES.actions.filter((action) => action !== "merge" || input.canWrite), - comment: true, - resolve: true, - verdicts: CAPABILITIES.review.verdicts, - requestReviewers: true, - }; -} - -/** The failures that mean the credentials are the problem, rather than one request. */ -export function bitbucketProviderFailure( - error: BitbucketPullRequestApi.BitbucketPullRequestApiError, -): PullRequestProviderFailure { - // Bitbucket is read over HTTP with credentials from the environment, so there is no tool to be - // missing: unusable always means the credentials are absent or refused. - if (error._tag === "BitbucketResponseError" && error.status === 401) { - return { reason: "unauthenticated" }; - } - if ( - (error._tag === "BitbucketResponseError" || error._tag === "BitbucketResponseBodyReadError") && - error.status === 429 - ) { - return { - reason: "rate-limited", - ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }), - }; - } - if ( - (error._tag === "BitbucketResponseError" || error._tag === "BitbucketResponseBodyReadError") && - error.status === 404 - ) { - return { reason: "not-found" }; - } - return { reason: "failed" }; -} - -function toChangeRequest(pullRequest: BitbucketPullRequest): ProviderChangeRequest { - return { - number: pullRequest.number, - title: pullRequest.title, - url: pullRequest.url, - author: pullRequest.author, - headBranch: pullRequest.headBranch, - ...(pullRequest.headRepositoryNameWithOwner - ? { headRepositoryNameWithOwner: pullRequest.headRepositoryNameWithOwner } - : {}), - baseBranch: pullRequest.baseBranch, - state: pullRequest.state, - isDraft: pullRequest.isDraft, - mergeability: pullRequest.mergeability, - // Line counts are a separate read, which only the detail is worth spending on. - additions: 0, - deletions: 0, - createdAt: pullRequest.createdAt, - updatedAt: pullRequest.updatedAt, - reviewRequestLogins: pullRequest.reviewRequestLogins, - // Bitbucket has no labels on a pull request. - labels: [], - }; -} - -export const make = Effect.gen(function* () { - const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; - - const fail = - (operation: string) => (error: BitbucketPullRequestApi.BitbucketPullRequestApiError) => - new PullRequestProviderError({ - provider: "bitbucket", - operation, - ...bitbucketProviderFailure(error), - // Every Bitbucket failure states its own fact; this names the operation around it, so - // the two do not stack into "failed in x: failed in y: ...". - detail: error.detail, - cause: error, - }); - - const recoverRead = ( - read: Effect.Effect, - fallback: A, - ) => { - const recover = () => Effect.succeed(fallback); - return Effect.catchTags(read, { - BitbucketResponseError: (error) => (error.status === 429 ? Effect.fail(error) : recover()), - BitbucketUntrustedUrlError: recover, - BitbucketRepositoryLocatorError: recover, - BitbucketRequestError: recover, - BitbucketResponseBodyReadError: (error) => - error.status === 429 ? Effect.fail(error) : recover(), - BitbucketResponseDecodeError: recover, - BitbucketRepositoryVcsResolveError: recover, - BitbucketRepositoryRemotesListError: recover, - BitbucketRepositoryRemoteNotFoundError: recover, - BitbucketPullRequestBodyReadError: recover, - BitbucketCheckoutError: recover, - BitbucketPullRequestReadError: recover, - BitbucketViewerUnavailableError: recover, - BitbucketRepositoryUnsupportedError: recover, - BitbucketDiffCommitError: recover, - }); - }; - - const provider: PullRequestProviderApi = { - kind: "bitbucket", - capabilities: CAPABILITIES, - - // Bitbucket credentials come from the server's environment rather than a checkout, so the - // account is the same whichever workspace asks. - getViewer: () => api.getViewer().pipe(Effect.mapError(fail("getViewer"))), - - listChangeRequests: (input) => - api - .listPullRequests({ - repository: input.repository, - state: input.state, - limit: input.limit, - query: input.query, - cursor: input.cursor, - }) - .pipe( - Effect.mapError(fail("listChangeRequests")), - Effect.map((batch) => ({ - items: batch.items.map(toChangeRequest), - truncated: batch.truncated, - // Bitbucket is asked for `-updated_on` whether or not it is being carried on from, - // so every page it answers is one a cursor can continue. - continues: true, - })), - ), - - getChangeRequestChecks: (input) => - Effect.all([api.getPullRequest(input), api.listChecks(input)], { concurrency: 2 }).pipe( - Effect.map(([pullRequest, checks]) => ({ state: pullRequest.state, checks })), - Effect.mapError(fail("getChangeRequestChecks")), - ), - - getChangeRequest: (input) => { - const target = { repository: input.repository, number: input.number }; - return Effect.all( - [ - api.getPullRequest(target), - api.getDiffStat(target), - recoverRead(api.getMergeability(target), "unknown" as const), - recoverRead(api.listChecks(target), []), - // A permission that could not be read is an unknown one, which is granted: a hidden - // Merge leaves someone entitled to it with no way through, and one Bitbucket refuses - // at least says why. - recoverRead(api.getRepositoryPermission(target), true), - ], - { concurrency: 5 }, - ).pipe( - Effect.mapError(fail("getChangeRequest")), - Effect.map( - ([ - pullRequest, - diffStat, - mergeability, - checks, - canWrite, - ]): ProviderChangeRequestDetail => ({ - ...toChangeRequest(pullRequest), - mergeability, - additions: diffStat.additions, - deletions: diffStat.deletions, - changedFiles: diffStat.changedFiles, - body: pullRequest.body, - mergedAt: null, - closedAt: null, - reviewers: pullRequest.reviewers, - checks, - // Bitbucket publishes no per-repository list of allowed strategies, so the ones it - // supports are all offered and a strategy the repository forbids fails on merge. - mergeCapabilities: { merge: true, squash: true, rebase: true }, - viewerPermissions: bitbucketViewerPermissions({ canWrite }), - }), - ), - ); - }, - - getChangeRequestActivity: (input) => { - const target = { repository: input.repository, number: input.number }; - return Effect.all( - [ - // Reviews ride on the pull request itself, so this inexpensive core read is repeated - // here rather than making the core response wait for the conversation endpoints. - api.getPullRequest(target), - recoverRead(api.listComments(target), { comments: [], threads: [], truncated: true }), - recoverRead(api.listCommits(target), []), - ], - { concurrency: 3 }, - ).pipe( - Effect.mapError(fail("getChangeRequestActivity")), - Effect.map(([pullRequest, comments, commits]): ProviderChangeRequestActivity => ({ - comments: [...comments.comments, ...pullRequest.reviews].toSorted((left, right) => - left.createdAt.localeCompare(right.createdAt), - ), - commentCount: comments.comments.length + pullRequest.reviews.length, - commentsTruncated: comments.truncated, - reviewThreads: comments.threads, - commits, - })), - ); - }, - - getViewerPermissions: (input) => - api.getRepositoryPermission({ repository: input.repository }).pipe( - Effect.mapError(fail("getViewerPermissions")), - Effect.map((canWrite) => bitbucketViewerPermissions({ canWrite })), - ), - - // `/diff` answers with the whole patch and pages nothing, so the first slice is the last. - getDiff: (input) => - api - .getPullRequestDiff({ - repository: input.repository, - number: input.number, - ...(input.commit === undefined ? {} : { commit: input.commit }), - }) - .pipe( - Effect.mapError(fail("getDiff")), - Effect.map((diff) => ({ ...diff, nextCursor: null })), - ), - - getFileRevisions: (input) => - api - .getFileRevisions({ - repository: input.repository, - number: input.number, - paths: input.paths, - }) - .pipe(Effect.mapError(fail("getFileRevisions"))), - - // Users only: Bitbucket requests a review of an account, and has no group that stands in for - // one on a pull request. - listReviewerCandidates: (input) => - api - .listReviewerCandidates({ repository: input.repository, number: input.number }) - .pipe(Effect.mapError(fail("listReviewerCandidates"))), - - setReviewerRequest: (input) => - api - .setReviewerRequest({ - repository: input.repository, - number: input.number, - reviewers: input.reviewers, - requested: input.requested, - }) - .pipe(Effect.mapError(fail("setReviewerRequest"))), - - runAction: (input) => - api - .runAction({ - repository: input.repository, - number: input.number, - action: input.action, - ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), - }) - .pipe(Effect.mapError(fail("runAction"))), - - updateChangeRequest: (input) => - api - .updateChangeRequest({ - repository: input.repository, - number: input.number, - title: input.title, - body: input.body, - }) - .pipe(Effect.mapError(fail("updateChangeRequest"))), - - comment: (input) => - api - .comment({ repository: input.repository, number: input.number, body: input.body }) - .pipe(Effect.mapError(fail("comment"))), - - updateComment: (input) => - api - .updateComment({ - repository: input.repository, - number: input.number, - commentId: input.commentId, - body: input.body, - }) - .pipe(Effect.mapError(fail("updateComment"))), - - submitReview: (input) => - api - .submitReview({ - repository: input.repository, - number: input.number, - verdict: input.verdict, - body: input.body, - comments: input.comments, - }) - .pipe(Effect.mapError(fail("submitReview"))), - - replyToThread: (input) => - api - .replyToComment({ - repository: input.repository, - number: input.number, - commentId: input.threadId, - body: input.body, - }) - .pipe(Effect.mapError(fail("replyToThread"))), - - // Never called: `capabilities.reactions` is false, and the service refuses without it. - setReaction: () => - Effect.fail( - new PullRequestProviderError({ - provider: "bitbucket", - operation: "setReaction", - reason: "failed", - detail: "Bitbucket does not support reactions.", - }), - ), - - setThreadResolution: (input) => - api - .setCommentResolution({ - repository: input.repository, - number: input.number, - commentId: input.threadId, - resolved: input.resolved, - }) - .pipe(Effect.mapError(fail("setThreadResolution"))), - }; - - return provider; -}); diff --git a/apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts b/apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts index b26bbe8ae9bad5f5bdd5be773baf27689df1728f..6d35adc0724669b8ab4998d7aab6e00730ed7119 100644 --- a/apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts +++ b/apps/server/src/pullRequest/PullRequestProviderRateLimit.test.ts @@ -1,12 +1,12 @@ import { assert, it } from "@effect/vitest"; import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli"; -import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; +import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; import { azureDevOpsProviderFailure } from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestProvider"; -import { bitbucketProviderFailure } from "./BitbucketPullRequestProvider.ts"; +import { bitbucketProviderFailure } from "@t3tools/source-control-bitbucket/server/BitbucketPullRequestProvider"; import { gitHubProviderFailure } from "./GitHubPullRequestProvider.ts"; import { gitLabProviderFailure } from "@t3tools/source-control-gitlab/server/GitLabPullRequestProvider"; diff --git a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts index 74d7dc47174848c1568e2fd95e37182bb77d8c80..5702957a0ff421ee5aa1b4c54c5fd585fd610b93 100644 --- a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts +++ b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts @@ -3,10 +3,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import type { SourceControlProviderKind } from "@t3tools/contracts"; -import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; -import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts"; -import * as BitbucketPullRequestProvider from "./BitbucketPullRequestProvider.ts"; import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts"; import * as BuiltInDrivers from "../sourceControl/builtInDrivers.ts"; @@ -39,10 +36,7 @@ export function fromProviders( * @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { - const providers = yield* Effect.all([ - GitHubPullRequestProvider.make, - BitbucketPullRequestProvider.make, - ]); + const providers = yield* Effect.all([GitHubPullRequestProvider.make]); const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) => driver.make.pipe(Effect.map((instance) => instance.pullRequests)), ); @@ -62,5 +56,4 @@ export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe( ), ), Layer.provide(BuiltInDrivers.layer), - Layer.provide(BitbucketPullRequestApi.layer.pipe(Layer.provide(BitbucketApi.layer))), ); diff --git a/apps/server/src/pullRequest/bitbucketPullRequestJson.test.ts b/apps/server/src/pullRequest/bitbucketPullRequestJson.test.ts deleted file mode 100644 index 356c5da96f333345ff4104a4217be496978f0357..0000000000000000000000000000000000000000 --- a/apps/server/src/pullRequest/bitbucketPullRequestJson.test.ts +++ /dev/null @@ -1,377 +0,0 @@ -import * as Result from "effect/Result"; -import { describe, expect, it } from "vite-plus/test"; - -import { - decodeCommentsJson, - decodeCommitsJson, - decodeConflictsJson, - decodeDiffstatJson, - decodePullRequestJson, - decodePullRequestPageJson, - decodeRepositoryPermissionJson, - decodeStatusesJson, - decodeViewerJson, -} from "./bitbucketPullRequestJson.ts"; - -/** Shaped after a real api.bitbucket.org pull request, trimmed to the fields that are read. */ -function pullRequest(overrides: Record = {}): Record { - return { - id: 897, - title: "Add trustabl-pipe", - description: "# Add trustabl-pipe", - state: "OPEN", - draft: false, - created_on: "2026-06-16T05:04:32.258456+00:00", - updated_on: "2026-06-16T05:04:33.750542+00:00", - author: { display_name: "Bilal Hassan", nickname: "bilal", type: "user" }, - source: { branch: { name: "feat/page" }, repository: { full_name: "fork/web" } }, - destination: { branch: { name: "master" } }, - links: { html: { href: "https://bitbucket.org/acme/web/pull-requests/897" } }, - ...overrides, - }; -} - -function page(values: ReadonlyArray, extra: Record = {}): string { - return JSON.stringify({ pagelen: 50, page: 1, size: values.length, values, ...extra }); -} - -function expectSuccess(result: Result.Result): A { - expect(Result.isSuccess(result)).toBe(true); - if (!Result.isSuccess(result)) throw new Error("expected a successful decode"); - return result.success; -} - -describe("decodePullRequestPageJson", () => { - it("reads a pull request as a change request", () => { - const decoded = expectSuccess(decodePullRequestPageJson(page([pullRequest()]))); - - expect(decoded.items).toHaveLength(1); - expect(decoded.items[0]).toMatchObject({ - number: 897, - title: "Add trustabl-pipe", - url: "https://bitbucket.org/acme/web/pull-requests/897", - author: { login: "bilal", name: "Bilal Hassan" }, - headBranch: "feat/page", - headRepositoryNameWithOwner: "fork/web", - baseBranch: "master", - state: "open", - isDraft: false, - // Bitbucket says nothing about conflicts on the pull request itself. - mergeability: "unknown", - }); - expect(decoded.next).toBeNull(); - }); - - it("normalizes Bitbucket's offset timestamps, which the page sorts against other hosts", () => { - const decoded = expectSuccess(decodePullRequestPageJson(page([pullRequest()]))); - - expect(decoded.items[0]).toMatchObject({ - createdAt: "2026-06-16T05:04:32.258Z", - updatedAt: "2026-06-16T05:04:33.750Z", - }); - }); - - it("reports the next page as the whole URL Bitbucket sends", () => { - const next = "https://api.bitbucket.org/2.0/repositories/acme/web/pullrequests?page=2"; - const decoded = expectSuccess(decodePullRequestPageJson(page([pullRequest()], { next }))); - - expect(decoded.next).toBe(next); - }); - - it.each([ - ["MERGED", "merged"], - ["DECLINED", "closed"], - ["SUPERSEDED", "closed"], - ["OPEN", "open"], - ["something new", "open"], - ])("reads the %s state as %s", (state, expected) => { - const decoded = expectSuccess(decodePullRequestPageJson(page([pullRequest({ state })]))); - - expect(decoded.items[0]?.state).toBe(expected); - }); - - it("skips a malformed row rather than failing the page", () => { - const decoded = expectSuccess( - decodePullRequestPageJson(page([{ id: "not a number" }, pullRequest()])), - ); - - expect(decoded.items).toHaveLength(1); - }); - - it("fails when Bitbucket did not answer with a page", () => { - expect(Result.isFailure(decodePullRequestPageJson(JSON.stringify({ error: "nope" })))).toBe( - true, - ); - }); -}); - -describe("decodePullRequestJson", () => { - it("reads reviewers as review requests", () => { - const decoded = expectSuccess( - decodePullRequestJson( - JSON.stringify( - pullRequest({ - reviewers: [{ nickname: "julius", display_name: "Julius" }], - }), - ), - ), - ); - - expect(decoded.reviewRequestLogins).toEqual(["julius"]); - expect(decoded.reviewers).toEqual([{ login: "julius", name: "Julius", avatarUrl: null }]); - }); - - it("reads a participant's vote as a review", () => { - const decoded = expectSuccess( - decodePullRequestJson( - JSON.stringify( - pullRequest({ - participants: [ - { - user: { nickname: "julius", display_name: "Julius" }, - role: "REVIEWER", - approved: true, - state: "approved", - participated_on: "2026-06-17T09:00:00+00:00", - }, - // Added as a reviewer but has not voted, so there is no verdict to show. - { - user: { nickname: "sam", display_name: "Sam" }, - role: "REVIEWER", - approved: false, - state: null, - participated_on: null, - }, - ], - }), - ), - ), - ); - - expect(decoded.reviews).toHaveLength(1); - expect(decoded.reviews[0]).toMatchObject({ - kind: "review", - author: { login: "julius" }, - reviewState: "approved", - createdAt: "2026-06-17T09:00:00.000Z", - }); - }); -}); - -describe("decodeViewerJson", () => { - it("reads the signed-in nickname", () => { - const decoded = decodeViewerJson(JSON.stringify({ nickname: "bilal", display_name: "Bilal" })); - - expect(expectSuccess(decoded)).toBe("bilal"); - }); - - it("falls back to the display name, which app accounts have instead", () => { - const decoded = decodeViewerJson(JSON.stringify({ display_name: "Release Bot" })); - - expect(expectSuccess(decoded)).toBe("Release Bot"); - }); - - it("returns nothing when the account has neither", () => { - expect(expectSuccess(decodeViewerJson(JSON.stringify({})))).toBeNull(); - }); -}); - -describe("decodeCommentsJson", () => { - it("keeps a posted comment and drops deleted and unposted ones", () => { - const decoded = expectSuccess( - decodeCommentsJson( - page([ - { - id: 797230941, - content: { raw: "The issue is ready for review." }, - user: { display_name: "Release Bot", type: "app_user" }, - created_on: "2026-05-15T01:58:38.220690+00:00", - deleted: false, - pending: false, - links: { html: { href: "https://bitbucket.org/acme/web/pull-requests/892#c1" } }, - }, - { - id: 2, - content: { raw: "gone" }, - created_on: "2026-05-15T02:00:00+00:00", - deleted: true, - }, - { - id: 3, - content: { raw: "wip" }, - created_on: "2026-05-15T02:00:00+00:00", - pending: true, - }, - { id: 4, content: { raw: " " }, created_on: "2026-05-15T02:00:00+00:00" }, - ]), - ), - ); - - expect(decoded.comments).toHaveLength(1); - expect(decoded.comments[0]).toMatchObject({ - id: "797230941", - kind: "issue-comment", - // An app account has no nickname, so its display name is the only handle it has. - author: { login: "Release Bot" }, - createdAt: "2026-05-15T01:58:38.220Z", - }); - }); - - it("reads a comment pinned to a file as a review comment", () => { - const decoded = expectSuccess( - decodeCommentsJson( - page([ - { - id: 5, - content: { raw: "Rename this." }, - created_on: "2026-05-15T02:00:00+00:00", - inline: { path: "src/app.ts" }, - }, - ]), - ), - ); - - expect(decoded.comments[0]).toMatchObject({ kind: "review-comment", path: "src/app.ts" }); - }); -}); - -describe("decodeCommitsJson", () => { - it("returns commits oldest first with only the subject line", () => { - const decoded = expectSuccess( - decodeCommitsJson( - page([ - { hash: "bbb", message: "second\n\nbody text\n", date: "2026-06-16T04:51:00+00:00" }, - { - hash: "aaa", - message: "first\n", - date: "2026-06-16T04:50:49+00:00", - author: { - raw: "Ada Lovelace ", - user: { nickname: "ada", display_name: "Ada Lovelace" }, - }, - }, - ]), - ), - ); - - expect(decoded.items.map((commit) => commit.oid)).toEqual(["aaa", "bbb"]); - expect(decoded.items[0]?.authors).toEqual([ - { login: "ada", name: "Ada Lovelace", avatarUrl: null }, - ]); - expect(decoded.items[1]?.messageHeadline).toBe("second"); - expect(decoded.next).toBeNull(); - }); - - it("skips commits whose hash is empty", () => { - const decoded = expectSuccess( - decodeCommitsJson( - page([ - { hash: " ", message: "invalid", date: "2026-06-16T04:51:00+00:00" }, - { hash: "aaa", date: "2026-06-16T04:50:49+00:00" }, - ]), - ), - ); - - expect(decoded.items.map((commit) => commit.oid)).toEqual(["aaa"]); - }); -}); - -describe("decodeStatusesJson", () => { - it("reads a build status as a check", () => { - const decoded = expectSuccess( - decodeStatusesJson( - page([ - { - key: "custom:check-version-and-pr", - name: "Pipeline - custom: check-version-and-pr", - state: "SUCCESSFUL", - description: "", - url: "https://bitbucket.org/acme/web/pipelines/results/8126", - }, - ]), - ), - ); - - expect(decoded).toEqual({ - items: [ - { - name: "Pipeline - custom: check-version-and-pr", - status: "success", - description: null, - url: "https://bitbucket.org/acme/web/pipelines/results/8126", - }, - ], - next: null, - }); - }); - - it.each([ - ["SUCCESSFUL", "success"], - ["FAILED", "failure"], - ["INPROGRESS", "pending"], - ["STOPPED", "cancelled"], - ["something new", "neutral"], - ])("reads the %s build state as %s", (state, expected) => { - const decoded = expectSuccess(decodeStatusesJson(page([{ name: "Pipeline", state }]))); - - expect(decoded.items[0]?.status).toBe(expected); - }); - - it("keeps two statuses that share a display name but have different keys", () => { - const decoded = expectSuccess( - decodeStatusesJson( - page([ - { key: "build", name: "Pipeline", state: "SUCCESSFUL" }, - { key: "deploy", name: "Pipeline", state: "FAILED" }, - ]), - ), - ); - - expect(decoded.items.map((check) => [check.name, check.status])).toEqual([ - ["build / Pipeline", "success"], - ["deploy / Pipeline", "failure"], - ]); - }); -}); - -describe("decodeDiffstatJson", () => { - it("adds up the per-file counts", () => { - const decoded = expectSuccess( - decodeDiffstatJson( - page([ - { lines_added: 9, lines_removed: 2 }, - { lines_added: 32, lines_removed: 14 }, - ]), - ), - ); - - expect(decoded).toEqual({ additions: 41, deletions: 16, changedFiles: 2, next: null }); - }); -}); - -describe("decodeConflictsJson", () => { - it("calls an empty conflict list mergeable", () => { - expect(expectSuccess(decodeConflictsJson(page([])))).toBe("mergeable"); - }); - - it("calls any reported conflict conflicting", () => { - expect(expectSuccess(decodeConflictsJson(page([{ path: "src/app.ts" }])))).toBe("conflicting"); - }); -}); - -describe("repository permission decoding", () => { - const permissionPage = (permission: string) => - page([{ type: "repository_permission", permission }]); - - it("counts admin and write as write, and read as not", () => { - expect(expectSuccess(decodeRepositoryPermissionJson(permissionPage("admin")))).toBe(true); - expect(expectSuccess(decodeRepositoryPermissionJson(permissionPage("write")))).toBe(true); - expect(expectSuccess(decodeRepositoryPermissionJson(permissionPage("read")))).toBe(false); - }); - - it("grants write where Bitbucket named no permission at all", () => { - // An empty page is Bitbucket declining to say, which is an unknown standing rather than a - // refusal — and an unknown one is granted. - expect(expectSuccess(decodeRepositoryPermissionJson(page([])))).toBe(true); - }); -}); diff --git a/apps/server/src/pullRequest/bitbucketPullRequestJson.ts b/apps/server/src/pullRequest/bitbucketPullRequestJson.ts deleted file mode 100644 index 8f0b520ca089b64d6851b8c1bd8e159964ac48b1..0000000000000000000000000000000000000000 --- a/apps/server/src/pullRequest/bitbucketPullRequestJson.ts +++ /dev/null @@ -1,631 +0,0 @@ -import * as Cause from "effect/Cause"; -import * as DateTime from "effect/DateTime"; -import * as Exit from "effect/Exit"; -import * as Option from "effect/Option"; -import * as Result from "effect/Result"; -import * as Schema from "effect/Schema"; -import type { - PullRequestActor, - PullRequestCheck, - PullRequestCheckStatus, - PullRequestComment, - PullRequestCommit, - PullRequestMergeability, - PullRequestReviewThread, - PullRequestReviewerCandidate, - PullRequestState, -} from "@t3tools/contracts"; -import { TrimmedNonEmptyString } from "@t3tools/contracts"; -import { decodeJsonResult } from "@t3tools/shared/schemaJson"; - -import { dedupeChecks } from "@t3tools/source-control-core/server/pullRequestChecks"; - -/** - * Bitbucket's enums are decoded as plain strings and normalized here, in the same tolerant - * style as the GitHub and GitLab decoders: a new pull request state or build status must not - * fail a whole payload. - */ -const RawUserSchema = Schema.Struct({ - /** - * How Bitbucket addresses an account when a reviewer set is written; the handles it shows are - * not accepted there. Braced, and sent back exactly as it arrived. - */ - uuid: Schema.optional(Schema.NullOr(Schema.String)), - /** Absent on an app account, which is why `display_name` has to stand in for it. */ - nickname: Schema.optional(Schema.NullOr(Schema.String)), - display_name: Schema.optional(Schema.NullOr(Schema.String)), - links: Schema.optional( - Schema.NullOr( - Schema.Struct({ - avatar: Schema.optional( - Schema.NullOr(Schema.Struct({ href: Schema.optional(Schema.String) })), - ), - }), - ), - ), -}); - -/** - * Required, and required to be non-empty: the wire contract will not carry a change request - * without a branch or a link, so a row missing one is skipped rather than breaking the response - * it travels in. - */ -const RawBranchSchema = Schema.Struct({ - branch: Schema.Struct({ name: TrimmedNonEmptyString }), - repository: Schema.optional(Schema.NullOr(Schema.Struct({ full_name: TrimmedNonEmptyString }))), -}); - -const RawLinkSchema = Schema.Struct({ href: Schema.optional(Schema.String) }); - -const RawPullRequestSchema = Schema.Struct({ - id: Schema.Int, - title: Schema.String, - description: Schema.optional(Schema.NullOr(Schema.String)), - state: Schema.optional(Schema.NullOr(Schema.String)), - draft: Schema.optional(Schema.Boolean), - author: Schema.optional(Schema.NullOr(RawUserSchema)), - source: RawBranchSchema, - destination: RawBranchSchema, - created_on: Schema.String, - updated_on: Schema.String, - reviewers: Schema.optional(Schema.NullOr(Schema.Array(RawUserSchema))), - participants: Schema.optional( - Schema.NullOr( - Schema.Array( - Schema.Struct({ - user: Schema.optional(Schema.NullOr(RawUserSchema)), - role: Schema.optional(Schema.NullOr(Schema.String)), - approved: Schema.optional(Schema.Boolean), - state: Schema.optional(Schema.NullOr(Schema.String)), - participated_on: Schema.optional(Schema.NullOr(Schema.String)), - }), - ), - ), - ), - links: Schema.Struct({ html: Schema.Struct({ href: TrimmedNonEmptyString }) }), -}); - -const RawPageSchema = Schema.Struct({ - values: Schema.Array(Schema.Unknown), - /** A total count, which Bitbucket omits on some endpoints. */ - size: Schema.optional(Schema.NullOr(Schema.Int)), - /** Present only while a further page exists. */ - next: Schema.optional(Schema.NullOr(Schema.String)), -}); - -const RawCommentSchema = Schema.Struct({ - id: Schema.Int, - content: Schema.optional(Schema.NullOr(Schema.Struct({ raw: Schema.optional(Schema.String) }))), - user: Schema.optional(Schema.NullOr(RawUserSchema)), - created_on: Schema.String, - deleted: Schema.optional(Schema.Boolean), - /** A comment still being drafted by its author. */ - pending: Schema.optional(Schema.Boolean), - /** Set on a reply, to the comment it answers — which may itself be a reply. */ - parent: Schema.optional(Schema.NullOr(Schema.Struct({ id: Schema.Int }))), - inline: Schema.optional( - Schema.NullOr( - Schema.Struct({ - path: Schema.optional(Schema.NullOr(Schema.String)), - /** The line in the file as it was; set instead of `to` on a removed line. */ - from: Schema.optional(Schema.NullOr(Schema.Int)), - /** The line in the file as it is now. */ - to: Schema.optional(Schema.NullOr(Schema.Int)), - outdated: Schema.optional(Schema.NullOr(Schema.Boolean)), - }), - ), - ), - /** Non-null once someone has marked the thread resolved. */ - resolution: Schema.optional(Schema.NullOr(Schema.Unknown)), - links: Schema.optional( - Schema.NullOr(Schema.Struct({ html: Schema.optional(Schema.NullOr(RawLinkSchema)) })), - ), -}); - -const RawCommitSchema = Schema.Struct({ - hash: TrimmedNonEmptyString, - message: Schema.optional(Schema.NullOr(Schema.String)), - date: Schema.optional(Schema.NullOr(Schema.String)), - author: Schema.optional( - Schema.NullOr( - Schema.Struct({ - raw: Schema.optional(Schema.NullOr(Schema.String)), - user: Schema.optional(Schema.NullOr(RawUserSchema)), - }), - ), - ), -}); - -const RawStatusSchema = Schema.Struct({ - key: Schema.optional(Schema.NullOr(Schema.String)), - name: Schema.optional(Schema.NullOr(Schema.String)), - state: Schema.optional(Schema.NullOr(Schema.String)), - description: Schema.optional(Schema.NullOr(Schema.String)), - url: Schema.optional(Schema.NullOr(Schema.String)), -}); - -const RawDiffstatSchema = Schema.Struct({ - lines_added: Schema.optional(Schema.NullOr(Schema.Int)), - lines_removed: Schema.optional(Schema.NullOr(Schema.Int)), -}); - -/** One row of `/workspaces/{workspace}/members`, which wraps the account it is about. */ -const RawMemberSchema = Schema.Struct({ - user: Schema.optional(Schema.NullOr(RawUserSchema)), -}); - -const RawViewerSchema = Schema.Struct({ - nickname: Schema.optional(Schema.NullOr(Schema.String)), - display_name: Schema.optional(Schema.NullOr(Schema.String)), -}); - -/** - * `/user/permissions/repositories` filtered to one repository, which is the only place Bitbucket - * states what the credentials may do with it: nothing on the repository, the pull request or the - * workspace carries it. One row, or none where Bitbucket names no permission for this account. - */ -const RawRepositoryPermissionsSchema = Schema.Struct({ - values: Schema.optional( - Schema.NullOr( - Schema.Array(Schema.Struct({ permission: Schema.optional(Schema.NullOr(Schema.String)) })), - ), - ), -}); - -export interface BitbucketPullRequest { - readonly number: number; - readonly title: string; - readonly url: string; - readonly author: PullRequestActor | null; - readonly headBranch: string; - readonly headRepositoryNameWithOwner: string | null; - readonly baseBranch: string; - readonly state: PullRequestState; - readonly isDraft: boolean; - /** - * Bitbucket reports no conflict state on a pull request, so the list leaves it unknown. The - * detail read asks the conflicts endpoint, which does answer. - */ - readonly mergeability: PullRequestMergeability; - readonly createdAt: string; - readonly updatedAt: string; - readonly body: string; - readonly reviewRequestLogins: ReadonlyArray; - readonly reviewers: ReadonlyArray; - /** The reviewers as Bitbucket addresses them, which is what writing the set back takes. */ - readonly reviewerIds: ReadonlyArray; - /** Approvals and change requests, which Bitbucket keeps on its participants. */ - readonly reviews: ReadonlyArray; -} - -function trimmed(value: string | null | undefined): string | null { - const text = value?.trim() ?? ""; - return text.length > 0 ? text : null; -} - -/** - * Bitbucket stamps times as `+00:00` with microseconds. The page sorts change requests from - * every host against each other as plain strings, so they are normalized to the same `Z` form - * the other hosts already use. - */ -function toIsoUtc(value: string): string { - return Option.match(DateTime.make(value), { - onNone: () => value, - onSome: DateTime.formatIso, - }); -} - -/** An app account has no nickname, so the display name is the only handle it has. */ -function toActor(raw: Schema.Schema.Type | null | undefined) { - const login = trimmed(raw?.nickname) ?? trimmed(raw?.display_name); - return login === null - ? null - : { - login, - name: trimmed(raw?.display_name), - avatarUrl: trimmed(raw?.links?.avatar?.href), - }; -} - -function toState(raw: Schema.Schema.Type): PullRequestState { - switch (raw.state?.trim().toUpperCase()) { - case "MERGED": - return "merged"; - case "DECLINED": - case "SUPERSEDED": - return "closed"; - default: - return "open"; - } -} - -function toBuildStatus(value: string | null | undefined): PullRequestCheckStatus { - switch (value?.trim().toUpperCase()) { - case "SUCCESSFUL": - return "success"; - case "FAILED": - return "failure"; - case "STOPPED": - return "cancelled"; - case "INPROGRESS": - return "pending"; - default: - return "neutral"; - } -} - -/** - * A participant who has voted is the closest Bitbucket has to a review, so it reads as one in - * the conversation. Participants who have only been added carry no verdict and are skipped. - */ -function toReviews( - raw: Schema.Schema.Type, -): ReadonlyArray { - return (raw.participants ?? []).flatMap((participant): ReadonlyArray => { - const author = toActor(participant.user); - const votedAt = trimmed(participant.participated_on); - const reviewState = - trimmed(participant.state) ?? (participant.approved === true ? "approved" : null); - if (author === null || votedAt === null || reviewState === null) return []; - return [ - { - id: `${raw.id}:${author.login}`, - kind: "review", - author, - body: "", - createdAt: toIsoUtc(votedAt), - url: null, - path: null, - reviewState, - }, - ]; - }); -} - -function toPullRequest(raw: Schema.Schema.Type): BitbucketPullRequest { - const reviewers = (raw.reviewers ?? []).flatMap((reviewer) => { - const actor = toActor(reviewer); - return actor === null ? [] : [actor]; - }); - return { - number: raw.id, - title: raw.title, - url: raw.links.html.href, - author: toActor(raw.author), - headBranch: raw.source.branch.name, - headRepositoryNameWithOwner: raw.source.repository?.full_name ?? null, - baseBranch: raw.destination.branch.name, - state: toState(raw), - isDraft: raw.draft ?? false, - mergeability: "unknown", - createdAt: toIsoUtc(raw.created_on), - updatedAt: toIsoUtc(raw.updated_on), - body: raw.description ?? "", - reviewRequestLogins: reviewers.map((reviewer) => reviewer.login), - reviewers, - reviewerIds: (raw.reviewers ?? []).flatMap((reviewer) => trimmed(reviewer.uuid) ?? []), - reviews: toReviews(raw), - }; -} - -const decodePage = decodeJsonResult(RawPageSchema); -const decodePullRequestEntry = Schema.decodeUnknownExit(RawPullRequestSchema); -const decodePullRequest = decodeJsonResult(RawPullRequestSchema); -const decodeCommentEntry = Schema.decodeUnknownExit(RawCommentSchema); -const decodeCommitEntry = Schema.decodeUnknownExit(RawCommitSchema); -const decodeStatusEntry = Schema.decodeUnknownExit(RawStatusSchema); -const decodeDiffstatEntry = Schema.decodeUnknownExit(RawDiffstatSchema); -const decodeMemberEntry = Schema.decodeUnknownExit(RawMemberSchema); -const decodeViewer = decodeJsonResult(RawViewerSchema); -const decodeConflicts = decodeJsonResult(RawPageSchema); -const decodeRepositoryPermissions = decodeJsonResult(RawRepositoryPermissionsSchema); - -type DecodeFailure = Cause.Cause; - -export interface BitbucketPage { - readonly items: ReadonlyArray; - /** The whole URL of the next page, which Bitbucket sends rather than an offset. */ - readonly next: string | null; -} - -/** Malformed entries are skipped rather than failing the page, as on the other hosts. */ -export function decodePullRequestPageJson( - raw: string, -): Result.Result, DecodeFailure> { - const decoded = decodePage(raw); - if (!Result.isSuccess(decoded)) { - return Result.fail(decoded.failure); - } - const items: BitbucketPullRequest[] = []; - for (const entry of decoded.success.values) { - const item = decodePullRequestEntry(entry); - if (Exit.isSuccess(item)) { - items.push(toPullRequest(item.value)); - } - } - return Result.succeed({ items, next: trimmed(decoded.success.next) }); -} - -export function decodePullRequestJson( - raw: string, -): Result.Result { - const decoded = decodePullRequest(raw); - return Result.isSuccess(decoded) - ? Result.succeed(toPullRequest(decoded.success)) - : Result.fail(decoded.failure); -} - -export function decodeViewerJson(raw: string): Result.Result { - const decoded = decodeViewer(raw); - return Result.isSuccess(decoded) - ? Result.succeed(trimmed(decoded.success.nickname) ?? trimmed(decoded.success.display_name)) - : Result.fail(decoded.failure); -} - -/** - * Whether the configured credentials can write to the repository, which is what merging needs. - * Bitbucket answers `admin`, `write` or `read`, and an empty page means it named no permission at - * all for this account — an unknown standing, which is granted rather than guessed away. - */ -export function decodeRepositoryPermissionJson(raw: string): Result.Result { - const decoded = decodeRepositoryPermissions(raw); - if (!Result.isSuccess(decoded)) { - return Result.fail(decoded.failure); - } - const permission = trimmed(decoded.success.values?.[0]?.permission)?.toLowerCase() ?? null; - return Result.succeed(permission === null || permission === "admin" || permission === "write"); -} - -/** - * The workspace's members, which is the nearest thing Bitbucket has to "who may review this". - * Nothing on a repository lists the people with access to it — `permissions-config/users` is for - * administrators only — and a pull request can be sent to anyone in the workspace, so this is the - * list Bitbucket's own reviewer field is filled from too. - * - * Nobody is marked requested here: who has been asked lives on the pull request, and only the - * caller holds both. - */ -export function decodeWorkspaceMembersJson( - raw: string, -): Result.Result, DecodeFailure> { - const decoded = decodePage(raw); - if (!Result.isSuccess(decoded)) { - return Result.fail(decoded.failure); - } - const items: PullRequestReviewerCandidate[] = []; - for (const entry of decoded.success.values) { - const member = decodeMemberEntry(entry); - if (Exit.isFailure(member)) continue; - const uuid = trimmed(member.value.user?.uuid); - const actor = toActor(member.value.user); - if (uuid === null || actor === null) continue; - items.push({ ...actor, id: uuid, kind: "user", isRequested: false }); - } - return Result.succeed({ items, next: trimmed(decoded.success.next) }); -} - -/** One comment as Bitbucket sent it, kept so threads can be assembled across pages. */ -export type BitbucketRawComment = Schema.Schema.Type; - -export interface BitbucketComments { - readonly comments: ReadonlyArray; - /** - * The same comments unread, for `buildReviewThreads`. A reply and the remark it answers can - * land on different pages, and only the caller holding every page can put them together. - */ - readonly entries: ReadonlyArray; - readonly next: string | null; -} - -/** - * Bitbucket returns one flat list, so a thread is reassembled from it: a comment pinned to a - * line opens a thread, and every reply that leads back to it belongs in it. A reply whose - * parent is on a page that was not read has nowhere to go, and is left out rather than shown - * as a thread of its own — it still stands in the flat conversation, which needs no parent. - */ -export function buildReviewThreads( - comments: ReadonlyArray, -): ReadonlyArray { - const byId = new Map(comments.map((comment) => [comment.id, comment])); - const rootOf = (comment: Schema.Schema.Type) => { - // Bounded by the number of comments read, so a parent cycle cannot spin here. - let current = comment; - for (let step = 0; step < byId.size; step += 1) { - const parent = current.parent === null ? undefined : byId.get(current.parent?.id ?? -1); - if (parent === undefined) return current; - current = parent; - } - return current; - }; - - const threads = new Map(); - const replies = new Map>>(); - for (const comment of comments) { - const root = rootOf(comment); - const inline = root.inline; - const path = trimmed(inline?.path); - if (path === null) continue; - if (root.id === comment.id) { - // `to` is the line as the file stands now, `from` the line it replaced; a comment that - // carries only `from` was written against the removed side. - const side = inline?.to === null || inline?.to === undefined ? "left" : "right"; - const line = side === "left" ? inline?.from : inline?.to; - threads.set(root.id, { - id: String(root.id), - path, - line: typeof line === "number" && line > 0 ? line : null, - side, - isResolved: root.resolution !== null && root.resolution !== undefined, - isOutdated: inline?.outdated === true, - comments: [], - }); - } - const bucket = replies.get(root.id); - if (bucket === undefined) replies.set(root.id, [comment]); - else bucket.push(comment); - } - - return [...threads.values()].flatMap((thread) => { - const entries = (replies.get(Number(thread.id)) ?? []) - .toSorted((left, right) => left.created_on.localeCompare(right.created_on)) - .map((comment) => ({ - id: String(comment.id), - author: toActor(comment.user), - body: comment.content?.raw ?? "", - createdAt: toIsoUtc(comment.created_on), - url: trimmed(comment.links?.html?.href), - })); - return entries.length === 0 ? [] : [{ ...thread, comments: entries }]; - }); -} - -/** - * Deleted comments and ones their author has not posted yet carry nothing to show. A comment - * pinned to a file is a line-level review comment, which is what that kind means. - */ -export function decodeCommentsJson(raw: string): Result.Result { - const decoded = decodePage(raw); - if (!Result.isSuccess(decoded)) { - return Result.fail(decoded.failure); - } - const comments: PullRequestComment[] = []; - const kept: Array = []; - for (const entry of decoded.success.values) { - const decodedComment = decodeCommentEntry(entry); - if (Exit.isFailure(decodedComment)) continue; - const comment = decodedComment.value; - if (comment.deleted === true || comment.pending === true) continue; - const body = comment.content?.raw ?? ""; - if (body.trim().length === 0) continue; - kept.push(comment); - const path = trimmed(comment.inline?.path); - comments.push({ - id: String(comment.id), - kind: path === null ? "issue-comment" : "review-comment", - author: toActor(comment.user), - body, - createdAt: toIsoUtc(comment.created_on), - url: trimmed(comment.links?.html?.href), - path, - reviewState: null, - }); - } - return Result.succeed({ comments, entries: kept, next: trimmed(decoded.success.next) }); -} - -export function decodeCommitsJson( - raw: string, -): Result.Result, DecodeFailure> { - const decoded = decodePage(raw); - if (!Result.isSuccess(decoded)) { - return Result.fail(decoded.failure); - } - const commits: PullRequestCommit[] = []; - for (const entry of decoded.success.values) { - const decodedCommit = decodeCommitEntry(entry); - if (Exit.isFailure(decodedCommit)) continue; - const commit = decodedCommit.value; - const committedDate = trimmed(commit.date); - if (committedDate === null) continue; - const linkedAuthor = toActor(commit.author?.user); - const rawAuthor = trimmed(commit.author?.raw); - commits.push({ - oid: commit.hash, - messageHeadline: (commit.message ?? "").split("\n")[0] ?? "", - committedDate: toIsoUtc(committedDate), - authors: - linkedAuthor !== null - ? [linkedAuthor] - : rawAuthor === null - ? [] - : [{ login: rawAuthor, name: rawAuthor, avatarUrl: null }], - }); - } - // Bitbucket lists a pull request's commits newest first; the timeline reads oldest first. - return Result.succeed({ items: commits.toReversed(), next: trimmed(decoded.success.next) }); -} - -export function decodeStatusesJson( - raw: string, -): Result.Result, DecodeFailure> { - const decoded = decodePage(raw); - if (!Result.isSuccess(decoded)) { - return Result.fail(decoded.failure); - } - const checks: Array<{ - readonly check: PullRequestCheck; - readonly workflowName: string | null; - readonly at: string | null; - }> = []; - for (const entry of decoded.success.values) { - const decodedStatus = decodeStatusEntry(entry); - if (Exit.isFailure(decodedStatus)) continue; - const status = decodedStatus.value; - const name = trimmed(status.name) ?? trimmed(status.key); - if (name === null) continue; - // Bitbucket re-uses a status key when a pipeline is run again, so the same check can appear - // twice on one page. Nothing decoded here says which copy is newer, so the later one wins, - // which is the order Bitbucket writes an update in. The key is kept as the workflow name so - // two different pipelines that display the same name are not folded into one. - checks.push({ - check: { - name, - status: toBuildStatus(status.state), - description: trimmed(status.description), - url: trimmed(status.url), - }, - workflowName: trimmed(status.key), - at: null, - }); - } - return Result.succeed({ items: dedupeChecks(checks), next: trimmed(decoded.success.next) }); -} - -export interface BitbucketDiffStat { - readonly additions: number; - readonly deletions: number; - readonly changedFiles: number; -} - -export interface BitbucketDiffStatPage extends BitbucketDiffStat { - readonly next: string | null; -} - -/** One entry per changed file, each carrying that file's line counts. */ -export function decodeDiffstatJson( - raw: string, -): Result.Result { - const decoded = decodePage(raw); - if (!Result.isSuccess(decoded)) { - return Result.fail(decoded.failure); - } - let additions = 0; - let deletions = 0; - let changedFiles = 0; - for (const entry of decoded.success.values) { - const decodedStat = decodeDiffstatEntry(entry); - if (Exit.isFailure(decodedStat)) continue; - additions += decodedStat.value.lines_added ?? 0; - deletions += decodedStat.value.lines_removed ?? 0; - changedFiles += 1; - } - return Result.succeed({ - additions, - deletions, - changedFiles, - next: trimmed(decoded.success.next), - }); -} - -/** - * The conflicts endpoint answers with one entry per conflicting path, so an empty page is the - * only statement Bitbucket makes that a pull request merges cleanly. - */ -export function decodeConflictsJson( - raw: string, -): Result.Result { - const decoded = decodeConflicts(raw); - return Result.isSuccess(decoded) - ? Result.succeed(decoded.success.values.length === 0 ? "mergeable" : "conflicting") - : Result.fail(decoded.failure); -} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 8ea3b5cda1981b9e190c440df1927dd1bc05d869..ff4c4ef6c2146d6cbbc5cd9ba3ff2391f1163018 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -52,7 +52,6 @@ import * as ProviderHostLive from "./provider/ProviderHostLive.ts"; import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRegistrySupport"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; -import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; import * as GitHubApi from "./sourceControl/GitHubApi.ts"; import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts"; @@ -297,13 +296,7 @@ const layerPersistence = Layer.empty.pipe(Layer.provideMerge(SqlitePersistence.l const layerVcsDriverRegistry = VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer)); const layerSourceControlProviderRegistry = SourceControlProviderRegistry.layer.pipe( - Layer.provide( - Layer.mergeAll( - BitbucketApi.layer, - GitHubApi.layerWithDependencies, - SourceControlBuiltInDrivers.layer, - ), - ), + Layer.provide(Layer.mergeAll(GitHubApi.layerWithDependencies, SourceControlBuiltInDrivers.layer)), Layer.provideMerge(GitVcsDriver.layer), Layer.provideMerge(layerVcsDriverRegistry), ); diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts index 352bfc60d970673d7b1655e1f3d8254a1be58fba..85e14a61bc62b1c1dd8f2f27be1a58cc662271d5 100644 --- a/apps/server/src/sourceControl/BitbucketApi.test.ts +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -11,11 +11,13 @@ import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http"; import { GitCommandError } from "@t3tools/contracts"; -import * as BitbucketApi from "./BitbucketApi.ts"; +import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; import * as ServerSettings from "../serverSettings.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import type * as VcsDriver from "../vcs/VcsDriver.ts"; +import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; const isBitbucketRepositoryLocatorError = Schema.is(BitbucketApi.BitbucketRepositoryLocatorError); @@ -139,6 +141,8 @@ function makeLayer(input: { ); const layer = BitbucketApi.layer.pipe( + Layer.provide(ServerSourceControlHost.layer), + Layer.provide(Layer.mock(VcsProcess.VcsProcess)({})), Layer.provide( Layer.succeed( HttpClient.HttpClient, diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts deleted file mode 100644 index f7fa5cc4568c705fb8df0868298ab93c043eda8c..0000000000000000000000000000000000000000 --- a/apps/server/src/sourceControl/BitbucketApi.ts +++ /dev/null @@ -1,1163 +0,0 @@ -import * as Clock from "effect/Clock"; -import * as Config from "effect/Config"; -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import { - DEFAULT_SERVER_SETTINGS, - NonNegativeInt, - TrimmedNonEmptyString, - type BitbucketSettings, - type SourceControlProviderAuth, - type SourceControlRepositoryCloneUrls, - type SourceControlRepositoryVisibility, -} from "@t3tools/contracts"; -import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; -import { sanitizeBranchFragment } from "@t3tools/shared/git"; -import { - detectSourceControlProviderFromRemoteUrl, - isSshRemoteUrl, -} from "@t3tools/shared/sourceControl"; - -import { - BitbucketPullRequestListSchema, - BitbucketPullRequestSchema, - normalizeBitbucketPullRequestRecord, - type NormalizedBitbucketPullRequestRecord, -} from "./bitbucketPullRequests.ts"; -import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText"; -import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; -import * as ServerSettings from "../serverSettings.ts"; -import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; -import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; -import { retryAtFromHeader } from "@t3tools/source-control-core/server/SourceControlRateLimit"; - -const DEFAULT_API_BASE_URL = "https://api.bitbucket.org/2.0"; -/** A response body past this is cut short, so one huge diff cannot exhaust the server. */ -const DEFAULT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; -/** Bitbucket redirects a diff once; this leaves room without following a chain forever. */ -const MAX_REDIRECTS = 3; - -const BitbucketApiEnvConfig = Config.all({ - baseUrl: Config.String("T3CODE_BITBUCKET_API_BASE_URL").pipe( - Config.withDefault(DEFAULT_API_BASE_URL), - ), - accessToken: Config.String("T3CODE_BITBUCKET_ACCESS_TOKEN").pipe(Config.option), - email: Config.String("T3CODE_BITBUCKET_EMAIL").pipe(Config.option), - apiToken: Config.String("T3CODE_BITBUCKET_API_TOKEN").pipe(Config.option), -}); - -const BitbucketApiOperation = Schema.Literals([ - "resolveRepository", - "getRepository", - "getBranchingModel", - "getPullRequest", - "listPullRequests", - "createRepository", - "createPullRequest", - "probeAuth", - "checkoutPullRequest", - // The raw escape hatch. Callers name their own operation in their own error, the way the - // pull request wrappers do on top of `gh` and `glab`. - "request", -]); -type BitbucketApiOperation = typeof BitbucketApiOperation.Type; - -export class BitbucketRepositoryLocatorError extends Schema.TaggedError()( - "BitbucketRepositoryLocatorError", - { - repository: Schema.String, - }, -) { - static readonly detail = "Bitbucket repositories must be specified as workspace/repository."; - - get detail(): string { - return BitbucketRepositoryLocatorError.detail; - } - - override get message(): string { - return `Bitbucket API failed: ${this.detail}`; - } -} - -export class BitbucketRequestError extends Schema.TaggedError()( - "BitbucketRequestError", - { - operation: BitbucketApiOperation, - cause: Schema.Defect(), - }, -) { - get detail(): string { - return "Failed to send the Bitbucket request."; - } - - override get message(): string { - return `Bitbucket API failed in ${this.operation}: ${this.detail}`; - } -} - -export class BitbucketResponseError extends Schema.TaggedError()( - "BitbucketResponseError", - { - operation: BitbucketApiOperation, - status: Schema.Int, - responseBodyLength: NonNegativeInt, - retryAt: Schema.optional(Schema.Number), - }, -) { - get detail(): string { - return `Bitbucket returned HTTP ${this.status}.`; - } - - override get message(): string { - return `Bitbucket API failed in ${this.operation}: ${this.detail}`; - } -} - -export class BitbucketResponseBodyReadError extends Schema.TaggedError()( - "BitbucketResponseBodyReadError", - { - operation: BitbucketApiOperation, - status: Schema.Int, - retryAt: Schema.optional(Schema.Number), - cause: Schema.Defect(), - }, -) { - get detail(): string { - return `Bitbucket returned HTTP ${this.status}.`; - } - - override get message(): string { - return `Bitbucket API failed in ${this.operation}: ${this.detail}`; - } -} - -export class BitbucketResponseDecodeError extends Schema.TaggedError()( - "BitbucketResponseDecodeError", - { - operation: BitbucketApiOperation, - status: Schema.Int, - cause: Schema.Defect(), - }, -) { - get detail(): string { - return "Bitbucket returned invalid JSON for the requested resource."; - } - - override get message(): string { - return `Bitbucket API failed in ${this.operation}: ${this.detail}`; - } -} - -export class BitbucketRepositoryVcsResolveError extends Schema.TaggedError()( - "BitbucketRepositoryVcsResolveError", - { - cwd: Schema.String, - cause: Schema.Defect(), - }, -) { - get detail(): string { - return `Failed to resolve VCS repository for ${this.cwd}.`; - } - - override get message(): string { - return `Bitbucket API failed in resolveRepository: ${this.detail}`; - } -} - -export class BitbucketRepositoryRemotesListError extends Schema.TaggedError()( - "BitbucketRepositoryRemotesListError", - { - cwd: Schema.String, - cause: Schema.Defect(), - }, -) { - get detail(): string { - return `Failed to list remotes for ${this.cwd}.`; - } - - override get message(): string { - return `Bitbucket API failed in resolveRepository: ${this.detail}`; - } -} - -export class BitbucketRepositoryRemoteNotFoundError extends Schema.TaggedError()( - "BitbucketRepositoryRemoteNotFoundError", - { - cwd: Schema.String, - }, -) { - get detail(): string { - return `No Bitbucket repository remote was detected for ${this.cwd}.`; - } - - override get message(): string { - return `Bitbucket API failed in resolveRepository: ${this.detail}`; - } -} - -export class BitbucketPullRequestBodyReadError extends Schema.TaggedError()( - "BitbucketPullRequestBodyReadError", - { - cwd: Schema.String, - bodyFile: Schema.String, - cause: Schema.Defect(), - }, -) { - get detail(): string { - return `Failed to read pull request body file ${this.bodyFile}.`; - } - - override get message(): string { - return `Bitbucket API failed in createPullRequest: ${this.detail}`; - } -} - -export class BitbucketCheckoutError extends Schema.TaggedError()( - "BitbucketCheckoutError", - { - cwd: Schema.String, - reference: Schema.String, - cause: Schema.Defect(), - }, -) { - get detail(): string { - return "Failed to check out the Bitbucket pull request."; - } - - override get message(): string { - return `Bitbucket API failed in checkoutPullRequest: ${this.detail}`; - } -} - -/** - * A url that does not belong to the configured Bitbucket. Refused rather than followed, because - * the request carries the account's credentials and a url that came back in a response — a - * pagination cursor, or the target of a redirect — is not this server's to trust. - */ -export class BitbucketUntrustedUrlError extends Schema.TaggedError()( - "BitbucketUntrustedUrlError", - { - /** The host only. A rejected hop is often a signed url, whose query carries a credential. */ - host: Schema.String, - }, -) { - get detail(): string { - return `The response pointed at ${this.host}, outside the configured Bitbucket.`; - } - - override get message(): string { - return `Bitbucket API failed in request: ${this.detail}`; - } -} - -export const BitbucketApiError = Schema.Union([ - BitbucketUntrustedUrlError, - BitbucketRepositoryLocatorError, - BitbucketRequestError, - BitbucketResponseError, - BitbucketResponseBodyReadError, - BitbucketResponseDecodeError, - BitbucketRepositoryVcsResolveError, - BitbucketRepositoryRemotesListError, - BitbucketRepositoryRemoteNotFoundError, - BitbucketPullRequestBodyReadError, - BitbucketCheckoutError, -]); -export type BitbucketApiError = typeof BitbucketApiError.Type; -const isBitbucketApiError = Schema.is(BitbucketApiError); - -const RawBitbucketRepositorySchema = Schema.Struct({ - full_name: TrimmedNonEmptyString, - links: Schema.Struct({ - html: Schema.optional( - Schema.Struct({ - href: TrimmedNonEmptyString, - }), - ), - clone: Schema.optional( - Schema.Array( - Schema.Struct({ - name: TrimmedNonEmptyString, - href: TrimmedNonEmptyString, - }), - ), - ), - }), - mainbranch: Schema.optional( - Schema.NullOr( - Schema.Struct({ - name: TrimmedNonEmptyString, - }), - ), - ), -}); - -const RawBitbucketBranchingModelSchema = Schema.Struct({ - development: Schema.optional( - Schema.Struct({ - branch: Schema.optional( - Schema.NullOr( - Schema.Struct({ - name: Schema.optional(TrimmedNonEmptyString), - }), - ), - ), - is_valid: Schema.optional(Schema.Boolean), - name: Schema.optional(Schema.NullOr(Schema.String)), - use_mainbranch: Schema.optional(Schema.Boolean), - }), - ), -}); - -const BitbucketUserSchema = Schema.Struct({ - username: Schema.optional(TrimmedNonEmptyString), - display_name: Schema.optional(TrimmedNonEmptyString), - account_id: Schema.optional(TrimmedNonEmptyString), -}); - -export interface BitbucketRepositoryLocator { - readonly workspace: string; - readonly repoSlug: string; -} - -export class BitbucketApi extends Context.Service< - BitbucketApi, - { - readonly probeAuth: Effect.Effect; - - /** - * One authenticated request, returning the body verbatim. Bitbucket answers most endpoints - * with JSON and a few — a pull request diff, for one — with plain text, so the body is - * handed back undecoded for the caller to read as it sees fit. - */ - readonly request: (input: { - readonly method: "GET" | "POST" | "PUT" | "DELETE"; - /** - * A path below the API base, or a whole URL as a paged response reports its next page. - * A whole URL is refused unless it belongs to the configured Bitbucket. - */ - readonly url: string; - /** A JSON document, for the endpoints that take one. */ - readonly body?: string; - /** Response bytes to keep; past this the body comes back cut short and marked. */ - readonly maxBytes?: number; - }) => Effect.Effect<{ readonly body: string; readonly truncated: boolean }, BitbucketApiError>; - readonly listPullRequests: (input: { - readonly cwd: string; - readonly context?: SourceControlProvider.SourceControlProviderContext; - readonly headSelector: string; - readonly source?: SourceControlProvider.SourceControlRefSelector; - readonly state: "open" | "closed" | "merged" | "all"; - readonly limit?: number; - }) => Effect.Effect, BitbucketApiError>; - readonly getPullRequest: (input: { - readonly cwd: string; - readonly context?: SourceControlProvider.SourceControlProviderContext; - readonly reference: string; - }) => Effect.Effect; - readonly getRepositoryCloneUrls: (input: { - readonly cwd: string; - readonly context?: SourceControlProvider.SourceControlProviderContext; - readonly repository: string; - }) => Effect.Effect; - readonly createRepository: (input: { - readonly cwd: string; - readonly repository: string; - readonly visibility: SourceControlRepositoryVisibility; - }) => Effect.Effect; - readonly createPullRequest: (input: { - readonly cwd: string; - readonly context?: SourceControlProvider.SourceControlProviderContext; - readonly baseBranch: string; - readonly headSelector: string; - readonly source?: SourceControlProvider.SourceControlRefSelector; - readonly target?: SourceControlProvider.SourceControlRefSelector; - readonly title: string; - readonly bodyFile: string; - }) => Effect.Effect; - readonly getDefaultBranch: (input: { - readonly cwd: string; - readonly context?: SourceControlProvider.SourceControlProviderContext; - }) => Effect.Effect; - readonly checkoutPullRequest: (input: { - readonly cwd: string; - readonly context?: SourceControlProvider.SourceControlProviderContext; - readonly reference: string; - readonly force?: boolean; - }) => Effect.Effect; - } ->()("t3/sourceControl/BitbucketApi") {} - -function nonEmpty(value: string | undefined): Option.Option { - const trimmed = value?.trim(); - return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); -} - -function normalizeChangeRequestId(reference: string): string { - const trimmed = reference.trim().replace(/^#/, ""); - const urlMatch = /(?:pull-requests|pullrequests|pull-request|pull|pr)\/(\d+)(?:\D.*)?$/i.exec( - trimmed, - ); - return urlMatch?.[1] ?? trimmed; -} - -function sourceWorkspace(input: { - readonly headSelector: string; - readonly source?: SourceControlProvider.SourceControlRefSelector; -}): string | undefined { - if (input.source?.owner) return input.source.owner; - return SourceControlProvider.parseSourceControlOwnerRef(input.headSelector)?.owner; -} - -function toBitbucketStates(state: "open" | "closed" | "merged" | "all"): ReadonlyArray { - switch (state) { - case "open": - return ["OPEN"]; - case "closed": - return ["DECLINED", "SUPERSEDED"]; - case "merged": - return ["MERGED"]; - case "all": - return ["OPEN", "MERGED", "DECLINED", "SUPERSEDED"]; - } -} - -function bitbucketQueryString(filters: ReadonlyArray): string { - return filters.join(" AND "); -} - -function bitbucketStateFilter(states: ReadonlyArray): string { - return states.length === 1 - ? `state = "${states[0]}"` - : `(${states.map((state) => `state = "${state}"`).join(" OR ")})`; -} - -function parseBitbucketRepositorySlug(value: string): BitbucketRepositoryLocator | null { - const normalized = value.trim().replace(/\.git$/u, ""); - const parts = normalized.split("/").filter((part) => part.length > 0); - if (parts.length < 2) return null; - const workspace = parts.at(-2); - const repoSlug = parts.at(-1); - return workspace && repoSlug ? { workspace, repoSlug } : null; -} - -function requireRepositoryLocator( - repository: string, -): Effect.Effect { - const locator = parseBitbucketRepositorySlug(repository); - return locator - ? Effect.succeed(locator) - : Effect.fail( - new BitbucketRepositoryLocatorError({ - repository, - }), - ); -} - -function parseBitbucketRemoteUrl(remoteUrl: string): BitbucketRepositoryLocator | null { - const trimmed = remoteUrl.trim(); - const scpMatch = /^[a-zA-Z0-9._-]+@[^:/]+:(.+)$/.exec(trimmed); - if (scpMatch?.[1]) { - return parseBitbucketRepositorySlug(scpMatch[1]); - } - - try { - return parseBitbucketRepositorySlug(new URL(trimmed).pathname); - } catch { - return null; - } -} - -function normalizeRepositoryCloneUrls( - raw: typeof RawBitbucketRepositorySchema.Type, -): SourceControlRepositoryCloneUrls { - const httpClone = - raw.links.clone?.find((entry) => entry.name.toLowerCase() === "https")?.href ?? - raw.links.html?.href; - const sshClone = raw.links.clone?.find((entry) => entry.name.toLowerCase() === "ssh")?.href; - - return { - nameWithOwner: raw.full_name, - url: httpClone ?? raw.links.html?.href ?? raw.full_name, - sshUrl: sshClone ?? httpClone ?? raw.full_name, - }; -} - -function defaultChangeRequestTargetBranch(input: { - readonly repository: typeof RawBitbucketRepositorySchema.Type; - readonly branchingModel: typeof RawBitbucketBranchingModelSchema.Type | null; -}): string | null { - const repositoryMainBranch = input.repository.mainbranch?.name ?? null; - const development = input.branchingModel?.development; - if (!development || development.use_mainbranch === true || development.is_valid === false) { - return repositoryMainBranch; - } - - const developmentBranch = development.branch?.name?.trim() ?? development.name?.trim() ?? ""; - if (developmentBranch.length === 0 || developmentBranch === "null") { - return repositoryMainBranch; - } - - return developmentBranch; -} - -function shouldPreferSshRemote(originRemoteUrl: string | null): boolean { - if (!originRemoteUrl) return false; - return isSshRemoteUrl(originRemoteUrl); -} - -function selectCloneUrl(input: { - readonly cloneUrls: SourceControlRepositoryCloneUrls; - readonly originRemoteUrl: string | null; -}): string { - return shouldPreferSshRemote(input.originRemoteUrl) - ? input.cloneUrls.sshUrl - : input.cloneUrls.url; -} - -function checkoutBranchName(input: { - readonly pullRequestId: number; - readonly headBranch: string; - readonly isCrossRepository: boolean; -}): string { - if (!input.isCrossRepository) { - return input.headBranch; - } - - return `t3code/pr-${input.pullRequestId}/${sanitizeBranchFragment(input.headBranch)}`; -} - -function repositoryNameWithOwner( - repository: Schema.Schema.Type["source"]["repository"], -): string | null { - const fullName = repository?.full_name?.trim() ?? ""; - return fullName.length > 0 ? fullName : null; -} - -function repositoryOwnerName(repositoryName: string): string { - return repositoryName.split("/")[0]?.trim() || "bitbucket"; -} - -type BitbucketCredential = - | { readonly kind: "access-token"; readonly accessToken: string } - | { readonly kind: "api-token"; readonly email: string; readonly apiToken: string }; - -/** - * Visible ASCII only. A value the HTTP stack rejects makes it throw an error quoting the whole - * header, and that error travels to clients as a cause, so an unusable token is treated as unset. - */ -const HEADER_SAFE = /^[\x21-\x7e]+$/u; - -function credentialFrom(input: { - readonly accessToken: string; - readonly email: string; - readonly apiToken: string; -}): BitbucketCredential | null { - if (HEADER_SAFE.test(input.accessToken)) { - return { kind: "access-token", accessToken: input.accessToken }; - } - if (HEADER_SAFE.test(input.email) && HEADER_SAFE.test(input.apiToken)) { - return { kind: "api-token", email: input.email, apiToken: input.apiToken }; - } - return null; -} - -/** - * Credentials saved in settings win over the `T3CODE_BITBUCKET_*` environment variables, which - * stay as a fallback. Within each source the access token wins. - */ -function resolveCredential( - settings: BitbucketSettings, - env: Config.Success, -): BitbucketCredential | null { - return ( - credentialFrom(settings) ?? - credentialFrom({ - accessToken: Option.getOrElse(env.accessToken, () => ""), - email: Option.getOrElse(env.email, () => ""), - apiToken: Option.getOrElse(env.apiToken, () => ""), - }) - ); -} - -function authFromCredential(credential: BitbucketCredential | null): SourceControlProviderAuth { - if (credential?.kind === "access-token") { - return { - status: "unknown", - account: Option.none(), - host: Option.some("bitbucket.org"), - detail: Option.some("An access token is configured."), - }; - } - - if (credential?.kind === "api-token") { - return { - status: "unknown", - account: Option.some(credential.email), - host: Option.some("bitbucket.org"), - detail: Option.some("An API token is configured."), - }; - } - - return { - status: "unauthenticated", - account: Option.none(), - host: Option.some("bitbucket.org"), - detail: Option.some( - "Add a Bitbucket token in Settings → Source Control, or set the T3CODE_BITBUCKET_* environment variables on the server.", - ), - }; -} - -/** Null for anything that is not a url at all, which is never the configured Bitbucket. */ -function originOf(value: string): string | null { - try { - return new URL(value).origin; - } catch { - return null; - } -} - -function responseError( - operation: BitbucketApiOperation, - response: HttpClientResponse.HttpClientResponse, -): Effect.Effect { - // Bounded like any other body: an error response is no smaller than a successful one, and - // only its length is reported anyway. - return Effect.gen(function* () { - const now = yield* Clock.currentTimeMillis; - const retryAt = retryAtFromHeader(response.headers["retry-after"], now); - const collected = yield* collectUint8StreamText({ - stream: response.stream, - maxBytes: DEFAULT_MAX_RESPONSE_BYTES, - }).pipe( - Effect.mapError( - (cause) => - new BitbucketResponseBodyReadError({ - operation, - status: response.status, - retryAt, - cause, - }), - ), - ); - return yield* new BitbucketResponseError({ - operation, - status: response.status, - responseBodyLength: collected.text.length, - retryAt, - }); - }); -} - -/** @public Service construction is part of the canonical Effect module API. */ -export const make = Effect.gen(function* () { - const config = yield* BitbucketApiEnvConfig; - const serverSettings = yield* ServerSettings.ServerSettingsService; - const httpClient = yield* HttpClient.HttpClient; - const fileSystem = yield* FileSystem.FileSystem; - const git = yield* GitVcsDriver.GitVcsDriver; - const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry; - - const apiUrl = (path: string) => `${config.baseUrl.replace(/\/+$/u, "")}${path}`; - - // Read on every request so credentials saved in settings apply without a restart. - const currentCredential = serverSettings.getSettings.pipe( - Effect.map((settings) => resolveCredential(settings.bitbucket, config)), - Effect.catch((error) => - // No cause: a settings decode error can quote a hand-edited token. - Effect.logWarning("failed to read Bitbucket credentials from settings", { - operation: error.operation, - }).pipe(Effect.as(resolveCredential(DEFAULT_SERVER_SETTINGS.bitbucket, config))), - ), - ); - - const withAuth = (request: HttpClientRequest.HttpClientRequest) => - currentCredential.pipe( - Effect.map((credential) => - credential === null - ? request - : credential.kind === "access-token" - ? request.pipe(HttpClientRequest.bearerToken(credential.accessToken)) - : request.pipe(HttpClientRequest.basicAuth(credential.email, credential.apiToken)), - ), - ); - - const decodeResponse = ( - operation: BitbucketApiOperation, - schema: S, - response: HttpClientResponse.HttpClientResponse, - ): Effect.Effect => - HttpClientResponse.matchStatus({ - "2xx": (success) => - HttpClientResponse.schemaBodyJson(schema)(success).pipe( - Effect.mapError( - (cause) => - new BitbucketResponseDecodeError({ - operation, - status: success.status, - cause, - }), - ), - ), - orElse: (failed) => responseError(operation, failed), - })(response); - - const executeJson = ( - operation: BitbucketApiOperation, - request: HttpClientRequest.HttpClientRequest, - schema: S, - ): Effect.Effect => - withAuth(request.pipe(HttpClientRequest.acceptJson)).pipe( - Effect.flatMap(httpClient.execute), - Effect.mapError( - (cause) => - new BitbucketRequestError({ - operation, - cause, - }), - ), - Effect.flatMap((response) => decodeResponse(operation, schema, response)), - ); - - const resolveRepository = Effect.fn("BitbucketApi.resolveRepository")(function* (input: { - readonly cwd: string; - readonly context?: SourceControlProvider.SourceControlProviderContext; - readonly repository?: string; - }) { - if (input.repository !== undefined) { - return yield* requireRepositoryLocator(input.repository); - } - - const fromContext = - input.context?.provider.kind === "bitbucket" - ? parseBitbucketRemoteUrl(input.context.remoteUrl) - : null; - if (fromContext) return fromContext; - - const handle = yield* vcsRegistry.resolve({ cwd: input.cwd }).pipe( - Effect.mapError( - (cause) => - new BitbucketRepositoryVcsResolveError({ - cwd: input.cwd, - cause, - }), - ), - ); - const remotes = yield* handle.driver.listRemotes(input.cwd).pipe( - Effect.mapError( - (cause) => - new BitbucketRepositoryRemotesListError({ - cwd: input.cwd, - cause, - }), - ), - ); - - for (const remote of remotes.remotes) { - if (detectSourceControlProviderFromRemoteUrl(remote.url)?.kind !== "bitbucket") continue; - const parsed = parseBitbucketRemoteUrl(remote.url); - if (parsed) return parsed; - } - - return yield* new BitbucketRepositoryRemoteNotFoundError({ - cwd: input.cwd, - }); - }); - - const getRepositoryFromLocator = (repository: BitbucketRepositoryLocator) => - executeJson( - "getRepository", - HttpClientRequest.get( - apiUrl( - `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}`, - ), - ), - RawBitbucketRepositorySchema, - ); - - const getRepository = (input: { - readonly cwd: string; - readonly context?: SourceControlProvider.SourceControlProviderContext; - readonly repository?: string; - }) => resolveRepository(input).pipe(Effect.flatMap(getRepositoryFromLocator)); - - const getBranchingModelFromLocator = (repository: BitbucketRepositoryLocator) => - executeJson( - "getBranchingModel", - HttpClientRequest.get( - apiUrl( - `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/branching-model`, - ), - ), - RawBitbucketBranchingModelSchema, - ); - - const getRawPullRequestFromRepository = ( - repository: BitbucketRepositoryLocator, - reference: string, - ) => - executeJson( - "getPullRequest", - HttpClientRequest.get( - apiUrl( - `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests/${encodeURIComponent(normalizeChangeRequestId(reference))}`, - ), - ), - BitbucketPullRequestSchema, - ); - - const getRawPullRequest = (input: { - readonly cwd: string; - readonly context?: SourceControlProvider.SourceControlProviderContext; - readonly reference: string; - }) => - resolveRepository(input).pipe( - Effect.flatMap((repository) => getRawPullRequestFromRepository(repository, input.reference)), - ); - - const readConfigValueNullable = (cwd: string, key: string) => - git.readConfigValue(cwd, key).pipe(Effect.orElseSucceed(() => null)); - - const resolveCheckoutRemote = Effect.fn("BitbucketApi.resolveCheckoutRemote")(function* (input: { - readonly cwd: string; - readonly context?: SourceControlProvider.SourceControlProviderContext; - readonly destinationRepository: BitbucketRepositoryLocator; - readonly sourceRepositoryName: string; - readonly isCrossRepository: boolean; - }) { - if ( - input.context?.provider.kind === "bitbucket" && - !input.isCrossRepository && - parseBitbucketRemoteUrl(input.context.remoteUrl) !== null - ) { - return input.context.remoteName; - } - - if (!input.isCrossRepository) { - const remoteName = yield* git - .resolvePrimaryRemoteName(input.cwd) - .pipe(Effect.orElseSucceed(() => null)); - if (remoteName) return remoteName; - } - - const cloneUrls = yield* getRepository({ - cwd: input.cwd, - repository: input.sourceRepositoryName, - ...(input.context ? { context: input.context } : {}), - }).pipe(Effect.map(normalizeRepositoryCloneUrls)); - const originRemoteUrl = yield* readConfigValueNullable(input.cwd, "remote.origin.url"); - return yield* git.ensureRemote({ - cwd: input.cwd, - preferredName: input.isCrossRepository - ? repositoryOwnerName(input.sourceRepositoryName) - : input.destinationRepository.workspace, - url: selectCloneUrl({ cloneUrls, originRemoteUrl }), - }); - }); - - // A pull request's diff, diffstat and conflicts are served as redirects to a commit-range - // URL, and the client does not follow redirects unless asked. The hop stays on the same host, - // so the credentials travel with it. - /** - * The one host these credentials may be sent to. A url that came back inside a response — a - * pagination cursor, or the target of a redirect — is data, not instruction, so it is checked - * against this before the account's token travels with it. - */ - const apiOrigin = originOf(config.baseUrl); - - const trustedUrl = (value: string): string | null => { - if (!/^https?:\/\//u.test(value)) return apiUrl(value); - const origin = originOf(value); - return origin !== null && origin === apiOrigin ? value : null; - }; - - /** - * Redirects are followed here rather than by the client, which forwards every header to - * whatever host it is sent to. A pull request diff, diffstat and conflicts are all served as - * redirects, so they have to be followed — but only back to the same Bitbucket. - */ - const send = (input: { - readonly method: "GET" | "POST" | "PUT" | "DELETE"; - readonly url: string; - readonly body?: string; - readonly redirects: number; - }): Effect.Effect => { - const url = trustedUrl(input.url); - if (url === null) { - return Effect.fail( - new BitbucketUntrustedUrlError({ host: originOf(input.url) ?? "an unreadable url" }), - ); - } - const base = - input.method === "GET" - ? HttpClientRequest.get(url) - : input.method === "POST" - ? HttpClientRequest.post(url) - : input.method === "DELETE" - ? HttpClientRequest.make("DELETE")(url) - : HttpClientRequest.put(url); - // No `Accept: application/json`: the diff endpoints answer with a patch, not JSON. - const withBody = - input.body === undefined - ? base - : base.pipe(HttpClientRequest.bodyText(input.body, "application/json")); - return withAuth(withBody).pipe( - Effect.flatMap(httpClient.execute), - Effect.mapError( - (cause): BitbucketApiError => new BitbucketRequestError({ operation: "request", cause }), - ), - Effect.flatMap((response) => { - const location = response.headers.location; - if ( - response.status >= 300 && - response.status < 400 && - location !== undefined && - input.redirects < MAX_REDIRECTS - ) { - return send({ - ...input, - url: new URL(location, url).toString(), - redirects: input.redirects + 1, - }); - } - return Effect.succeed(response); - }), - ); - }; - - const request: BitbucketApi["Service"]["request"] = (input) => - send({ ...input, redirects: 0 }).pipe( - Effect.flatMap((response) => - HttpClientResponse.matchStatus({ - // Read through the body stream rather than `text`, so an oversized diff is stopped - // as it arrives instead of being materialized whole and then cut. The same collector - // the process runner bounds command output with. - "2xx": (success) => - collectUint8StreamText({ - stream: success.stream, - maxBytes: input.maxBytes ?? DEFAULT_MAX_RESPONSE_BYTES, - }).pipe( - Effect.mapError( - (cause) => - new BitbucketResponseBodyReadError({ - operation: "request", - status: success.status, - cause, - }), - ), - Effect.map((collected) => ({ - body: collected.text, - truncated: collected.truncated, - })), - ), - orElse: (failed) => responseError("request", failed), - })(response), - ), - ); - - return BitbucketApi.of({ - request, - probeAuth: executeJson( - "probeAuth", - HttpClientRequest.get(apiUrl("/user")), - BitbucketUserSchema, - ).pipe( - Effect.map((user) => ({ - status: "authenticated" as const, - account: nonEmpty(user.username ?? user.display_name ?? user.account_id), - host: Option.some("bitbucket.org"), - detail: Option.none(), - })), - Effect.catch(() => currentCredential.pipe(Effect.map(authFromCredential))), - ), - listPullRequests: (input) => - resolveRepository(input).pipe( - Effect.flatMap((repository) => { - const states = toBitbucketStates(input.state); - const query: Record> = { - pagelen: String(Math.max(1, Math.min(input.limit ?? 20, 50))), - sort: "-updated_on", - q: bitbucketQueryString([ - `source.branch.name = "${SourceControlProvider.sourceBranch(input).replaceAll('"', '\\"')}"`, - bitbucketStateFilter(states), - ]), - state: states, - }; - - return executeJson( - "listPullRequests", - HttpClientRequest.get( - apiUrl( - `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests`, - ), - { urlParams: query }, - ), - BitbucketPullRequestListSchema, - ); - }), - Effect.map((list) => list.values.map(normalizeBitbucketPullRequestRecord)), - ), - getPullRequest: (input) => - getRawPullRequest(input).pipe(Effect.map(normalizeBitbucketPullRequestRecord)), - getRepositoryCloneUrls: (input) => - getRepository(input).pipe(Effect.map(normalizeRepositoryCloneUrls)), - createRepository: (input) => - requireRepositoryLocator(input.repository).pipe( - Effect.flatMap((repository) => - executeJson( - "createRepository", - HttpClientRequest.post( - apiUrl( - `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}`, - ), - ).pipe( - HttpClientRequest.bodyJsonUnsafe({ - scm: "git", - is_private: input.visibility === "private", - }), - ), - RawBitbucketRepositorySchema, - ), - ), - Effect.map(normalizeRepositoryCloneUrls), - ), - createPullRequest: (input) => - Effect.gen(function* () { - const repository = yield* resolveRepository(input); - const description = yield* fileSystem.readFileString(input.bodyFile).pipe( - Effect.mapError( - (cause) => - new BitbucketPullRequestBodyReadError({ - cwd: input.cwd, - bodyFile: input.bodyFile, - cause, - }), - ), - ); - const sourceOwner = sourceWorkspace(input); - const body = { - title: input.title, - description, - source: { - branch: { - name: SourceControlProvider.sourceBranch(input), - }, - ...(sourceOwner - ? { - repository: { - full_name: `${sourceOwner}/${input.source?.repository ?? repository.repoSlug}`, - }, - } - : {}), - }, - destination: { - branch: { - name: input.target?.refName ?? input.baseBranch, - }, - }, - }; - - yield* executeJson( - "createPullRequest", - HttpClientRequest.post( - apiUrl( - `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests`, - ), - ).pipe(HttpClientRequest.bodyJsonUnsafe(body)), - BitbucketPullRequestSchema, - ); - }), - getDefaultBranch: (input) => - resolveRepository(input).pipe( - Effect.flatMap((locator) => - Effect.all( - { - repository: getRepositoryFromLocator(locator), - branchingModel: getBranchingModelFromLocator(locator).pipe( - Effect.orElseSucceed( - (): typeof RawBitbucketBranchingModelSchema.Type | null => null, - ), - ), - }, - { concurrency: "unbounded" }, - ), - ), - Effect.map(defaultChangeRequestTargetBranch), - ), - // Bitbucket Cloud pull requests are Git-backed and Bitbucket does not provide - // an official checkout CLI. This provider-local path uses GitVcsDriver as a - // narrow escape hatch to materialize Bitbucket PR refs. Do not generalize this - // as the source-control provider model: if we support non-Git-compatible - // hosting providers or native JJ/Sapling checkout flows, move this into a - // VCS-specific change-request checkout capability. - checkoutPullRequest: (input) => - Effect.gen(function* () { - const destinationRepository = yield* resolveRepository(input); - const pullRequest = yield* getRawPullRequestFromRepository( - destinationRepository, - input.reference, - ); - const destinationRepositoryName = - repositoryNameWithOwner(pullRequest.destination.repository) ?? - `${destinationRepository.workspace}/${destinationRepository.repoSlug}`; - const sourceRepositoryName = - repositoryNameWithOwner(pullRequest.source.repository) ?? destinationRepositoryName; - const isCrossRepository = sourceRepositoryName !== destinationRepositoryName; - const remoteName = yield* resolveCheckoutRemote({ - cwd: input.cwd, - destinationRepository, - sourceRepositoryName, - isCrossRepository, - ...(input.context ? { context: input.context } : {}), - }); - const remoteBranch = pullRequest.source.branch.name; - const localBranch = checkoutBranchName({ - pullRequestId: pullRequest.id, - headBranch: remoteBranch, - isCrossRepository, - }); - const localBranchNames = yield* git.listLocalBranchNames(input.cwd); - const localBranchExists = localBranchNames.includes(localBranch); - - if (input.force === true || !localBranchExists) { - yield* git.fetchRemoteBranch({ - cwd: input.cwd, - remoteName, - remoteBranch, - localBranch, - }); - } else { - yield* git.fetchRemoteTrackingBranch({ - cwd: input.cwd, - remoteName, - remoteBranch, - }); - } - - yield* git.setBranchUpstream({ - cwd: input.cwd, - branch: localBranch, - remoteName, - remoteBranch, - }); - yield* Effect.scoped(git.switchRef({ cwd: input.cwd, refName: localBranch })); - }).pipe( - Effect.mapError((cause) => - isBitbucketApiError(cause) - ? cause - : new BitbucketCheckoutError({ - cwd: input.cwd, - reference: input.reference, - cause, - }), - ), - ), - }); -}); - -export const layer = Layer.effect(BitbucketApi, make); diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts deleted file mode 100644 index 52a15547c57329cb2930887e7e88596576727e0f..0000000000000000000000000000000000000000 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { assert, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; - -import * as BitbucketApi from "./BitbucketApi.ts"; -import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; - -function makeProvider(bitbucket: Partial) { - return BitbucketSourceControlProvider.make.pipe( - Effect.provide(Layer.mock(BitbucketApi.BitbucketApi)(bitbucket)), - ); -} - -it.effect("maps Bitbucket PR summaries into provider-neutral change requests", () => - Effect.gen(function* () { - const provider = yield* makeProvider({ - getPullRequest: () => - Effect.succeed({ - number: 42, - title: "Add Bitbucket provider", - url: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42", - baseRefName: "main", - headRefName: "feature/source-control", - state: "open", - updatedAt: Option.none(), - isCrossRepository: true, - headRepositoryNameWithOwner: "fork/t3code", - headRepositoryOwnerLogin: "fork", - }), - }); - - const changeRequest = yield* provider.getChangeRequest({ - cwd: "/repo", - reference: "42", - }); - - assert.deepStrictEqual(changeRequest, { - provider: "bitbucket", - number: 42, - title: "Add Bitbucket provider", - url: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42", - baseRefName: "main", - headRefName: "feature/source-control", - state: "open", - updatedAt: Option.none(), - isCrossRepository: true, - headRepositoryNameWithOwner: "fork/t3code", - headRepositoryOwnerLogin: "fork", - }); - }), -); - -it.effect("adds repository context while retaining Bitbucket API causes", () => - Effect.gen(function* () { - const upstreamCause = new Error("raw upstream failure"); - const cause = new BitbucketApi.BitbucketRequestError({ - operation: "getRepository", - cause: upstreamCause, - }); - const provider = yield* makeProvider({ - getRepositoryCloneUrls: () => Effect.fail(cause), - }); - - const error = yield* provider - .getRepositoryCloneUrls({ cwd: "/repo", repository: "owner/repo" }) - .pipe(Effect.flip); - - assert.deepStrictEqual( - { - provider: error.provider, - operation: error.operation, - command: error.command, - cwd: error.cwd, - repository: error.repository, - detail: error.detail, - }, - { - provider: "bitbucket", - operation: "getRepositoryCloneUrls", - command: undefined, - cwd: "/repo", - repository: "owner/repo", - detail: "Failed to get repository clone URLs.", - }, - ); - assert.strictEqual(error.cause, cause); - assert.equal(error.message.includes(upstreamCause.message), false); - }), -); - -it.effect("lists Bitbucket PRs through provider-neutral input names", () => - Effect.gen(function* () { - let listInput: Parameters[0] | null = - null; - const provider = yield* makeProvider({ - listPullRequests: (input) => { - listInput = input; - return Effect.succeed([]); - }, - }); - - yield* provider.listChangeRequests({ - cwd: "/repo", - headSelector: "feature/provider", - state: "all", - limit: 10, - }); - - assert.deepStrictEqual(listInput, { - cwd: "/repo", - headSelector: "feature/provider", - state: "all", - limit: 10, - }); - }), -); - -it.effect("creates Bitbucket PRs through provider-neutral input names", () => - Effect.gen(function* () { - let createInput: - | Parameters[0] - | null = null; - const provider = yield* makeProvider({ - createPullRequest: (input) => { - createInput = input; - return Effect.void; - }, - }); - - yield* provider.createChangeRequest({ - cwd: "/repo", - baseRefName: "main", - headSelector: "owner:feature/provider", - title: "Provider PR", - bodyFile: "/tmp/body.md", - }); - - assert.deepStrictEqual(createInput, { - cwd: "/repo", - baseBranch: "main", - headSelector: "owner:feature/provider", - source: { - owner: "owner", - refName: "feature/provider", - }, - title: "Provider PR", - bodyFile: "/tmp/body.md", - }); - }), -); diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts deleted file mode 100644 index b9afd60bc389657b4d7c0c04decbab9747a69a0c..0000000000000000000000000000000000000000 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts +++ /dev/null @@ -1,198 +0,0 @@ -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; - -import * as BitbucketApi from "./BitbucketApi.ts"; -import type { NormalizedBitbucketPullRequestRecord } from "./bitbucketPullRequests.ts"; -import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; -import type { SourceControlApiDiscoverySpec } from "@t3tools/source-control-core/server/discovery"; - -function toChangeRequest(summary: NormalizedBitbucketPullRequestRecord): ChangeRequest { - return { - provider: "bitbucket", - number: summary.number, - title: summary.title, - url: summary.url, - baseRefName: summary.baseRefName, - headRefName: summary.headRefName, - state: summary.state, - ...(summary.isDraft === true ? { isDraft: true } : {}), - updatedAt: summary.updatedAt ?? Option.none(), - ...(summary.isCrossRepository !== undefined - ? { isCrossRepository: summary.isCrossRepository } - : {}), - ...(summary.headRepositoryNameWithOwner !== undefined - ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner } - : {}), - ...(summary.headRepositoryOwnerLogin !== undefined - ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin } - : {}), - }; -} - -export const make = Effect.gen(function* () { - const bitbucket = yield* BitbucketApi.BitbucketApi; - - return SourceControlProvider.SourceControlProvider.of({ - kind: "bitbucket", - listChangeRequests: (input) => { - const source = SourceControlProvider.sourceControlRefFromInput(input); - return bitbucket - .listPullRequests({ - cwd: input.cwd, - ...(input.context ? { context: input.context } : {}), - headSelector: input.headSelector, - ...(source ? { source } : {}), - state: input.state, - ...(input.limit !== undefined ? { limit: input.limit } : {}), - }) - .pipe( - Effect.map((items) => items.map(toChangeRequest)), - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "bitbucket", - operation: "listChangeRequests", - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.headSelector, - ), - detail: "Failed to list change requests.", - cause: error, - }), - ), - ); - }, - getChangeRequest: (input) => - bitbucket.getPullRequest(input).pipe( - Effect.map(toChangeRequest), - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "bitbucket", - operation: "getChangeRequest", - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.reference, - ), - detail: "Failed to get change request.", - cause: error, - }), - ), - ), - createChangeRequest: (input) => { - const source = SourceControlProvider.sourceControlRefFromInput(input); - return bitbucket - .createPullRequest({ - cwd: input.cwd, - ...(input.context ? { context: input.context } : {}), - baseBranch: input.baseRefName, - headSelector: input.headSelector, - ...(source ? { source } : {}), - ...(input.target ? { target: input.target } : {}), - title: input.title, - bodyFile: input.bodyFile, - }) - .pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "bitbucket", - operation: "createChangeRequest", - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.headSelector, - ), - detail: "Failed to create change request.", - cause: error, - }), - ), - ); - }, - getRepositoryCloneUrls: (input) => - bitbucket.getRepositoryCloneUrls(input).pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "bitbucket", - operation: "getRepositoryCloneUrls", - cwd: input.cwd, - repository: SourceControlProvider.transportSafeSourceControlErrorValue( - input.repository, - ), - detail: "Failed to get repository clone URLs.", - cause: error, - }), - ), - ), - createRepository: (input) => - bitbucket.createRepository(input).pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "bitbucket", - operation: "createRepository", - cwd: input.cwd, - repository: SourceControlProvider.transportSafeSourceControlErrorValue( - input.repository, - ), - detail: "Failed to create repository.", - cause: error, - }), - ), - ), - getDefaultBranch: (input) => - bitbucket - .getDefaultBranch({ - cwd: input.cwd, - ...(input.context ? { context: input.context } : {}), - }) - .pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "bitbucket", - operation: "getDefaultBranch", - cwd: input.cwd, - detail: "Failed to get default branch.", - cause: error, - }), - ), - ), - checkoutChangeRequest: (input) => - bitbucket - .checkoutPullRequest({ - cwd: input.cwd, - ...(input.context ? { context: input.context } : {}), - reference: input.reference, - ...(input.force !== undefined ? { force: input.force } : {}), - }) - .pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "bitbucket", - operation: "checkoutChangeRequest", - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.reference, - ), - detail: "Failed to check out change request.", - cause: error, - }), - ), - ), - }); -}); - -export const makeDiscovery = Effect.gen(function* () { - const bitbucket = yield* BitbucketApi.BitbucketApi; - - return { - type: "api", - kind: "bitbucket", - label: "Bitbucket", - installHint: "Add a Bitbucket token in Settings → Source Control.", - probeAuth: bitbucket.probeAuth, - } satisfies SourceControlApiDiscoverySpec; -}); diff --git a/apps/server/src/sourceControl/ServerSourceControlHost.ts b/apps/server/src/sourceControl/ServerSourceControlHost.ts index f458795ab76e90c5c69b41cc3cf23fdd0a991669..a21e7d5fe089ff4a567f83cf9bc1d0dc8e8ceb0a 100644 --- a/apps/server/src/sourceControl/ServerSourceControlHost.ts +++ b/apps/server/src/sourceControl/ServerSourceControlHost.ts @@ -9,6 +9,8 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ServerSettings from "../serverSettings.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; export const layer = Layer.effect( @@ -16,9 +18,23 @@ export const layer = Layer.effect( Effect.gen(function* () { const serverSettings = yield* ServerSettings.ServerSettingsService; const process = yield* VcsProcess.VcsProcess; + const git = yield* GitVcsDriver.GitVcsDriver; + const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry; return SourceControlHost.SourceControlHost.of({ settings: { get: serverSettings.getSettings }, process: { run: process.run }, + git: { + remotes: (cwd) => + vcsRegistry.resolve({ cwd }).pipe(Effect.map((handle) => handle.driver.listRemotes(cwd))), + readConfigValue: git.readConfigValue, + resolvePrimaryRemoteName: git.resolvePrimaryRemoteName, + ensureRemote: git.ensureRemote, + listLocalBranchNames: git.listLocalBranchNames, + fetchRemoteBranch: git.fetchRemoteBranch, + fetchRemoteTrackingBranch: git.fetchRemoteTrackingBranch, + setBranchUpstream: git.setBranchUpstream, + switchRef: git.switchRef, + }, }); }), ); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index c87d731953da9812cd4360ae850deadc347df3c8..3cda7d31d9fb14a6a78385c95304b8bb1b178f5c 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -18,7 +18,8 @@ import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli"; import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli"; -import * as BitbucketApi from "./BitbucketApi.ts"; +import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; +import * as BitbucketPullRequestApi from "@t3tools/source-control-bitbucket/server/BitbucketPullRequestApi"; import * as GitHubApi from "./GitHubApi.ts"; import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; @@ -43,6 +44,7 @@ const layerSourceControlProviderRegistryTest = (input: { Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({}), Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket), + Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({}), ServerSettings.ServerSettingsService.layerTest(), Layer.mock(GitHubApi.GitHubApi)({}), Layer.mock(GitVcsDriver.GitVcsDriver)({}), @@ -53,9 +55,11 @@ const layerSourceControlProviderRegistryTest = (input: { Layer.mock(VcsProcess.VcsProcess)(input.process), ServerSourceControlHost.layer.pipe( Layer.provide( - Layer.merge( + Layer.mergeAll( Layer.mock(VcsProcess.VcsProcess)(input.process), ServerSettings.ServerSettingsService.layerTest(), + Layer.mock(GitVcsDriver.GitVcsDriver)({}), + Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}), ), ), ), @@ -395,6 +399,7 @@ it.effect( SourceControlHost.SourceControlHost, SourceControlHost.SourceControlHost.of({ settings: host.settings, + git: host.git, process: { run: (input) => { if (input.args[0] !== "fetch") return git.run(input); @@ -529,6 +534,8 @@ it.effect( ServerSourceControlHost.layer.pipe( Layer.provideMerge(VcsProcess.layer), Layer.provideMerge(ServerSettings.ServerSettingsService.layerTest()), + Layer.provideMerge(Layer.mock(GitVcsDriver.GitVcsDriver)({})), + Layer.provideMerge(Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({})), Layer.provideMerge(NodeServices.layer), ), ), diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index 582848b38a4b65a3a52bcba421ccc4f2a7e30bf5..b16ccfa44e732cfb0bfb261812a1aeaa592b005d 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -15,7 +15,8 @@ import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli"; import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli"; -import * as BitbucketApi from "./BitbucketApi.ts"; +import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; +import * as BitbucketPullRequestApi from "@t3tools/source-control-bitbucket/server/BitbucketPullRequestApi"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as GitHubApi from "./GitHubApi.ts"; import * as GitHubCredentials from "./GitHubCredentials.ts"; @@ -101,12 +102,18 @@ function makeRegistry(input: { layerProcess, ServerSourceControlHost.layer.pipe( Layer.provide( - Layer.merge(layerProcess, ServerSettings.ServerSettingsService.layerTest()), + Layer.mergeAll( + layerProcess, + layerRegistry, + ServerSettings.ServerSettingsService.layerTest(), + Layer.mock(GitVcsDriver.GitVcsDriver)({}), + ), ), ), Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({}), Layer.mock(BitbucketApi.BitbucketApi)({}), + Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({}), ServerSettings.ServerSettingsService.layerTest(), Layer.mock(GitHubApi.GitHubApi)({ // No GitHub credential unless a test supplies one, so custom hosts stay unclaimed. diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index 9eeae3d935f7a80ac606a0005a594b12f593290e..607e1b957b9bbfcce50fd90c3f51bdb2b01a4496 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -11,7 +11,6 @@ import { import type { SourceControlProviderKind } from "@t3tools/contracts"; import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; -import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; import * as BuiltInDrivers from "./builtInDrivers.ts"; import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; @@ -305,8 +304,6 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit export const make = Effect.gen(function* () { const github = yield* GitHubSourceControlProvider.make; const githubDiscovery = yield* GitHubSourceControlProvider.makeDiscovery; - const bitbucket = yield* BitbucketSourceControlProvider.make; - const bitbucketDiscovery = yield* BitbucketSourceControlProvider.makeDiscovery; const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) => driver.make.pipe( Effect.map((instance): SourceControlProviderRegistration => ({ @@ -324,11 +321,6 @@ export const make = Effect.gen(function* () { provider: github, discovery: githubDiscovery, }, - { - kind: "bitbucket", - provider: bitbucket, - discovery: bitbucketDiscovery, - }, ...drivers, ]; return yield* makeWithProviders( diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 8aba02284403b021faff895ecccea956df65f2c7..c051ab47f42f5399dbefdecdf18cdc48b8b5955c 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -13,8 +13,8 @@ import { GitCommandError, SourceControlProviderError } from "@t3tools/contracts" import * as ServerConfig from "../config.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; -import * as BitbucketApi from "./BitbucketApi.ts"; -import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; +import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; +import * as BitbucketSourceControlProvider from "@t3tools/source-control-bitbucket/server/BitbucketSourceControlProvider"; import type * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; import * as SourceControlRepositoryService from "./SourceControlRepositoryService.ts"; diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index 4a4d1fec6434a517023c9d6193a951285b8f5094..1bbca1e2b3d501b20fb40d0d12d3cc9b7b859b65 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -27,7 +27,7 @@ import { type GitCloneProgressLine, } from "../project/gitCloneProgress.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; -import * as BitbucketApi from "./BitbucketApi.ts"; +import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError); const isSourceControlProviderError = Schema.is(SourceControlProviderError); diff --git a/apps/server/src/sourceControl/bitbucketPullRequests.ts b/apps/server/src/sourceControl/bitbucketPullRequests.ts deleted file mode 100644 index 3b07334a80409db06faf79ae89893086b2cd8b37..0000000000000000000000000000000000000000 --- a/apps/server/src/sourceControl/bitbucketPullRequests.ts +++ /dev/null @@ -1,109 +0,0 @@ -import * as DateTime from "effect/DateTime"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; - -export interface NormalizedBitbucketPullRequestRecord { - readonly number: number; - readonly title: string; - readonly url: string; - readonly baseRefName: string; - readonly headRefName: string; - readonly state: "open" | "closed" | "merged"; - readonly isDraft?: boolean; - readonly updatedAt: Option.Option; - readonly isCrossRepository?: boolean; - readonly headRepositoryNameWithOwner?: string | null; - readonly headRepositoryOwnerLogin?: string | null; -} - -export const BitbucketRepositoryRefSchema = Schema.Struct({ - full_name: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), - workspace: Schema.optional( - Schema.NullOr( - Schema.Struct({ - slug: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), - }), - ), - ), -}); - -export const BitbucketPullRequestBranchSchema = Schema.Struct({ - repository: Schema.optional(Schema.NullOr(BitbucketRepositoryRefSchema)), - branch: Schema.Struct({ - name: TrimmedNonEmptyString, - }), -}); - -export const BitbucketPullRequestSchema = Schema.Struct({ - id: PositiveInt, - title: TrimmedNonEmptyString, - state: Schema.optional(Schema.NullOr(Schema.String)), - draft: Schema.optional(Schema.Boolean), - updated_on: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), - links: Schema.Struct({ - html: Schema.Struct({ - href: TrimmedNonEmptyString, - }), - }), - source: BitbucketPullRequestBranchSchema, - destination: BitbucketPullRequestBranchSchema, -}); - -export const BitbucketPullRequestListSchema = Schema.Struct({ - values: Schema.Array(BitbucketPullRequestSchema), - next: Schema.optional(TrimmedNonEmptyString), -}); - -function trimOptionalString(value: string | null | undefined): string | null { - const trimmed = value?.trim() ?? ""; - return trimmed.length > 0 ? trimmed : null; -} - -function repositoryOwner(repository: Schema.Schema.Type) { - return ( - trimOptionalString(repository.workspace?.slug) ?? - (repository.full_name?.includes("/") ? (repository.full_name.split("/")[0] ?? null) : null) - ); -} - -function normalizeBitbucketPullRequestState(state: string | null | undefined) { - switch (state?.trim().toUpperCase()) { - case "MERGED": - return "merged" as const; - case "DECLINED": - case "SUPERSEDED": - return "closed" as const; - case "OPEN": - default: - return "open" as const; - } -} - -export function normalizeBitbucketPullRequestRecord( - raw: Schema.Schema.Type, -): NormalizedBitbucketPullRequestRecord { - const headRepositoryNameWithOwner = trimOptionalString(raw.source.repository?.full_name); - const baseRepositoryNameWithOwner = trimOptionalString(raw.destination.repository?.full_name); - const headRepositoryOwnerLogin = raw.source.repository - ? repositoryOwner(raw.source.repository) - : null; - const isCrossRepository = - headRepositoryNameWithOwner !== null && - baseRepositoryNameWithOwner !== null && - headRepositoryNameWithOwner !== baseRepositoryNameWithOwner; - - return { - number: raw.id, - title: raw.title, - url: raw.links.html.href, - baseRefName: raw.destination.branch.name, - headRefName: raw.source.branch.name, - state: normalizeBitbucketPullRequestState(raw.state), - ...(raw.draft === true ? { isDraft: true } : {}), - updatedAt: raw.updated_on ?? Option.none(), - ...(isCrossRepository ? { isCrossRepository: true } : {}), - ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}), - ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}), - }; -} diff --git a/apps/server/src/sourceControl/builtInDrivers.ts b/apps/server/src/sourceControl/builtInDrivers.ts index 83ee1d35e8f5f91a9a897e04a827f1ada4dca455..9a972e9e5f00e0043da471c4cfd5ca457ed55d53 100644 --- a/apps/server/src/sourceControl/builtInDrivers.ts +++ b/apps/server/src/sourceControl/builtInDrivers.ts @@ -10,6 +10,9 @@ import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli"; import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli"; import * as AzureDevOpsDriver from "@t3tools/source-control-azure-devops/server/driver"; +import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; +import * as BitbucketPullRequestApi from "@t3tools/source-control-bitbucket/server/BitbucketPullRequestApi"; +import * as BitbucketDriver from "@t3tools/source-control-bitbucket/server/driver"; import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; import * as ForgejoDriver from "@t3tools/source-control-forgejo/server/driver"; import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; @@ -20,7 +23,12 @@ import * as Layer from "effect/Layer"; import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; -const drivers = [GitLabDriver.driver, AzureDevOpsDriver.driver, ForgejoDriver.driver]; +const drivers = [ + GitLabDriver.driver, + AzureDevOpsDriver.driver, + BitbucketDriver.driver, + ForgejoDriver.driver, +]; /** Every service a built-in driver's `make` needs; the server's layers must provide them all. */ export type BuiltInSourceControlDriversEnv = @@ -33,6 +41,7 @@ export const BUILT_IN_SOURCE_CONTROL_DRIVERS: ReadonlyArray< /** The services the built-in drivers' packages own, plus the host port they all run against. */ export const layer = Layer.mergeAll( AzureDevOpsPullRequestCli.layer.pipe(Layer.provideMerge(AzureDevOpsCli.layer)), + BitbucketPullRequestApi.layer.pipe(Layer.provideMerge(BitbucketApi.layer)), ForgejoCli.layer, GitLabPullRequestCli.layer.pipe(Layer.provideMerge(GitLabCli.layer)), ).pipe(Layer.provideMerge(ServerSourceControlHost.layer)); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 19ed2600ba5e739daf3375cf1234363b77484c93..a3b239ffaa6230b22b9f12bb346aaaa93265abda 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -229,7 +229,6 @@ import * as SqlClient from "effect/sql/SqlClient"; import * as PullRequestSyncReactor from "./orchestration-v2/PullRequestSyncReactor.ts"; import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; -import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; import * as GitHubApi from "./sourceControl/GitHubApi.ts"; import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts"; import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; @@ -3202,7 +3201,6 @@ export const layer = Layer.unwrap( SourceControlProviderRegistry.layer.pipe( Layer.provide( Layer.mergeAll( - BitbucketApi.layer, GitHubApi.layerWithDependencies, SourceControlBuiltInDrivers.layer, ), diff --git a/docs/internals/pull-request-file-revisions.md b/docs/internals/pull-request-file-revisions.md index 8fd0dfdb69db12746bbcab551fee15af12369540..dfc03a436aefc013db367b7249b96d69a68fb9fe 100644 --- a/docs/internals/pull-request-file-revisions.md +++ b/docs/internals/pull-request-file-revisions.md @@ -27,7 +27,7 @@ What "never got to look at" is belongs to the host, and the contract cannot know - [Azure DevOps](../../packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts) reads every version off one iteration listing, so a change too long to follow to its end leaves the paths past that point out. -- [Bitbucket](../../apps/server/src/pullRequest/BitbucketPullRequestApi.ts) reads them off the +- [Bitbucket](../../packages/source-control-bitbucket/src/server/BitbucketPullRequestApi.ts) reads them off the pull request's own patch, the only place it states a file's version, so a patch cut short at the byte ceiling leaves the paths past the cut out. - [GitLab](../../packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts) asks in batches, so a batch diff --git a/package.json b/package.json index 4e0ba35ecc7e09b2990be8fe7e7ea12d0de6a349..764f45f27c975d9992af63f15e587d6001c88243 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "tc": "vp run -r --concurrency-limit 2 typecheck", "lint": "vp lint --report-unused-disable-directives", "knip": "knip --preprocessor ./scripts/knip-schemas.ts", - "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --workspace packages/source-control-core --workspace packages/source-control-azure-devops --workspace packages/source-control-forgejo --workspace packages/source-control-gitlab --workspace packages/source-control-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", + "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --workspace packages/source-control-core --workspace packages/source-control-azure-devops --workspace packages/source-control-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", "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", "lint:mobile": "node scripts/mobile-native-static-check.ts", "test": "vp run -r test", diff --git a/packages/source-control-bitbucket/package.json b/packages/source-control-bitbucket/package.json new file mode 100644 index 0000000000000000000000000000000000000000..c878093ef54e260cd824130d42bdc5862cf1178f --- /dev/null +++ b/packages/source-control-bitbucket/package.json @@ -0,0 +1,51 @@ +{ + "name": "@t3tools/source-control-bitbucket", + "private": true, + "type": "module", + "exports": { + "./server/BitbucketApi": { + "types": "./src/server/BitbucketApi.ts", + "import": "./src/server/BitbucketApi.ts" + }, + "./server/BitbucketPullRequestApi": { + "types": "./src/server/BitbucketPullRequestApi.ts", + "import": "./src/server/BitbucketPullRequestApi.ts" + }, + "./server/bitbucketPullRequestJson": { + "types": "./src/server/bitbucketPullRequestJson.ts", + "import": "./src/server/bitbucketPullRequestJson.ts" + }, + "./server/BitbucketPullRequestProvider": { + "types": "./src/server/BitbucketPullRequestProvider.ts", + "import": "./src/server/BitbucketPullRequestProvider.ts" + }, + "./server/bitbucketPullRequests": { + "types": "./src/server/bitbucketPullRequests.ts", + "import": "./src/server/bitbucketPullRequests.ts" + }, + "./server/BitbucketSourceControlProvider": { + "types": "./src/server/BitbucketSourceControlProvider.ts", + "import": "./src/server/BitbucketSourceControlProvider.ts" + }, + "./server/driver": { + "types": "./src/server/driver.ts", + "import": "./src/server/driver.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vp test run --config ../../vite.config.ts --dir ." + }, + "dependencies": { + "@t3tools/contracts": "workspace:*", + "@t3tools/provider-core": "workspace:*", + "@t3tools/shared": "workspace:*", + "@t3tools/source-control-core": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@types/node": "catalog:", + "vite-plus": "catalog:" + } +} diff --git a/packages/source-control-bitbucket/src/server/BitbucketApi.ts b/packages/source-control-bitbucket/src/server/BitbucketApi.ts new file mode 100644 index 0000000000000000000000000000000000000000..3df66527baeeaf92676e12f4a507d705447d82ac --- /dev/null +++ b/packages/source-control-bitbucket/src/server/BitbucketApi.ts @@ -0,0 +1,1160 @@ +import * as Clock from "effect/Clock"; +import * as Config from "effect/Config"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { + DEFAULT_SERVER_SETTINGS, + NonNegativeInt, + TrimmedNonEmptyString, + type BitbucketSettings, + type SourceControlProviderAuth, + type SourceControlRepositoryCloneUrls, + type SourceControlRepositoryVisibility, +} from "@t3tools/contracts"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; +import { sanitizeBranchFragment } from "@t3tools/shared/git"; +import { + detectSourceControlProviderFromRemoteUrl, + isSshRemoteUrl, +} from "@t3tools/shared/sourceControl"; + +import { + BitbucketPullRequestListSchema, + BitbucketPullRequestSchema, + normalizeBitbucketPullRequestRecord, + type NormalizedBitbucketPullRequestRecord, +} from "./bitbucketPullRequests.ts"; +import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText"; +import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; +import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; +import { retryAtFromHeader } from "@t3tools/source-control-core/server/SourceControlRateLimit"; + +const DEFAULT_API_BASE_URL = "https://api.bitbucket.org/2.0"; +/** A response body past this is cut short, so one huge diff cannot exhaust the server. */ +const DEFAULT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; +/** Bitbucket redirects a diff once; this leaves room without following a chain forever. */ +const MAX_REDIRECTS = 3; + +const BitbucketApiEnvConfig = Config.all({ + baseUrl: Config.String("T3CODE_BITBUCKET_API_BASE_URL").pipe( + Config.withDefault(DEFAULT_API_BASE_URL), + ), + accessToken: Config.String("T3CODE_BITBUCKET_ACCESS_TOKEN").pipe(Config.option), + email: Config.String("T3CODE_BITBUCKET_EMAIL").pipe(Config.option), + apiToken: Config.String("T3CODE_BITBUCKET_API_TOKEN").pipe(Config.option), +}); + +const BitbucketApiOperation = Schema.Literals([ + "resolveRepository", + "getRepository", + "getBranchingModel", + "getPullRequest", + "listPullRequests", + "createRepository", + "createPullRequest", + "probeAuth", + "checkoutPullRequest", + // The raw escape hatch. Callers name their own operation in their own error, the way the + // pull request wrappers do on top of `gh` and `glab`. + "request", +]); +type BitbucketApiOperation = typeof BitbucketApiOperation.Type; + +export class BitbucketRepositoryLocatorError extends Schema.TaggedError()( + "BitbucketRepositoryLocatorError", + { + repository: Schema.String, + }, +) { + static readonly detail = "Bitbucket repositories must be specified as workspace/repository."; + + get detail(): string { + return BitbucketRepositoryLocatorError.detail; + } + + override get message(): string { + return `Bitbucket API failed: ${this.detail}`; + } +} + +export class BitbucketRequestError extends Schema.TaggedError()( + "BitbucketRequestError", + { + operation: BitbucketApiOperation, + cause: Schema.Defect(), + }, +) { + get detail(): string { + return "Failed to send the Bitbucket request."; + } + + override get message(): string { + return `Bitbucket API failed in ${this.operation}: ${this.detail}`; + } +} + +export class BitbucketResponseError extends Schema.TaggedError()( + "BitbucketResponseError", + { + operation: BitbucketApiOperation, + status: Schema.Int, + responseBodyLength: NonNegativeInt, + retryAt: Schema.optional(Schema.Number), + }, +) { + get detail(): string { + return `Bitbucket returned HTTP ${this.status}.`; + } + + override get message(): string { + return `Bitbucket API failed in ${this.operation}: ${this.detail}`; + } +} + +export class BitbucketResponseBodyReadError extends Schema.TaggedError()( + "BitbucketResponseBodyReadError", + { + operation: BitbucketApiOperation, + status: Schema.Int, + retryAt: Schema.optional(Schema.Number), + cause: Schema.Defect(), + }, +) { + get detail(): string { + return `Bitbucket returned HTTP ${this.status}.`; + } + + override get message(): string { + return `Bitbucket API failed in ${this.operation}: ${this.detail}`; + } +} + +export class BitbucketResponseDecodeError extends Schema.TaggedError()( + "BitbucketResponseDecodeError", + { + operation: BitbucketApiOperation, + status: Schema.Int, + cause: Schema.Defect(), + }, +) { + get detail(): string { + return "Bitbucket returned invalid JSON for the requested resource."; + } + + override get message(): string { + return `Bitbucket API failed in ${this.operation}: ${this.detail}`; + } +} + +export class BitbucketRepositoryVcsResolveError extends Schema.TaggedError()( + "BitbucketRepositoryVcsResolveError", + { + cwd: Schema.String, + cause: Schema.Defect(), + }, +) { + get detail(): string { + return `Failed to resolve VCS repository for ${this.cwd}.`; + } + + override get message(): string { + return `Bitbucket API failed in resolveRepository: ${this.detail}`; + } +} + +export class BitbucketRepositoryRemotesListError extends Schema.TaggedError()( + "BitbucketRepositoryRemotesListError", + { + cwd: Schema.String, + cause: Schema.Defect(), + }, +) { + get detail(): string { + return `Failed to list remotes for ${this.cwd}.`; + } + + override get message(): string { + return `Bitbucket API failed in resolveRepository: ${this.detail}`; + } +} + +export class BitbucketRepositoryRemoteNotFoundError extends Schema.TaggedError()( + "BitbucketRepositoryRemoteNotFoundError", + { + cwd: Schema.String, + }, +) { + get detail(): string { + return `No Bitbucket repository remote was detected for ${this.cwd}.`; + } + + override get message(): string { + return `Bitbucket API failed in resolveRepository: ${this.detail}`; + } +} + +export class BitbucketPullRequestBodyReadError extends Schema.TaggedError()( + "BitbucketPullRequestBodyReadError", + { + cwd: Schema.String, + bodyFile: Schema.String, + cause: Schema.Defect(), + }, +) { + get detail(): string { + return `Failed to read pull request body file ${this.bodyFile}.`; + } + + override get message(): string { + return `Bitbucket API failed in createPullRequest: ${this.detail}`; + } +} + +export class BitbucketCheckoutError extends Schema.TaggedError()( + "BitbucketCheckoutError", + { + cwd: Schema.String, + reference: Schema.String, + cause: Schema.Defect(), + }, +) { + get detail(): string { + return "Failed to check out the Bitbucket pull request."; + } + + override get message(): string { + return `Bitbucket API failed in checkoutPullRequest: ${this.detail}`; + } +} + +/** + * A url that does not belong to the configured Bitbucket. Refused rather than followed, because + * the request carries the account's credentials and a url that came back in a response — a + * pagination cursor, or the target of a redirect — is not this server's to trust. + */ +export class BitbucketUntrustedUrlError extends Schema.TaggedError()( + "BitbucketUntrustedUrlError", + { + /** The host only. A rejected hop is often a signed url, whose query carries a credential. */ + host: Schema.String, + }, +) { + get detail(): string { + return `The response pointed at ${this.host}, outside the configured Bitbucket.`; + } + + override get message(): string { + return `Bitbucket API failed in request: ${this.detail}`; + } +} + +export const BitbucketApiError = Schema.Union([ + BitbucketUntrustedUrlError, + BitbucketRepositoryLocatorError, + BitbucketRequestError, + BitbucketResponseError, + BitbucketResponseBodyReadError, + BitbucketResponseDecodeError, + BitbucketRepositoryVcsResolveError, + BitbucketRepositoryRemotesListError, + BitbucketRepositoryRemoteNotFoundError, + BitbucketPullRequestBodyReadError, + BitbucketCheckoutError, +]); +export type BitbucketApiError = typeof BitbucketApiError.Type; +const isBitbucketApiError = Schema.is(BitbucketApiError); + +const RawBitbucketRepositorySchema = Schema.Struct({ + full_name: TrimmedNonEmptyString, + links: Schema.Struct({ + html: Schema.optional( + Schema.Struct({ + href: TrimmedNonEmptyString, + }), + ), + clone: Schema.optional( + Schema.Array( + Schema.Struct({ + name: TrimmedNonEmptyString, + href: TrimmedNonEmptyString, + }), + ), + ), + }), + mainbranch: Schema.optional( + Schema.NullOr( + Schema.Struct({ + name: TrimmedNonEmptyString, + }), + ), + ), +}); + +const RawBitbucketBranchingModelSchema = Schema.Struct({ + development: Schema.optional( + Schema.Struct({ + branch: Schema.optional( + Schema.NullOr( + Schema.Struct({ + name: Schema.optional(TrimmedNonEmptyString), + }), + ), + ), + is_valid: Schema.optional(Schema.Boolean), + name: Schema.optional(Schema.NullOr(Schema.String)), + use_mainbranch: Schema.optional(Schema.Boolean), + }), + ), +}); + +const BitbucketUserSchema = Schema.Struct({ + username: Schema.optional(TrimmedNonEmptyString), + display_name: Schema.optional(TrimmedNonEmptyString), + account_id: Schema.optional(TrimmedNonEmptyString), +}); + +export interface BitbucketRepositoryLocator { + readonly workspace: string; + readonly repoSlug: string; +} + +export class BitbucketApi extends Context.Service< + BitbucketApi, + { + readonly probeAuth: Effect.Effect; + + /** + * One authenticated request, returning the body verbatim. Bitbucket answers most endpoints + * with JSON and a few — a pull request diff, for one — with plain text, so the body is + * handed back undecoded for the caller to read as it sees fit. + */ + readonly request: (input: { + readonly method: "GET" | "POST" | "PUT" | "DELETE"; + /** + * A path below the API base, or a whole URL as a paged response reports its next page. + * A whole URL is refused unless it belongs to the configured Bitbucket. + */ + readonly url: string; + /** A JSON document, for the endpoints that take one. */ + readonly body?: string; + /** Response bytes to keep; past this the body comes back cut short and marked. */ + readonly maxBytes?: number; + }) => Effect.Effect<{ readonly body: string; readonly truncated: boolean }, BitbucketApiError>; + readonly listPullRequests: (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly headSelector: string; + readonly source?: SourceControlProvider.SourceControlRefSelector; + readonly state: "open" | "closed" | "merged" | "all"; + readonly limit?: number; + }) => Effect.Effect, BitbucketApiError>; + readonly getPullRequest: (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly reference: string; + }) => Effect.Effect; + readonly getRepositoryCloneUrls: (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly repository: string; + }) => Effect.Effect; + readonly createRepository: (input: { + readonly cwd: string; + readonly repository: string; + readonly visibility: SourceControlRepositoryVisibility; + }) => Effect.Effect; + readonly createPullRequest: (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly baseBranch: string; + readonly headSelector: string; + readonly source?: SourceControlProvider.SourceControlRefSelector; + readonly target?: SourceControlProvider.SourceControlRefSelector; + readonly title: string; + readonly bodyFile: string; + }) => Effect.Effect; + readonly getDefaultBranch: (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + }) => Effect.Effect; + readonly checkoutPullRequest: (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly reference: string; + readonly force?: boolean; + }) => Effect.Effect; + } +>()("@t3tools/source-control-bitbucket/server/BitbucketApi") {} + +function nonEmpty(value: string | undefined): Option.Option { + const trimmed = value?.trim(); + return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); +} + +function normalizeChangeRequestId(reference: string): string { + const trimmed = reference.trim().replace(/^#/, ""); + const urlMatch = /(?:pull-requests|pullrequests|pull-request|pull|pr)\/(\d+)(?:\D.*)?$/i.exec( + trimmed, + ); + return urlMatch?.[1] ?? trimmed; +} + +function sourceWorkspace(input: { + readonly headSelector: string; + readonly source?: SourceControlProvider.SourceControlRefSelector; +}): string | undefined { + if (input.source?.owner) return input.source.owner; + return SourceControlProvider.parseSourceControlOwnerRef(input.headSelector)?.owner; +} + +function toBitbucketStates(state: "open" | "closed" | "merged" | "all"): ReadonlyArray { + switch (state) { + case "open": + return ["OPEN"]; + case "closed": + return ["DECLINED", "SUPERSEDED"]; + case "merged": + return ["MERGED"]; + case "all": + return ["OPEN", "MERGED", "DECLINED", "SUPERSEDED"]; + } +} + +function bitbucketQueryString(filters: ReadonlyArray): string { + return filters.join(" AND "); +} + +function bitbucketStateFilter(states: ReadonlyArray): string { + return states.length === 1 + ? `state = "${states[0]}"` + : `(${states.map((state) => `state = "${state}"`).join(" OR ")})`; +} + +function parseBitbucketRepositorySlug(value: string): BitbucketRepositoryLocator | null { + const normalized = value.trim().replace(/\.git$/u, ""); + const parts = normalized.split("/").filter((part) => part.length > 0); + if (parts.length < 2) return null; + const workspace = parts.at(-2); + const repoSlug = parts.at(-1); + return workspace && repoSlug ? { workspace, repoSlug } : null; +} + +function requireRepositoryLocator( + repository: string, +): Effect.Effect { + const locator = parseBitbucketRepositorySlug(repository); + return locator + ? Effect.succeed(locator) + : Effect.fail( + new BitbucketRepositoryLocatorError({ + repository, + }), + ); +} + +function parseBitbucketRemoteUrl(remoteUrl: string): BitbucketRepositoryLocator | null { + const trimmed = remoteUrl.trim(); + const scpMatch = /^[a-zA-Z0-9._-]+@[^:/]+:(.+)$/.exec(trimmed); + if (scpMatch?.[1]) { + return parseBitbucketRepositorySlug(scpMatch[1]); + } + + try { + return parseBitbucketRepositorySlug(new URL(trimmed).pathname); + } catch { + return null; + } +} + +function normalizeRepositoryCloneUrls( + raw: typeof RawBitbucketRepositorySchema.Type, +): SourceControlRepositoryCloneUrls { + const httpClone = + raw.links.clone?.find((entry) => entry.name.toLowerCase() === "https")?.href ?? + raw.links.html?.href; + const sshClone = raw.links.clone?.find((entry) => entry.name.toLowerCase() === "ssh")?.href; + + return { + nameWithOwner: raw.full_name, + url: httpClone ?? raw.links.html?.href ?? raw.full_name, + sshUrl: sshClone ?? httpClone ?? raw.full_name, + }; +} + +function defaultChangeRequestTargetBranch(input: { + readonly repository: typeof RawBitbucketRepositorySchema.Type; + readonly branchingModel: typeof RawBitbucketBranchingModelSchema.Type | null; +}): string | null { + const repositoryMainBranch = input.repository.mainbranch?.name ?? null; + const development = input.branchingModel?.development; + if (!development || development.use_mainbranch === true || development.is_valid === false) { + return repositoryMainBranch; + } + + const developmentBranch = development.branch?.name?.trim() ?? development.name?.trim() ?? ""; + if (developmentBranch.length === 0 || developmentBranch === "null") { + return repositoryMainBranch; + } + + return developmentBranch; +} + +function shouldPreferSshRemote(originRemoteUrl: string | null): boolean { + if (!originRemoteUrl) return false; + return isSshRemoteUrl(originRemoteUrl); +} + +function selectCloneUrl(input: { + readonly cloneUrls: SourceControlRepositoryCloneUrls; + readonly originRemoteUrl: string | null; +}): string { + return shouldPreferSshRemote(input.originRemoteUrl) + ? input.cloneUrls.sshUrl + : input.cloneUrls.url; +} + +function checkoutBranchName(input: { + readonly pullRequestId: number; + readonly headBranch: string; + readonly isCrossRepository: boolean; +}): string { + if (!input.isCrossRepository) { + return input.headBranch; + } + + return `t3code/pr-${input.pullRequestId}/${sanitizeBranchFragment(input.headBranch)}`; +} + +function repositoryNameWithOwner( + repository: Schema.Schema.Type["source"]["repository"], +): string | null { + const fullName = repository?.full_name?.trim() ?? ""; + return fullName.length > 0 ? fullName : null; +} + +function repositoryOwnerName(repositoryName: string): string { + return repositoryName.split("/")[0]?.trim() || "bitbucket"; +} + +type BitbucketCredential = + | { readonly kind: "access-token"; readonly accessToken: string } + | { readonly kind: "api-token"; readonly email: string; readonly apiToken: string }; + +/** + * Visible ASCII only. A value the HTTP stack rejects makes it throw an error quoting the whole + * header, and that error travels to clients as a cause, so an unusable token is treated as unset. + */ +const HEADER_SAFE = /^[\x21-\x7e]+$/u; + +function credentialFrom(input: { + readonly accessToken: string; + readonly email: string; + readonly apiToken: string; +}): BitbucketCredential | null { + if (HEADER_SAFE.test(input.accessToken)) { + return { kind: "access-token", accessToken: input.accessToken }; + } + if (HEADER_SAFE.test(input.email) && HEADER_SAFE.test(input.apiToken)) { + return { kind: "api-token", email: input.email, apiToken: input.apiToken }; + } + return null; +} + +/** + * Credentials saved in settings win over the `T3CODE_BITBUCKET_*` environment variables, which + * stay as a fallback. Within each source the access token wins. + */ +function resolveCredential( + settings: BitbucketSettings, + env: Config.Success, +): BitbucketCredential | null { + return ( + credentialFrom(settings) ?? + credentialFrom({ + accessToken: Option.getOrElse(env.accessToken, () => ""), + email: Option.getOrElse(env.email, () => ""), + apiToken: Option.getOrElse(env.apiToken, () => ""), + }) + ); +} + +function authFromCredential(credential: BitbucketCredential | null): SourceControlProviderAuth { + if (credential?.kind === "access-token") { + return { + status: "unknown", + account: Option.none(), + host: Option.some("bitbucket.org"), + detail: Option.some("An access token is configured."), + }; + } + + if (credential?.kind === "api-token") { + return { + status: "unknown", + account: Option.some(credential.email), + host: Option.some("bitbucket.org"), + detail: Option.some("An API token is configured."), + }; + } + + return { + status: "unauthenticated", + account: Option.none(), + host: Option.some("bitbucket.org"), + detail: Option.some( + "Add a Bitbucket token in Settings → Source Control, or set the T3CODE_BITBUCKET_* environment variables on the server.", + ), + }; +} + +/** Null for anything that is not a url at all, which is never the configured Bitbucket. */ +function originOf(value: string): string | null { + try { + return new URL(value).origin; + } catch { + return null; + } +} + +function responseError( + operation: BitbucketApiOperation, + response: HttpClientResponse.HttpClientResponse, +): Effect.Effect { + // Bounded like any other body: an error response is no smaller than a successful one, and + // only its length is reported anyway. + return Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + const retryAt = retryAtFromHeader(response.headers["retry-after"], now); + const collected = yield* collectUint8StreamText({ + stream: response.stream, + maxBytes: DEFAULT_MAX_RESPONSE_BYTES, + }).pipe( + Effect.mapError( + (cause) => + new BitbucketResponseBodyReadError({ + operation, + status: response.status, + retryAt, + cause, + }), + ), + ); + return yield* new BitbucketResponseError({ + operation, + status: response.status, + responseBodyLength: collected.text.length, + retryAt, + }); + }); +} + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const config = yield* BitbucketApiEnvConfig; + const host = yield* SourceControlHost.SourceControlHost; + const git = host.git; + const httpClient = yield* HttpClient.HttpClient; + const fileSystem = yield* FileSystem.FileSystem; + + const apiUrl = (path: string) => `${config.baseUrl.replace(/\/+$/u, "")}${path}`; + + // Read on every request so credentials saved in settings apply without a restart. + const currentCredential = host.settings.get.pipe( + Effect.map((settings) => resolveCredential(settings.bitbucket, config)), + Effect.catch((error) => + // No cause: a settings decode error can quote a hand-edited token. + Effect.logWarning("failed to read Bitbucket credentials from settings", { + operation: error.operation, + }).pipe(Effect.as(resolveCredential(DEFAULT_SERVER_SETTINGS.bitbucket, config))), + ), + ); + + const withAuth = (request: HttpClientRequest.HttpClientRequest) => + currentCredential.pipe( + Effect.map((credential) => + credential === null + ? request + : credential.kind === "access-token" + ? request.pipe(HttpClientRequest.bearerToken(credential.accessToken)) + : request.pipe(HttpClientRequest.basicAuth(credential.email, credential.apiToken)), + ), + ); + + const decodeResponse = ( + operation: BitbucketApiOperation, + schema: S, + response: HttpClientResponse.HttpClientResponse, + ): Effect.Effect => + HttpClientResponse.matchStatus({ + "2xx": (success) => + HttpClientResponse.schemaBodyJson(schema)(success).pipe( + Effect.mapError( + (cause) => + new BitbucketResponseDecodeError({ + operation, + status: success.status, + cause, + }), + ), + ), + orElse: (failed) => responseError(operation, failed), + })(response); + + const executeJson = ( + operation: BitbucketApiOperation, + request: HttpClientRequest.HttpClientRequest, + schema: S, + ): Effect.Effect => + withAuth(request.pipe(HttpClientRequest.acceptJson)).pipe( + Effect.flatMap(httpClient.execute), + Effect.mapError( + (cause) => + new BitbucketRequestError({ + operation, + cause, + }), + ), + Effect.flatMap((response) => decodeResponse(operation, schema, response)), + ); + + const resolveRepository = Effect.fn("BitbucketApi.resolveRepository")(function* (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly repository?: string; + }) { + if (input.repository !== undefined) { + return yield* requireRepositoryLocator(input.repository); + } + + const fromContext = + input.context?.provider.kind === "bitbucket" + ? parseBitbucketRemoteUrl(input.context.remoteUrl) + : null; + if (fromContext) return fromContext; + + const listRemotes = yield* git.remotes(input.cwd).pipe( + Effect.mapError( + (cause) => + new BitbucketRepositoryVcsResolveError({ + cwd: input.cwd, + cause, + }), + ), + ); + const remotes = yield* listRemotes.pipe( + Effect.mapError( + (cause) => + new BitbucketRepositoryRemotesListError({ + cwd: input.cwd, + cause, + }), + ), + ); + + for (const remote of remotes.remotes) { + if (detectSourceControlProviderFromRemoteUrl(remote.url)?.kind !== "bitbucket") continue; + const parsed = parseBitbucketRemoteUrl(remote.url); + if (parsed) return parsed; + } + + return yield* new BitbucketRepositoryRemoteNotFoundError({ + cwd: input.cwd, + }); + }); + + const getRepositoryFromLocator = (repository: BitbucketRepositoryLocator) => + executeJson( + "getRepository", + HttpClientRequest.get( + apiUrl( + `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}`, + ), + ), + RawBitbucketRepositorySchema, + ); + + const getRepository = (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly repository?: string; + }) => resolveRepository(input).pipe(Effect.flatMap(getRepositoryFromLocator)); + + const getBranchingModelFromLocator = (repository: BitbucketRepositoryLocator) => + executeJson( + "getBranchingModel", + HttpClientRequest.get( + apiUrl( + `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/branching-model`, + ), + ), + RawBitbucketBranchingModelSchema, + ); + + const getRawPullRequestFromRepository = ( + repository: BitbucketRepositoryLocator, + reference: string, + ) => + executeJson( + "getPullRequest", + HttpClientRequest.get( + apiUrl( + `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests/${encodeURIComponent(normalizeChangeRequestId(reference))}`, + ), + ), + BitbucketPullRequestSchema, + ); + + const getRawPullRequest = (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly reference: string; + }) => + resolveRepository(input).pipe( + Effect.flatMap((repository) => getRawPullRequestFromRepository(repository, input.reference)), + ); + + const readConfigValueNullable = (cwd: string, key: string) => + git.readConfigValue(cwd, key).pipe(Effect.orElseSucceed(() => null)); + + const resolveCheckoutRemote = Effect.fn("BitbucketApi.resolveCheckoutRemote")(function* (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly destinationRepository: BitbucketRepositoryLocator; + readonly sourceRepositoryName: string; + readonly isCrossRepository: boolean; + }) { + if ( + input.context?.provider.kind === "bitbucket" && + !input.isCrossRepository && + parseBitbucketRemoteUrl(input.context.remoteUrl) !== null + ) { + return input.context.remoteName; + } + + if (!input.isCrossRepository) { + const remoteName = yield* git + .resolvePrimaryRemoteName(input.cwd) + .pipe(Effect.orElseSucceed(() => null)); + if (remoteName) return remoteName; + } + + const cloneUrls = yield* getRepository({ + cwd: input.cwd, + repository: input.sourceRepositoryName, + ...(input.context ? { context: input.context } : {}), + }).pipe(Effect.map(normalizeRepositoryCloneUrls)); + const originRemoteUrl = yield* readConfigValueNullable(input.cwd, "remote.origin.url"); + return yield* git.ensureRemote({ + cwd: input.cwd, + preferredName: input.isCrossRepository + ? repositoryOwnerName(input.sourceRepositoryName) + : input.destinationRepository.workspace, + url: selectCloneUrl({ cloneUrls, originRemoteUrl }), + }); + }); + + // A pull request's diff, diffstat and conflicts are served as redirects to a commit-range + // URL, and the client does not follow redirects unless asked. The hop stays on the same host, + // so the credentials travel with it. + /** + * The one host these credentials may be sent to. A url that came back inside a response — a + * pagination cursor, or the target of a redirect — is data, not instruction, so it is checked + * against this before the account's token travels with it. + */ + const apiOrigin = originOf(config.baseUrl); + + const trustedUrl = (value: string): string | null => { + if (!/^https?:\/\//u.test(value)) return apiUrl(value); + const origin = originOf(value); + return origin !== null && origin === apiOrigin ? value : null; + }; + + /** + * Redirects are followed here rather than by the client, which forwards every header to + * whatever host it is sent to. A pull request diff, diffstat and conflicts are all served as + * redirects, so they have to be followed — but only back to the same Bitbucket. + */ + const send = (input: { + readonly method: "GET" | "POST" | "PUT" | "DELETE"; + readonly url: string; + readonly body?: string; + readonly redirects: number; + }): Effect.Effect => { + const url = trustedUrl(input.url); + if (url === null) { + return Effect.fail( + new BitbucketUntrustedUrlError({ host: originOf(input.url) ?? "an unreadable url" }), + ); + } + const base = + input.method === "GET" + ? HttpClientRequest.get(url) + : input.method === "POST" + ? HttpClientRequest.post(url) + : input.method === "DELETE" + ? HttpClientRequest.make("DELETE")(url) + : HttpClientRequest.put(url); + // No `Accept: application/json`: the diff endpoints answer with a patch, not JSON. + const withBody = + input.body === undefined + ? base + : base.pipe(HttpClientRequest.bodyText(input.body, "application/json")); + return withAuth(withBody).pipe( + Effect.flatMap(httpClient.execute), + Effect.mapError( + (cause): BitbucketApiError => new BitbucketRequestError({ operation: "request", cause }), + ), + Effect.flatMap((response) => { + const location = response.headers.location; + if ( + response.status >= 300 && + response.status < 400 && + location !== undefined && + input.redirects < MAX_REDIRECTS + ) { + return send({ + ...input, + url: new URL(location, url).toString(), + redirects: input.redirects + 1, + }); + } + return Effect.succeed(response); + }), + ); + }; + + const request: BitbucketApi["Service"]["request"] = (input) => + send({ ...input, redirects: 0 }).pipe( + Effect.flatMap((response) => + HttpClientResponse.matchStatus({ + // Read through the body stream rather than `text`, so an oversized diff is stopped + // as it arrives instead of being materialized whole and then cut. The same collector + // the process runner bounds command output with. + "2xx": (success) => + collectUint8StreamText({ + stream: success.stream, + maxBytes: input.maxBytes ?? DEFAULT_MAX_RESPONSE_BYTES, + }).pipe( + Effect.mapError( + (cause) => + new BitbucketResponseBodyReadError({ + operation: "request", + status: success.status, + cause, + }), + ), + Effect.map((collected) => ({ + body: collected.text, + truncated: collected.truncated, + })), + ), + orElse: (failed) => responseError("request", failed), + })(response), + ), + ); + + return BitbucketApi.of({ + request, + probeAuth: executeJson( + "probeAuth", + HttpClientRequest.get(apiUrl("/user")), + BitbucketUserSchema, + ).pipe( + Effect.map((user) => ({ + status: "authenticated" as const, + account: nonEmpty(user.username ?? user.display_name ?? user.account_id), + host: Option.some("bitbucket.org"), + detail: Option.none(), + })), + Effect.catch(() => currentCredential.pipe(Effect.map(authFromCredential))), + ), + listPullRequests: (input) => + resolveRepository(input).pipe( + Effect.flatMap((repository) => { + const states = toBitbucketStates(input.state); + const query: Record> = { + pagelen: String(Math.max(1, Math.min(input.limit ?? 20, 50))), + sort: "-updated_on", + q: bitbucketQueryString([ + `source.branch.name = "${SourceControlProvider.sourceBranch(input).replaceAll('"', '\\"')}"`, + bitbucketStateFilter(states), + ]), + state: states, + }; + + return executeJson( + "listPullRequests", + HttpClientRequest.get( + apiUrl( + `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests`, + ), + { urlParams: query }, + ), + BitbucketPullRequestListSchema, + ); + }), + Effect.map((list) => list.values.map(normalizeBitbucketPullRequestRecord)), + ), + getPullRequest: (input) => + getRawPullRequest(input).pipe(Effect.map(normalizeBitbucketPullRequestRecord)), + getRepositoryCloneUrls: (input) => + getRepository(input).pipe(Effect.map(normalizeRepositoryCloneUrls)), + createRepository: (input) => + requireRepositoryLocator(input.repository).pipe( + Effect.flatMap((repository) => + executeJson( + "createRepository", + HttpClientRequest.post( + apiUrl( + `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}`, + ), + ).pipe( + HttpClientRequest.bodyJsonUnsafe({ + scm: "git", + is_private: input.visibility === "private", + }), + ), + RawBitbucketRepositorySchema, + ), + ), + Effect.map(normalizeRepositoryCloneUrls), + ), + createPullRequest: (input) => + Effect.gen(function* () { + const repository = yield* resolveRepository(input); + const description = yield* fileSystem.readFileString(input.bodyFile).pipe( + Effect.mapError( + (cause) => + new BitbucketPullRequestBodyReadError({ + cwd: input.cwd, + bodyFile: input.bodyFile, + cause, + }), + ), + ); + const sourceOwner = sourceWorkspace(input); + const body = { + title: input.title, + description, + source: { + branch: { + name: SourceControlProvider.sourceBranch(input), + }, + ...(sourceOwner + ? { + repository: { + full_name: `${sourceOwner}/${input.source?.repository ?? repository.repoSlug}`, + }, + } + : {}), + }, + destination: { + branch: { + name: input.target?.refName ?? input.baseBranch, + }, + }, + }; + + yield* executeJson( + "createPullRequest", + HttpClientRequest.post( + apiUrl( + `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests`, + ), + ).pipe(HttpClientRequest.bodyJsonUnsafe(body)), + BitbucketPullRequestSchema, + ); + }), + getDefaultBranch: (input) => + resolveRepository(input).pipe( + Effect.flatMap((locator) => + Effect.all( + { + repository: getRepositoryFromLocator(locator), + branchingModel: getBranchingModelFromLocator(locator).pipe( + Effect.orElseSucceed( + (): typeof RawBitbucketBranchingModelSchema.Type | null => null, + ), + ), + }, + { concurrency: "unbounded" }, + ), + ), + Effect.map(defaultChangeRequestTargetBranch), + ), + // Bitbucket Cloud pull requests are Git-backed and Bitbucket does not provide + // an official checkout CLI. This provider-local path uses GitVcsDriver as a + // narrow escape hatch to materialize Bitbucket PR refs. Do not generalize this + // as the source-control provider model: if we support non-Git-compatible + // hosting providers or native JJ/Sapling checkout flows, move this into a + // VCS-specific change-request checkout capability. + checkoutPullRequest: (input) => + Effect.gen(function* () { + const destinationRepository = yield* resolveRepository(input); + const pullRequest = yield* getRawPullRequestFromRepository( + destinationRepository, + input.reference, + ); + const destinationRepositoryName = + repositoryNameWithOwner(pullRequest.destination.repository) ?? + `${destinationRepository.workspace}/${destinationRepository.repoSlug}`; + const sourceRepositoryName = + repositoryNameWithOwner(pullRequest.source.repository) ?? destinationRepositoryName; + const isCrossRepository = sourceRepositoryName !== destinationRepositoryName; + const remoteName = yield* resolveCheckoutRemote({ + cwd: input.cwd, + destinationRepository, + sourceRepositoryName, + isCrossRepository, + ...(input.context ? { context: input.context } : {}), + }); + const remoteBranch = pullRequest.source.branch.name; + const localBranch = checkoutBranchName({ + pullRequestId: pullRequest.id, + headBranch: remoteBranch, + isCrossRepository, + }); + const localBranchNames = yield* git.listLocalBranchNames(input.cwd); + const localBranchExists = localBranchNames.includes(localBranch); + + if (input.force === true || !localBranchExists) { + yield* git.fetchRemoteBranch({ + cwd: input.cwd, + remoteName, + remoteBranch, + localBranch, + }); + } else { + yield* git.fetchRemoteTrackingBranch({ + cwd: input.cwd, + remoteName, + remoteBranch, + }); + } + + yield* git.setBranchUpstream({ + cwd: input.cwd, + branch: localBranch, + remoteName, + remoteBranch, + }); + yield* Effect.scoped(git.switchRef({ cwd: input.cwd, refName: localBranch })); + }).pipe( + Effect.mapError((cause) => + isBitbucketApiError(cause) + ? cause + : new BitbucketCheckoutError({ + cwd: input.cwd, + reference: input.reference, + cause, + }), + ), + ), + }); +}); + +export const layer = Layer.effect(BitbucketApi, make); diff --git a/packages/source-control-bitbucket/src/server/BitbucketPullRequestApi.test.ts b/packages/source-control-bitbucket/src/server/BitbucketPullRequestApi.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..8d8feaba81503f75921a123f24c8f6510edf83e0 --- /dev/null +++ b/packages/source-control-bitbucket/src/server/BitbucketPullRequestApi.test.ts @@ -0,0 +1,1127 @@ +import { afterEach, assert, expect, it, vi } from "@effect/vitest"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; + +import * as BitbucketApi from "./BitbucketApi.ts"; +import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts"; + +const mockedRequest = vi.fn(); + +const layer = it.layer( + BitbucketPullRequestApi.layer.pipe( + Layer.provide( + Layer.mock(BitbucketApi.BitbucketApi)({ + request: mockedRequest, + }), + ), + ), +); + +/** The shape `request` answers with: a body plus whether it had to be cut short. */ +function response(body: string) { + return { body, truncated: false }; +} + +function page(count: number, firstNumber: number, next?: string): string { + return JSON.stringify({ + pagelen: 50, + size: count, + values: Array.from({ length: count }, (_, index) => ({ + id: firstNumber + index, + title: `Pull request ${firstNumber + index}`, + state: "OPEN", + created_on: "2026-06-16T05:04:32+00:00", + updated_on: "2026-06-16T05:04:33+00:00", + source: { branch: { name: "feat/page" } }, + destination: { branch: { name: "master" } }, + links: { html: { href: `https://bitbucket.org/acme/web/pull-requests/${firstNumber}` } }, + })), + ...(next === undefined ? {} : { next }), + }); +} + +function valuePage(values: ReadonlyArray, next?: string): string { + return JSON.stringify({ values, ...(next === undefined ? {} : { next }) }); +} + +/** Who opened the pull request, and two accounts that could review it. */ +const bilal = { uuid: "{bilal}", nickname: "bilal" }; +const octocat = { uuid: "{octocat}", nickname: "octocat" }; +const hubot = { uuid: "{hubot}", nickname: "hubot" }; + +/** One pull request as `/pullrequests/{id}` answers with it. */ +function pullRequestJson(overrides: Record): string { + return JSON.stringify({ + id: 7, + title: "Pull request 7", + state: "OPEN", + author: bilal, + created_on: "2026-06-16T05:04:32+00:00", + updated_on: "2026-06-16T05:04:33+00:00", + source: { branch: { name: "feat/page" } }, + destination: { branch: { name: "master" } }, + links: { html: { href: "https://bitbucket.org/acme/web/pull-requests/7" } }, + ...overrides, + }); +} + +/** The request the nth call made. */ +function callAt(index: number) { + const call = mockedRequest.mock.calls[index]; + assert.isDefined(call); + return call[0]; +} + +/** The filter expression of the nth request, read back out of its query string. */ +function filterOfCall(index: number): string | null { + const url = callAt(index).url; + return new URLSearchParams(url.slice(url.indexOf("?") + 1)).get("q"); +} + +afterEach(() => { + mockedRequest.mockReset(); +}); + +layer("BitbucketPullRequestApi.layer", (it) => { + it.effect("asks for reviewers, newest first, at Bitbucket's page ceiling", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(3, 1)))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const batch = yield* api.listPullRequests({ + repository: "acme/web", + state: "open", + limit: 50, + }); + + assert.strictEqual(batch.items.length, 3); + assert.isFalse(batch.truncated); + const url = callAt(0).url; + expect(url).toContain("/repositories/acme/web/pullrequests"); + expect(url).toContain("state=OPEN"); + // Over 50 Bitbucket answers with an empty page and no error, so it is never exceeded. + expect(url).toContain("pagelen=50"); + expect(url).toContain("sort=-updated_on"); + expect(url).toContain("fields=%2Bvalues.reviewers"); + }), + ); + + it.effect("follows the cursor Bitbucket sends rather than counting offsets", () => + Effect.gen(function* () { + const next = "https://api.bitbucket.org/2.0/repositories/acme/web/pullrequests?page=2"; + mockedRequest + .mockReturnValueOnce(Effect.succeed(response(page(50, 1, next)))) + .mockReturnValueOnce(Effect.succeed(response(page(50, 51)))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const batch = yield* api.listPullRequests({ + repository: "acme/web", + state: "open", + limit: 100, + }); + + assert.strictEqual(batch.items.length, 100); + assert.isFalse(batch.truncated); + assert.strictEqual(callAt(1).url, next); + }), + ); + + it.effect("stops at the caller's page and says more remain", () => + Effect.gen(function* () { + const next = "https://api.bitbucket.org/2.0/repositories/acme/web/pullrequests?page=2"; + mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(50, 1, next)))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const batch = yield* api.listPullRequests({ + repository: "acme/web", + state: "open", + limit: 50, + }); + + assert.strictEqual(batch.items.length, 50); + assert.isTrue(batch.truncated); + assert.strictEqual(mockedRequest.mock.calls.length, 1); + }), + ); + + it.effect("counts the rows it walked past as more to come", () => + Effect.gen(function* () { + // Bitbucket pages in fifties whatever was asked for, so a request for ninety-nine reads a + // hundred and drops one. That row is more results, and saying otherwise takes the "load + // more" away from a listing that has not finished. + const next = "https://api.bitbucket.org/2.0/repositories/acme/web/pullrequests?page=2"; + mockedRequest + .mockReturnValueOnce(Effect.succeed(response(page(50, 1, next)))) + .mockReturnValueOnce(Effect.succeed(response(page(50, 51)))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const batch = yield* api.listPullRequests({ + repository: "acme/web", + state: "open", + limit: 99, + }); + + assert.strictEqual(batch.items.length, 99); + assert.isTrue(batch.truncated); + }), + ); + + it.effect("searches with a filter expression, which is all Bitbucket offers", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.listPullRequests({ + repository: "acme/web", + state: "open", + limit: 50, + query: "page", + }); + + expect(filterOfCall(0)).toBe('(title ~ "page" OR description ~ "page")'); + // The state filter beside it still stands, which the brackets are there to keep. + expect(callAt(0).url).toContain("state=OPEN"); + }), + ); + + it.effect("escapes a quote and a backslash, so a search cannot reshape the filter", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.listPullRequests({ + repository: "acme/web", + state: "open", + limit: 50, + query: String.raw`a\" OR state = "MERGED"`, + }); + + const literal = String.raw`a\\\" OR state = \"MERGED\"`; + expect(filterOfCall(0)).toBe(`(title ~ "${literal}" OR description ~ "${literal}")`); + }), + ); + + it.effect("asks for no filter at all when the reader typed only spaces", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.listPullRequests({ + repository: "acme/web", + state: "open", + limit: 50, + query: " ", + }); + + assert.isNull(filterOfCall(0)); + }), + ); + + it.effect("carries on from the instant the last slice ended on", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.listPullRequests({ + repository: "acme/web", + state: "open", + limit: 50, + cursor: { updatedBefore: "2026-07-02T00:00:00.123456+00:00", delivered: 50 }, + }); + + // Inclusive, so the rows already sent at that instant come back for the caller to drop. + expect(filterOfCall(0)).toBe("updated_on <= 2026-07-02T00:00:00.123456+00:00"); + expect(callAt(0).url).toContain("sort=-updated_on"); + }), + ); + + it.effect("narrows by the reader's words and by where it left off at once", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.listPullRequests({ + repository: "acme/web", + state: "open", + limit: 50, + query: "page", + cursor: { updatedBefore: "2026-07-02T00:00:00+00:00", delivered: 50 }, + }); + + // Bitbucket takes one `q`, so the two narrowings are joined rather than one replacing the + // other — and the search keeps its brackets, which is what keeps the AND out of its OR. + expect(filterOfCall(0)).toBe( + '(title ~ "page" OR description ~ "page") AND updated_on <= 2026-07-02T00:00:00+00:00', + ); + }), + ); + + it.effect("asks for declined pull requests on the closed tab", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.listPullRequests({ repository: "acme/web", state: "closed", limit: 50 }); + + expect(callAt(0).url).toContain("state=DECLINED"); + }), + ); + + it.effect("asks for every state at once on the All tab", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.listPullRequests({ repository: "acme/web", state: "all", limit: 50 }); + + // Bitbucket unions repeated state parameters, which is the only way to span them. + const url = callAt(0).url; + for (const state of ["OPEN", "MERGED", "DECLINED", "SUPERSEDED"]) { + expect(url).toContain(`state=${state}`); + } + }), + ); + + it.effect("counts a superseded pull request as closed", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.listPullRequests({ repository: "acme/web", state: "closed", limit: 50 }); + + expect(callAt(0).url).toContain("state=DECLINED"); + expect(callAt(0).url).toContain("state=SUPERSEDED"); + }), + ); + + it.effect("refuses a repository that is not workspace and slug", () => + Effect.gen(function* () { + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const error = yield* Effect.flip( + api.listPullRequests({ repository: "acme/team/web", state: "open", limit: 50 }), + ); + + assert.strictEqual(error._tag, "BitbucketRepositoryUnsupportedError"); + assert.strictEqual(mockedRequest.mock.calls.length, 0); + }), + ); + + it.effect("returns the diff verbatim, because Bitbucket already sends a patch", () => + Effect.gen(function* () { + 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"; + mockedRequest.mockReturnValueOnce(Effect.succeed(response(patch))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const diff = yield* api.getPullRequestDiff({ repository: "acme/web", number: 7 }); + + assert.strictEqual(diff.patch, patch); + assert.isFalse(diff.truncated); + expect(callAt(0)).toMatchObject({ + url: "/repositories/acme/web/pullrequests/7/diff", + // A diff of any size would otherwise be read into memory whole. + maxBytes: 8 * 1024 * 1024, + }); + }), + ); + + it.effect("reads a named commit's own patch, which pages no further than the whole of it", () => + Effect.gen(function* () { + 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"; + mockedRequest.mockReturnValueOnce(Effect.succeed(response(patch))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const diff = yield* api.getPullRequestDiff({ + repository: "acme/web", + number: 7, + commit: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", + }); + + assert.strictEqual(diff.patch, patch); + expect(callAt(0)).toMatchObject({ + url: "/repositories/acme/web/diff/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", + maxBytes: 8 * 1024 * 1024, + }); + }), + ); + + it.effect("refuses a commit that is not a sha rather than reading it into a URL", () => + Effect.gen(function* () { + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const error = yield* Effect.flip( + api.getPullRequestDiff({ + repository: "acme/web", + number: 7, + commit: "../../acme/other/diff/deadbeef", + }), + ); + + assert.strictEqual(error._tag, "BitbucketDiffCommitError"); + assert.strictEqual(mockedRequest.mock.calls.length, 0); + }), + ); + + it.effect("reads every file version the patch states, not only the paths asked about", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce( + Effect.succeed( + response( + [ + "diff --git a/a.ts b/a.ts", + "index 1111111..2222222 100644", + "--- a/a.ts", + "+++ b/a.ts", + "@@ -1 +1 @@", + "-a", + "+b", + "diff --git a/b.ts b/b.ts", + "index 3333333..4444444 100644", + "--- a/b.ts", + "+++ b/b.ts", + "@@ -1 +1 @@", + "-c", + "+d", + "", + ].join("\n"), + ), + ), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const revisions = yield* api.getFileRevisions({ + repository: "acme/web", + number: 71, + paths: ["a.ts", "missing.ts"], + }); + + // `b.ts` was not asked about and is reported anyway: parsing the patch for `a.ts` read it + // too, and the caller holding it is what stops the next tick paying for the patch again. + // `missing.ts` was asked about and the whole patch was read without finding it, which is + // what a file this pull request deletes looks like, so it is answered as the empty version. + assert.deepStrictEqual( + [...revisions.revisions], + [ + ["a.ts", "2222222"], + ["b.ts", "4444444"], + ["missing.ts", ""], + ], + ); + assert.strictEqual(revisions.complete, true); + expect(callAt(0)).toMatchObject({ url: "/repositories/acme/web/pullrequests/71/diff" }); + }), + ); + + it.effect("says nothing about the files past the end of a patch it could not read whole", () => + Effect.gen(function* () { + // Bitbucket's patch is read up to a byte ceiling, and a file past the cut was not looked at. + // Answering for it as deleted would clear a mark on it once and for good. + mockedRequest.mockReturnValueOnce( + Effect.succeed({ + body: "diff --git a/a.ts b/a.ts\nindex 1111111..2222222 100644\n@@ -1 +1 @@\n", + truncated: true, + }), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const revisions = yield* api.getFileRevisions({ + repository: "acme/web", + number: 72, + paths: ["a.ts", "past-the-cut.ts"], + }); + + assert.deepStrictEqual([...revisions.revisions], [["a.ts", "2222222"]]); + // `past-the-cut.ts` gets no empty version, and nothing here may be held as the whole story. + assert.strictEqual(revisions.complete, false); + }), + ); + + it.effect("reads the patch once for a run of ticks, not once a tick", () => + Effect.gen(function* () { + mockedRequest.mockReturnValue( + Effect.succeed( + response( + [ + "diff --git a/a.ts b/a.ts", + "index 1111111..2222222 100644", + "@@ -1 +1 @@", + "diff --git a/b.ts b/b.ts", + "index 3333333..4444444 100644", + "@@ -1 +1 @@", + "", + ].join("\n"), + ), + ), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const first = yield* api.getFileRevisions({ + repository: "acme/web", + number: 74, + paths: ["a.ts"], + }); + // A path nobody has asked about before, which is what every tick after the first names. + const second = yield* api.getFileRevisions({ + repository: "acme/web", + number: 74, + paths: ["b.ts"], + }); + + const both = [ + ["a.ts", "2222222"], + ["b.ts", "4444444"], + ]; + assert.deepStrictEqual([...first.revisions], both); + assert.deepStrictEqual([...second.revisions], both); + assert.strictEqual(mockedRequest.mock.calls.length, 1); + }), + ); + + it.effect("reads the patch afresh once the one it held has aged out", () => + Effect.gen(function* () { + mockedRequest.mockReturnValue( + Effect.succeed( + response("diff --git a/a.ts b/a.ts\nindex 1111111..2222222 100644\n@@ -1 +1 @@\n"), + ), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + const read = () => + api.getFileRevisions({ repository: "acme/web", number: 75, paths: ["a.ts"] }); + + yield* read(); + yield* read(); + assert.strictEqual(mockedRequest.mock.calls.length, 1); + + // Well inside the window the caller holds versions for: a refresh drops what it holds so + // that the read after it reaches Bitbucket, and this must not answer that read instead. + yield* TestClock.adjust(Duration.seconds(30)); + yield* read(); + assert.strictEqual(mockedRequest.mock.calls.length, 2); + }), + ); + + it.effect("asks Bitbucket nothing when no file has been ticked off", () => + Effect.gen(function* () { + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const revisions = yield* api.getFileRevisions({ + repository: "acme/web", + number: 73, + paths: [], + }); + + assert.strictEqual(revisions.revisions.size, 0); + assert.strictEqual(mockedRequest.mock.calls.length, 0); + }), + ); + + it.effect("aggregates every diffstat page", () => + Effect.gen(function* () { + const next = "https://api.bitbucket.org/2.0/diffstat?page=2"; + mockedRequest + .mockReturnValueOnce( + Effect.succeed( + response( + valuePage( + [ + { lines_added: 9, lines_removed: 2 }, + { lines_added: 3, lines_removed: 1 }, + ], + next, + ), + ), + ), + ) + .mockReturnValueOnce( + Effect.succeed(response(valuePage([{ lines_added: 4, lines_removed: 7 }]))), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const stat = yield* api.getDiffStat({ repository: "acme/web", number: 7 }); + + expect(stat).toEqual({ additions: 16, deletions: 10, changedFiles: 3 }); + expect(callAt(1).url).toBe(next); + }), + ); + + it.effect("returns the complete commit timeline oldest first across pages", () => + Effect.gen(function* () { + const next = "https://api.bitbucket.org/2.0/commits?page=2"; + mockedRequest + .mockReturnValueOnce( + Effect.succeed( + response( + valuePage( + [ + { hash: "ddd", message: "fourth", date: "2026-07-04T00:00:00Z" }, + { hash: "ccc", message: "third", date: "2026-07-03T00:00:00Z" }, + ], + next, + ), + ), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + response( + valuePage([ + { hash: "bbb", message: "second", date: "2026-07-02T00:00:00Z" }, + { hash: "aaa", message: "first", date: "2026-07-01T00:00:00Z" }, + ]), + ), + ), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const commits = yield* api.listCommits({ repository: "acme/web", number: 7 }); + + expect(commits.map((commit) => commit.oid)).toEqual(["aaa", "bbb", "ccc", "ddd"]); + expect(callAt(1).url).toBe(next); + }), + ); + + it.effect("returns build statuses from every page", () => + Effect.gen(function* () { + const next = "https://api.bitbucket.org/2.0/statuses?page=2"; + mockedRequest + .mockReturnValueOnce( + Effect.succeed(response(valuePage([{ name: "Build", state: "SUCCESSFUL" }], next))), + ) + .mockReturnValueOnce( + Effect.succeed(response(valuePage([{ name: "Lint", state: "FAILED" }]))), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const checks = yield* api.listChecks({ repository: "acme/web", number: 7 }); + + expect(checks.map((check) => [check.name, check.status])).toEqual([ + ["Build", "success"], + ["Lint", "failure"], + ]); + expect(callAt(1).url).toBe(next); + }), + ); + + it.effect("reads an empty conflict list as mergeable", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1)))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const mergeability = yield* api.getMergeability({ repository: "acme/web", number: 7 }); + + assert.strictEqual(mergeability, "mergeable"); + expect(callAt(0).url).toBe("/repositories/acme/web/pullrequests/7/conflicts"); + }), + ); + + it.effect("merges with Bitbucket's own name for the strategy", () => + Effect.gen(function* () { + mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.runAction({ + repository: "acme/web", + number: 7, + action: "merge", + mergeMethod: "rebase", + }); + + expect(callAt(0)).toMatchObject({ + method: "POST", + url: "/repositories/acme/web/pullrequests/7/merge", + body: '{"merge_strategy":"rebase_fast_forward"}', + }); + }), + ); + + it.effect("closes a pull request by declining it", () => + Effect.gen(function* () { + mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.runAction({ repository: "acme/web", number: 7, action: "close" }); + + expect(callAt(0)).toMatchObject({ + method: "POST", + url: "/repositories/acme/web/pullrequests/7/decline", + }); + }), + ); + + it.effect("posts a comment as a JSON document, so the body stays text", () => + Effect.gen(function* () { + mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.comment({ repository: "acme/web", number: 7, body: "true" }); + + expect(callAt(0)).toMatchObject({ + method: "POST", + url: "/repositories/acme/web/pullrequests/7/comments", + body: '{"content":{"raw":"true"}}', + }); + }), + ); + + it.effect("rewrites a title alone, without touching anything else", () => + Effect.gen(function* () { + mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.updateChangeRequest({ repository: "acme/web", number: 7, title: "A new title" }); + + const call = callAt(0); + expect(call.method).toBe("PUT"); + expect(call.url).toBe("/repositories/acme/web/pullrequests/7"); + // Bitbucket's PUT is a partial update, so a field left out of the body is left as it was. + expect(JSON.parse(call.body ?? "")).toEqual({ title: "A new title" }); + }), + ); + + it.effect("leaves out the half of the pull request it was not asked about", () => + Effect.gen(function* () { + mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.updateChangeRequest({ repository: "acme/web", number: 7, body: "New body." }); + + expect(JSON.parse(callAt(0).body ?? "")).toEqual({ description: "New body." }); + }), + ); + + it.effect("writes both fields when both were rewritten", () => + Effect.gen(function* () { + mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.updateChangeRequest({ + repository: "acme/web", + number: 7, + title: "A new title", + body: "New body.", + }); + + expect(JSON.parse(callAt(0).body ?? "")).toEqual({ + title: "A new title", + description: "New body.", + }); + }), + ); + + it.effect("rewrites a comment where it stands, whichever kind it is", () => + Effect.gen(function* () { + mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.updateComment({ + repository: "acme/web", + number: 7, + commentId: "10", + body: "Edited.", + }); + + expect(callAt(0)).toMatchObject({ + method: "PUT", + url: "/repositories/acme/web/pullrequests/7/comments/10", + body: '{"content":{"raw":"Edited."}}', + }); + }), + ); + + it.effect("fails the read when Bitbucket answers with something unreadable", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce( + Effect.succeed(response(JSON.stringify({ error: "nope" }))), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const error = yield* Effect.flip(api.getPullRequest({ repository: "acme/web", number: 7 })); + + assert.strictEqual(error._tag, "BitbucketPullRequestReadError"); + }), + ); + + it.effect("states a failure once, without stacking one message inside another", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce( + Effect.fail( + new BitbucketApi.BitbucketResponseError({ + operation: "request", + status: 500, + responseBodyLength: 0, + }), + ), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const error = yield* Effect.flip(api.getViewer()); + + // The fact only; the provider adds the operation around it. + assert.strictEqual(error.detail, "Bitbucket returned HTTP 500."); + }), + ); + + it.effect("fails when the credentials belong to no named account", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce(Effect.succeed(response(JSON.stringify({})))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const error = yield* Effect.flip(api.getViewer()); + + assert.strictEqual(error._tag, "BitbucketViewerUnavailableError"); + }), + ); + + it.effect("follows Bitbucket's cursor and reassembles a thread that spans two pages", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce( + Effect.succeed( + response( + JSON.stringify({ + next: "https://api.bitbucket.org/2.0/comments?page=2", + values: [ + { + id: 10, + content: { raw: "rename this" }, + user: { nickname: "bilal" }, + created_on: "2026-06-16T05:04:32+00:00", + inline: { path: "src/a.ts", to: 12 }, + }, + ], + }), + ), + ), + ); + mockedRequest.mockReturnValueOnce( + Effect.succeed( + response( + // The reply arrives a page after the remark it answers, which is why the threads + // are only assembled once every page is in hand. + JSON.stringify({ + values: [ + { + id: 11, + content: { raw: "done" }, + user: { nickname: "julius" }, + created_on: "2026-06-16T06:04:32+00:00", + parent: { id: 10 }, + }, + ], + }), + ), + ), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const { comments, threads, truncated } = yield* api.listComments({ + repository: "acme/web", + number: 7, + }); + + expect(callAt(1).url).toBe("https://api.bitbucket.org/2.0/comments?page=2"); + expect(comments.map((comment) => comment.id)).toEqual(["10", "11"]); + expect(threads[0]?.comments.map((comment) => comment.id)).toEqual(["10", "11"]); + assert.isFalse(truncated); + }), + ); + + it.effect("stops the comment walk at its bound and says the conversation was cut short", () => + Effect.gen(function* () { + // Bitbucket that always names a next page: the walk has to end itself. + mockedRequest.mockReturnValue( + Effect.succeed( + response( + JSON.stringify({ + next: "https://api.bitbucket.org/2.0/comments?page=2", + values: [ + { + id: 10, + content: { raw: "again" }, + created_on: "2026-06-16T05:04:32+00:00", + }, + ], + }), + ), + ), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const { truncated } = yield* api.listComments({ repository: "acme/web", number: 7 }); + + assert.strictEqual(mockedRequest.mock.calls.length, 10); + assert.isTrue(truncated); + }), + ); + + it.effect("reassembles a thread from the flat comment list, replies included", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce( + Effect.succeed( + response( + JSON.stringify({ + values: [ + { + id: 10, + content: { raw: "rename this" }, + user: { nickname: "bilal" }, + created_on: "2026-06-16T05:04:32+00:00", + inline: { path: "src/a.ts", to: 12, from: null }, + resolution: { type: "pullrequest_comment_resolution" }, + }, + { + id: 11, + content: { raw: "done" }, + user: { nickname: "julius" }, + created_on: "2026-06-16T06:04:32+00:00", + parent: { id: 10 }, + }, + // A reply to a reply still belongs to the thread its root opened. + { + id: 12, + content: { raw: "thanks" }, + user: { nickname: "bilal" }, + created_on: "2026-06-16T07:04:32+00:00", + parent: { id: 11 }, + }, + { + id: 13, + content: { raw: "ship it" }, + user: { nickname: "bilal" }, + created_on: "2026-06-16T08:04:32+00:00", + }, + ], + }), + ), + ), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const { threads } = yield* api.listComments({ repository: "acme/web", number: 7 }); + + assert.strictEqual(threads.length, 1); + expect(threads[0]).toMatchObject({ + id: "10", + path: "src/a.ts", + line: 12, + side: "right", + isResolved: true, + }); + expect(threads[0]?.comments.map((comment) => comment.id)).toEqual(["10", "11", "12"]); + }), + ); + + it.effect("writes a review's line comments, its summary, then its verdict", () => + Effect.gen(function* () { + mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.submitReview({ + repository: "acme/web", + number: 7, + verdict: "request-changes", + body: "Two things.", + comments: [ + { + path: "src/a.ts", + position: { kind: "deleted", oldLine: 12 }, + body: "why remove?", + }, + ], + }); + + expect(callAt(0).url).toContain("/pullrequests/7/comments"); + expect(JSON.parse(callAt(0).body ?? "")).toEqual({ + content: { raw: "why remove?" }, + inline: { path: "src/a.ts", from: 12 }, + }); + expect(callAt(1).url).toContain("/pullrequests/7/comments"); + // The verdict goes last, so a review that failed part-way is never a rejection either. + expect(callAt(2).url).toContain("/pullrequests/7/request-changes"); + }), + ); + + it.effect("resolves by creating the sub-resource and unresolves by deleting it", () => + Effect.gen(function* () { + mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.setCommentResolution({ + repository: "acme/web", + number: 7, + commentId: "10", + resolved: true, + }); + yield* api.setCommentResolution({ + repository: "acme/web", + number: 7, + commentId: "10", + resolved: false, + }); + + assert.strictEqual(callAt(0).method, "POST"); + assert.strictEqual(callAt(1).method, "DELETE"); + expect(callAt(0).url).toContain("/comments/10/resolve"); + }), + ); + + it.effect("replies by naming the comment it answers", () => + Effect.gen(function* () { + mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.replyToComment({ + repository: "acme/web", + number: 7, + commentId: "10", + body: "Fixed.", + }); + + expect(JSON.parse(callAt(0).body ?? "")).toEqual({ + content: { raw: "Fixed." }, + parent: { id: 10 }, + }); + }), + ); + + it.effect("asks for the credentials' permission on this repository, and nobody else's", () => + Effect.gen(function* () { + mockedRequest.mockReturnValue( + Effect.succeed( + response( + JSON.stringify({ values: [{ type: "repository_permission", permission: "read" }] }), + ), + ), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + assert.isFalse(yield* api.getRepositoryPermission({ repository: "acme/web" })); + + expect(callAt(0).url).toContain("/user/permissions/repositories"); + assert.strictEqual(filterOfCall(0), 'repository.full_name="acme/web"'); + }), + ); + + it.effect("escapes a repository name before it goes inside a filter literal", () => + Effect.gen(function* () { + mockedRequest.mockReturnValue(Effect.succeed(response(JSON.stringify({ values: [] })))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.getRepositoryPermission({ repository: 'acme/we"b' }); + + // A quote would otherwise end the literal and leave the rest standing as filter syntax. + assert.strictEqual(filterOfCall(0), 'repository.full_name="acme/we\\"b"'); + }), + ); + + it.effect( + "reads a removed permissions endpoint as granted rather than failing the merge on it", + () => + Effect.gen(function* () { + // Bitbucket retired /user/permissions/repositories under CHANGE-2770: every account now + // gets HTTP 410 here, whatever it may do. + mockedRequest.mockReturnValue( + Effect.fail( + new BitbucketApi.BitbucketResponseError({ + operation: "request", + status: 410, + responseBodyLength: 0, + }), + ), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + assert.isTrue(yield* api.getRepositoryPermission({ repository: "acme/web" })); + }), + ); + + it.effect("still fails the permission read on a failure that is not the removed endpoint", () => + Effect.gen(function* () { + mockedRequest.mockReturnValue( + Effect.fail( + new BitbucketApi.BitbucketResponseError({ + operation: "request", + status: 401, + responseBodyLength: 0, + }), + ), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const error = yield* Effect.flip(api.getRepositoryPermission({ repository: "acme/web" })); + + assert.strictEqual(error._tag, "BitbucketResponseError"); + }), + ); + + it.effect("reads the workspace's people and marks whoever is already a reviewer", () => + Effect.gen(function* () { + mockedRequest + .mockReturnValueOnce(Effect.succeed(response(pullRequestJson({ reviewers: [octocat] })))) + .mockReturnValueOnce( + Effect.succeed( + response( + JSON.stringify({ values: [{ user: bilal }, { user: octocat }, { user: hubot }] }), + ), + ), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const list = yield* api.listReviewerCandidates({ repository: "acme/web", number: 7 }); + + // The people live on the workspace: nothing on a repository lists who may review it. + expect(callAt(1).url).toBe("/workspaces/acme/members?pagelen=50"); + expect(list.candidates.map((candidate) => [candidate.id, candidate.isRequested])).toEqual([ + ["{octocat}", true], + ["{hubot}", false], + ]); + assert.isFalse(list.truncated); + }), + ); + + it.effect("writes the reviewer set back with the one being asked added to it", () => + Effect.gen(function* () { + mockedRequest + .mockReturnValueOnce(Effect.succeed(response(pullRequestJson({ reviewers: [octocat] })))) + .mockReturnValueOnce(Effect.succeed(response("{}"))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.setReviewerRequest({ + repository: "acme/web", + number: 7, + reviewers: [{ id: "{hubot}" }], + requested: true, + }); + + // Bitbucket writes `reviewers` whole, so the one already on the pull request travels with + // the new one or the request would take them off it. + const call = callAt(1); + expect(call.method).toBe("PUT"); + expect(call.url).toBe("/repositories/acme/web/pullrequests/7"); + expect(JSON.parse(call.body ?? "")).toEqual({ + reviewers: [{ uuid: "{octocat}" }, { uuid: "{hubot}" }], + }); + }), + ); + + it.effect("takes a reviewer out of the set rather than clearing it", () => + Effect.gen(function* () { + mockedRequest + .mockReturnValueOnce( + Effect.succeed(response(pullRequestJson({ reviewers: [octocat, hubot] }))), + ) + .mockReturnValueOnce(Effect.succeed(response("{}"))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.setReviewerRequest({ + repository: "acme/web", + number: 7, + reviewers: [{ id: "{hubot}" }], + requested: false, + }); + + expect(JSON.parse(callAt(1).body ?? "")).toEqual({ reviewers: [{ uuid: "{octocat}" }] }); + }), + ); +}); diff --git a/packages/source-control-bitbucket/src/server/BitbucketPullRequestApi.ts b/packages/source-control-bitbucket/src/server/BitbucketPullRequestApi.ts new file mode 100644 index 0000000000000000000000000000000000000000..6dd04a4a07736546eb65d4c2f1d2981aeea97a6c --- /dev/null +++ b/packages/source-control-bitbucket/src/server/BitbucketPullRequestApi.ts @@ -0,0 +1,943 @@ +import * as Cache from "effect/Cache"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import type { + PullRequestAction, + PullRequestCheck, + PullRequestComment, + PullRequestCommit, + PullRequestListState, + PullRequestMergeMethod, + PullRequestMergeability, + PullRequestReviewCommentDraft, + PullRequestReviewPosition, + PullRequestReviewThread, + PullRequestReviewVerdict, + PullRequestReviewerCandidateList, +} from "@t3tools/contracts"; + +import * as BitbucketApi from "./BitbucketApi.ts"; +import { parseDiffFileRevisions } from "@t3tools/source-control-core/server/diffFileRevisions"; +import { + buildReviewThreads, + decodeCommentsJson, + decodeCommitsJson, + decodeConflictsJson, + decodeDiffstatJson, + decodePullRequestJson, + decodePullRequestPageJson, + decodeRepositoryPermissionJson, + decodeStatusesJson, + decodeViewerJson, + decodeWorkspaceMembersJson, + type BitbucketDiffStat, + type BitbucketPullRequest, + type BitbucketRawComment, +} from "./bitbucketPullRequestJson.ts"; +import type { ProviderListCursor } from "@t3tools/source-control-core/server/PullRequestProvider"; + +/** + * Names the read that produced unusable output, so a failure reports the call it came from + * rather than borrowing another operation's message. + */ +export class BitbucketPullRequestReadError extends Schema.TaggedError()( + "BitbucketPullRequestReadError", + { + operation: Schema.String, + cause: Schema.Defect(), + }, +) { + get detail(): string { + return `Bitbucket returned an unreadable ${this.operation} response.`; + } + + override get message(): string { + return `Bitbucket failed in ${this.operation}: ${this.detail}`; + } +} + +/** Not a decode failure: Bitbucket answered, the account it answered for just has no handle. */ +export class BitbucketViewerUnavailableError extends Schema.TaggedError()( + "BitbucketViewerUnavailableError", + {}, +) { + get detail(): string { + return "Bitbucket returned no account name for the configured credentials."; + } + + override get message(): string { + return `Bitbucket failed in getViewer: ${this.detail}`; + } +} + +/** A repository that is not `workspace/slug`, which is the only form Bitbucket addresses. */ +export class BitbucketRepositoryUnsupportedError extends Schema.TaggedError()( + "BitbucketRepositoryUnsupportedError", + { + repository: Schema.String, + }, +) { + get detail(): string { + return "A Bitbucket repository is addressed as workspace/repository."; + } + + override get message(): string { + return `Bitbucket failed in resolveRepository: ${this.detail}`; + } +} + +/** Not a decode failure: the reader named a commit that is not a sha this repository could hold. */ +export class BitbucketDiffCommitError extends Schema.TaggedError()( + "BitbucketDiffCommitError", + {}, +) { + get detail(): string { + return "The named commit was not a commit sha."; + } + + override get message(): string { + return `Bitbucket failed in getPullRequestDiff: ${this.detail}`; + } +} + +export type BitbucketPullRequestApiError = + | BitbucketApi.BitbucketApiError + | BitbucketPullRequestReadError + | BitbucketViewerUnavailableError + | BitbucketRepositoryUnsupportedError + | BitbucketDiffCommitError; + +/** + * `/user/permissions/repositories` answering CHANGE-2770's removal notice rather than a + * permission — Bitbucket sends this for every account now, not only ones it would have refused. + */ +function isRepositoryPermissionRemovedError( + error: BitbucketPullRequestApiError, +): error is BitbucketApi.BitbucketResponseError { + return error._tag === "BitbucketResponseError" && error.status === 410; +} + +/** + * Bitbucket's own ceiling. Asking for more does not fail — it answers with an empty page and no + * error at all, so this is a number to respect rather than to push against. + */ +const MAX_PAGE_SIZE = 50; +/** Pages to walk before a listing is reported as truncated. */ +const MAX_LIST_PAGES = 10; +/** The page size for pull request conversations, commits, and checks. */ +const CONVERSATION_PAGE_SIZE = 50; +/** + * Pages of the conversation to follow before it is reported as truncated. Bitbucket serves + * fifty comments a page, so this is five hundred — beyond any pull request a person is reading, + * and an end to a walk whose only other stop is Bitbucket running out. + */ +const CONVERSATION_PAGES = 10; +/** The same ceiling the gh and glab diff reads use. */ +const DIFF_MAX_BYTES = 8 * 1024 * 1024; +/** + * Deliberately far shorter than the window the caller holds versions for: a refresh drops what the + * caller holds precisely so the next read reaches Bitbucket, and this must not be what answers it + * instead. + */ +const REVISION_PATCH_TTL = Duration.seconds(5); +const REVISION_PATCH_CAPACITY = 16; +export interface BitbucketPullRequestBatch { + readonly items: ReadonlyArray; + readonly truncated: boolean; +} + +export class BitbucketPullRequestApi extends Context.Service< + BitbucketPullRequestApi, + { + /** A function rather than a value, so the request is built per call and not at layer time. */ + readonly getViewer: () => Effect.Effect; + + readonly listPullRequests: (input: { + readonly repository: string; + readonly state: PullRequestListState; + readonly limit: number; + /** Free text, matched against a pull request's title and description. */ + readonly query?: string | undefined; + /** Where to carry on from, as a predicate on `updated_on` beside any other. */ + readonly cursor?: ProviderListCursor | undefined; + }) => Effect.Effect; + + readonly getPullRequest: (input: { + readonly repository: string; + readonly number: number; + }) => Effect.Effect; + + /** True where the credentials can write to the repository, which is what merging needs. */ + readonly getRepositoryPermission: (input: { + readonly repository: string; + }) => Effect.Effect; + + readonly getPullRequestDiff: (input: { + readonly repository: string; + readonly number: number; + /** One commit's own changes, rather than everything the pull request carries. */ + readonly commit?: string | undefined; + }) => Effect.Effect< + { readonly patch: string; readonly truncated: boolean }, + BitbucketPullRequestApiError + >; + + readonly getDiffStat: (input: { + readonly repository: string; + readonly number: number; + }) => Effect.Effect; + + /** + * What the pull request's head has of each of these paths, as opaque ids, read off the pull + * request's own patch, the only place Bitbucket states a file's version. A path the patch does + * not carry is answered as the empty revision, and left out altogether when the patch was cut + * short at the byte ceiling and so cannot be spoken for. + * + * Answers with every file the patch carries rather than only the paths asked about, since + * reading one file's version here means parsing all of them. `complete` is false for a patch + * cut short, which cannot speak for what came after the cut. + */ + readonly getFileRevisions: (input: { + readonly repository: string; + readonly number: number; + readonly paths: ReadonlyArray; + }) => Effect.Effect< + { readonly revisions: ReadonlyMap; readonly complete: boolean }, + BitbucketPullRequestApiError + >; + + readonly getMergeability: (input: { + readonly repository: string; + readonly number: number; + }) => Effect.Effect; + + readonly listComments: (input: { + readonly repository: string; + readonly number: number; + }) => Effect.Effect< + { + readonly comments: ReadonlyArray; + readonly threads: ReadonlyArray; + readonly truncated: boolean; + }, + BitbucketPullRequestApiError + >; + + readonly listCommits: (input: { + readonly repository: string; + readonly number: number; + }) => Effect.Effect, BitbucketPullRequestApiError>; + + readonly listChecks: (input: { + readonly repository: string; + readonly number: number; + }) => Effect.Effect, BitbucketPullRequestApiError>; + + /** + * Who this pull request may be sent to, and who it has already been sent to. Two reads at + * once, because Bitbucket keeps the people on the workspace and the reviewers on the pull + * request, and neither answers for the other. + */ + readonly listReviewerCandidates: (input: { + readonly repository: string; + readonly number: number; + }) => Effect.Effect; + + readonly setReviewerRequest: (input: { + readonly repository: string; + readonly number: number; + readonly reviewers: ReadonlyArray<{ readonly id: string }>; + readonly requested: boolean; + }) => Effect.Effect; + + readonly runAction: (input: { + readonly repository: string; + readonly number: number; + readonly action: PullRequestAction; + readonly mergeMethod?: PullRequestMergeMethod; + }) => Effect.Effect; + + readonly updateChangeRequest: (input: { + readonly repository: string; + readonly number: number; + readonly title?: string | undefined; + readonly body?: string | undefined; + }) => Effect.Effect; + + readonly comment: (input: { + readonly repository: string; + readonly number: number; + readonly body: string; + }) => Effect.Effect; + + readonly updateComment: (input: { + readonly repository: string; + readonly number: number; + readonly commentId: string; + readonly body: string; + }) => Effect.Effect; + + readonly submitReview: (input: { + readonly repository: string; + readonly number: number; + readonly verdict: PullRequestReviewVerdict; + readonly body: string; + readonly comments: ReadonlyArray; + }) => Effect.Effect; + + readonly replyToComment: (input: { + readonly repository: string; + readonly number: number; + readonly commentId: string; + readonly body: string; + }) => Effect.Effect; + + readonly setCommentResolution: (input: { + readonly repository: string; + readonly number: number; + readonly commentId: string; + readonly resolved: boolean; + }) => Effect.Effect; + } +>()("@t3tools/source-control-bitbucket/server/BitbucketPullRequestApi") {} + +/** `workspace/slug`; Bitbucket has no deeper nesting to address. */ +function repositorySegments( + repository: string, +): Result.Result< + { readonly workspace: string; readonly slug: string }, + BitbucketRepositoryUnsupportedError +> { + const segments = repository + .split("/") + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0); + const [workspace, slug] = segments; + if (segments.length !== 2 || workspace === undefined || slug === undefined) { + return Result.fail(new BitbucketRepositoryUnsupportedError({ repository })); + } + return Result.succeed({ workspace, slug }); +} + +function repositoryPathOf(segments: { readonly workspace: string; readonly slug: string }): string { + return `/repositories/${encodeURIComponent(segments.workspace)}/${encodeURIComponent( + segments.slug, + )}`; +} + +/** + * A commit sha arrives from the reader and goes straight into a request path, so it is checked + * rather than trusted: hexadecimal only, from the shortest abbreviation a host prints up to a + * whole sha. + */ +function isCommitSha(value: string): boolean { + return /^[0-9a-f]{7,64}$/i.test(value); +} + +/** + * Bitbucket unions repeated `state` parameters, so a tab that spans several of its states asks + * for each. It separates a declined pull request from one superseded by another, and both read + * as closed here. + */ +function stateParams(state: PullRequestListState): ReadonlyArray { + switch (state) { + case "open": + return ["OPEN"]; + case "merged": + return ["MERGED"]; + case "closed": + return ["DECLINED", "SUPERSEDED"]; + case "all": + return ["OPEN", "MERGED", "DECLINED", "SUPERSEDED"]; + } +} + +/** + * Bitbucket has no search term, only a filter expression, so free text becomes one: a + * case-insensitive contains against the two fields a pull request carries words in. The + * parentheses matter, because the expression is ANDed with the state filter beside it and an + * unbracketed `OR` would swallow it. + * + * A string literal in that grammar is delimited by double quotes, so the reader's text is + * escaped before it goes inside one — a quote would otherwise end the literal and leave the + * rest of the text standing as filter syntax. The whole expression is then URL-encoded, so + * nothing in it reaches the query string as a parameter of its own. + */ +function searchFilter(query: string): string { + const literal = filterLiteral(query); + return `(title ~ "${literal}" OR description ~ "${literal}")`; +} + +/** + * Text as a string literal of Bitbucket's filter grammar. The backslash is escaped first, or + * escaping the quote would only produce a literal backslash followed by a live quote. + */ +function filterLiteral(value: string): string { + return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); +} + +/** Bitbucket's merge strategies, named differently from the three the contract carries. */ +function mergeStrategy(method: PullRequestMergeMethod | undefined): string { + switch (method) { + case "squash": + return "squash"; + case "rebase": + // The linear history GitHub calls "rebase and merge". + return "rebase_fast_forward"; + default: + return "merge_commit"; + } +} + +function bitbucketReviewPosition( + position: PullRequestReviewPosition, +): { readonly from: number } | { readonly to: number } { + switch (position.kind) { + case "added": + return { to: position.newLine }; + case "deleted": + return { from: position.oldLine }; + case "context": + return position.side === "left" ? { from: position.oldLine } : { to: position.newLine }; + } +} + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + + /** + * The repository's own path, and the workspace above it — which the people who may review are + * kept on rather than on the repository, so both are handed over at once. + */ + const withRepository = ( + repository: string, + use: (path: string, workspace: string) => Effect.Effect, + ): Effect.Effect => { + const segments = repositorySegments(repository); + return Result.isSuccess(segments) + ? use(repositoryPathOf(segments.success), segments.success.workspace) + : Effect.fail(segments.failure); + }; + + /** + * Bitbucket pages with a cursor rather than an offset, so the walk follows the `next` URL it + * sends. It stops once the caller's page is filled, when Bitbucket reports no next page, or at + * the page cap — and anything but running out of pages means there is more to be had. + */ + const listPage = (input: { + readonly url: string; + readonly limit: number; + readonly page: number; + readonly collected: ReadonlyArray; + }): Effect.Effect => + bitbucket.request({ method: "GET", url: input.url }).pipe( + Effect.flatMap((response) => { + const decoded = decodePullRequestPageJson(response.body); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new BitbucketPullRequestReadError({ + operation: "listPullRequests", + cause: decoded.failure, + }), + ); + } + const collected = [...input.collected, ...decoded.success.items]; + const next = decoded.success.next; + if (next === null || collected.length >= input.limit || input.page >= MAX_LIST_PAGES) { + return Effect.succeed({ + items: collected.slice(0, input.limit), + // Bitbucket pages in fifties whatever was asked for, so a walk that stopped on the + // count rather than on the last page is holding rows it is about to drop. Those are + // more results just as surely as another page would be. + truncated: next !== null || collected.length > input.limit, + }); + } + return listPage({ ...input, url: next, page: input.page + 1, collected }); + }), + ); + + const readPage = (input: { + readonly operation: string; + readonly url: string; + readonly decode: (body: string) => Result.Result; + }): Effect.Effect => + bitbucket.request({ method: "GET", url: input.url }).pipe( + Effect.flatMap((response) => { + const decoded = input.decode(response.body); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new BitbucketPullRequestReadError({ + operation: input.operation, + cause: decoded.failure, + }), + ); + }), + ); + + /** + * The conversation, following the `next` Bitbucket sends until it sends none. Threads are + * assembled once at the end rather than per page, because a reply and the remark it answers + * can land either side of a page boundary. + */ + const commentsPage = (input: { + readonly url: string; + readonly page: number; + readonly comments: ReadonlyArray; + readonly entries: ReadonlyArray; + }): Effect.Effect< + { + readonly comments: ReadonlyArray; + readonly threads: ReadonlyArray; + readonly truncated: boolean; + }, + BitbucketPullRequestApiError + > => + readPage({ operation: "listComments", url: input.url, decode: decodeCommentsJson }).pipe( + Effect.flatMap((page) => { + const comments = [...input.comments, ...page.comments]; + const entries = [...input.entries, ...page.entries]; + if (page.next !== null && input.page < CONVERSATION_PAGES) { + return commentsPage({ url: page.next, page: input.page + 1, comments, entries }); + } + return Effect.succeed({ + comments, + threads: buildReviewThreads(entries), + truncated: page.next !== null, + }); + }), + ); + + /** Walks a Bitbucket cursor to its end and combines every decoded item. */ + const itemPages = (input: { + readonly operation: string; + readonly url: string; + readonly decode: ( + body: string, + ) => Result.Result<{ readonly items: ReadonlyArray; readonly next: string | null }, unknown>; + readonly items: ReadonlyArray; + /** Commit pages are individually oldest-first, so older pages are prepended. */ + readonly prepend: boolean; + }): Effect.Effect, BitbucketPullRequestApiError> => + readPage({ operation: input.operation, url: input.url, decode: input.decode }).pipe( + Effect.flatMap((page) => { + const items = input.prepend + ? [...page.items, ...input.items] + : [...input.items, ...page.items]; + return page.next === null + ? Effect.succeed(items) + : itemPages({ ...input, url: page.next, items }); + }), + ); + + /** Diffstat has one aggregate per page, so its totals are folded while following `next`. */ + const diffStatPages = (input: { + readonly url: string; + readonly totals: BitbucketDiffStat; + }): Effect.Effect => + readPage({ operation: "getDiffStat", url: input.url, decode: decodeDiffstatJson }).pipe( + Effect.flatMap((page) => { + const totals = { + additions: input.totals.additions + page.additions, + deletions: input.totals.deletions + page.deletions, + changedFiles: input.totals.changedFiles + page.changedFiles, + }; + return page.next === null + ? Effect.succeed(totals) + : diffStatPages({ url: page.next, totals }); + }), + ); + + const pullRequestDiff = (input: { + readonly repository: string; + readonly number: number; + readonly commit?: string | undefined; + }): Effect.Effect< + { readonly patch: string; readonly truncated: boolean }, + BitbucketPullRequestApiError + > => + input.commit !== undefined && !isCommitSha(input.commit) + ? Effect.fail(new BitbucketDiffCommitError()) + : withRepository(input.repository, (path) => + // Already a unified patch, so it needs no decoding at all, only a bound, which a + // diff of any size would otherwise ignore. A commit's own patch sits beside the pull + // request's at `/diff/{sha}` and reads the same way. + bitbucket + .request({ + method: "GET", + url: + input.commit === undefined + ? `${path}/pullrequests/${input.number}/diff` + : `${path}/diff/${input.commit}`, + maxBytes: DIFF_MAX_BYTES, + }) + .pipe( + Effect.map((response) => ({ patch: response.body, truncated: response.truncated })), + ), + ); + + /** + * What the version reads that come one tick at a time want out of the pull request's whole + * patch, shared between them. The parsed answer rather than the patch, which at this capacity + * would hold sixteen bodies of up to the byte ceiling each resident, and saves walking a patch + * of a hundred thousand lines again on every tick. + */ + const revisionPatches = yield* Cache.makeWith( + (key: string) => { + const [repository, number] = JSON.parse(key) as [string, number]; + return pullRequestDiff({ repository, number }).pipe( + Effect.map((diff) => ({ + revisions: parseDiffFileRevisions(diff.patch), + truncated: diff.truncated, + })), + ); + }, + { + capacity: REVISION_PATCH_CAPACITY, + // A failure is not held: the tick after it should reach Bitbucket rather than be handed the + // same error for as long as a good patch would have lasted. + timeToLive: (exit) => (Exit.isSuccess(exit) ? REVISION_PATCH_TTL : Duration.zero), + }, + ); + + return BitbucketPullRequestApi.of({ + getViewer: () => + bitbucket.request({ method: "GET", url: "/user" }).pipe( + Effect.flatMap((response): Effect.Effect => { + const decoded = decodeViewerJson(response.body); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new BitbucketPullRequestReadError({ operation: "getViewer", cause: decoded.failure }), + ); + } + return decoded.success === null + ? Effect.fail(new BitbucketViewerUnavailableError()) + : Effect.succeed(decoded.success); + }), + ), + + listPullRequests: (input) => + withRepository(input.repository, (path) => { + const search = input.query?.trim() ?? ""; + // Both narrowings share the one `q` Bitbucket takes, so they are ANDed rather than one + // replacing the other. The boundary instant is read inclusively — the rows already sent + // at it come back and the caller drops them, which is what keeps their neighbours at the + // same instant from being skipped. A date is a bare literal in this grammar, and this one + // was checked against a timestamp's shape before it got here. + const predicates = [ + ...(search.length === 0 ? [] : [searchFilter(search)]), + ...(input.cursor === undefined ? [] : [`updated_on <= ${input.cursor.updatedBefore}`]), + ]; + return listPage({ + // Reviewers are not on a listing by default, and `viewerReviewRequested` needs them. + url: `${path}/pullrequests?${stateParams(input.state) + .map((state) => `state=${state}`) + .join("&")}&pagelen=${MAX_PAGE_SIZE}&sort=-updated_on&fields=%2Bvalues.reviewers${ + predicates.length === 0 ? "" : `&q=${encodeURIComponent(predicates.join(" AND "))}` + }`, + limit: input.limit, + page: 1, + collected: [], + }); + }), + + getPullRequest: (input) => + withRepository(input.repository, (path) => + readPage({ + operation: "getPullRequest", + url: `${path}/pullrequests/${input.number}`, + decode: decodePullRequestJson, + }), + ), + + // Nothing on the repository, the pull request or the workspace states what the credentials + // may do, so this endpoint is the one request Bitbucket makes unavoidable. It is asked + // alongside the reads the detail was already making, so it costs no round trip of its own. + // + // Bitbucket permanently removed this endpoint (CHANGE-2770): every account now gets HTTP 410 + // in place of an answer, whatever it may do. That is the deprecated-endpoint signal, not a + // permission being refused, so it is read the same way an unreachable read already is + // elsewhere — as a permission that could not be learned, which grants rather than blocks, and + // leaves the actual merge or write to say why if the account may not do it. Any other failure + // (a bad token, a network fault, an unreadable body) still fails as it did before. + getRepositoryPermission: (input) => + withRepository(input.repository, () => + readPage({ + operation: "getRepositoryPermission", + url: `/user/permissions/repositories?q=${encodeURIComponent( + `repository.full_name="${filterLiteral(input.repository.trim())}"`, + )}`, + decode: decodeRepositoryPermissionJson, + }), + ).pipe(Effect.catchIf(isRepositoryPermissionRemovedError, () => Effect.succeed(true))), + + getPullRequestDiff: pullRequestDiff, + + getFileRevisions: (input) => + input.paths.length === 0 + ? Effect.succeed({ revisions: new Map(), complete: false }) + : Cache.get(revisionPatches, JSON.stringify([input.repository, input.number])).pipe( + Effect.map((diff) => { + const revisions = new Map(diff.revisions); + // A patch cut short at the byte ceiling says nothing about the files past the cut, + // so those paths are left out rather than reported as removed. + if (!diff.truncated) { + for (const path of input.paths) { + if (!revisions.has(path)) revisions.set(path, ""); + } + } + return { revisions, complete: !diff.truncated }; + }), + ), + + getDiffStat: (input) => + withRepository(input.repository, (path) => + diffStatPages({ + url: `${path}/pullrequests/${input.number}/diffstat?pagelen=${MAX_PAGE_SIZE}`, + totals: { additions: 0, deletions: 0, changedFiles: 0 }, + }), + ), + + getMergeability: (input) => + withRepository(input.repository, (path) => + readPage({ + operation: "getMergeability", + url: `${path}/pullrequests/${input.number}/conflicts`, + decode: decodeConflictsJson, + }), + ), + + listComments: (input) => + withRepository(input.repository, (path) => + commentsPage({ + url: `${path}/pullrequests/${input.number}/comments?pagelen=${CONVERSATION_PAGE_SIZE}`, + page: 1, + comments: [], + entries: [], + }), + ), + + listCommits: (input) => + withRepository(input.repository, (path) => + itemPages({ + operation: "listCommits", + url: `${path}/pullrequests/${input.number}/commits?pagelen=${CONVERSATION_PAGE_SIZE}`, + decode: decodeCommitsJson, + items: [], + prepend: true, + }), + ), + + listChecks: (input) => + withRepository(input.repository, (path) => + itemPages({ + operation: "listChecks", + url: `${path}/pullrequests/${input.number}/statuses?pagelen=${CONVERSATION_PAGE_SIZE}`, + decode: decodeStatusesJson, + items: [], + prepend: false, + }), + ), + + listReviewerCandidates: (input) => + withRepository(input.repository, (path, workspace) => + Effect.all( + [ + readPage({ + operation: "getPullRequest", + url: `${path}/pullrequests/${input.number}`, + decode: decodePullRequestJson, + }), + readPage({ + operation: "listReviewerCandidates", + url: `/workspaces/${encodeURIComponent(workspace)}/members?pagelen=${MAX_PAGE_SIZE}`, + decode: decodeWorkspaceMembersJson, + }), + ], + { concurrency: 2 }, + ).pipe( + Effect.map(([pullRequest, members]) => { + const requested = new Set(pullRequest.reviewerIds); + const author = pullRequest.author?.login; + return { + // The author is dropped rather than shown unusable: Bitbucket refuses to make the + // person who opened a pull request its reviewer. + candidates: members.items.flatMap((candidate) => + candidate.login === author + ? [] + : [{ ...candidate, isRequested: requested.has(candidate.id) }], + ), + truncated: members.next !== null, + }; + }), + ), + ), + + setReviewerRequest: (input) => + withRepository(input.repository, (path) => { + const pullRequest = `${path}/pullrequests/${input.number}`; + return readPage({ + operation: "getPullRequest", + url: pullRequest, + decode: decodePullRequestJson, + }).pipe( + Effect.flatMap((current) => { + // Bitbucket has no endpoint that adds or removes one reviewer: the pull request's + // `reviewers` is written whole, so the set that is already there is read first and + // the change applied to it. Everything else about the pull request is left out of + // the body, which leaves it as it was. + const uuids = new Set(current.reviewerIds); + for (const reviewer of input.reviewers) { + if (input.requested) uuids.add(reviewer.id); + else uuids.delete(reviewer.id); + } + return bitbucket.request({ + method: "PUT", + url: pullRequest, + body: JSON.stringify({ reviewers: [...uuids].map((uuid) => ({ uuid })) }), + }); + }), + Effect.asVoid, + ); + }), + + runAction: (input) => + withRepository(input.repository, (path) => { + const pullRequest = `${path}/pullrequests/${input.number}`; + // Only merge and close reach here: the provider declares the others unsupported, so the + // surface never offers them. + if (input.action === "merge") { + return bitbucket + .request({ + method: "POST", + url: `${pullRequest}/merge`, + body: JSON.stringify({ merge_strategy: mergeStrategy(input.mergeMethod) }), + }) + .pipe(Effect.asVoid); + } + return bitbucket + .request({ method: "POST", url: `${pullRequest}/decline` }) + .pipe(Effect.asVoid); + }), + + updateChangeRequest: (input) => + withRepository(input.repository, (path) => + // Only the words this call rewrites travel in the body: as `setReviewerRequest` above + // relies on, Bitbucket's PUT is a partial update, so any field left out is left as it + // was — sending `reviewers` back here would overwrite a change another user made to it + // between this call being issued and the request landing. + bitbucket + .request({ + method: "PUT", + url: `${path}/pullrequests/${input.number}`, + body: JSON.stringify({ + ...(input.title === undefined ? {} : { title: input.title }), + ...(input.body === undefined ? {} : { description: input.body }), + }), + }) + .pipe(Effect.asVoid), + ), + + comment: (input) => + withRepository(input.repository, (path) => + bitbucket + .request({ + method: "POST", + url: `${path}/pullrequests/${input.number}/comments`, + // A JSON document rather than a form field, so the body stays text whatever it says. + body: JSON.stringify({ content: { raw: input.body } }), + }) + .pipe(Effect.asVoid), + ), + + updateComment: (input) => + withRepository(input.repository, (path) => + bitbucket + .request({ + // Bitbucket keeps a pull request's remarks and its line comments in the one + // collection, so this endpoint rewrites either kind. + method: "PUT", + url: `${path}/pullrequests/${input.number}/comments/${encodeURIComponent( + input.commentId, + )}`, + body: JSON.stringify({ content: { raw: input.body } }), + }) + .pipe(Effect.asVoid), + ), + + submitReview: (input) => + withRepository(input.repository, (path) => + Effect.gen(function* () { + const pullRequest = `${path}/pullrequests/${input.number}`; + // Bitbucket has no pending review, so a review is replayed as the requests it is + // made of: the line comments, then the summary, then the verdict. The verdict goes + // last so a review that fails part-way is never left standing as an approval. + yield* Effect.forEach( + input.comments, + (comment) => + bitbucket.request({ + method: "POST", + url: `${pullRequest}/comments`, + body: JSON.stringify({ + content: { raw: comment.body }, + inline: { + path: comment.path, + ...bitbucketReviewPosition(comment.position), + }, + }), + }), + { discard: true }, + ); + if (input.body.trim().length > 0) { + yield* bitbucket.request({ + method: "POST", + url: `${pullRequest}/comments`, + // @effect-diagnostics-next-line preferSchemaOverJson:off + body: JSON.stringify({ content: { raw: input.body } }), + }); + } + if (input.verdict === "approve") { + yield* bitbucket.request({ method: "POST", url: `${pullRequest}/approve` }); + } + if (input.verdict === "request-changes") { + yield* bitbucket.request({ method: "POST", url: `${pullRequest}/request-changes` }); + } + }), + ), + + replyToComment: (input) => + withRepository(input.repository, (path) => + bitbucket + .request({ + method: "POST", + url: `${path}/pullrequests/${input.number}/comments`, + body: JSON.stringify({ + content: { raw: input.body }, + parent: { id: Number(input.commentId) }, + }), + }) + .pipe(Effect.asVoid), + ), + + setCommentResolution: (input) => + withRepository(input.repository, (path) => + bitbucket + .request({ + // Resolving is a sub-resource that is created and deleted, rather than a field. + method: input.resolved ? "POST" : "DELETE", + url: `${path}/pullrequests/${input.number}/comments/${encodeURIComponent( + input.commentId, + )}/resolve`, + }) + .pipe(Effect.asVoid), + ), + }); +}); + +export const layer = Layer.effect(BitbucketPullRequestApi, make); diff --git a/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.test.ts b/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..b5470628be0aa9d7ca31bae2fd31e62cf98f0a96 --- /dev/null +++ b/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Result from "effect/Result"; + +import * as BitbucketApi from "./BitbucketApi.ts"; +import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts"; +import { decodePullRequestJson } from "./bitbucketPullRequestJson.ts"; +import { + bitbucketProviderFailure, + bitbucketViewerPermissions, + make, +} from "./BitbucketPullRequestProvider.ts"; + +for (const operation of [ + "getMergeability", + "listChecks", + "getRepositoryPermission", + "listComments", + "listCommits", +] as const) { + it.effect.each(["response", "body read"])( + `preserves rate limits from ${operation} on %s errors while recovering other optional-read failures`, + (variant) => + Effect.gen(function* () { + const pullRequest = Result.getOrThrow( + decodePullRequestJson(`{ + "id": 1, "title": "Check polling", "state": "OPEN", + "source": { "branch": { "name": "feature" } }, + "destination": { "branch": { "name": "main" } }, + "created_on": "2026-09-16T00:00:00Z", + "updated_on": "2026-09-16T00:00:00Z", + "links": { "html": { "href": "https://bitbucket.org/acme/web/pull-requests/1" } } + }`), + ); + for (const status of [429, 403]) { + const provider = yield* make.pipe( + Effect.provide( + Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({ + getPullRequest: () => Effect.succeed(pullRequest), + getDiffStat: () => Effect.succeed({ additions: 0, deletions: 0, changedFiles: 0 }), + getMergeability: () => Effect.succeed("unknown" as const), + listChecks: () => Effect.succeed([]), + getRepositoryPermission: () => Effect.succeed(true), + listComments: () => Effect.succeed({ comments: [], threads: [], truncated: false }), + listCommits: () => Effect.succeed([]), + [operation]: () => + Effect.fail( + variant === "response" + ? new BitbucketApi.BitbucketResponseError({ + operation: "request", + status, + responseBodyLength: 0, + retryAt: 120_000, + }) + : new BitbucketApi.BitbucketResponseBodyReadError({ + operation: "request", + status, + cause: new Error("response stream failed"), + retryAt: 120_000, + }), + ), + }), + ), + ); + const reference = { + cwd: "/repo", + repository: "acme/web", + number: 1, + host: "bitbucket.org", + }; + const result = yield* operation === "listComments" || operation === "listCommits" + ? Effect.result(provider.getChangeRequestActivity(reference)) + : Effect.result(provider.getChangeRequest(reference)); + if (status === 429) { + expect(result).toMatchObject({ + _tag: "Failure", + failure: { reason: "rate-limited", retryAt: 120_000 }, + }); + } else { + expect(result._tag).toBe("Success"); + } + } + }), + ); +} + +it.effect("reads checks and PR state without diff, mergeability, or permission requests", () => + Effect.gen(function* () { + const pullRequest = Result.getOrThrow( + decodePullRequestJson(`{ + "id": 1, "title": "Checks", "state": "OPEN", + "source": { "branch": { "name": "feature" } }, + "destination": { "branch": { "name": "main" } }, + "created_on": "2026-09-16T00:00:00Z", "updated_on": "2026-09-16T00:00:00Z", + "links": { "html": { "href": "https://bitbucket.org/acme/web/pull-requests/1" } } + }`), + ); + let limited = false; + const provider = yield* make.pipe( + Effect.provide( + Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({ + getPullRequest: () => Effect.succeed(pullRequest), + listChecks: () => + limited + ? Effect.fail( + new BitbucketApi.BitbucketResponseError({ + operation: "request", + status: 429, + responseBodyLength: 0, + retryAt: 120_000, + }), + ) + : Effect.succeed([ + { name: "build", status: "failure" as const, description: null, url: null }, + ]), + }), + ), + ); + const read = provider.getChangeRequestChecks; + if (read === undefined) return yield* Effect.die("checks read missing"); + const input = { cwd: "/repo", repository: "acme/web", host: "bitbucket.org", number: 1 }; + expect((yield* read(input)).checks[0]?.status).toBe("failure"); + limited = true; + expect(yield* Effect.flip(read(input))).toMatchObject({ + reason: "rate-limited", + retryAt: 120_000, + }); + }), +); + +describe("bitbucketProviderFailure", () => { + it("treats only an HTTP 401 as unusable credentials", () => { + const responseError = (status: number) => + new BitbucketApi.BitbucketResponseError({ + operation: "request", + status, + responseBodyLength: 0, + }); + + expect(bitbucketProviderFailure(responseError(401)).reason).toBe("unauthenticated"); + expect(bitbucketProviderFailure(responseError(403)).reason).toBe("failed"); + }); +}); + +describe("bitbucketViewerPermissions", () => { + it("offers both actions to credentials with write access", () => { + expect(bitbucketViewerPermissions({ canWrite: true })).toEqual({ + actions: ["merge", "close"], + comment: true, + resolve: true, + verdicts: ["comment", "approve", "request-changes"], + // Bitbucket says nothing about who may set a reviewer, and an unreported permission is + // granted. + requestReviewers: true, + }); + }); + + it("keeps merge from credentials that can only read the repository", () => { + expect(bitbucketViewerPermissions({ canWrite: false })).toEqual({ + actions: ["close"], + comment: true, + resolve: true, + verdicts: ["comment", "approve", "request-changes"], + requestReviewers: true, + }); + }); + + it("treats an author with read access as any other reader, which is all Bitbucket says", () => { + // The repository permission is the whole of what Bitbucket reports per account; it says + // nothing about who opened this pull request, and its author may decline it with read access + // alone — so declining stays offered rather than being taken from them. + expect(bitbucketViewerPermissions({ canWrite: false }).actions).toEqual(["close"]); + }); +}); diff --git a/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts b/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts new file mode 100644 index 0000000000000000000000000000000000000000..0abcb7d95e7e745350044265fea70c145aae5fba --- /dev/null +++ b/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts @@ -0,0 +1,385 @@ +import * as Effect from "effect/Effect"; +import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts"; + +import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts"; +import { + PullRequestProviderError, + type PullRequestProviderFailure, + type ProviderChangeRequest, + type ProviderChangeRequestActivity, + type ProviderChangeRequestDetail, + type PullRequestProviderApi, +} from "@t3tools/source-control-core/server/PullRequestProvider"; +import type { BitbucketPullRequest } from "./bitbucketPullRequestJson.ts"; + +const CAPABILITIES: PullRequestCapabilities = { + diff: true, + comment: true, + // Bitbucket has no endpoint that reopens a declined pull request, and nothing documented that + // moves one in or out of draft, so neither is offered rather than failing when pressed. + actions: ["merge", "close"], + mergeMethods: ["merge", "squash", "rebase"], + search: true, + // Bitbucket Cloud's API exposes no reaction on a pull request or on a comment, so none is + // read and none is offered. + reactions: false, + review: { + inlineComment: true, + reply: true, + resolve: true, + verdicts: ["comment", "approve", "request-changes"], + }, + reviewers: { request: true, listCandidates: true }, + edit: { changeRequest: true, comment: true }, + // Bitbucket Cloud states nothing about what a reviewer has already read: no endpoint carries a + // viewed file, and the per-pull-request properties it does offer are one value shared by + // everyone rather than one per reader. So the marks are kept here, and the client says whose + // they are rather than implying bitbucket.org will show them. + viewedFiles: "environment", +}; + +/** + * What the configured account may do here, from the one thing Bitbucket states per viewer: the + * repository permission. Merging needs `write` or `admin`, so that is what narrows. + * + * Declining stays offered whatever the permission. Bitbucket lets the author of a pull request + * decline their own with no more than read access, and the permission response says nothing about + * who opened this one — so withholding the control from the one person entitled to it is the + * worse of the two mistakes. Commenting and reviewing are not narrowed either: read access is + * enough to say something, to approve and to ask for changes. + * + * Asking for a review is left open for the same reason: Bitbucket takes a reviewer set from the + * author of a pull request as well as from whoever can write, and says nothing here about which + * of the two this account is. + */ +export function bitbucketViewerPermissions(input: { + readonly canWrite: boolean; +}): PullRequestViewerPermissions { + return { + actions: CAPABILITIES.actions.filter((action) => action !== "merge" || input.canWrite), + comment: true, + resolve: true, + verdicts: CAPABILITIES.review.verdicts, + requestReviewers: true, + }; +} + +/** The failures that mean the credentials are the problem, rather than one request. */ +export function bitbucketProviderFailure( + error: BitbucketPullRequestApi.BitbucketPullRequestApiError, +): PullRequestProviderFailure { + // Bitbucket is read over HTTP with credentials from the environment, so there is no tool to be + // missing: unusable always means the credentials are absent or refused. + if (error._tag === "BitbucketResponseError" && error.status === 401) { + return { reason: "unauthenticated" }; + } + if ( + (error._tag === "BitbucketResponseError" || error._tag === "BitbucketResponseBodyReadError") && + error.status === 429 + ) { + return { + reason: "rate-limited", + ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }), + }; + } + if ( + (error._tag === "BitbucketResponseError" || error._tag === "BitbucketResponseBodyReadError") && + error.status === 404 + ) { + return { reason: "not-found" }; + } + return { reason: "failed" }; +} + +function toChangeRequest(pullRequest: BitbucketPullRequest): ProviderChangeRequest { + return { + number: pullRequest.number, + title: pullRequest.title, + url: pullRequest.url, + author: pullRequest.author, + headBranch: pullRequest.headBranch, + ...(pullRequest.headRepositoryNameWithOwner + ? { headRepositoryNameWithOwner: pullRequest.headRepositoryNameWithOwner } + : {}), + baseBranch: pullRequest.baseBranch, + state: pullRequest.state, + isDraft: pullRequest.isDraft, + mergeability: pullRequest.mergeability, + // Line counts are a separate read, which only the detail is worth spending on. + additions: 0, + deletions: 0, + createdAt: pullRequest.createdAt, + updatedAt: pullRequest.updatedAt, + reviewRequestLogins: pullRequest.reviewRequestLogins, + // Bitbucket has no labels on a pull request. + labels: [], + }; +} + +export const make = Effect.gen(function* () { + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const fail = + (operation: string) => (error: BitbucketPullRequestApi.BitbucketPullRequestApiError) => + new PullRequestProviderError({ + provider: "bitbucket", + operation, + ...bitbucketProviderFailure(error), + // Every Bitbucket failure states its own fact; this names the operation around it, so + // the two do not stack into "failed in x: failed in y: ...". + detail: error.detail, + cause: error, + }); + + const recoverRead = ( + read: Effect.Effect, + fallback: A, + ) => { + const recover = () => Effect.succeed(fallback); + return Effect.catchTags(read, { + BitbucketResponseError: (error) => (error.status === 429 ? Effect.fail(error) : recover()), + BitbucketUntrustedUrlError: recover, + BitbucketRepositoryLocatorError: recover, + BitbucketRequestError: recover, + BitbucketResponseBodyReadError: (error) => + error.status === 429 ? Effect.fail(error) : recover(), + BitbucketResponseDecodeError: recover, + BitbucketRepositoryVcsResolveError: recover, + BitbucketRepositoryRemotesListError: recover, + BitbucketRepositoryRemoteNotFoundError: recover, + BitbucketPullRequestBodyReadError: recover, + BitbucketCheckoutError: recover, + BitbucketPullRequestReadError: recover, + BitbucketViewerUnavailableError: recover, + BitbucketRepositoryUnsupportedError: recover, + BitbucketDiffCommitError: recover, + }); + }; + + const provider: PullRequestProviderApi = { + kind: "bitbucket", + capabilities: CAPABILITIES, + + // Bitbucket credentials come from the server's environment rather than a checkout, so the + // account is the same whichever workspace asks. + getViewer: () => api.getViewer().pipe(Effect.mapError(fail("getViewer"))), + + listChangeRequests: (input) => + api + .listPullRequests({ + repository: input.repository, + state: input.state, + limit: input.limit, + query: input.query, + cursor: input.cursor, + }) + .pipe( + Effect.mapError(fail("listChangeRequests")), + Effect.map((batch) => ({ + items: batch.items.map(toChangeRequest), + truncated: batch.truncated, + // Bitbucket is asked for `-updated_on` whether or not it is being carried on from, + // so every page it answers is one a cursor can continue. + continues: true, + })), + ), + + getChangeRequestChecks: (input) => + Effect.all([api.getPullRequest(input), api.listChecks(input)], { concurrency: 2 }).pipe( + Effect.map(([pullRequest, checks]) => ({ state: pullRequest.state, checks })), + Effect.mapError(fail("getChangeRequestChecks")), + ), + + getChangeRequest: (input) => { + const target = { repository: input.repository, number: input.number }; + return Effect.all( + [ + api.getPullRequest(target), + api.getDiffStat(target), + recoverRead(api.getMergeability(target), "unknown" as const), + recoverRead(api.listChecks(target), []), + // A permission that could not be read is an unknown one, which is granted: a hidden + // Merge leaves someone entitled to it with no way through, and one Bitbucket refuses + // at least says why. + recoverRead(api.getRepositoryPermission(target), true), + ], + { concurrency: 5 }, + ).pipe( + Effect.mapError(fail("getChangeRequest")), + Effect.map( + ([ + pullRequest, + diffStat, + mergeability, + checks, + canWrite, + ]): ProviderChangeRequestDetail => ({ + ...toChangeRequest(pullRequest), + mergeability, + additions: diffStat.additions, + deletions: diffStat.deletions, + changedFiles: diffStat.changedFiles, + body: pullRequest.body, + mergedAt: null, + closedAt: null, + reviewers: pullRequest.reviewers, + checks, + // Bitbucket publishes no per-repository list of allowed strategies, so the ones it + // supports are all offered and a strategy the repository forbids fails on merge. + mergeCapabilities: { merge: true, squash: true, rebase: true }, + viewerPermissions: bitbucketViewerPermissions({ canWrite }), + }), + ), + ); + }, + + getChangeRequestActivity: (input) => { + const target = { repository: input.repository, number: input.number }; + return Effect.all( + [ + // Reviews ride on the pull request itself, so this inexpensive core read is repeated + // here rather than making the core response wait for the conversation endpoints. + api.getPullRequest(target), + recoverRead(api.listComments(target), { comments: [], threads: [], truncated: true }), + recoverRead(api.listCommits(target), []), + ], + { concurrency: 3 }, + ).pipe( + Effect.mapError(fail("getChangeRequestActivity")), + Effect.map(([pullRequest, comments, commits]): ProviderChangeRequestActivity => ({ + comments: [...comments.comments, ...pullRequest.reviews].toSorted((left, right) => + left.createdAt.localeCompare(right.createdAt), + ), + commentCount: comments.comments.length + pullRequest.reviews.length, + commentsTruncated: comments.truncated, + reviewThreads: comments.threads, + commits, + })), + ); + }, + + getViewerPermissions: (input) => + api.getRepositoryPermission({ repository: input.repository }).pipe( + Effect.mapError(fail("getViewerPermissions")), + Effect.map((canWrite) => bitbucketViewerPermissions({ canWrite })), + ), + + // `/diff` answers with the whole patch and pages nothing, so the first slice is the last. + getDiff: (input) => + api + .getPullRequestDiff({ + repository: input.repository, + number: input.number, + ...(input.commit === undefined ? {} : { commit: input.commit }), + }) + .pipe( + Effect.mapError(fail("getDiff")), + Effect.map((diff) => ({ ...diff, nextCursor: null })), + ), + + getFileRevisions: (input) => + api + .getFileRevisions({ + repository: input.repository, + number: input.number, + paths: input.paths, + }) + .pipe(Effect.mapError(fail("getFileRevisions"))), + + // Users only: Bitbucket requests a review of an account, and has no group that stands in for + // one on a pull request. + listReviewerCandidates: (input) => + api + .listReviewerCandidates({ repository: input.repository, number: input.number }) + .pipe(Effect.mapError(fail("listReviewerCandidates"))), + + setReviewerRequest: (input) => + api + .setReviewerRequest({ + repository: input.repository, + number: input.number, + reviewers: input.reviewers, + requested: input.requested, + }) + .pipe(Effect.mapError(fail("setReviewerRequest"))), + + runAction: (input) => + api + .runAction({ + repository: input.repository, + number: input.number, + action: input.action, + ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), + }) + .pipe(Effect.mapError(fail("runAction"))), + + updateChangeRequest: (input) => + api + .updateChangeRequest({ + repository: input.repository, + number: input.number, + title: input.title, + body: input.body, + }) + .pipe(Effect.mapError(fail("updateChangeRequest"))), + + comment: (input) => + api + .comment({ repository: input.repository, number: input.number, body: input.body }) + .pipe(Effect.mapError(fail("comment"))), + + updateComment: (input) => + api + .updateComment({ + repository: input.repository, + number: input.number, + commentId: input.commentId, + body: input.body, + }) + .pipe(Effect.mapError(fail("updateComment"))), + + submitReview: (input) => + api + .submitReview({ + repository: input.repository, + number: input.number, + verdict: input.verdict, + body: input.body, + comments: input.comments, + }) + .pipe(Effect.mapError(fail("submitReview"))), + + replyToThread: (input) => + api + .replyToComment({ + repository: input.repository, + number: input.number, + commentId: input.threadId, + body: input.body, + }) + .pipe(Effect.mapError(fail("replyToThread"))), + + // Never called: `capabilities.reactions` is false, and the service refuses without it. + setReaction: () => + Effect.fail( + new PullRequestProviderError({ + provider: "bitbucket", + operation: "setReaction", + reason: "failed", + detail: "Bitbucket does not support reactions.", + }), + ), + + setThreadResolution: (input) => + api + .setCommentResolution({ + repository: input.repository, + number: input.number, + commentId: input.threadId, + resolved: input.resolved, + }) + .pipe(Effect.mapError(fail("setThreadResolution"))), + }; + + return provider; +}); diff --git a/packages/source-control-bitbucket/src/server/BitbucketSourceControlProvider.test.ts b/packages/source-control-bitbucket/src/server/BitbucketSourceControlProvider.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..52a15547c57329cb2930887e7e88596576727e0f --- /dev/null +++ b/packages/source-control-bitbucket/src/server/BitbucketSourceControlProvider.test.ts @@ -0,0 +1,151 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as BitbucketApi from "./BitbucketApi.ts"; +import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; + +function makeProvider(bitbucket: Partial) { + return BitbucketSourceControlProvider.make.pipe( + Effect.provide(Layer.mock(BitbucketApi.BitbucketApi)(bitbucket)), + ); +} + +it.effect("maps Bitbucket PR summaries into provider-neutral change requests", () => + Effect.gen(function* () { + const provider = yield* makeProvider({ + getPullRequest: () => + Effect.succeed({ + number: 42, + title: "Add Bitbucket provider", + url: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42", + baseRefName: "main", + headRefName: "feature/source-control", + state: "open", + updatedAt: Option.none(), + isCrossRepository: true, + headRepositoryNameWithOwner: "fork/t3code", + headRepositoryOwnerLogin: "fork", + }), + }); + + const changeRequest = yield* provider.getChangeRequest({ + cwd: "/repo", + reference: "42", + }); + + assert.deepStrictEqual(changeRequest, { + provider: "bitbucket", + number: 42, + title: "Add Bitbucket provider", + url: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42", + baseRefName: "main", + headRefName: "feature/source-control", + state: "open", + updatedAt: Option.none(), + isCrossRepository: true, + headRepositoryNameWithOwner: "fork/t3code", + headRepositoryOwnerLogin: "fork", + }); + }), +); + +it.effect("adds repository context while retaining Bitbucket API causes", () => + Effect.gen(function* () { + const upstreamCause = new Error("raw upstream failure"); + const cause = new BitbucketApi.BitbucketRequestError({ + operation: "getRepository", + cause: upstreamCause, + }); + const provider = yield* makeProvider({ + getRepositoryCloneUrls: () => Effect.fail(cause), + }); + + const error = yield* provider + .getRepositoryCloneUrls({ cwd: "/repo", repository: "owner/repo" }) + .pipe(Effect.flip); + + assert.deepStrictEqual( + { + provider: error.provider, + operation: error.operation, + command: error.command, + cwd: error.cwd, + repository: error.repository, + detail: error.detail, + }, + { + provider: "bitbucket", + operation: "getRepositoryCloneUrls", + command: undefined, + cwd: "/repo", + repository: "owner/repo", + detail: "Failed to get repository clone URLs.", + }, + ); + assert.strictEqual(error.cause, cause); + assert.equal(error.message.includes(upstreamCause.message), false); + }), +); + +it.effect("lists Bitbucket PRs through provider-neutral input names", () => + Effect.gen(function* () { + let listInput: Parameters[0] | null = + null; + const provider = yield* makeProvider({ + listPullRequests: (input) => { + listInput = input; + return Effect.succeed([]); + }, + }); + + yield* provider.listChangeRequests({ + cwd: "/repo", + headSelector: "feature/provider", + state: "all", + limit: 10, + }); + + assert.deepStrictEqual(listInput, { + cwd: "/repo", + headSelector: "feature/provider", + state: "all", + limit: 10, + }); + }), +); + +it.effect("creates Bitbucket PRs through provider-neutral input names", () => + Effect.gen(function* () { + let createInput: + | Parameters[0] + | null = null; + const provider = yield* makeProvider({ + createPullRequest: (input) => { + createInput = input; + return Effect.void; + }, + }); + + yield* provider.createChangeRequest({ + cwd: "/repo", + baseRefName: "main", + headSelector: "owner:feature/provider", + title: "Provider PR", + bodyFile: "/tmp/body.md", + }); + + assert.deepStrictEqual(createInput, { + cwd: "/repo", + baseBranch: "main", + headSelector: "owner:feature/provider", + source: { + owner: "owner", + refName: "feature/provider", + }, + title: "Provider PR", + bodyFile: "/tmp/body.md", + }); + }), +); diff --git a/packages/source-control-bitbucket/src/server/BitbucketSourceControlProvider.ts b/packages/source-control-bitbucket/src/server/BitbucketSourceControlProvider.ts new file mode 100644 index 0000000000000000000000000000000000000000..b9afd60bc389657b4d7c0c04decbab9747a69a0c --- /dev/null +++ b/packages/source-control-bitbucket/src/server/BitbucketSourceControlProvider.ts @@ -0,0 +1,198 @@ +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; + +import * as BitbucketApi from "./BitbucketApi.ts"; +import type { NormalizedBitbucketPullRequestRecord } from "./bitbucketPullRequests.ts"; +import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; +import type { SourceControlApiDiscoverySpec } from "@t3tools/source-control-core/server/discovery"; + +function toChangeRequest(summary: NormalizedBitbucketPullRequestRecord): ChangeRequest { + return { + provider: "bitbucket", + number: summary.number, + title: summary.title, + url: summary.url, + baseRefName: summary.baseRefName, + headRefName: summary.headRefName, + state: summary.state, + ...(summary.isDraft === true ? { isDraft: true } : {}), + updatedAt: summary.updatedAt ?? Option.none(), + ...(summary.isCrossRepository !== undefined + ? { isCrossRepository: summary.isCrossRepository } + : {}), + ...(summary.headRepositoryNameWithOwner !== undefined + ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner } + : {}), + ...(summary.headRepositoryOwnerLogin !== undefined + ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin } + : {}), + }; +} + +export const make = Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + + return SourceControlProvider.SourceControlProvider.of({ + kind: "bitbucket", + listChangeRequests: (input) => { + const source = SourceControlProvider.sourceControlRefFromInput(input); + return bitbucket + .listPullRequests({ + cwd: input.cwd, + ...(input.context ? { context: input.context } : {}), + headSelector: input.headSelector, + ...(source ? { source } : {}), + state: input.state, + ...(input.limit !== undefined ? { limit: input.limit } : {}), + }) + .pipe( + Effect.map((items) => items.map(toChangeRequest)), + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "bitbucket", + operation: "listChangeRequests", + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.headSelector, + ), + detail: "Failed to list change requests.", + cause: error, + }), + ), + ); + }, + getChangeRequest: (input) => + bitbucket.getPullRequest(input).pipe( + Effect.map(toChangeRequest), + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "bitbucket", + operation: "getChangeRequest", + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.reference, + ), + detail: "Failed to get change request.", + cause: error, + }), + ), + ), + createChangeRequest: (input) => { + const source = SourceControlProvider.sourceControlRefFromInput(input); + return bitbucket + .createPullRequest({ + cwd: input.cwd, + ...(input.context ? { context: input.context } : {}), + baseBranch: input.baseRefName, + headSelector: input.headSelector, + ...(source ? { source } : {}), + ...(input.target ? { target: input.target } : {}), + title: input.title, + bodyFile: input.bodyFile, + }) + .pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "bitbucket", + operation: "createChangeRequest", + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.headSelector, + ), + detail: "Failed to create change request.", + cause: error, + }), + ), + ); + }, + getRepositoryCloneUrls: (input) => + bitbucket.getRepositoryCloneUrls(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "bitbucket", + operation: "getRepositoryCloneUrls", + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue( + input.repository, + ), + detail: "Failed to get repository clone URLs.", + cause: error, + }), + ), + ), + createRepository: (input) => + bitbucket.createRepository(input).pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "bitbucket", + operation: "createRepository", + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue( + input.repository, + ), + detail: "Failed to create repository.", + cause: error, + }), + ), + ), + getDefaultBranch: (input) => + bitbucket + .getDefaultBranch({ + cwd: input.cwd, + ...(input.context ? { context: input.context } : {}), + }) + .pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "bitbucket", + operation: "getDefaultBranch", + cwd: input.cwd, + detail: "Failed to get default branch.", + cause: error, + }), + ), + ), + checkoutChangeRequest: (input) => + bitbucket + .checkoutPullRequest({ + cwd: input.cwd, + ...(input.context ? { context: input.context } : {}), + reference: input.reference, + ...(input.force !== undefined ? { force: input.force } : {}), + }) + .pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "bitbucket", + operation: "checkoutChangeRequest", + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.reference, + ), + detail: "Failed to check out change request.", + cause: error, + }), + ), + ), + }); +}); + +export const makeDiscovery = Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + + return { + type: "api", + kind: "bitbucket", + label: "Bitbucket", + installHint: "Add a Bitbucket token in Settings → Source Control.", + probeAuth: bitbucket.probeAuth, + } satisfies SourceControlApiDiscoverySpec; +}); diff --git a/packages/source-control-bitbucket/src/server/bitbucketPullRequestJson.test.ts b/packages/source-control-bitbucket/src/server/bitbucketPullRequestJson.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..356c5da96f333345ff4104a4217be496978f0357 --- /dev/null +++ b/packages/source-control-bitbucket/src/server/bitbucketPullRequestJson.test.ts @@ -0,0 +1,377 @@ +import * as Result from "effect/Result"; +import { describe, expect, it } from "vite-plus/test"; + +import { + decodeCommentsJson, + decodeCommitsJson, + decodeConflictsJson, + decodeDiffstatJson, + decodePullRequestJson, + decodePullRequestPageJson, + decodeRepositoryPermissionJson, + decodeStatusesJson, + decodeViewerJson, +} from "./bitbucketPullRequestJson.ts"; + +/** Shaped after a real api.bitbucket.org pull request, trimmed to the fields that are read. */ +function pullRequest(overrides: Record = {}): Record { + return { + id: 897, + title: "Add trustabl-pipe", + description: "# Add trustabl-pipe", + state: "OPEN", + draft: false, + created_on: "2026-06-16T05:04:32.258456+00:00", + updated_on: "2026-06-16T05:04:33.750542+00:00", + author: { display_name: "Bilal Hassan", nickname: "bilal", type: "user" }, + source: { branch: { name: "feat/page" }, repository: { full_name: "fork/web" } }, + destination: { branch: { name: "master" } }, + links: { html: { href: "https://bitbucket.org/acme/web/pull-requests/897" } }, + ...overrides, + }; +} + +function page(values: ReadonlyArray, extra: Record = {}): string { + return JSON.stringify({ pagelen: 50, page: 1, size: values.length, values, ...extra }); +} + +function expectSuccess(result: Result.Result): A { + expect(Result.isSuccess(result)).toBe(true); + if (!Result.isSuccess(result)) throw new Error("expected a successful decode"); + return result.success; +} + +describe("decodePullRequestPageJson", () => { + it("reads a pull request as a change request", () => { + const decoded = expectSuccess(decodePullRequestPageJson(page([pullRequest()]))); + + expect(decoded.items).toHaveLength(1); + expect(decoded.items[0]).toMatchObject({ + number: 897, + title: "Add trustabl-pipe", + url: "https://bitbucket.org/acme/web/pull-requests/897", + author: { login: "bilal", name: "Bilal Hassan" }, + headBranch: "feat/page", + headRepositoryNameWithOwner: "fork/web", + baseBranch: "master", + state: "open", + isDraft: false, + // Bitbucket says nothing about conflicts on the pull request itself. + mergeability: "unknown", + }); + expect(decoded.next).toBeNull(); + }); + + it("normalizes Bitbucket's offset timestamps, which the page sorts against other hosts", () => { + const decoded = expectSuccess(decodePullRequestPageJson(page([pullRequest()]))); + + expect(decoded.items[0]).toMatchObject({ + createdAt: "2026-06-16T05:04:32.258Z", + updatedAt: "2026-06-16T05:04:33.750Z", + }); + }); + + it("reports the next page as the whole URL Bitbucket sends", () => { + const next = "https://api.bitbucket.org/2.0/repositories/acme/web/pullrequests?page=2"; + const decoded = expectSuccess(decodePullRequestPageJson(page([pullRequest()], { next }))); + + expect(decoded.next).toBe(next); + }); + + it.each([ + ["MERGED", "merged"], + ["DECLINED", "closed"], + ["SUPERSEDED", "closed"], + ["OPEN", "open"], + ["something new", "open"], + ])("reads the %s state as %s", (state, expected) => { + const decoded = expectSuccess(decodePullRequestPageJson(page([pullRequest({ state })]))); + + expect(decoded.items[0]?.state).toBe(expected); + }); + + it("skips a malformed row rather than failing the page", () => { + const decoded = expectSuccess( + decodePullRequestPageJson(page([{ id: "not a number" }, pullRequest()])), + ); + + expect(decoded.items).toHaveLength(1); + }); + + it("fails when Bitbucket did not answer with a page", () => { + expect(Result.isFailure(decodePullRequestPageJson(JSON.stringify({ error: "nope" })))).toBe( + true, + ); + }); +}); + +describe("decodePullRequestJson", () => { + it("reads reviewers as review requests", () => { + const decoded = expectSuccess( + decodePullRequestJson( + JSON.stringify( + pullRequest({ + reviewers: [{ nickname: "julius", display_name: "Julius" }], + }), + ), + ), + ); + + expect(decoded.reviewRequestLogins).toEqual(["julius"]); + expect(decoded.reviewers).toEqual([{ login: "julius", name: "Julius", avatarUrl: null }]); + }); + + it("reads a participant's vote as a review", () => { + const decoded = expectSuccess( + decodePullRequestJson( + JSON.stringify( + pullRequest({ + participants: [ + { + user: { nickname: "julius", display_name: "Julius" }, + role: "REVIEWER", + approved: true, + state: "approved", + participated_on: "2026-06-17T09:00:00+00:00", + }, + // Added as a reviewer but has not voted, so there is no verdict to show. + { + user: { nickname: "sam", display_name: "Sam" }, + role: "REVIEWER", + approved: false, + state: null, + participated_on: null, + }, + ], + }), + ), + ), + ); + + expect(decoded.reviews).toHaveLength(1); + expect(decoded.reviews[0]).toMatchObject({ + kind: "review", + author: { login: "julius" }, + reviewState: "approved", + createdAt: "2026-06-17T09:00:00.000Z", + }); + }); +}); + +describe("decodeViewerJson", () => { + it("reads the signed-in nickname", () => { + const decoded = decodeViewerJson(JSON.stringify({ nickname: "bilal", display_name: "Bilal" })); + + expect(expectSuccess(decoded)).toBe("bilal"); + }); + + it("falls back to the display name, which app accounts have instead", () => { + const decoded = decodeViewerJson(JSON.stringify({ display_name: "Release Bot" })); + + expect(expectSuccess(decoded)).toBe("Release Bot"); + }); + + it("returns nothing when the account has neither", () => { + expect(expectSuccess(decodeViewerJson(JSON.stringify({})))).toBeNull(); + }); +}); + +describe("decodeCommentsJson", () => { + it("keeps a posted comment and drops deleted and unposted ones", () => { + const decoded = expectSuccess( + decodeCommentsJson( + page([ + { + id: 797230941, + content: { raw: "The issue is ready for review." }, + user: { display_name: "Release Bot", type: "app_user" }, + created_on: "2026-05-15T01:58:38.220690+00:00", + deleted: false, + pending: false, + links: { html: { href: "https://bitbucket.org/acme/web/pull-requests/892#c1" } }, + }, + { + id: 2, + content: { raw: "gone" }, + created_on: "2026-05-15T02:00:00+00:00", + deleted: true, + }, + { + id: 3, + content: { raw: "wip" }, + created_on: "2026-05-15T02:00:00+00:00", + pending: true, + }, + { id: 4, content: { raw: " " }, created_on: "2026-05-15T02:00:00+00:00" }, + ]), + ), + ); + + expect(decoded.comments).toHaveLength(1); + expect(decoded.comments[0]).toMatchObject({ + id: "797230941", + kind: "issue-comment", + // An app account has no nickname, so its display name is the only handle it has. + author: { login: "Release Bot" }, + createdAt: "2026-05-15T01:58:38.220Z", + }); + }); + + it("reads a comment pinned to a file as a review comment", () => { + const decoded = expectSuccess( + decodeCommentsJson( + page([ + { + id: 5, + content: { raw: "Rename this." }, + created_on: "2026-05-15T02:00:00+00:00", + inline: { path: "src/app.ts" }, + }, + ]), + ), + ); + + expect(decoded.comments[0]).toMatchObject({ kind: "review-comment", path: "src/app.ts" }); + }); +}); + +describe("decodeCommitsJson", () => { + it("returns commits oldest first with only the subject line", () => { + const decoded = expectSuccess( + decodeCommitsJson( + page([ + { hash: "bbb", message: "second\n\nbody text\n", date: "2026-06-16T04:51:00+00:00" }, + { + hash: "aaa", + message: "first\n", + date: "2026-06-16T04:50:49+00:00", + author: { + raw: "Ada Lovelace ", + user: { nickname: "ada", display_name: "Ada Lovelace" }, + }, + }, + ]), + ), + ); + + expect(decoded.items.map((commit) => commit.oid)).toEqual(["aaa", "bbb"]); + expect(decoded.items[0]?.authors).toEqual([ + { login: "ada", name: "Ada Lovelace", avatarUrl: null }, + ]); + expect(decoded.items[1]?.messageHeadline).toBe("second"); + expect(decoded.next).toBeNull(); + }); + + it("skips commits whose hash is empty", () => { + const decoded = expectSuccess( + decodeCommitsJson( + page([ + { hash: " ", message: "invalid", date: "2026-06-16T04:51:00+00:00" }, + { hash: "aaa", date: "2026-06-16T04:50:49+00:00" }, + ]), + ), + ); + + expect(decoded.items.map((commit) => commit.oid)).toEqual(["aaa"]); + }); +}); + +describe("decodeStatusesJson", () => { + it("reads a build status as a check", () => { + const decoded = expectSuccess( + decodeStatusesJson( + page([ + { + key: "custom:check-version-and-pr", + name: "Pipeline - custom: check-version-and-pr", + state: "SUCCESSFUL", + description: "", + url: "https://bitbucket.org/acme/web/pipelines/results/8126", + }, + ]), + ), + ); + + expect(decoded).toEqual({ + items: [ + { + name: "Pipeline - custom: check-version-and-pr", + status: "success", + description: null, + url: "https://bitbucket.org/acme/web/pipelines/results/8126", + }, + ], + next: null, + }); + }); + + it.each([ + ["SUCCESSFUL", "success"], + ["FAILED", "failure"], + ["INPROGRESS", "pending"], + ["STOPPED", "cancelled"], + ["something new", "neutral"], + ])("reads the %s build state as %s", (state, expected) => { + const decoded = expectSuccess(decodeStatusesJson(page([{ name: "Pipeline", state }]))); + + expect(decoded.items[0]?.status).toBe(expected); + }); + + it("keeps two statuses that share a display name but have different keys", () => { + const decoded = expectSuccess( + decodeStatusesJson( + page([ + { key: "build", name: "Pipeline", state: "SUCCESSFUL" }, + { key: "deploy", name: "Pipeline", state: "FAILED" }, + ]), + ), + ); + + expect(decoded.items.map((check) => [check.name, check.status])).toEqual([ + ["build / Pipeline", "success"], + ["deploy / Pipeline", "failure"], + ]); + }); +}); + +describe("decodeDiffstatJson", () => { + it("adds up the per-file counts", () => { + const decoded = expectSuccess( + decodeDiffstatJson( + page([ + { lines_added: 9, lines_removed: 2 }, + { lines_added: 32, lines_removed: 14 }, + ]), + ), + ); + + expect(decoded).toEqual({ additions: 41, deletions: 16, changedFiles: 2, next: null }); + }); +}); + +describe("decodeConflictsJson", () => { + it("calls an empty conflict list mergeable", () => { + expect(expectSuccess(decodeConflictsJson(page([])))).toBe("mergeable"); + }); + + it("calls any reported conflict conflicting", () => { + expect(expectSuccess(decodeConflictsJson(page([{ path: "src/app.ts" }])))).toBe("conflicting"); + }); +}); + +describe("repository permission decoding", () => { + const permissionPage = (permission: string) => + page([{ type: "repository_permission", permission }]); + + it("counts admin and write as write, and read as not", () => { + expect(expectSuccess(decodeRepositoryPermissionJson(permissionPage("admin")))).toBe(true); + expect(expectSuccess(decodeRepositoryPermissionJson(permissionPage("write")))).toBe(true); + expect(expectSuccess(decodeRepositoryPermissionJson(permissionPage("read")))).toBe(false); + }); + + it("grants write where Bitbucket named no permission at all", () => { + // An empty page is Bitbucket declining to say, which is an unknown standing rather than a + // refusal — and an unknown one is granted. + expect(expectSuccess(decodeRepositoryPermissionJson(page([])))).toBe(true); + }); +}); diff --git a/packages/source-control-bitbucket/src/server/bitbucketPullRequestJson.ts b/packages/source-control-bitbucket/src/server/bitbucketPullRequestJson.ts new file mode 100644 index 0000000000000000000000000000000000000000..8f0b520ca089b64d6851b8c1bd8e159964ac48b1 --- /dev/null +++ b/packages/source-control-bitbucket/src/server/bitbucketPullRequestJson.ts @@ -0,0 +1,631 @@ +import * as Cause from "effect/Cause"; +import * as DateTime from "effect/DateTime"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import type { + PullRequestActor, + PullRequestCheck, + PullRequestCheckStatus, + PullRequestComment, + PullRequestCommit, + PullRequestMergeability, + PullRequestReviewThread, + PullRequestReviewerCandidate, + PullRequestState, +} from "@t3tools/contracts"; +import { TrimmedNonEmptyString } from "@t3tools/contracts"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; + +import { dedupeChecks } from "@t3tools/source-control-core/server/pullRequestChecks"; + +/** + * Bitbucket's enums are decoded as plain strings and normalized here, in the same tolerant + * style as the GitHub and GitLab decoders: a new pull request state or build status must not + * fail a whole payload. + */ +const RawUserSchema = Schema.Struct({ + /** + * How Bitbucket addresses an account when a reviewer set is written; the handles it shows are + * not accepted there. Braced, and sent back exactly as it arrived. + */ + uuid: Schema.optional(Schema.NullOr(Schema.String)), + /** Absent on an app account, which is why `display_name` has to stand in for it. */ + nickname: Schema.optional(Schema.NullOr(Schema.String)), + display_name: Schema.optional(Schema.NullOr(Schema.String)), + links: Schema.optional( + Schema.NullOr( + Schema.Struct({ + avatar: Schema.optional( + Schema.NullOr(Schema.Struct({ href: Schema.optional(Schema.String) })), + ), + }), + ), + ), +}); + +/** + * Required, and required to be non-empty: the wire contract will not carry a change request + * without a branch or a link, so a row missing one is skipped rather than breaking the response + * it travels in. + */ +const RawBranchSchema = Schema.Struct({ + branch: Schema.Struct({ name: TrimmedNonEmptyString }), + repository: Schema.optional(Schema.NullOr(Schema.Struct({ full_name: TrimmedNonEmptyString }))), +}); + +const RawLinkSchema = Schema.Struct({ href: Schema.optional(Schema.String) }); + +const RawPullRequestSchema = Schema.Struct({ + id: Schema.Int, + title: Schema.String, + description: Schema.optional(Schema.NullOr(Schema.String)), + state: Schema.optional(Schema.NullOr(Schema.String)), + draft: Schema.optional(Schema.Boolean), + author: Schema.optional(Schema.NullOr(RawUserSchema)), + source: RawBranchSchema, + destination: RawBranchSchema, + created_on: Schema.String, + updated_on: Schema.String, + reviewers: Schema.optional(Schema.NullOr(Schema.Array(RawUserSchema))), + participants: Schema.optional( + Schema.NullOr( + Schema.Array( + Schema.Struct({ + user: Schema.optional(Schema.NullOr(RawUserSchema)), + role: Schema.optional(Schema.NullOr(Schema.String)), + approved: Schema.optional(Schema.Boolean), + state: Schema.optional(Schema.NullOr(Schema.String)), + participated_on: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), + ), + links: Schema.Struct({ html: Schema.Struct({ href: TrimmedNonEmptyString }) }), +}); + +const RawPageSchema = Schema.Struct({ + values: Schema.Array(Schema.Unknown), + /** A total count, which Bitbucket omits on some endpoints. */ + size: Schema.optional(Schema.NullOr(Schema.Int)), + /** Present only while a further page exists. */ + next: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawCommentSchema = Schema.Struct({ + id: Schema.Int, + content: Schema.optional(Schema.NullOr(Schema.Struct({ raw: Schema.optional(Schema.String) }))), + user: Schema.optional(Schema.NullOr(RawUserSchema)), + created_on: Schema.String, + deleted: Schema.optional(Schema.Boolean), + /** A comment still being drafted by its author. */ + pending: Schema.optional(Schema.Boolean), + /** Set on a reply, to the comment it answers — which may itself be a reply. */ + parent: Schema.optional(Schema.NullOr(Schema.Struct({ id: Schema.Int }))), + inline: Schema.optional( + Schema.NullOr( + Schema.Struct({ + path: Schema.optional(Schema.NullOr(Schema.String)), + /** The line in the file as it was; set instead of `to` on a removed line. */ + from: Schema.optional(Schema.NullOr(Schema.Int)), + /** The line in the file as it is now. */ + to: Schema.optional(Schema.NullOr(Schema.Int)), + outdated: Schema.optional(Schema.NullOr(Schema.Boolean)), + }), + ), + ), + /** Non-null once someone has marked the thread resolved. */ + resolution: Schema.optional(Schema.NullOr(Schema.Unknown)), + links: Schema.optional( + Schema.NullOr(Schema.Struct({ html: Schema.optional(Schema.NullOr(RawLinkSchema)) })), + ), +}); + +const RawCommitSchema = Schema.Struct({ + hash: TrimmedNonEmptyString, + message: Schema.optional(Schema.NullOr(Schema.String)), + date: Schema.optional(Schema.NullOr(Schema.String)), + author: Schema.optional( + Schema.NullOr( + Schema.Struct({ + raw: Schema.optional(Schema.NullOr(Schema.String)), + user: Schema.optional(Schema.NullOr(RawUserSchema)), + }), + ), + ), +}); + +const RawStatusSchema = Schema.Struct({ + key: Schema.optional(Schema.NullOr(Schema.String)), + name: Schema.optional(Schema.NullOr(Schema.String)), + state: Schema.optional(Schema.NullOr(Schema.String)), + description: Schema.optional(Schema.NullOr(Schema.String)), + url: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawDiffstatSchema = Schema.Struct({ + lines_added: Schema.optional(Schema.NullOr(Schema.Int)), + lines_removed: Schema.optional(Schema.NullOr(Schema.Int)), +}); + +/** One row of `/workspaces/{workspace}/members`, which wraps the account it is about. */ +const RawMemberSchema = Schema.Struct({ + user: Schema.optional(Schema.NullOr(RawUserSchema)), +}); + +const RawViewerSchema = Schema.Struct({ + nickname: Schema.optional(Schema.NullOr(Schema.String)), + display_name: Schema.optional(Schema.NullOr(Schema.String)), +}); + +/** + * `/user/permissions/repositories` filtered to one repository, which is the only place Bitbucket + * states what the credentials may do with it: nothing on the repository, the pull request or the + * workspace carries it. One row, or none where Bitbucket names no permission for this account. + */ +const RawRepositoryPermissionsSchema = Schema.Struct({ + values: Schema.optional( + Schema.NullOr( + Schema.Array(Schema.Struct({ permission: Schema.optional(Schema.NullOr(Schema.String)) })), + ), + ), +}); + +export interface BitbucketPullRequest { + readonly number: number; + readonly title: string; + readonly url: string; + readonly author: PullRequestActor | null; + readonly headBranch: string; + readonly headRepositoryNameWithOwner: string | null; + readonly baseBranch: string; + readonly state: PullRequestState; + readonly isDraft: boolean; + /** + * Bitbucket reports no conflict state on a pull request, so the list leaves it unknown. The + * detail read asks the conflicts endpoint, which does answer. + */ + readonly mergeability: PullRequestMergeability; + readonly createdAt: string; + readonly updatedAt: string; + readonly body: string; + readonly reviewRequestLogins: ReadonlyArray; + readonly reviewers: ReadonlyArray; + /** The reviewers as Bitbucket addresses them, which is what writing the set back takes. */ + readonly reviewerIds: ReadonlyArray; + /** Approvals and change requests, which Bitbucket keeps on its participants. */ + readonly reviews: ReadonlyArray; +} + +function trimmed(value: string | null | undefined): string | null { + const text = value?.trim() ?? ""; + return text.length > 0 ? text : null; +} + +/** + * Bitbucket stamps times as `+00:00` with microseconds. The page sorts change requests from + * every host against each other as plain strings, so they are normalized to the same `Z` form + * the other hosts already use. + */ +function toIsoUtc(value: string): string { + return Option.match(DateTime.make(value), { + onNone: () => value, + onSome: DateTime.formatIso, + }); +} + +/** An app account has no nickname, so the display name is the only handle it has. */ +function toActor(raw: Schema.Schema.Type | null | undefined) { + const login = trimmed(raw?.nickname) ?? trimmed(raw?.display_name); + return login === null + ? null + : { + login, + name: trimmed(raw?.display_name), + avatarUrl: trimmed(raw?.links?.avatar?.href), + }; +} + +function toState(raw: Schema.Schema.Type): PullRequestState { + switch (raw.state?.trim().toUpperCase()) { + case "MERGED": + return "merged"; + case "DECLINED": + case "SUPERSEDED": + return "closed"; + default: + return "open"; + } +} + +function toBuildStatus(value: string | null | undefined): PullRequestCheckStatus { + switch (value?.trim().toUpperCase()) { + case "SUCCESSFUL": + return "success"; + case "FAILED": + return "failure"; + case "STOPPED": + return "cancelled"; + case "INPROGRESS": + return "pending"; + default: + return "neutral"; + } +} + +/** + * A participant who has voted is the closest Bitbucket has to a review, so it reads as one in + * the conversation. Participants who have only been added carry no verdict and are skipped. + */ +function toReviews( + raw: Schema.Schema.Type, +): ReadonlyArray { + return (raw.participants ?? []).flatMap((participant): ReadonlyArray => { + const author = toActor(participant.user); + const votedAt = trimmed(participant.participated_on); + const reviewState = + trimmed(participant.state) ?? (participant.approved === true ? "approved" : null); + if (author === null || votedAt === null || reviewState === null) return []; + return [ + { + id: `${raw.id}:${author.login}`, + kind: "review", + author, + body: "", + createdAt: toIsoUtc(votedAt), + url: null, + path: null, + reviewState, + }, + ]; + }); +} + +function toPullRequest(raw: Schema.Schema.Type): BitbucketPullRequest { + const reviewers = (raw.reviewers ?? []).flatMap((reviewer) => { + const actor = toActor(reviewer); + return actor === null ? [] : [actor]; + }); + return { + number: raw.id, + title: raw.title, + url: raw.links.html.href, + author: toActor(raw.author), + headBranch: raw.source.branch.name, + headRepositoryNameWithOwner: raw.source.repository?.full_name ?? null, + baseBranch: raw.destination.branch.name, + state: toState(raw), + isDraft: raw.draft ?? false, + mergeability: "unknown", + createdAt: toIsoUtc(raw.created_on), + updatedAt: toIsoUtc(raw.updated_on), + body: raw.description ?? "", + reviewRequestLogins: reviewers.map((reviewer) => reviewer.login), + reviewers, + reviewerIds: (raw.reviewers ?? []).flatMap((reviewer) => trimmed(reviewer.uuid) ?? []), + reviews: toReviews(raw), + }; +} + +const decodePage = decodeJsonResult(RawPageSchema); +const decodePullRequestEntry = Schema.decodeUnknownExit(RawPullRequestSchema); +const decodePullRequest = decodeJsonResult(RawPullRequestSchema); +const decodeCommentEntry = Schema.decodeUnknownExit(RawCommentSchema); +const decodeCommitEntry = Schema.decodeUnknownExit(RawCommitSchema); +const decodeStatusEntry = Schema.decodeUnknownExit(RawStatusSchema); +const decodeDiffstatEntry = Schema.decodeUnknownExit(RawDiffstatSchema); +const decodeMemberEntry = Schema.decodeUnknownExit(RawMemberSchema); +const decodeViewer = decodeJsonResult(RawViewerSchema); +const decodeConflicts = decodeJsonResult(RawPageSchema); +const decodeRepositoryPermissions = decodeJsonResult(RawRepositoryPermissionsSchema); + +type DecodeFailure = Cause.Cause; + +export interface BitbucketPage { + readonly items: ReadonlyArray; + /** The whole URL of the next page, which Bitbucket sends rather than an offset. */ + readonly next: string | null; +} + +/** Malformed entries are skipped rather than failing the page, as on the other hosts. */ +export function decodePullRequestPageJson( + raw: string, +): Result.Result, DecodeFailure> { + const decoded = decodePage(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const items: BitbucketPullRequest[] = []; + for (const entry of decoded.success.values) { + const item = decodePullRequestEntry(entry); + if (Exit.isSuccess(item)) { + items.push(toPullRequest(item.value)); + } + } + return Result.succeed({ items, next: trimmed(decoded.success.next) }); +} + +export function decodePullRequestJson( + raw: string, +): Result.Result { + const decoded = decodePullRequest(raw); + return Result.isSuccess(decoded) + ? Result.succeed(toPullRequest(decoded.success)) + : Result.fail(decoded.failure); +} + +export function decodeViewerJson(raw: string): Result.Result { + const decoded = decodeViewer(raw); + return Result.isSuccess(decoded) + ? Result.succeed(trimmed(decoded.success.nickname) ?? trimmed(decoded.success.display_name)) + : Result.fail(decoded.failure); +} + +/** + * Whether the configured credentials can write to the repository, which is what merging needs. + * Bitbucket answers `admin`, `write` or `read`, and an empty page means it named no permission at + * all for this account — an unknown standing, which is granted rather than guessed away. + */ +export function decodeRepositoryPermissionJson(raw: string): Result.Result { + const decoded = decodeRepositoryPermissions(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const permission = trimmed(decoded.success.values?.[0]?.permission)?.toLowerCase() ?? null; + return Result.succeed(permission === null || permission === "admin" || permission === "write"); +} + +/** + * The workspace's members, which is the nearest thing Bitbucket has to "who may review this". + * Nothing on a repository lists the people with access to it — `permissions-config/users` is for + * administrators only — and a pull request can be sent to anyone in the workspace, so this is the + * list Bitbucket's own reviewer field is filled from too. + * + * Nobody is marked requested here: who has been asked lives on the pull request, and only the + * caller holds both. + */ +export function decodeWorkspaceMembersJson( + raw: string, +): Result.Result, DecodeFailure> { + const decoded = decodePage(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const items: PullRequestReviewerCandidate[] = []; + for (const entry of decoded.success.values) { + const member = decodeMemberEntry(entry); + if (Exit.isFailure(member)) continue; + const uuid = trimmed(member.value.user?.uuid); + const actor = toActor(member.value.user); + if (uuid === null || actor === null) continue; + items.push({ ...actor, id: uuid, kind: "user", isRequested: false }); + } + return Result.succeed({ items, next: trimmed(decoded.success.next) }); +} + +/** One comment as Bitbucket sent it, kept so threads can be assembled across pages. */ +export type BitbucketRawComment = Schema.Schema.Type; + +export interface BitbucketComments { + readonly comments: ReadonlyArray; + /** + * The same comments unread, for `buildReviewThreads`. A reply and the remark it answers can + * land on different pages, and only the caller holding every page can put them together. + */ + readonly entries: ReadonlyArray; + readonly next: string | null; +} + +/** + * Bitbucket returns one flat list, so a thread is reassembled from it: a comment pinned to a + * line opens a thread, and every reply that leads back to it belongs in it. A reply whose + * parent is on a page that was not read has nowhere to go, and is left out rather than shown + * as a thread of its own — it still stands in the flat conversation, which needs no parent. + */ +export function buildReviewThreads( + comments: ReadonlyArray, +): ReadonlyArray { + const byId = new Map(comments.map((comment) => [comment.id, comment])); + const rootOf = (comment: Schema.Schema.Type) => { + // Bounded by the number of comments read, so a parent cycle cannot spin here. + let current = comment; + for (let step = 0; step < byId.size; step += 1) { + const parent = current.parent === null ? undefined : byId.get(current.parent?.id ?? -1); + if (parent === undefined) return current; + current = parent; + } + return current; + }; + + const threads = new Map(); + const replies = new Map>>(); + for (const comment of comments) { + const root = rootOf(comment); + const inline = root.inline; + const path = trimmed(inline?.path); + if (path === null) continue; + if (root.id === comment.id) { + // `to` is the line as the file stands now, `from` the line it replaced; a comment that + // carries only `from` was written against the removed side. + const side = inline?.to === null || inline?.to === undefined ? "left" : "right"; + const line = side === "left" ? inline?.from : inline?.to; + threads.set(root.id, { + id: String(root.id), + path, + line: typeof line === "number" && line > 0 ? line : null, + side, + isResolved: root.resolution !== null && root.resolution !== undefined, + isOutdated: inline?.outdated === true, + comments: [], + }); + } + const bucket = replies.get(root.id); + if (bucket === undefined) replies.set(root.id, [comment]); + else bucket.push(comment); + } + + return [...threads.values()].flatMap((thread) => { + const entries = (replies.get(Number(thread.id)) ?? []) + .toSorted((left, right) => left.created_on.localeCompare(right.created_on)) + .map((comment) => ({ + id: String(comment.id), + author: toActor(comment.user), + body: comment.content?.raw ?? "", + createdAt: toIsoUtc(comment.created_on), + url: trimmed(comment.links?.html?.href), + })); + return entries.length === 0 ? [] : [{ ...thread, comments: entries }]; + }); +} + +/** + * Deleted comments and ones their author has not posted yet carry nothing to show. A comment + * pinned to a file is a line-level review comment, which is what that kind means. + */ +export function decodeCommentsJson(raw: string): Result.Result { + const decoded = decodePage(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const comments: PullRequestComment[] = []; + const kept: Array = []; + for (const entry of decoded.success.values) { + const decodedComment = decodeCommentEntry(entry); + if (Exit.isFailure(decodedComment)) continue; + const comment = decodedComment.value; + if (comment.deleted === true || comment.pending === true) continue; + const body = comment.content?.raw ?? ""; + if (body.trim().length === 0) continue; + kept.push(comment); + const path = trimmed(comment.inline?.path); + comments.push({ + id: String(comment.id), + kind: path === null ? "issue-comment" : "review-comment", + author: toActor(comment.user), + body, + createdAt: toIsoUtc(comment.created_on), + url: trimmed(comment.links?.html?.href), + path, + reviewState: null, + }); + } + return Result.succeed({ comments, entries: kept, next: trimmed(decoded.success.next) }); +} + +export function decodeCommitsJson( + raw: string, +): Result.Result, DecodeFailure> { + const decoded = decodePage(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const commits: PullRequestCommit[] = []; + for (const entry of decoded.success.values) { + const decodedCommit = decodeCommitEntry(entry); + if (Exit.isFailure(decodedCommit)) continue; + const commit = decodedCommit.value; + const committedDate = trimmed(commit.date); + if (committedDate === null) continue; + const linkedAuthor = toActor(commit.author?.user); + const rawAuthor = trimmed(commit.author?.raw); + commits.push({ + oid: commit.hash, + messageHeadline: (commit.message ?? "").split("\n")[0] ?? "", + committedDate: toIsoUtc(committedDate), + authors: + linkedAuthor !== null + ? [linkedAuthor] + : rawAuthor === null + ? [] + : [{ login: rawAuthor, name: rawAuthor, avatarUrl: null }], + }); + } + // Bitbucket lists a pull request's commits newest first; the timeline reads oldest first. + return Result.succeed({ items: commits.toReversed(), next: trimmed(decoded.success.next) }); +} + +export function decodeStatusesJson( + raw: string, +): Result.Result, DecodeFailure> { + const decoded = decodePage(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const checks: Array<{ + readonly check: PullRequestCheck; + readonly workflowName: string | null; + readonly at: string | null; + }> = []; + for (const entry of decoded.success.values) { + const decodedStatus = decodeStatusEntry(entry); + if (Exit.isFailure(decodedStatus)) continue; + const status = decodedStatus.value; + const name = trimmed(status.name) ?? trimmed(status.key); + if (name === null) continue; + // Bitbucket re-uses a status key when a pipeline is run again, so the same check can appear + // twice on one page. Nothing decoded here says which copy is newer, so the later one wins, + // which is the order Bitbucket writes an update in. The key is kept as the workflow name so + // two different pipelines that display the same name are not folded into one. + checks.push({ + check: { + name, + status: toBuildStatus(status.state), + description: trimmed(status.description), + url: trimmed(status.url), + }, + workflowName: trimmed(status.key), + at: null, + }); + } + return Result.succeed({ items: dedupeChecks(checks), next: trimmed(decoded.success.next) }); +} + +export interface BitbucketDiffStat { + readonly additions: number; + readonly deletions: number; + readonly changedFiles: number; +} + +export interface BitbucketDiffStatPage extends BitbucketDiffStat { + readonly next: string | null; +} + +/** One entry per changed file, each carrying that file's line counts. */ +export function decodeDiffstatJson( + raw: string, +): Result.Result { + const decoded = decodePage(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + let additions = 0; + let deletions = 0; + let changedFiles = 0; + for (const entry of decoded.success.values) { + const decodedStat = decodeDiffstatEntry(entry); + if (Exit.isFailure(decodedStat)) continue; + additions += decodedStat.value.lines_added ?? 0; + deletions += decodedStat.value.lines_removed ?? 0; + changedFiles += 1; + } + return Result.succeed({ + additions, + deletions, + changedFiles, + next: trimmed(decoded.success.next), + }); +} + +/** + * The conflicts endpoint answers with one entry per conflicting path, so an empty page is the + * only statement Bitbucket makes that a pull request merges cleanly. + */ +export function decodeConflictsJson( + raw: string, +): Result.Result { + const decoded = decodeConflicts(raw); + return Result.isSuccess(decoded) + ? Result.succeed(decoded.success.values.length === 0 ? "mergeable" : "conflicting") + : Result.fail(decoded.failure); +} diff --git a/packages/source-control-bitbucket/src/server/bitbucketPullRequests.ts b/packages/source-control-bitbucket/src/server/bitbucketPullRequests.ts new file mode 100644 index 0000000000000000000000000000000000000000..3b07334a80409db06faf79ae89893086b2cd8b37 --- /dev/null +++ b/packages/source-control-bitbucket/src/server/bitbucketPullRequests.ts @@ -0,0 +1,109 @@ +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; + +export interface NormalizedBitbucketPullRequestRecord { + readonly number: number; + readonly title: string; + readonly url: string; + readonly baseRefName: string; + readonly headRefName: string; + readonly state: "open" | "closed" | "merged"; + readonly isDraft?: boolean; + readonly updatedAt: Option.Option; + readonly isCrossRepository?: boolean; + readonly headRepositoryNameWithOwner?: string | null; + readonly headRepositoryOwnerLogin?: string | null; +} + +export const BitbucketRepositoryRefSchema = Schema.Struct({ + full_name: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + workspace: Schema.optional( + Schema.NullOr( + Schema.Struct({ + slug: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + }), + ), + ), +}); + +export const BitbucketPullRequestBranchSchema = Schema.Struct({ + repository: Schema.optional(Schema.NullOr(BitbucketRepositoryRefSchema)), + branch: Schema.Struct({ + name: TrimmedNonEmptyString, + }), +}); + +export const BitbucketPullRequestSchema = Schema.Struct({ + id: PositiveInt, + title: TrimmedNonEmptyString, + state: Schema.optional(Schema.NullOr(Schema.String)), + draft: Schema.optional(Schema.Boolean), + updated_on: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), + links: Schema.Struct({ + html: Schema.Struct({ + href: TrimmedNonEmptyString, + }), + }), + source: BitbucketPullRequestBranchSchema, + destination: BitbucketPullRequestBranchSchema, +}); + +export const BitbucketPullRequestListSchema = Schema.Struct({ + values: Schema.Array(BitbucketPullRequestSchema), + next: Schema.optional(TrimmedNonEmptyString), +}); + +function trimOptionalString(value: string | null | undefined): string | null { + const trimmed = value?.trim() ?? ""; + return trimmed.length > 0 ? trimmed : null; +} + +function repositoryOwner(repository: Schema.Schema.Type) { + return ( + trimOptionalString(repository.workspace?.slug) ?? + (repository.full_name?.includes("/") ? (repository.full_name.split("/")[0] ?? null) : null) + ); +} + +function normalizeBitbucketPullRequestState(state: string | null | undefined) { + switch (state?.trim().toUpperCase()) { + case "MERGED": + return "merged" as const; + case "DECLINED": + case "SUPERSEDED": + return "closed" as const; + case "OPEN": + default: + return "open" as const; + } +} + +export function normalizeBitbucketPullRequestRecord( + raw: Schema.Schema.Type, +): NormalizedBitbucketPullRequestRecord { + const headRepositoryNameWithOwner = trimOptionalString(raw.source.repository?.full_name); + const baseRepositoryNameWithOwner = trimOptionalString(raw.destination.repository?.full_name); + const headRepositoryOwnerLogin = raw.source.repository + ? repositoryOwner(raw.source.repository) + : null; + const isCrossRepository = + headRepositoryNameWithOwner !== null && + baseRepositoryNameWithOwner !== null && + headRepositoryNameWithOwner !== baseRepositoryNameWithOwner; + + return { + number: raw.id, + title: raw.title, + url: raw.links.html.href, + baseRefName: raw.destination.branch.name, + headRefName: raw.source.branch.name, + state: normalizeBitbucketPullRequestState(raw.state), + ...(raw.draft === true ? { isDraft: true } : {}), + updatedAt: raw.updated_on ?? Option.none(), + ...(isCrossRepository ? { isCrossRepository: true } : {}), + ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}), + ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}), + }; +} diff --git a/packages/source-control-bitbucket/src/server/driver.ts b/packages/source-control-bitbucket/src/server/driver.ts new file mode 100644 index 0000000000000000000000000000000000000000..06cd225ab4e4cdb21a12a81146c01939eaa6da45 --- /dev/null +++ b/packages/source-control-bitbucket/src/server/driver.ts @@ -0,0 +1,20 @@ +/** + * The Bitbucket Cloud driver: repository operations, discovery, and pull requests through the + * Bitbucket REST API. + * + * @module source-control-bitbucket/server/driver + */ +import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver"; +import * as Effect from "effect/Effect"; + +import * as BitbucketPullRequestProvider from "./BitbucketPullRequestProvider.ts"; +import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; + +export const driver = defineSourceControlDriver({ + kind: "bitbucket", + make: Effect.all({ + sourceControl: BitbucketSourceControlProvider.make, + discovery: BitbucketSourceControlProvider.makeDiscovery, + pullRequests: BitbucketPullRequestProvider.make, + }), +}); diff --git a/packages/source-control-bitbucket/tsconfig.json b/packages/source-control-bitbucket/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..374bac55202ddcad695ede1c567ec219a61e9c48 --- /dev/null +++ b/packages/source-control-bitbucket/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src"] +} diff --git a/packages/source-control-core/src/server/SourceControlHost.ts b/packages/source-control-core/src/server/SourceControlHost.ts index d779f86fe0e2019251667ba9b3a9d40781dd6494..564b318d85b4283c8125bb830b33dc980d8e85ce 100644 --- a/packages/source-control-core/src/server/SourceControlHost.ts +++ b/packages/source-control-core/src/server/SourceControlHost.ts @@ -7,9 +7,20 @@ * its own API and CLI code. HTTP, the filesystem, and paths come from Effect's platform * services directly. * + * `git` is the subset of the server's git driver that checking out a change request needs; it + * grows only when a provider needs another operation. + * * @module source-control-core/server/SourceControlHost */ -import type { ServerSettings, ServerSettingsError, VcsError } from "@t3tools/contracts"; +import type { + GitCommandError, + ServerSettings, + ServerSettingsError, + VcsError, + VcsListRemotesResult, + VcsSwitchRefInput, + VcsSwitchRefResult, +} from "@t3tools/contracts"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; import type * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; @@ -43,6 +54,12 @@ export interface SourceControlProcessOutput { readonly stderrInvalidUtf8?: boolean; } +export interface SourceControlGitRemoteBranch { + readonly cwd: string; + readonly remoteName: string; + readonly remoteBranch: string; +} + export class SourceControlHost extends Context.Service< SourceControlHost, { @@ -55,5 +72,38 @@ export class SourceControlHost extends Context.Service< input: SourceControlProcessInput, ) => Effect.Effect; }; + readonly git: { + /** + * Lists the remotes of the repository at `cwd`. The outer effect resolves the repository + * and fails when the server cannot drive one there; the inner one lists its remotes. + */ + readonly remotes: ( + cwd: string, + ) => Effect.Effect, VcsError>; + readonly readConfigValue: ( + cwd: string, + key: string, + ) => Effect.Effect; + readonly resolvePrimaryRemoteName: (cwd: string) => Effect.Effect; + /** Adds a remote for `url` unless one exists, and returns the name it is under. */ + readonly ensureRemote: (input: { + readonly cwd: string; + readonly preferredName: string; + readonly url: string; + }) => Effect.Effect; + readonly listLocalBranchNames: (cwd: string) => Effect.Effect; + readonly fetchRemoteBranch: ( + input: SourceControlGitRemoteBranch & { readonly localBranch: string }, + ) => Effect.Effect; + readonly fetchRemoteTrackingBranch: ( + input: SourceControlGitRemoteBranch, + ) => Effect.Effect; + readonly setBranchUpstream: ( + input: SourceControlGitRemoteBranch & { readonly branch: string }, + ) => Effect.Effect; + readonly switchRef: ( + input: VcsSwitchRefInput, + ) => Effect.Effect; + }; } >()("@t3tools/source-control-core/server/SourceControlHost") {} diff --git a/packages/source-control-testing/src/TestSourceControlHost.ts b/packages/source-control-testing/src/TestSourceControlHost.ts index 293339d0867e6c6357af68971a4a6f78a6b25877..eee1a96f13a97fd9632bb755e430174b4ee950d5 100644 --- a/packages/source-control-testing/src/TestSourceControlHost.ts +++ b/packages/source-control-testing/src/TestSourceControlHost.ts @@ -1,7 +1,7 @@ /** * A `SourceControlHost.SourceControlHost` for provider tests. Settings are fixed unless the test - * supplies its own, and every process run goes to the test's `run`, which fails by default so an - * unexpected CLI call is visible. + * supplies its own. Process runs and git operations go to what the test supplies; anything else + * dies, so an unexpected CLI or git call is visible. * * @module source-control-testing/TestSourceControlHost */ @@ -14,8 +14,26 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; export interface TestSourceControlHostOptions { readonly settings?: ServerSettings; readonly process?: Partial; + readonly git?: Partial; } +type Git = SourceControlHost.SourceControlHost["Service"]["git"]; + +const unexpectedGit = (operation: string) => () => + Effect.die(`Unexpected git ${operation} in a test that supplied none.`); + +const failingGit: Git = { + remotes: unexpectedGit("remotes"), + readConfigValue: unexpectedGit("readConfigValue"), + resolvePrimaryRemoteName: unexpectedGit("resolvePrimaryRemoteName"), + ensureRemote: unexpectedGit("ensureRemote"), + listLocalBranchNames: unexpectedGit("listLocalBranchNames"), + fetchRemoteBranch: unexpectedGit("fetchRemoteBranch"), + fetchRemoteTrackingBranch: unexpectedGit("fetchRemoteTrackingBranch"), + setBranchUpstream: unexpectedGit("setBranchUpstream"), + switchRef: unexpectedGit("switchRef"), +}; + export const layer = ( options: TestSourceControlHostOptions = {}, ): Layer.Layer => @@ -28,6 +46,7 @@ export const layer = ( options.process?.run ?? ((input) => Effect.die(`Unexpected ${input.command} run in ${input.operation}.`)), }, + git: { ...failingGit, ...options.git }, }), ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 13c583be13f07dd763f20fb41a61ff0e4fa948bb..5ad28290d9852de72f412b8fad85fa7bac544658 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -651,6 +651,9 @@ importers: '@t3tools/source-control-azure-devops': specifier: workspace:* version: link:../../packages/source-control-azure-devops + '@t3tools/source-control-bitbucket': + specifier: workspace:* + version: link:../../packages/source-control-bitbucket '@t3tools/source-control-core': specifier: workspace:* version: link:../../packages/source-control-core @@ -1556,6 +1559,34 @@ importers: specifier: 1.0.0 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/source-control-bitbucket: + dependencies: + '@t3tools/contracts': + specifier: workspace:* + version: link:../contracts + '@t3tools/provider-core': + specifier: workspace:* + version: link:../provider-core + '@t3tools/shared': + specifier: workspace:* + version: link:../shared + '@t3tools/source-control-core': + specifier: workspace:* + version: link:../source-control-core + effect: + specifier: 4.0.2 + version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688) + devDependencies: + '@effect/vitest': + specifier: 4.0.2 + version: 4.0.2(patch_hash=595b7d26fb635caba9c3d39fcb189bb78a000192f8d3b60ead7174b310d1ade9)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + vite-plus: + specifier: 1.0.0 + version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/source-control-core: dependencies: '@t3tools/contracts': diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index c19a1ee9cac4b73fd498f50bd264a469425ecf86..96b1df41e1ea8cfa972590fa4871cee13f17b318 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -38,6 +38,7 @@ const workspaceFiles = [ "packages/provider-muse/package.json", "packages/provider-opencode/package.json", "packages/source-control-azure-devops/package.json", + "packages/source-control-bitbucket/package.json", "packages/source-control-core/package.json", "packages/source-control-forgejo/package.json", "packages/source-control-gitlab/package.json",