1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Result from "effect/Result";
5
6import * as BitbucketApi from "./BitbucketApi.ts";
7import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts";
8import { decodePullRequestJson } from "./bitbucketPullRequestJson.ts";
9import {
10 bitbucketProviderFailure,
11 bitbucketViewerPermissions,
12 make,
13} from "./BitbucketPullRequestProvider.ts";
14
15it.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
73for (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
146it.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
190describe("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
204describe("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});