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
137 changes: 120 additions & 17 deletions apps/server/src/sourceControl/BitbucketApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Schema from "effect/Schema";
import * as TestClock from "effect/testing/TestClock";
import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http";

Expand All @@ -16,6 +17,8 @@ import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
import type * as VcsDriver from "../vcs/VcsDriver.ts";

const isBitbucketRepositoryLocatorError = Schema.is(BitbucketApi.BitbucketRepositoryLocatorError);

const bitbucketPullRequest = {
id: 42,
title: "Add Bitbucket provider",
Expand Down Expand Up @@ -99,7 +102,7 @@ function makeLayer(input: {
} satisfies Partial<GitVcsDriver.GitVcsDriver["Service"]>;

const driver = {
listRemotes: () =>
listRemotes: vi.fn(() =>
Effect.succeed({
remotes: [
{
Expand All @@ -115,8 +118,26 @@ function makeLayer(input: {
expiresAt: Option.none(),
},
}),
),
} satisfies Partial<VcsDriver.VcsDriver["Service"]>;

const resolve = vi.fn(() =>
Effect.succeed({
kind: "git" as const,
repository: {
kind: "git" as const,
rootPath: "/repo",
metadataPath: null,
freshness: {
source: "live-local" as const,
observedAt: DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"),
expiresAt: Option.none(),
},
},
driver: driver as unknown as VcsDriver.VcsDriver["Service"],
}),
);

const layer = BitbucketApi.layer.pipe(
Layer.provide(
Layer.succeed(
Expand All @@ -126,21 +147,7 @@ function makeLayer(input: {
),
Layer.provide(
Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({
resolve: () =>
Effect.succeed({
kind: "git",
repository: {
kind: "git",
rootPath: "/repo",
metadataPath: null,
freshness: {
source: "live-local" as const,
observedAt: DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"),
expiresAt: Option.none(),
},
},
driver: driver as unknown as VcsDriver.VcsDriver["Service"],
}),
resolve,
}),
),
Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)(git)),
Expand All @@ -159,7 +166,7 @@ function makeLayer(input: {
Layer.provideMerge(NodeServices.layer),
);

return { execute, git: gitMock, layer };
return { execute, git: gitMock, resolve, listRemotes: driver.listRemotes, layer };
}

it.effect("parses pull request responses from the Bitbucket REST API", () => {
Expand Down Expand Up @@ -333,6 +340,102 @@ it.effect("reads repository clone URLs and default branch", () => {
}).pipe(Effect.provide(layer));
});

it.effect.each([false, true])(
"rejects invalid explicit repositories before fallback, context=%s",
(withContext) => {
const { execute, resolve, listRemotes, layer } = makeLayer({
response: () => Response.json(repositoryJson),
});

return Effect.gen(function* () {
const bitbucket = yield* BitbucketApi.BitbucketApi;
for (const repository of ["t3code", "", " \t "]) {
const error = yield* bitbucket
.getRepositoryCloneUrls({
cwd: "/repo",
repository,
...(withContext
? {
context: {
provider: {
kind: "bitbucket" as const,
name: "Bitbucket",
baseUrl: "https://bitbucket.org",
},
remoteName: "origin",
remoteUrl: "git@bitbucket.org:pingdotgg/t3code.git",
},
}
: {}),
})
.pipe(Effect.match({ onFailure: (error) => error, onSuccess: () => null }));

assert.instanceOf(error, BitbucketApi.BitbucketRepositoryLocatorError);
assert.strictEqual(
isBitbucketRepositoryLocatorError(error) ? error.repository : null,
repository,
);
assert.strictEqual(resolve.mock.calls.length, 0);
assert.strictEqual(listRemotes.mock.calls.length, 0);
assert.strictEqual(execute.mock.calls.length, 0);
}
}).pipe(Effect.provide(layer));
},
);

it.effect("prefers an explicit repository and uses context when the repository is omitted", () => {
const { execute, resolve, listRemotes, layer } = makeLayer({
response: (request) =>
Response.json(request.url.endsWith("/branching-model") ? {} : repositoryJson),
});

return Effect.gen(function* () {
const bitbucket = yield* BitbucketApi.BitbucketApi;
const context = {
provider: { kind: "bitbucket" as const, name: "Bitbucket", baseUrl: "https://bitbucket.org" },
remoteName: "origin",
remoteUrl: "git@bitbucket.org:another/context.git",
};
for (const repository of ["pingdotgg/t3code", "https://bitbucket.org/pingdotgg/t3code.git"]) {
yield* bitbucket.getRepositoryCloneUrls({ cwd: "/repo", context, repository });
}
yield* bitbucket.getDefaultBranch({ cwd: "/repo", context });

assert.deepStrictEqual(
execute.mock.calls.map(([request]) => request.url).toSorted(),
[
"https://api.test.local/2.0/repositories/pingdotgg/t3code",
"https://api.test.local/2.0/repositories/pingdotgg/t3code",
"https://api.test.local/2.0/repositories/another/context",
"https://api.test.local/2.0/repositories/another/context/branching-model",
].toSorted(),
);
assert.strictEqual(resolve.mock.calls.length, 0);
assert.strictEqual(listRemotes.mock.calls.length, 0);
}).pipe(Effect.provide(layer));
});

it.effect("discovers the repository from cwd remotes when the repository is omitted", () => {
const { execute, resolve, listRemotes, layer } = makeLayer({
response: (request) =>
Response.json(request.url.endsWith("/branching-model") ? {} : repositoryJson),
});

return Effect.gen(function* () {
const bitbucket = yield* BitbucketApi.BitbucketApi;
const branch = yield* bitbucket.getDefaultBranch({ cwd: "/repo" });

assert.strictEqual(branch, "main");
assert.deepStrictEqual(resolve.mock.calls, [[{ cwd: "/repo" }]]);
assert.deepStrictEqual(listRemotes.mock.calls, [["/repo"]]);
assert.isTrue(
execute.mock.calls.every(([request]) =>
request.url.startsWith("https://api.test.local/2.0/repositories/pingdotgg/t3code"),
),
);
}).pipe(Effect.provide(layer));
});

it.effect(
"prefers the Bitbucket branching model development branch as the default PR target",
() => {
Expand Down
12 changes: 7 additions & 5 deletions apps/server/src/sourceControl/BitbucketApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,14 @@ export class BitbucketRepositoryLocatorError extends Schema.TaggedError<Bitbucke
repository: Schema.String,
},
) {
static readonly detail = "Bitbucket repositories must be specified as workspace/repository.";

get detail(): string {
return "Bitbucket repositories must be specified as workspace/repository.";
return BitbucketRepositoryLocatorError.detail;
}

override get message(): string {
return `Bitbucket API failed in createRepository: ${this.detail}`;
return `Bitbucket API failed: ${this.detail}`;
}
}

Expand Down Expand Up @@ -727,9 +729,9 @@ export const make = Effect.gen(function* () {
readonly context?: SourceControlProvider.SourceControlProviderContext;
readonly repository?: string;
}) {
const fromRepository =
input.repository !== undefined ? parseBitbucketRepositorySlug(input.repository) : null;
if (fromRepository) return fromRepository;
if (input.repository !== undefined) {
return yield* requireRepositoryLocator(input.repository);
}

const fromContext =
input.context?.provider.kind === "bitbucket"
Expand Down
111 changes: 111 additions & 0 deletions apps/server/src/sourceControl/SourceControlRepositoryService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,21 @@ import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import * as Layer from "effect/Layer";
import * as PlatformError from "effect/PlatformError";
import * as Schema from "effect/Schema";
import { ChildProcessSpawner } from "effect/process";

import { GitCommandError, SourceControlProviderError } from "@t3tools/contracts";

import * as ServerConfig from "../config.ts";
import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
import * as BitbucketApi from "./BitbucketApi.ts";
import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts";
import type * as SourceControlProvider from "./SourceControlProvider.ts";
import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
import * as SourceControlRepositoryService from "./SourceControlRepositoryService.ts";

const isSourceControlProviderError = Schema.is(SourceControlProviderError);

const CLONE_URLS = {
nameWithOwner: "octocat/t3code",
url: "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/octocat/t3code",
Expand Down Expand Up @@ -152,6 +157,112 @@ it.effect("preserves provider failures without deriving the repository message f
}).pipe(Effect.provide(layer({ provider })));
});

it.effect(
"reports the safe Bitbucket locator detail through repository lookup and publishing",
() =>
Effect.gen(function* () {
const locatorCause = new BitbucketApi.BitbucketRepositoryLocatorError({
repository: "credential-token-abc123",
});
const provider = yield* BitbucketSourceControlProvider.make.pipe(
Effect.provide(
Layer.mock(BitbucketApi.BitbucketApi)({
getRepositoryCloneUrls: () => Effect.fail(locatorCause),
createRepository: () => Effect.fail(locatorCause),
}),
),
);

yield* Effect.gen(function* () {
const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
const input = {
provider: "bitbucket" as const,
repository: locatorCause.repository,
cwd: "/workspace",
};
for (const operation of [
service.lookupRepository(input).pipe(Effect.asVoid),
service.publishRepository({ ...input, visibility: "private" }).pipe(Effect.asVoid),
]) {
const error = yield* Effect.flip(operation);

assert.strictEqual(error.provider, "bitbucket");
assert.strictEqual(
error.detail,
"Bitbucket repositories must be specified as workspace/repository.",
);
assert.notInclude(error.message, locatorCause.repository);
assert.instanceOf(error.cause, SourceControlProviderError);
assert.strictEqual(
isSourceControlProviderError(error.cause) ? error.cause.cause : null,
locatorCause,
);
}
}).pipe(Effect.provide(layer({ provider })));
}),
);

it.effect("keeps a plain Bitbucket locator lookalike out of the repository message", () => {
const provider = makeProvider({
kind: "bitbucket",
getRepositoryCloneUrls: () =>
Effect.fail(
new SourceControlProviderError({
provider: "bitbucket",
operation: "getRepositoryCloneUrls",
cwd: "/workspace",
detail: "credential token abc123 was rejected",
cause: {
_tag: "BitbucketRepositoryLocatorError",
repository: "t3code",
detail: "credential token abc123 was rejected",
},
}),
),
});

return Effect.gen(function* () {
const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
const error = yield* Effect.flip(
service.lookupRepository({ provider: "bitbucket", repository: "t3code" }),
);

assert.strictEqual(error.detail, "The source control operation could not be completed.");
assert.notInclude(error.message, "abc123");
}).pipe(Effect.provide(layer({ provider })));
});

it.effect("keeps arbitrary Bitbucket request failures out of the repository message", () =>
Effect.gen(function* () {
const cause = new BitbucketApi.BitbucketRequestError({
operation: "getRepository",
cause: new Error("credential token abc123 was rejected"),
});
const provider = yield* BitbucketSourceControlProvider.make.pipe(
Effect.provide(
Layer.mock(BitbucketApi.BitbucketApi)({
getRepositoryCloneUrls: () => Effect.fail(cause),
}),
),
);

yield* Effect.gen(function* () {
const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
const error = yield* Effect.flip(
service.lookupRepository({
provider: "bitbucket",
repository: "pingdotgg/t3code",
cwd: "/workspace",
}),
);

assert.strictEqual(error.detail, "The source control operation could not be completed.");
assert.notInclude(error.message, "abc123");
assert.notInclude(error.message, cause.detail);
}).pipe(Effect.provide(layer({ provider })));
}),
);

it.effect("clones a looked-up repository into the requested destination", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
Expand Down
11 changes: 10 additions & 1 deletion apps/server/src/sourceControl/SourceControlRepositoryService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import * as Schedule from "effect/Schedule";
import * as Schema from "effect/Schema";

import {
SourceControlProviderError,
SourceControlRepositoryError,
type SourceControlCloneRepositoryInput,
type SourceControlCloneRepositoryResult,
Expand All @@ -26,8 +27,11 @@ import {
type GitCloneProgressLine,
} from "../project/gitCloneProgress.ts";
import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
import * as BitbucketApi from "./BitbucketApi.ts";
import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError);
const isSourceControlProviderError = Schema.is(SourceControlProviderError);
const isBitbucketRepositoryLocatorError = Schema.is(BitbucketApi.BitbucketRepositoryLocatorError);

export class SourceControlRepositoryService extends Context.Service<
SourceControlRepositoryService,
Expand Down Expand Up @@ -92,7 +96,12 @@ function mapRepositoryError(operation: string, provider: SourceControlProviderKi
: new SourceControlRepositoryError({
operation,
provider,
detail: "The source control operation could not be completed.",
detail:
isSourceControlProviderError(cause) &&
cause.provider === "bitbucket" &&
isBitbucketRepositoryLocatorError(cause.cause)
? BitbucketApi.BitbucketRepositoryLocatorError.detail
: "The source control operation could not be completed.",
cause,
}),
);
Expand Down
Loading