Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
41 changes: 41 additions & 0 deletions apps/server/src/pullRequest/PullRequestService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
} from "@t3tools/contracts";

import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
import {
PullRequestProviderError,
type ProviderChangeRequest,
Expand Down Expand Up @@ -144,11 +145,16 @@ function fakeProvider(
function makeService(input: {
readonly projects: ReadonlyArray<OrchestrationProjectShell>;
readonly providers: ReadonlyArray<PullRequestProviderApi>;
readonly resolveHandle?: SourceControlProviderRegistry.SourceControlProviderRegistry["Service"]["resolveHandle"];
}) {
return PullRequestService.make.pipe(
Effect.provide(
Layer.mergeAll(
Layer.succeed(PullRequestProviderRegistry, fromProviders(input.providers)),
Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({
resolveHandle:
input.resolveHandle ?? (() => Effect.die("Unexpected provider refinement")),
}),
Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({
getShellSnapshot: () =>
Effect.succeed({
Expand All @@ -163,6 +169,41 @@ function makeService(input: {
);
}

it.effect("refines unknown self-hosted GitLab projects before listing merge requests", () =>
Effect.gen(function* () {
let refinementCalls = 0;
const selfHosted = project({
id: "p1",
title: "self-hosted",
workspaceRoot: "/gitlab",
repository: "group/project",
provider: "unknown",
host: "code.example.test",
});
const service = yield* makeService({
projects: [
selfHosted,
{ ...selfHosted, id: "p2" as ProjectId, workspaceRoot: "/gitlab-worktree" },
],
providers: [fakeProvider("gitlab")],
resolveHandle: ({ context }) => {
refinementCalls += 1;
assert.strictEqual(context?.remoteUrl, "https://code.example.test/group/project.git");
return Effect.succeed({
context: { ...context!, provider: { ...context!.provider, kind: "gitlab" } },
provider: undefined as never,
});
},
});

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

assert.strictEqual(refinementCalls, 1);
assert.strictEqual(result.providers[0]?.host, "code.example.test");
assert.strictEqual(result.providers[0]?.kind, "gitlab");
}),
);

/** A row as a host that reads several repositories at once hands it over. */
function batchedChangeRequest(number: number, repository: string, updatedAt: string) {
return { ...changeRequest(number, updatedAt), repository };
Expand Down
66 changes: 60 additions & 6 deletions apps/server/src/pullRequest/PullRequestService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,13 @@ import {
type PullRequestSubmitReviewInput,
type PullRequestThreadReplyInput,
type PullRequestThreadResolutionInput,
type SourceControlProviderInfo,
type SourceControlProviderKind,
} from "@t3tools/contracts";
import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl";

import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
import {
type ProviderChangeRequest,
type ProviderListCursor,
Expand Down Expand Up @@ -360,6 +363,49 @@ function repositoryIdentityOf(project: OrchestrationProjectShell): string | null
export const make = Effect.gen(function* () {
const registry = yield* PullRequestProviderRegistry;
const projections = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry;

const refineUnknownProjectKinds = (
projects: ReadonlyArray<OrchestrationProjectShell>,
filter: Pick<PullRequestListInput, "projectId" | "host">,
) => {
const refinements = new Map<
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
string,
{
project: OrchestrationProjectShell;
provider: SourceControlProviderInfo;
remoteName: string;
remoteUrl: string;
}
>();
for (const project of projects) {
if (filter.projectId !== undefined && project.id !== filter.projectId) continue;
const identity = project.repositoryIdentity;
if (identity?.provider !== "unknown" || repositoryIdentityOf(project) === null) continue;
const host = pullRequestHostOf(identity, "unknown");
if (filter.host !== undefined && host !== filter.host.toLowerCase()) continue;
const { remoteName, remoteUrl } = identity.locator;
const provider = detectSourceControlProviderFromRemoteUrl(remoteUrl);
if (provider !== null && !refinements.has(provider.baseUrl)) {
refinements.set(provider.baseUrl, { project, provider, remoteName, remoteUrl });
}
}

return Effect.forEach(
refinements,
([baseUrl, { project, provider, remoteName, remoteUrl }]) =>
sourceControlProviders
.resolveHandle({
cwd: project.workspaceRoot,
context: { provider, remoteName, remoteUrl },
})
.pipe(
Effect.map((handle) => [baseUrl, handle.context?.provider.kind ?? "unknown"] as const),
Effect.orElseSucceed(() => [baseUrl, "unknown"] as const),
),
{ concurrency: REPOSITORY_CONCURRENCY },
).pipe(Effect.map((resolved) => new Map(resolved)));
};

const listWorkspaceProjects = (
filter: Pick<PullRequestListInput, "projectId" | "host">,
Expand All @@ -373,7 +419,12 @@ export const make = Effect.gen(function* () {
cause: error,
}),
),
Effect.map((snapshot) => {
Effect.flatMap((snapshot) =>
refineUnknownProjectKinds(snapshot.projects, filter).pipe(
Effect.map((refinedKinds) => ({ refinedKinds, snapshot })),
),
),
Effect.map(({ refinedKinds, snapshot }) => {
const supported: SupportedProject[] = [];
const unimplemented = new Map<
string,
Expand All @@ -383,16 +434,19 @@ export const make = Effect.gen(function* () {
const seen = new Set<string>();
for (const project of snapshot.projects) {
if (filter.projectId !== undefined && project.id !== filter.projectId) continue;
const kind = project.repositoryIdentity?.provider as
| SourceControlProviderKind
| undefined;
const identity = project.repositoryIdentity;
let kind = identity?.provider as SourceControlProviderKind | undefined;
const repository = repositoryIdentityOf(project);
if (kind === undefined || repository === null) continue;
if (!identity || kind === undefined || repository === null) continue;
// Worktrees of one repository are separate projects; reading the remote once keeps
// the page from repeating every change request per local checkout. The host is part
// of the key, so the same `owner/repo` on two hosts stays two repositories.
const host = pullRequestHostOf(project.repositoryIdentity, kind);
const host = pullRequestHostOf(identity, kind);
if (filter.host !== undefined && host !== filter.host.toLowerCase()) continue;
if (kind === "unknown") {
const provider = detectSourceControlProviderFromRemoteUrl(identity.locator.remoteUrl);
kind = provider === null ? kind : (refinedKinds.get(provider.baseUrl) ?? kind);
}
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
const api = registry.get(kind);
// Recorded before the de-duplication below, so the viewer lookup keeps the alternates
// the listing is about to drop.
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,7 @@ const commandReadinessLayer = HttpRouter.middleware(
const PullRequestServiceLive = PullRequestService.layer.pipe(
// One registry entry per supported host; the service only knows the registry.
Layer.provide(PullRequestProviderRegistry.layer),
Layer.provide(SourceControlProviderRegistryLayerLive),
Layer.provide(VcsProcess.layer),
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,38 @@ self-hosted.example.test
}),
);

it.effect("refines the caller-selected remote instead of choosing another configured remote", () =>
Effect.gen(function* () {
const registry = yield* makeRegistry({
remotes: [{ name: "origin", url: "git@github.com:fork/project.git" }],
process: {
run: () =>
Effect.succeed(
processOutput(`self-hosted.example.test
✓ Logged in to self-hosted.example.test as gitlab-user
`),
),
},
});

const handle = yield* registry.resolveHandle({
cwd: "/repo",
context: {
provider: {
kind: "unknown",
name: "self-hosted.example.test",
baseUrl: "https://self-hosted.example.test",
},
remoteName: "upstream",
remoteUrl: "https://self-hosted.example.test/group/project.git",
},
});

assert.strictEqual(handle.context?.provider.kind, "gitlab");
assert.strictEqual(handle.context?.remoteName, "upstream");
}),
);

it.effect("routes authenticated self-hosted GitLab remotes on non-standard ports", () =>
Effect.gen(function* () {
const registry = yield* makeRegistry({
Expand Down
11 changes: 10 additions & 1 deletion apps/server/src/sourceControl/SourceControlProviderRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export class SourceControlProviderRegistry extends Context.Service<
>;
readonly resolveHandle: (input: {
readonly cwd: string;
readonly context?: SourceControlProvider.SourceControlProviderContext;
}) => Effect.Effect<SourceControlProviderHandle, SourceControlProviderError>;
readonly resolve: (input: {
readonly cwd: string;
Expand Down Expand Up @@ -254,7 +255,15 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit
});

const resolveHandle: SourceControlProviderRegistry["Service"]["resolveHandle"] = (input) =>
Cache.get(providerContextCache, input.cwd).pipe(
(input.context === undefined
? Cache.get(providerContextCache, input.cwd)
: refineUnknownRemoteProvider({
specs: discoverySpecs,
process,
cwd: input.cwd,
context: input.context,
})
).pipe(
Effect.map((context) => {
const kind = context?.provider.kind ?? "unknown";
const provider = providers.get(kind) ?? unsupportedProvider(kind);
Expand Down
Loading