Skip to content
Source
Sign inSign up

BitbucketPullRequestProvider.test.ts

250 lines9.4 KB

1import { SourceControlProviderKind } from "@t3tools/contracts";
2import { describe, expect, it } from "@effect/vitest";
3import * as ConfigProvider from "effect/ConfigProvider";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Result from "effect/Result";
7import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
8​
9import * as BitbucketApi from "./BitbucketApi.ts";
10import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts";
11import { decodePullRequestJson } from "./bitbucketPullRequestJson.ts";
12import {
13 bitbucketProviderFailure,
14 bitbucketViewerPermissions,
15 make,
16} from "./BitbucketPullRequestProvider.ts";
17​
18it.effect.each([
19 { state: "MERGED", mergedAt: "2025-06-16T23:40:46.222Z", closedAt: null },
20 { state: "DECLINED", mergedAt: null, closedAt: "2025-06-16T23:40:46.222Z" },
21 { state: "OPEN", mergedAt: null, closedAt: null },
22])("reads the $state time from closed_on", ({ state, mergedAt, closedAt }) =>
23 Effect.gen(function* () {
24 const pullRequest = Result.getOrThrow(
25 decodePullRequestJson(
26 JSON.stringify({
27 id: 52,
28 title: "Release",
29 state,
30 source: { branch: { name: "release" } },
31 destination: { branch: { name: "master" } },
32 created_on: "2025-06-16T06:20:58.415108+00:00",
33 // Comments after merging move this timestamp.
34 updated_on: "2025-06-20T10:00:00.000000+00:00",
35 closed_on: state === "OPEN" ? null : "2025-06-16T23:40:46.222238+00:00",
36 links: { html: { href: "https://bitbucket.org/acme/web/pull-requests/52" } },
37 }),
38 ),
39 );
40 const provider = yield* make.pipe(
41 Effect.provide(
42 Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({
43 getPullRequest: () => Effect.succeed(pullRequest),
44 getDiffStat: () => Effect.succeed({ additions: 0, deletions: 0, changedFiles: 0 }),
45 getMergeability: () => Effect.succeed("unknown"),
46 listChecks: () => Effect.succeed([]),
47 getRepositoryPermission: () => Effect.succeed(true),
48 }),
49 ),
50 );
51 const detail = yield* provider.getChangeRequest({
52 cwd: "/repo",
53 repository: "acme/web",
54 host: "bitbucket.org",
55 number: 52,
56 });
57​
58 expect({ mergedAt: detail.mergedAt, closedAt: detail.closedAt }).toEqual({
59 mergedAt,
60 closedAt,
61 });
62 }),
63);
64​
65for (const operation of [
66 "getMergeability",
67 "listChecks",
68 "getRepositoryPermission",
69 "listComments",
70 "listCommits",
71] as const) {
72 it.effect.each(["response", "body read"])(
73 `preserves rate limits from ${operation} on %s errors while recovering other optional-read failures`,
74 (variant) =>
75 Effect.gen(function* () {
76 const pullRequest = Result.getOrThrow(
77 decodePullRequestJson(`{
78 "id": 1, "title": "Check polling", "state": "OPEN",
79 "source": { "branch": { "name": "feature" } },
80 "destination": { "branch": { "name": "main" } },
81 "created_on": "2026-09-16T00:00:00Z",
82 "updated_on": "2026-09-16T00:00:00Z",
83 "links": { "html": { "href": "https://bitbucket.org/acme/web/pull-requests/1" } }
84 }`),
85 );
86 for (const status of [429, 403]) {
87 const provider = yield* make.pipe(
88 Effect.provide(
89 Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({
90 getPullRequest: () => Effect.succeed(pullRequest),
91 getDiffStat: () => Effect.succeed({ additions: 0, deletions: 0, changedFiles: 0 }),
92 getMergeability: () => Effect.succeed("unknown" as const),
93 listChecks: () => Effect.succeed([]),
94 getRepositoryPermission: () => Effect.succeed(true),
95 listComments: () => Effect.succeed({ comments: [], threads: [], truncated: false }),
96 listCommits: () => Effect.succeed([]),
97 [operation]: () =>
98 Effect.fail(
99 variant === "response"
100 ? new BitbucketApi.BitbucketResponseError({
101 operation: "request",
102 status,
103 responseBodyLength: 0,
104 retryAt: 120_000,
105 })
106 : new BitbucketApi.BitbucketResponseBodyReadError({
107 operation: "request",
108 status,
109 cause: new Error("response stream failed"),
110 retryAt: 120_000,
111 }),
112 ),
113 }),
114 ),
115 );
116 const reference = {
117 cwd: "/repo",
118 repository: "acme/web",
119 number: 1,
120 host: "bitbucket.org",
121 };
122 const result = yield* operation === "listComments" || operation === "listCommits"
123 ? Effect.result(provider.getChangeRequestActivity(reference))
124 : Effect.result(provider.getChangeRequest(reference));
125 if (status === 429) {
126 expect(result).toMatchObject({
127 _tag: "Failure",
128 failure: { reason: "rate-limited", retryAt: 120_000 },
129 });
130 } else {
131 expect(result._tag).toBe("Success");
132 }
133 }
134 }),
135 );
137​
138it.effect("reads checks and PR state without diff, mergeability, or permission requests", () =>
139 Effect.gen(function* () {
140 const pullRequest = Result.getOrThrow(
141 decodePullRequestJson(`{
142 "id": 1, "title": "Checks", "state": "OPEN",
143 "source": { "branch": { "name": "feature" } },
144 "destination": { "branch": { "name": "main" } },
145 "created_on": "2026-09-16T00:00:00Z", "updated_on": "2026-09-16T00:00:00Z",
146 "links": { "html": { "href": "https://bitbucket.org/acme/web/pull-requests/1" } }
147 }`),
148 );
149 let limited = false;
150 const provider = yield* make.pipe(
151 Effect.provide(
152 Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({
153 getPullRequest: () => Effect.succeed(pullRequest),
154 listChecks: () =>
155 limited
156 ? Effect.fail(
157 new BitbucketApi.BitbucketResponseError({
158 operation: "request",
159 status: 429,
160 responseBodyLength: 0,
161 retryAt: 120_000,
162 }),
163 )
164 : Effect.succeed([
165 { name: "build", status: "failure" as const, description: null, url: null },
166 ]),
167 }),
168 ),
169 );
170 const read = provider.getChangeRequestChecks;
171 if (read === undefined) return yield* Effect.die("checks read missing");
172 const input = { cwd: "/repo", repository: "acme/web", host: "bitbucket.org", number: 1 };
173 expect((yield* read(input)).checks[0]?.status).toBe("failure");
174 limited = true;
175 expect(yield* Effect.flip(read(input))).toMatchObject({
176 reason: "rate-limited",
177 retryAt: 120_000,
178 });
179 }),
180);
181​
182it.effect("serves only Bitbucket Cloud hosts unless an API base url is configured", () =>
183 Effect.gen(function* () {
184 const servedHosts = (env: Record<string, string>) =>
185 make.pipe(
186 Effect.map(({ servesHost }) =>
187 ["bitbucket.org", "altssh.bitbucket.org", "bitbucket.example.com"].filter(
188 (host) => servesHost?.(host) === true,
189 ),
190 ),
191 Effect.provide(
192 Layer.merge(
193 Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({}),
194 ConfigProvider.layer(ConfigProvider.fromEnv({ env })),
195 ),
196 ),
197 );
198​
199 expect(yield* servedHosts({})).toEqual(["bitbucket.org", "altssh.bitbucket.org"]);
200 expect(
201 yield* servedHosts({ T3CODE_BITBUCKET_API_BASE_URL: "https://bitbucket.example.com/api" }),
202 ).toEqual(["bitbucket.org", "altssh.bitbucket.org", "bitbucket.example.com"]);
203 }),
204);
205​
206describe("bitbucketProviderFailure", () => {
207 it("treats only an HTTP 401 as unusable credentials", () => {
208 const responseError = (status: number) =>
209 new BitbucketApi.BitbucketResponseError({
210 operation: "request",
211 status,
212 responseBodyLength: 0,
213 });
214​
215 expect(bitbucketProviderFailure(responseError(401)).reason).toBe("unauthenticated");
216 expect(bitbucketProviderFailure(responseError(403)).reason).toBe("failed");
217 });
218​
219 it("reports a request held back by the host-wide pause as rate limited until it ends", () => {
220 const paused = new SourceControlRateLimit.SourceControlRateLimitPausedError({
221 provider: SourceControlProviderKind.make("bitbucket"),
222 host: "bitbucket.org",
223 retryAt: 120_000,
224 });
225​
226 expect(bitbucketProviderFailure(paused)).toEqual({ reason: "rate-limited", retryAt: 120_000 });
227 });
228});
229​
230describe("bitbucketViewerPermissions", () => {
231 it("offers both actions to credentials with write access", () => {
232 expect(bitbucketViewerPermissions({ canWrite: true })).toEqual({
233 actions: ["merge", "close"],
234 comment: true,
235 resolve: true,
236 verdicts: ["comment", "approve", "request-changes"],
237 requestReviewers: true,
238 });
239 });
240​
241 it("keeps merge from credentials that can only read the repository", () => {
242 expect(bitbucketViewerPermissions({ canWrite: false })).toEqual({
243 actions: ["close"],
244 comment: true,
245 resolve: true,
246 verdicts: ["comment", "approve", "request-changes"],
247 requestReviewers: true,
248 });
249 });
250});