Skip to content
Open
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
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"@effect/vitest": "catalog:",
"@t3tools/contracts": "workspace:*",
"@t3tools/shared": "workspace:*",
"@t3tools/ssh": "workspace:*",
"@t3tools/tailscale": "workspace:*",
"@t3tools/web": "workspace:*",
"@types/bun": "1.3.14",
Expand Down
44 changes: 40 additions & 4 deletions apps/server/src/project/RepositoryIdentityResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
detectSourceControlProviderFromGitRemoteUrl,
normalizeGitRemoteUrl,
} from "@t3tools/shared/git";
import { parseSshResolveOutput } from "@t3tools/ssh/command";
import * as Cache from "effect/Cache";
import * as Context from "effect/Context";
import * as Duration from "effect/Duration";
Expand All @@ -11,6 +12,7 @@ import * as Exit from "effect/Exit";
import * as Layer from "effect/Layer";

import * as ProcessRunner from "../processRunner.ts";
import { resolveGitRemoteForSourceControl } from "../sourceControl/resolveGitRemoteForSourceControl.ts";

const DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY = 512;
const DEFAULT_POSITIVE_CACHE_TTL = Duration.minutes(1);
Expand Down Expand Up @@ -60,13 +62,39 @@ function pickPrimaryRemote(
return remoteName && remoteUrl ? { remoteName, remoteUrl } : null;
}

// This layer is provided in many tests without NodeServices. Resolve HostName
// through ProcessRunner so SSH Effect services do not leak onto the service.
function resolveSshHostnameWithProcessRunner(
processRunner: ProcessRunner.ProcessRunner["Service"],
): (alias: string) => Effect.Effect<string | null> {
return (alias) =>
processRunner
.run({
command: "ssh",
args: ["-G", alias],
timeout: Duration.seconds(5),
timeoutBehavior: "timedOutResult",
})
.pipe(
Effect.map((result) => {
if (result.timedOut || result.code !== 0) {
return null;
}
const hostname = parseSshResolveOutput(alias, result.stdout).hostname.trim();
return hostname.length > 0 ? hostname : null;
}),
Effect.orElseSucceed(() => null),
);
}

function buildRepositoryIdentity(input: {
readonly remoteName: string;
readonly remoteUrl: string;
readonly detectionUrl: string;
readonly rootPath: string;
}): RepositoryIdentity {
const canonicalKey = normalizeGitRemoteUrl(input.remoteUrl);
const sourceControlProvider = detectSourceControlProviderFromGitRemoteUrl(input.remoteUrl);
const canonicalKey = normalizeGitRemoteUrl(input.detectionUrl);
const sourceControlProvider = detectSourceControlProviderFromGitRemoteUrl(input.detectionUrl);
const repositoryPath = canonicalKey.split("/").slice(1).join("/");
const repositoryPathSegments = repositoryPath.split("/").filter((segment) => segment.length > 0);
const [owner] = repositoryPathSegments;
Expand Down Expand Up @@ -118,6 +146,7 @@ const resolveRepositoryIdentityFromCacheKey = Effect.fn(
"RepositoryIdentityResolver.resolveFromCacheKey",
)(function* (
cacheKey: string,
resolveDetectionUrl: (remoteUrl: string) => Effect.Effect<string>,
): Effect.fn.Return<RepositoryIdentity | null, never, ProcessRunner.ProcessRunner> {
const processRunner = yield* ProcessRunner.ProcessRunner;
const remoteResult = yield* processRunner
Expand All @@ -132,17 +161,24 @@ const resolveRepositoryIdentityFromCacheKey = Effect.fn(
}

const remote = pickPrimaryRemote(parseRemoteFetchUrls(remoteResult.value.stdout));
return remote ? buildRepositoryIdentity({ ...remote, rootPath: cacheKey }) : null;
if (!remote) {
return null;
}

const detectionUrl = yield* resolveDetectionUrl(remote.remoteUrl);
return buildRepositoryIdentity({ ...remote, detectionUrl, rootPath: cacheKey });
});

export const make = Effect.fn("RepositoryIdentityResolver.make")(function* (
options: RepositoryIdentityResolverOptions = {},
) {
const processRunner = yield* ProcessRunner.ProcessRunner;
const resolveDetectionUrl = (remoteUrl: string) =>
resolveGitRemoteForSourceControl(remoteUrl, resolveSshHostnameWithProcessRunner(processRunner));

const repositoryIdentityCache = yield* Cache.makeWith<string, RepositoryIdentity | null>(
(cacheKey) =>
resolveRepositoryIdentityFromCacheKey(cacheKey).pipe(
resolveRepositoryIdentityFromCacheKey(cacheKey, resolveDetectionUrl).pipe(
Effect.provideService(ProcessRunner.ProcessRunner, processRunner),
),
{
Expand Down
52 changes: 52 additions & 0 deletions apps/server/src/pullRequest/PullRequestService.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { assert, it } from "@effect/vitest";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import type {
Expand Down Expand Up @@ -155,6 +156,7 @@ function makeService(input: {
return PullRequestService.make.pipe(
Effect.provide(
Layer.mergeAll(
NodeServices.layer,
Layer.succeed(PullRequestProviderRegistry, fromProviders(input.providers)),
Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({
resolveHandle:
Expand All @@ -174,6 +176,56 @@ function makeService(input: {
);
}

it("keeps a nonstandard port when deriving a host from a provider base URL", () => {
assert.strictEqual(
PullRequestService.hostnameFromProviderBaseUrl("https://github.company:8443"),
"github.company:8443",
);
assert.strictEqual(
PullRequestService.hostnameFromProviderBaseUrl("https://github.company:8443/api/v3"),
"github.company:8443",
);
assert.strictEqual(
PullRequestService.hostnameFromProviderBaseUrl("https://github.com"),
"github.com",
);
});

it.effect("keeps a nonstandard port on a detected self-hosted GitHub host", () =>
Effect.gen(function* () {
const hosts: string[] = [];
const selfHosted = project({
id: "p1",
title: "ghe",
workspaceRoot: "/ghe",
repository: "owner/repo",
provider: "unknown",
host: "github.company:8443",
});
const service = yield* makeService({
projects: [selfHosted],
providers: [
fakeProvider("github", {
listChangeRequests: (input) => {
hosts.push(input.host);
return Effect.succeed({ items: [], truncated: false, continues: true });
},
}),
],
resolveHandle: ({ context }) =>
Effect.succeed({
context: { ...context!, provider: { ...context!.provider, kind: "github" } },
provider: undefined as never,
}),
});

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

assert.strictEqual(result.providers[0]?.host, "github.company:8443");
assert.deepStrictEqual(hosts, ["github.company:8443"]);
}),
);

it.effect("refines unknown self-hosted GitLab projects before listing merge requests", () =>
Effect.gen(function* () {
let refinementCalls = 0;
Expand Down
143 changes: 96 additions & 47 deletions apps/server/src/pullRequest/PullRequestService.ts
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ import {
import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl";

import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
import {
resolveGitRemoteForSourceControl,
type ResolveGitRemoteServices,
} from "../sourceControl/resolveGitRemoteForSourceControl.ts";
import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
import {
type ProviderChangeRequest,
Expand Down Expand Up @@ -390,10 +394,31 @@ export function repositoryIdentityOf(project: OrchestrationProjectShell): string
return identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null;
}

/**
* Host a detected provider base URL should be stored as. `URL.hostname` drops a
* nonstandard port, so `https://github.company:8443` must use `URL.host`.
*/
export function hostnameFromProviderBaseUrl(baseUrl: string): string | null {
try {
const host = new URL(baseUrl).host.toLowerCase();
return host.length > 0 ? host : null;
} catch {
return null;
}
}

export const make = Effect.gen(function* () {
const registry = yield* PullRequestProviderRegistry;
const projections = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry;
const sshContext = yield* Effect.context<ResolveGitRemoteServices>();
const resolveDetectionUrl = (remoteUrl: string) =>
resolveGitRemoteForSourceControl(remoteUrl).pipe(Effect.provideContext(sshContext));

const detectProviderForRemoteUrl = (remoteUrl: string) =>
resolveDetectionUrl(remoteUrl).pipe(
Effect.map((detectionUrl) => detectSourceControlProviderFromRemoteUrl(detectionUrl)),
);

const refineUnknownProjectKinds = (
projects: ReadonlyArray<OrchestrationProjectShell>,
Expand All @@ -405,52 +430,62 @@ export const make = Effect.gen(function* () {
readonly remoteName: string;
readonly remoteUrl: string;
};
const refinements = new Map<string, RefinementCandidate[]>();
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");
// A legacy identity has no canonical host until its provider is refined, so it must reach
// the refinement before a host filter can decide whether it belongs in the result.
if (filter.host !== undefined && host !== "unknown" && host !== filter.host.toLowerCase()) {
continue;
}
const { remoteName, remoteUrl } = identity.locator;
const provider = detectSourceControlProviderFromRemoteUrl(remoteUrl);
if (provider !== null) {
const candidates = refinements.get(provider.baseUrl);
const candidate = { project, provider, remoteName, remoteUrl };
if (candidates === undefined) refinements.set(provider.baseUrl, [candidate]);
else candidates.push(candidate);

return Effect.gen(function* () {
const refinements = new Map<string, RefinementCandidate[]>();
const detectedByRemoteUrl = new Map<string, SourceControlProviderInfo | null>();
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 { remoteName, remoteUrl } = identity.locator;
let provider = detectedByRemoteUrl.get(remoteUrl);
if (provider === undefined) {
provider = yield* detectProviderForRemoteUrl(remoteUrl);
detectedByRemoteUrl.set(remoteUrl, provider);
}
const host = pullRequestHostOf(identity, "unknown");
// A legacy identity has no canonical host until its provider is refined, so it must reach
// the refinement before a host filter can decide whether it belongs in the result.
if (filter.host !== undefined && host !== "unknown" && host !== filter.host.toLowerCase()) {
continue;
}
if (provider !== null) {
const candidates = refinements.get(provider.baseUrl);
const candidate = { project, provider, remoteName, remoteUrl };
if (candidates === undefined) refinements.set(provider.baseUrl, [candidate]);
else candidates.push(candidate);
}
}
}

return Effect.forEach(
refinements,
([baseUrl, candidates]) =>
Effect.firstSuccessOf(
candidates.map(({ project, provider, remoteName, remoteUrl }) =>
Effect.suspend(() =>
sourceControlProviders.resolveHandle({
cwd: project.workspaceRoot,
context: { provider, remoteName, remoteUrl },
}),
).pipe(
Effect.flatMap((handle) => {
const kind = handle.context?.provider.kind;
return kind === undefined || kind === "unknown"
? Effect.fail(undefined)
: Effect.succeed(kind);
}),
const refinedKinds = yield* Effect.forEach(
refinements,
([baseUrl, candidates]) =>
Effect.firstSuccessOf(
candidates.map(({ project, provider, remoteName, remoteUrl }) =>
Effect.suspend(() =>
sourceControlProviders.resolveHandle({
cwd: project.workspaceRoot,
context: { provider, remoteName, remoteUrl },
}),
).pipe(
Effect.flatMap((handle) => {
const kind = handle.context?.provider.kind;
return kind === undefined || kind === "unknown"
? Effect.fail(undefined)
: Effect.succeed(kind);
}),
),
),
).pipe(
Effect.map((kind) => [baseUrl, kind] as const),
Effect.orElseSucceed(() => [baseUrl, "unknown"] as const),
),
).pipe(
Effect.map((kind) => [baseUrl, kind] as const),
Effect.orElseSucceed(() => [baseUrl, "unknown"] as const),
),
{ concurrency: REPOSITORY_CONCURRENCY },
).pipe(Effect.map((resolved) => new Map(resolved)));
{ concurrency: REPOSITORY_CONCURRENCY },
).pipe(Effect.map((resolved) => new Map(resolved)));

return { refinedKinds, detectedByRemoteUrl };
});
};

const listWorkspaceProjects = (
Expand All @@ -467,10 +502,14 @@ export const make = Effect.gen(function* () {
),
Effect.flatMap((snapshot) =>
refineUnknownProjectKinds(snapshot.projects, filter).pipe(
Effect.map((refinedKinds) => ({ refinedKinds, snapshot })),
Effect.map(({ refinedKinds, detectedByRemoteUrl }) => ({
refinedKinds,
detectedByRemoteUrl,
snapshot,
})),
),
),
Effect.map(({ refinedKinds, snapshot }) => {
Effect.map(({ refinedKinds, detectedByRemoteUrl, snapshot }) => {
const supported: SupportedProject[] = [];
const unimplemented = new Map<
string,
Expand All @@ -488,11 +527,21 @@ export const make = Effect.gen(function* () {
// 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 detected =
kind === "unknown"
? (detectedByRemoteUrl.get(identity.locator.remoteUrl) ?? null)
: null;
if (kind === "unknown") {
const provider = detectSourceControlProviderFromRemoteUrl(identity.locator.remoteUrl);
kind = provider === null ? kind : (refinedKinds.get(provider.baseUrl) ?? kind);
kind =
detected === null
? kind
: (refinedKinds.get(detected.baseUrl) ??
(detected.kind === "unknown" ? kind : detected.kind));
}
const host = pullRequestHostOf(identity, kind);
const host =
detected !== null && detected.kind !== "unknown"
? (hostnameFromProviderBaseUrl(detected.baseUrl) ?? pullRequestHostOf(identity, kind))
: pullRequestHostOf(identity, kind);
if (filter.host !== undefined && host !== filter.host.toLowerCase()) continue;
const api = registry.get(kind);
// Recorded before the de-duplication below, so the viewer lookup keeps the alternates
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const sourceControlProviderRegistryTestLayer = (input: {
SourceControlProviderRegistry.layer.pipe(
Layer.provide(
Layer.mergeAll(
NodeServices.layer,
ServerConfig.layerTest(process.cwd(), {
prefix: "t3-source-control-registry-test-",
}).pipe(Layer.provide(NodeServices.layer)),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ function makeRegistry(input: {
Layer.mergeAll(
registryLayer,
processLayer,
NodeServices.layer,
Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),
Layer.mock(BitbucketApi.BitbucketApi)({}),
Layer.mock(GitHubCli.GitHubCli)({}),
Expand Down
Loading
Loading