Skip to content
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { SourceControlProviderKind } from "@t3tools/contracts";
import { describe, expect, it } from "@effect/vitest";
import * as ConfigProvider from "effect/ConfigProvider";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Result from "effect/Result";
import * as TestClock from "effect/testing/TestClock";
import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";

import * as BitbucketApi from "./BitbucketApi.ts";
Expand Down Expand Up @@ -179,6 +181,121 @@ it.effect("reads checks and PR state without diff, mergeability, or permission r
}),
);

it.effect("rereads only the pull request when sync reads an unchanged pull request again", () =>
Effect.gen(function* () {
const pullRequest = Result.getOrThrow(
decodePullRequestJson(`{
"id": 1, "title": "Sync", "state": "OPEN",
"source": { "branch": { "name": "feature" }, "commit": { "hash": "abc123" } },
"destination": { "branch": { "name": "main" }, "commit": { "hash": "def456" } },
"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" } }
}`),
);
const calls: Array<string> = [];
const provider = yield* make.pipe(
Effect.provide(
Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({
getPullRequest: () =>
Effect.sync(() => calls.push("pullRequest")).pipe(Effect.as(pullRequest)),
getDiffStat: () =>
Effect.sync(() => calls.push("diffstat")).pipe(
Effect.as({ additions: 12, deletions: 3, changedFiles: 2 }),
),
getMergeability: () =>
Effect.sync(() => calls.push("conflicts")).pipe(Effect.as("conflicting" as const)),
listChecks: () => Effect.sync(() => calls.push("statuses")).pipe(Effect.as([])),
getRepositoryPermission: () =>
Effect.sync(() => calls.push("permission")).pipe(Effect.as(true)),
}),
),
);
const readSummary = provider.getChangeRequestSummary ?? provider.getChangeRequest;
const input = { cwd: "/repo", repository: "acme/web", host: "bitbucket.org", number: 1 };
const expected = { additions: 12, deletions: 3, changedFiles: 2, mergeability: "conflicting" };

expect(yield* readSummary(input)).toMatchObject(expected);
const firstRead = calls.splice(0).toSorted();
expect(yield* readSummary(input)).toMatchObject(expected);
const secondRead = calls.splice(0).toSorted();

expect({ firstRead, secondRead }).toEqual({
firstRead: ["conflicts", "diffstat", "pullRequest"],
secondRead: ["pullRequest"],
});
}),
);

it.effect(
"rereads diffstat and conflicts when a commit moves, the reads expire or are forgotten, or conflicts failed",
() =>
Effect.gen(function* () {
const pullRequestAt = (head: string, base: string) =>
Result.getOrThrow(
decodePullRequestJson(
JSON.stringify({
id: 1,
title: "Sync",
state: "OPEN",
source: { branch: { name: "feature" }, commit: { hash: head } },
destination: { branch: { name: "main" }, commit: { hash: base } },
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 pullRequest = pullRequestAt("abc123", "def456");
let statReads = 0;
let conflictsFail = false;
const provider = yield* make.pipe(
Effect.provide(
Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({
getPullRequest: () => Effect.sync(() => pullRequest),
getDiffStat: () =>
Effect.sync(() => ++statReads).pipe(
Effect.as({ additions: 1, deletions: 0, changedFiles: 1 }),
),
getMergeability: () =>
conflictsFail
? Effect.fail(
new BitbucketApi.BitbucketResponseError({
operation: "request",
status: 503,
responseBodyLength: 0,
}),
)
: Effect.succeed("mergeable" as const),
}),
),
);
const readSummary = provider.getChangeRequestSummary;
const forget = provider.forgetChangeRequestReads;
if (readSummary === undefined || forget === undefined) {
return yield* Effect.die("summary reads missing");
}
const input = { cwd: "/repo", repository: "acme/web", host: "bitbucket.org", number: 1 };

yield* readSummary(input);
pullRequest = pullRequestAt("abc123", "fed789");
yield* readSummary(input);
pullRequest = pullRequestAt("cba321", "fed789");
yield* readSummary(input);
yield* readSummary(input);
yield* forget({ repository: "Acme/Web", number: 1 });
yield* readSummary(input);
yield* TestClock.adjust(Duration.minutes(15));
yield* readSummary(input);
pullRequest = pullRequestAt("cba321", "0a0b0c");
conflictsFail = true;
expect((yield* readSummary(input)).mergeability).toBe("unknown");
conflictsFail = false;
expect((yield* readSummary(input)).mergeability).toBe("mergeable");

expect(statReads).toBe(7);
}),
);

it.effect("serves only Bitbucket Cloud hosts unless an API base url is configured", () =>
Effect.gen(function* () {
const servedHosts = (env: Record<string, string>) =>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { resolvePullRequestAuthorFilter, SourceControlProviderKind } from "@t3tools/contracts";
import * as Cache from "effect/Cache";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts";

import * as BitbucketApi from "./BitbucketApi.ts";
Expand Down Expand Up @@ -37,6 +40,9 @@ const CAPABILITIES: PullRequestCapabilities = {
viewedFiles: "environment",
};

const COMMIT_READ_CAPACITY = 256;
const COMMIT_READ_TTL = Duration.minutes(15);

const UNAVAILABLE_REQUIREMENT = {
missing: "Bitbucket needs API credentials on the server. Add them in Settings → Source Control.",
unauthenticated:
Expand Down Expand Up @@ -169,6 +175,27 @@ export const make = Effect.gen(function* () {
});
};

// Null marks a conflicts read that failed and was recovered, so it is not held.
const readCommitStats = (target: { readonly repository: string; readonly number: number }) =>
Effect.all([api.getDiffStat(target), recoverRead(api.getMergeability(target), null)], {
concurrency: 2,
});

// Diffstat and conflicts follow from the source and destination commits, so sync rereads them
// when either moves. The expiry catches a destination branch that moved without the pull
// request's destination commit following; a refresh or a finished turn drops them sooner.
const commitStats = yield* Cache.makeWith(
(key: string) => {
const [repository, number] = JSON.parse(key) as [string, number, string, string];
return readCommitStats({ repository, number });
},
{
capacity: COMMIT_READ_CAPACITY,
timeToLive: (exit) =>
Exit.isSuccess(exit) && exit.value[1] !== null ? COMMIT_READ_TTL : Duration.zero,
},
);

const provider: PullRequestProviderApi = {
kind: SourceControlProviderKind.make("bitbucket"),
capabilities: CAPABILITIES,
Expand Down Expand Up @@ -260,6 +287,64 @@ export const make = Effect.gen(function* () {
);
},

getChangeRequestSummary: (input) => {
const target = { repository: input.repository, number: input.number };
return api.getPullRequest(target).pipe(
Effect.flatMap((pullRequest) =>
(pullRequest.headSha === null || pullRequest.baseSha === null
? readCommitStats(target)
: Cache.get(
commitStats,
JSON.stringify([
input.repository,
input.number,
pullRequest.headSha,
pullRequest.baseSha,
]),
)
).pipe(
Effect.map(([diffStat, mergeability]) => ({
number: pullRequest.number,
title: pullRequest.title,
url: pullRequest.url,
author: pullRequest.author,
headBranch: pullRequest.headBranch,
baseBranch: pullRequest.baseBranch,
state: pullRequest.state,
isDraft: pullRequest.isDraft,
mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null,
closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null,
updatedAt: pullRequest.updatedAt,
additions: diffStat.additions,
deletions: diffStat.deletions,
changedFiles: diffStat.changedFiles,
mergeability: mergeability ?? "unknown",
})),
),
),
Effect.mapError(fail("getChangeRequestSummary")),
);
},

forgetChangeRequestReads: (scope) =>
scope === undefined
? Cache.invalidateAll(commitStats)
: Cache.keys(commitStats).pipe(
Effect.flatMap((keys) =>
Effect.forEach(
Array.from(keys).filter((key) => {
const [repository, number] = JSON.parse(key) as [string, number];
return (
repository.toLowerCase() === scope.repository.toLowerCase() &&
(scope.number === undefined || number === scope.number)
);
}),
(key) => Cache.invalidate(commitStats, key),
{ discard: true },
),
),
),

getChangeRequestActivity: (input) => {
const target = { repository: input.repository, number: input.number };
return Effect.all(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ export interface BitbucketPullRequest {
readonly headSha: string | null;
readonly headRepositoryNameWithOwner: string | null;
readonly baseBranch: string;
readonly baseSha: string | null;
readonly state: PullRequestState;
readonly isDraft: boolean;
/** Listings omit conflict state; detail reads use the conflicts endpoint. */
Expand Down Expand Up @@ -254,6 +255,7 @@ function toPullRequest(raw: Schema.Schema.Type<typeof RawPullRequestSchema>): Bi
headSha: trimmed(raw.source.commit?.hash),
headRepositoryNameWithOwner: raw.source.repository?.full_name ?? null,
baseBranch: raw.destination.branch.name,
baseSha: trimmed(raw.destination.commit?.hash),
state: toState(raw),
isDraft: raw.draft ?? false,
mergeability: "unknown",
Expand Down
Loading