Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 80 additions & 1 deletion apps/server/src/pullRequest/BitbucketPullRequestProvider.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,90 @@
import { describe, expect, it } from "vite-plus/test";
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");
}
}
}),
);
}

describe("bitbucketProviderFailure", () => {
it("treats only an HTTP 401 as unusable credentials", () => {
const responseError = (status: number) =>
Expand Down
42 changes: 34 additions & 8 deletions apps/server/src/pullRequest/BitbucketPullRequestProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,10 @@ export function bitbucketProviderFailure(
if (error._tag === "BitbucketResponseError" && error.status === 401) {
return { reason: "unauthenticated" };
}
if (error._tag === "BitbucketResponseError" && error.status === 429) {
if (
(error._tag === "BitbucketResponseError" || error._tag === "BitbucketResponseBodyReadError") &&
error.status === 429
) {
return {
reason: "rate-limited",
...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }),
Expand Down Expand Up @@ -122,6 +125,31 @@ export const make = Effect.gen(function* () {
cause: error,
});

const recoverRead = <A>(
read: Effect.Effect<A, BitbucketPullRequestApi.BitbucketPullRequestApiError>,
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,
Expand Down Expand Up @@ -156,12 +184,12 @@ export const make = Effect.gen(function* () {
[
api.getPullRequest(target),
api.getDiffStat(target),
api.getMergeability(target).pipe(Effect.orElseSucceed(() => "unknown" as const)),
api.listChecks(target).pipe(Effect.orElseSucceed(() => [])),
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.
api.getRepositoryPermission(target).pipe(Effect.orElseSucceed(() => true)),
recoverRead(api.getRepositoryPermission(target), true),
],
{ concurrency: 5 },
).pipe(
Expand Down Expand Up @@ -200,10 +228,8 @@ export const make = Effect.gen(function* () {
// 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),
api
.listComments(target)
.pipe(Effect.orElseSucceed(() => ({ comments: [], threads: [], truncated: true }))),
api.listCommits(target).pipe(Effect.orElseSucceed(() => [])),
recoverRead(api.listComments(target), { comments: [], threads: [], truncated: true }),
recoverRead(api.listCommits(target), []),
],
{ concurrency: 3 },
).pipe(
Expand Down
24 changes: 24 additions & 0 deletions apps/server/src/sourceControl/BitbucketApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,30 @@ it.effect("preserves Bitbucket response body read failures as their immediate ca
}).pipe(Effect.provide(layer));
});

it.effect("keeps the 429 retry time when the response body cannot be read", () => {
const { layer } = makeLayer({
response: () =>
new Response(
new ReadableStream<Uint8Array>({
start: (controller) => controller.error(new Error("response stream failed")),
}),
{ status: 429, headers: { "Retry-After": "120" } },
),
});

return Effect.gen(function* () {
yield* TestClock.setTime(1_000);
const bitbucket = yield* BitbucketApi.BitbucketApi;
const error = yield* bitbucket
.request({ method: "GET", url: "/repositories/acme/web" })
.pipe(Effect.flip);

assert.instanceOf(error, BitbucketApi.BitbucketResponseBodyReadError);
assert.strictEqual(error.status, 429);
assert.strictEqual(error.retryAt, 121_000);
}).pipe(Effect.provide(layer));
});

it.effect("checks out same-repository pull requests with the existing Bitbucket remote", () => {
const { git, layer } = makeLayer({
response: () =>
Expand Down
5 changes: 4 additions & 1 deletion apps/server/src/sourceControl/BitbucketApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export class BitbucketResponseBodyReadError extends Schema.TaggedError<Bitbucket
{
operation: BitbucketApiOperation,
status: Schema.Int,
retryAt: Schema.optional(Schema.Number),
cause: Schema.Defect(),
},
) {
Expand Down Expand Up @@ -584,6 +585,7 @@ function responseError(
// 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,
Expand All @@ -593,6 +595,7 @@ function responseError(
new BitbucketResponseBodyReadError({
operation,
status: response.status,
retryAt,
cause,
}),
),
Expand All @@ -601,7 +604,7 @@ function responseError(
operation,
status: response.status,
responseBodyLength: collected.text.length,
retryAt: retryAtFromHeader(response.headers["retry-after"], now),
retryAt,
});
});
}
Expand Down
Loading