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
46 changes: 46 additions & 0 deletions apps/server/src/pullRequest/PullRequestService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,52 @@ it.effect("reads nothing from a host with no implementation, but reports it", ()
}),
);

it.effect("reads nothing from a host its provider does not serve, but reports it", () =>
Effect.gen(function* () {
const reached = new Set<string>();
const bitbucket = (id: string, host: string) =>
project({
id,
title: host,
workspaceRoot: `/${id}`,
repository: "proj/repo",
provider: SourceControlProviderKind.make("bitbucket"),
host,
});
const service = yield* makeService({
projects: [bitbucket("p1", "bitbucket.org"), bitbucket("p2", "bitbucket.example.com")],
providers: [
fakePullRequestProvider(SourceControlProviderKind.make("bitbucket"), {
servesHost: (host) => host === "bitbucket.org",
getViewer: ({ host }) => {
reached.add(host ?? "");
return Effect.succeed("bilal");
},
listChangeRequests: ({ host }) => {
reached.add(host);
return Effect.succeed({ items: [], truncated: false, continues: false });
},
}),
],
});

const result = yield* service.list({ state: "open" });

assert.deepStrictEqual([...reached], ["bitbucket.org"]);
assert.deepStrictEqual(
result.providers.map(({ host, configured, detail }) => ({ host, configured, detail })),
[
{ host: "bitbucket.org", configured: true, detail: null },
{
host: "bitbucket.example.com",
configured: false,
detail: "This host cannot be browsed here yet.",
},
],
);
}),
);

it.effect("says where each repository carries on, and from nothing it has run out of", () =>
Effect.gen(function* () {
const service = yield* makeService({
Expand Down
4 changes: 3 additions & 1 deletion apps/server/src/pullRequest/PullRequestService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,7 @@ function withRateLimitBackoff(
? {}
: { unavailableRequirement: api.unavailableRequirement }),
...(api.repositoryKey === undefined ? {} : { repositoryKey: api.repositoryKey }),
...(api.servesHost === undefined ? {} : { servesHost: api.servesHost }),
...(api.readsPublicHostWithoutCheckout === undefined
? {}
: { readsPublicHostWithoutCheckout: api.readsPublicHostWithoutCheckout }),
Expand Down Expand Up @@ -884,7 +885,8 @@ export const make = Effect.gen(function* () {
if (filter.host !== undefined && host !== filter.host.toLowerCase()) {
continue;
}
const api = registry.get(kind);
const registered = registry.get(kind);
const api = registered?.servesHost?.(host) === false ? null : registered;
if (api !== null) {
const roots = viewerRoots.get(host);
if (roots === undefined) viewerRoots.set(host, [project.workspaceRoot]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Queue from "effect/Queue";
import * as Result from "effect/Result";
import * as Schema from "effect/Schema";
import * as TestClock from "effect/testing/TestClock";
import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http";
Expand Down Expand Up @@ -72,6 +73,7 @@ function makeLayer(input: {
readonly stall?: boolean;
readonly git?: Partial<HostGit>;
readonly env?: Record<string, string>;
readonly remoteUrl?: string;
}) {
const execute = vi.fn((request: HttpClientRequest.HttpClientRequest) =>
input.stall
Expand Down Expand Up @@ -108,7 +110,7 @@ function makeLayer(input: {
remotes: [
{
name: "origin",
url: "git@bitbucket.org:pingdotgg/t3code.git",
url: input.remoteUrl ?? "git@bitbucket.org:pingdotgg/t3code.git",
pushUrl: Option.none(),
isPrimary: true,
},
Expand Down Expand Up @@ -337,6 +339,78 @@ it.effect("prefers an explicit repository and uses context when the repository i
}).pipe(Effect.provide(layer));
});

it.effect("sends a self-hosted Bitbucket remote nowhere unless an API base url is configured", () =>
Effect.gen(function* () {
const remoteUrl = "https://bitbucket.example.com/scm/proj/repo.git";
const context = {
provider: {
kind: SourceControlProviderKind.make("bitbucket"),
name: "Bitbucket Self-Hosted",
baseUrl: "https://bitbucket.example.com",
},
remoteName: "origin",
remoteUrl,
};
const cloudEnv = {
T3CODE_BITBUCKET_EMAIL: "user@example.com",
T3CODE_BITBUCKET_API_TOKEN: "t",
};
const response = (request: HttpClientRequest.HttpClientRequest) =>
Response.json(request.url.endsWith("/branching-model") ? {} : repositoryJson);
const defaultBranch = (setup: ReturnType<typeof makeLayer>, withContext: boolean) =>
BitbucketApi.BitbucketApi.pipe(
Effect.flatMap((bitbucket) =>
bitbucket.getDefaultBranch({ cwd: "/repo", ...(withContext ? { context } : {}) }),
),
Effect.provide(setup.layer),
);
const requestedUrls = (setup: ReturnType<typeof makeLayer>) =>
setup.execute.mock.calls.map(([request]) => request.url).toSorted();

// A self-hosted remote in the checkout, then a self-hosted context beside a Cloud remote.
for (const [setup, withContext] of [
[makeLayer({ response, env: cloudEnv, remoteUrl }), false],
[makeLayer({ response, env: cloudEnv }), true],
] as const) {
const result = yield* defaultBranch(setup, withContext).pipe(Effect.result);
assert.deepStrictEqual(requestedUrls(setup), []);
assert.strictEqual(
Result.isFailure(result) && result.failure._tag,
"BitbucketRepositoryRemoteNotFoundError",
);
}

// An explicit repository does not let a self-hosted context reach the Cloud API.
const explicit = makeLayer({ response, env: cloudEnv });
const cloneUrls = yield* BitbucketApi.BitbucketApi.pipe(
Effect.flatMap((bitbucket) =>
bitbucket.getRepositoryCloneUrls({ cwd: "/repo", context, repository: "proj/repo" }),
),
Effect.provide(explicit.layer),
Effect.result,
);
assert.deepStrictEqual(requestedUrls(explicit), []);
assert.strictEqual(
Result.isFailure(cloneUrls) && cloneUrls.failure._tag,
"BitbucketRepositoryRemoteNotFoundError",
);

const cloud = makeLayer({ response, env: cloudEnv });
yield* defaultBranch(cloud, false);
assert.deepStrictEqual(requestedUrls(cloud), [
"https://api.bitbucket.org/2.0/repositories/pingdotgg/t3code",
"https://api.bitbucket.org/2.0/repositories/pingdotgg/t3code/branching-model",
]);

const configured = makeLayer({ response, remoteUrl });
yield* defaultBranch(configured, true);
assert.deepStrictEqual(requestedUrls(configured), [
"https://api.test.local/2.0/repositories/proj/repo",
"https://api.test.local/2.0/repositories/proj/repo/branching-model",
]);
}),
);

it.effect(
"prefers the Bitbucket branching model development branch as the default PR target",
() => {
Expand Down
21 changes: 20 additions & 1 deletion packages/source-control-bitbucket/src/server/BitbucketApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
SourceControlProviderKind,
TrimmedNonEmptyString,
type SourceControlProviderAuth,
type SourceControlProviderInfo,
type SourceControlRepositoryCloneUrls,
type SourceControlRepositoryVisibility,
} from "@t3tools/contracts";
Expand Down Expand Up @@ -51,6 +52,15 @@ const BitbucketApiEnvConfig = Config.all({
apiToken: Config.String("T3CODE_BITBUCKET_API_TOKEN").pipe(Config.option),
});

// Server and Data Center use a different API; a custom Cloud endpoint must be explicit.
export const BitbucketServesHost = Config.String("T3CODE_BITBUCKET_API_BASE_URL").pipe(
Config.option,
Config.map(
(baseUrl) => (host: string) =>
Option.isSome(baseUrl) || host === "bitbucket.org" || host.endsWith(".bitbucket.org"),
),
);

const BitbucketApiOperation = Schema.Literals([
"resolveRepository",
"getRepository",
Expand Down Expand Up @@ -646,6 +656,11 @@ export const make = Effect.gen(function* () {
host: "bitbucket.org",
};

const servesHost = yield* BitbucketServesHost;

const servesProvider = (provider: SourceControlProviderInfo | null) =>
provider?.kind === "bitbucket" && servesHost(URL.parse(provider.baseUrl)?.hostname ?? "");

const apiUrl = (path: string) => `${config.baseUrl.replace(/\/+$/u, "")}${path}`;

const currentCredential = host.settings.get.pipe(
Expand Down Expand Up @@ -757,6 +772,10 @@ export const make = Effect.gen(function* () {
readonly context?: SourceControlProvider.SourceControlProviderContext;
readonly repository?: string;
}) {
if (input.context?.provider.kind === "bitbucket" && !servesProvider(input.context.provider)) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
// Falling back to another remote could act on a different repository.
return yield* new BitbucketRepositoryRemoteNotFoundError({ cwd: input.cwd });
}
if (input.repository !== undefined) {
return yield* requireRepositoryLocator(input.repository);
}
Expand Down Expand Up @@ -787,7 +806,7 @@ export const make = Effect.gen(function* () {
);

for (const remote of remotes.remotes) {
if (recognizeRemoteWith(definition, remote.url) === null) continue;
if (!servesProvider(recognizeRemoteWith(definition, remote.url))) continue;
const parsed = parseBitbucketRemoteUrl(remote.url);
if (parsed) return parsed;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { SourceControlProviderKind } from "@t3tools/contracts";
import { describe, expect, it } from "@effect/vitest";
import * as ConfigProvider from "effect/ConfigProvider";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Result from "effect/Result";
Expand Down Expand Up @@ -178,6 +179,30 @@ it.effect("reads checks and PR state without diff, mergeability, or permission r
}),
);

it.effect("serves only Bitbucket Cloud hosts unless an API base url is configured", () =>
Effect.gen(function* () {
const servedHosts = (env: Record<string, string>) =>
make.pipe(
Effect.map(({ servesHost }) =>
["bitbucket.org", "altssh.bitbucket.org", "bitbucket.example.com"].filter(
(host) => servesHost?.(host) === true,
),
),
Effect.provide(
Layer.merge(
Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({}),
ConfigProvider.layer(ConfigProvider.fromEnv({ env })),
),
),
);

expect(yield* servedHosts({})).toEqual(["bitbucket.org", "altssh.bitbucket.org"]);
expect(
yield* servedHosts({ T3CODE_BITBUCKET_API_BASE_URL: "https://bitbucket.example.com/api" }),
).toEqual(["bitbucket.org", "altssh.bitbucket.org", "bitbucket.example.com"]);
}),
);

describe("bitbucketProviderFailure", () => {
it("treats only an HTTP 401 as unusable credentials", () => {
const responseError = (status: number) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { resolvePullRequestAuthorFilter, SourceControlProviderKind } from "@t3to
import * as Effect from "effect/Effect";
import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts";

import * as BitbucketApi from "./BitbucketApi.ts";
import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts";
import {
PullRequestProviderError,
Expand Down Expand Up @@ -109,6 +110,7 @@ function toChangeRequest(pullRequest: BitbucketPullRequest): ProviderChangeReque

export const make = Effect.gen(function* () {
const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
const servesHost = yield* BitbucketApi.BitbucketServesHost.pipe(Effect.orDie);

const fail =
(operation: string) => (error: BitbucketPullRequestApi.BitbucketPullRequestApiError) =>
Expand Down Expand Up @@ -148,6 +150,7 @@ export const make = Effect.gen(function* () {
const provider: PullRequestProviderApi = {
kind: SourceControlProviderKind.make("bitbucket"),
capabilities: CAPABILITIES,
servesHost,
unavailableRequirement: UNAVAILABLE_REQUIREMENT,
readsPublicHostWithoutCheckout: true,

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,11 @@ export interface PullRequestProviderApi {
* definition's `publicHost` can be read from a project with no checkout of its repository.
*/
readonly readsPublicHostWithoutCheckout?: boolean;
/**
* Absent means every host detected as this kind is read. A host it refuses is listed as one that
* cannot be browsed here, and nothing is sent for it.
*/
readonly servesHost?: (host: string) => boolean;

readonly getViewer: (input: {
readonly cwd: string;
Expand Down
Loading