| 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Layer from "effect/Layer"; |
| 4 | import * as Result from "effect/Result"; |
| 5 | |
| 6 | import * as BitbucketApi from "./BitbucketApi.ts"; |
| 7 | import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts"; |
| 8 | import { decodePullRequestJson } from "./bitbucketPullRequestJson.ts"; |
| 9 | import { |
| 10 | bitbucketProviderFailure, |
| 11 | bitbucketViewerPermissions, |
| 12 | make, |
| 13 | } from "./BitbucketPullRequestProvider.ts"; |
| 14 | |
| 15 | it.effect.each([ |
| 16 | { |
| 17 | metadata: "source commit hash", |
| 18 | commit: { hash: "9f3a7c2d8b1e6f045a9c3d7e2b8f1a6c0d4e5f93" }, |
| 19 | headSha: "9f3a7c2d8b1e6f045a9c3d7e2b8f1a6c0d4e5f93", |
| 20 | }, |
| 21 | { |
| 22 | metadata: "short source commit hash", |
| 23 | commit: { hash: "9f3a7c2d8b1e" }, |
| 24 | headSha: "9f3a7c2d8b1e", |
| 25 | }, |
| 26 | { metadata: "absent commit", commit: undefined, headSha: undefined }, |
| 27 | { metadata: "null commit", commit: null, headSha: undefined }, |
| 28 | { metadata: "absent hash", commit: {}, headSha: undefined }, |
| 29 | { metadata: "null hash", commit: { hash: null }, headSha: undefined }, |
| 30 | ])("reads change request details with $metadata", ({ commit, headSha }) => |
| 31 | Effect.gen(function* () { |
| 32 | const pullRequest = Result.getOrThrow( |
| 33 | decodePullRequestJson( |
| 34 | JSON.stringify({ |
| 35 | id: 897, |
| 36 | title: "Add trustabl-pipe", |
| 37 | state: "OPEN", |
| 38 | source: { |
| 39 | branch: { name: "feat/page" }, |
| 40 | commit, |
| 41 | repository: { full_name: "fork/web" }, |
| 42 | }, |
| 43 | destination: { branch: { name: "master" }, commit: { hash: "base-commit" } }, |
| 44 | created_on: "2026-06-16T05:04:32.258456+00:00", |
| 45 | updated_on: "2026-06-16T05:04:33.750542+00:00", |
| 46 | links: { html: { href: "https://bitbucket.org/acme/web/pull-requests/897" } }, |
| 47 | }), |
| 48 | ), |
| 49 | ); |
| 50 | const provider = yield* make.pipe( |
| 51 | Effect.provide( |
| 52 | Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({ |
| 53 | getPullRequest: () => Effect.succeed(pullRequest), |
| 54 | getDiffStat: () => Effect.succeed({ additions: 0, deletions: 0, changedFiles: 0 }), |
| 55 | getMergeability: () => Effect.succeed("unknown"), |
| 56 | listChecks: () => Effect.succeed([]), |
| 57 | getRepositoryPermission: () => Effect.succeed(true), |
| 58 | }), |
| 59 | ), |
| 60 | ); |
| 61 | const detail = yield* provider.getChangeRequest({ |
| 62 | cwd: "/repo", |
| 63 | repository: "acme/web", |
| 64 | host: "bitbucket.org", |
| 65 | number: 897, |
| 66 | }); |
| 67 | |
| 68 | expect(detail).toMatchObject({ number: 897, headBranch: "feat/page" }); |
| 69 | expect(detail.headSha).toBe(headSha); |
| 70 | }), |
| 71 | ); |
| 72 | |
| 73 | for (const operation of [ |
| 74 | "getMergeability", |
| 75 | "listChecks", |
| 76 | "getRepositoryPermission", |
| 77 | "listComments", |
| 78 | "listCommits", |
| 79 | ] as const) { |
| 80 | it.effect.each(["response", "body read"])( |
| 81 | `preserves rate limits from ${operation} on %s errors while recovering other optional-read failures`, |
| 82 | (variant) => |
| 83 | Effect.gen(function* () { |
| 84 | const pullRequest = Result.getOrThrow( |
| 85 | decodePullRequestJson(`{ |
| 86 | "id": 1, "title": "Check polling", "state": "OPEN", |
| 87 | "source": { "branch": { "name": "feature" } }, |
| 88 | "destination": { "branch": { "name": "main" } }, |
| 89 | "created_on": "2026-09-16T00:00:00Z", |
| 90 | "updated_on": "2026-09-16T00:00:00Z", |
| 91 | "links": { "html": { "href": "https://bitbucket.org/acme/web/pull-requests/1" } } |
| 92 | }`), |
| 93 | ); |
| 94 | for (const status of [429, 403]) { |
| 95 | const provider = yield* make.pipe( |
| 96 | Effect.provide( |
| 97 | Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({ |
| 98 | getPullRequest: () => Effect.succeed(pullRequest), |
| 99 | getDiffStat: () => Effect.succeed({ additions: 0, deletions: 0, changedFiles: 0 }), |
| 100 | getMergeability: () => Effect.succeed("unknown" as const), |
| 101 | listChecks: () => Effect.succeed([]), |
| 102 | getRepositoryPermission: () => Effect.succeed(true), |
| 103 | listComments: () => Effect.succeed({ comments: [], threads: [], truncated: false }), |
| 104 | listCommits: () => Effect.succeed([]), |
| 105 | [operation]: () => |
| 106 | Effect.fail( |
| 107 | variant === "response" |
| 108 | ? new BitbucketApi.BitbucketResponseError({ |
| 109 | operation: "request", |
| 110 | status, |
| 111 | responseBodyLength: 0, |
| 112 | retryAt: 120_000, |
| 113 | }) |
| 114 | : new BitbucketApi.BitbucketResponseBodyReadError({ |
| 115 | operation: "request", |
| 116 | status, |
| 117 | cause: new Error("response stream failed"), |
| 118 | retryAt: 120_000, |
| 119 | }), |
| 120 | ), |
| 121 | }), |
| 122 | ), |
| 123 | ); |
| 124 | const reference = { |
| 125 | cwd: "/repo", |
| 126 | repository: "acme/web", |
| 127 | number: 1, |
| 128 | host: "bitbucket.org", |
| 129 | }; |
| 130 | const result = yield* operation === "listComments" || operation === "listCommits" |
| 131 | ? Effect.result(provider.getChangeRequestActivity(reference)) |
| 132 | : Effect.result(provider.getChangeRequest(reference)); |
| 133 | if (status === 429) { |
| 134 | expect(result).toMatchObject({ |
| 135 | _tag: "Failure", |
| 136 | failure: { reason: "rate-limited", retryAt: 120_000 }, |
| 137 | }); |
| 138 | } else { |
| 139 | expect(result._tag).toBe("Success"); |
| 140 | } |
| 141 | } |
| 142 | }), |
| 143 | ); |
| 144 | } |
| 145 | |
| 146 | it.effect("reads checks and PR state without diff, mergeability, or permission requests", () => |
| 147 | Effect.gen(function* () { |
| 148 | const pullRequest = Result.getOrThrow( |
| 149 | decodePullRequestJson(`{ |
| 150 | "id": 1, "title": "Checks", "state": "OPEN", |
| 151 | "source": { "branch": { "name": "feature" } }, |
| 152 | "destination": { "branch": { "name": "main" } }, |
| 153 | "created_on": "2026-09-16T00:00:00Z", "updated_on": "2026-09-16T00:00:00Z", |
| 154 | "links": { "html": { "href": "https://bitbucket.org/acme/web/pull-requests/1" } } |
| 155 | }`), |
| 156 | ); |
| 157 | let limited = false; |
| 158 | const provider = yield* make.pipe( |
| 159 | Effect.provide( |
| 160 | Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({ |
| 161 | getPullRequest: () => Effect.succeed(pullRequest), |
| 162 | listChecks: () => |
| 163 | limited |
| 164 | ? Effect.fail( |
| 165 | new BitbucketApi.BitbucketResponseError({ |
| 166 | operation: "request", |
| 167 | status: 429, |
| 168 | responseBodyLength: 0, |
| 169 | retryAt: 120_000, |
| 170 | }), |
| 171 | ) |
| 172 | : Effect.succeed([ |
| 173 | { name: "build", status: "failure" as const, description: null, url: null }, |
| 174 | ]), |
| 175 | }), |
| 176 | ), |
| 177 | ); |
| 178 | const read = provider.getChangeRequestChecks; |
| 179 | if (read === undefined) return yield* Effect.die("checks read missing"); |
| 180 | const input = { cwd: "/repo", repository: "acme/web", host: "bitbucket.org", number: 1 }; |
| 181 | expect((yield* read(input)).checks[0]?.status).toBe("failure"); |
| 182 | limited = true; |
| 183 | expect(yield* Effect.flip(read(input))).toMatchObject({ |
| 184 | reason: "rate-limited", |
| 185 | retryAt: 120_000, |
| 186 | }); |
| 187 | }), |
| 188 | ); |
| 189 | |
| 190 | describe("bitbucketProviderFailure", () => { |
| 191 | it("treats only an HTTP 401 as unusable credentials", () => { |
| 192 | const responseError = (status: number) => |
| 193 | new BitbucketApi.BitbucketResponseError({ |
| 194 | operation: "request", |
| 195 | status, |
| 196 | responseBodyLength: 0, |
| 197 | }); |
| 198 | |
| 199 | expect(bitbucketProviderFailure(responseError(401)).reason).toBe("unauthenticated"); |
| 200 | expect(bitbucketProviderFailure(responseError(403)).reason).toBe("failed"); |
| 201 | }); |
| 202 | }); |
| 203 | |
| 204 | describe("bitbucketViewerPermissions", () => { |
| 205 | it("offers both actions to credentials with write access", () => { |
| 206 | expect(bitbucketViewerPermissions({ canWrite: true })).toEqual({ |
| 207 | actions: ["merge", "close"], |
| 208 | comment: true, |
| 209 | resolve: true, |
| 210 | verdicts: ["comment", "approve", "request-changes"], |
| 211 | // Bitbucket says nothing about who may set a reviewer, and an unreported permission is |
| 212 | // granted. |
| 213 | requestReviewers: true, |
| 214 | }); |
| 215 | }); |
| 216 | |
| 217 | it("keeps merge from credentials that can only read the repository", () => { |
| 218 | expect(bitbucketViewerPermissions({ canWrite: false })).toEqual({ |
| 219 | actions: ["close"], |
| 220 | comment: true, |
| 221 | resolve: true, |
| 222 | verdicts: ["comment", "approve", "request-changes"], |
| 223 | requestReviewers: true, |
| 224 | }); |
| 225 | }); |
| 226 | |
| 227 | it("treats an author with read access as any other reader, which is all Bitbucket says", () => { |
| 228 | // The repository permission is the whole of what Bitbucket reports per account; it says |
| 229 | // nothing about who opened this pull request, and its author may decline it with read access |
| 230 | // alone — so declining stays offered rather than being taken from them. |
| 231 | expect(bitbucketViewerPermissions({ canWrite: false }).actions).toEqual(["close"]); |
| 232 | }); |
| 233 | }); |