History

Add pluggable source control provider detection

- Detect GitHub, GitLab, and Azure DevOps remotes in the shared parser - Cache provider resolution and return unsupported stubs for unregistered kinds - Remove deprecated git RPCs from the web IPC surface

Julius Marminge committed 6 months ago

Commit 506e0fc1 parent e8d67ff8 files · +214 −52Browse files

Showing the changes to apps/server/src/sourceControl (2 of 8 files). Show all

Unified · Split

2 files
  1. apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +82 −0
  2. apps/server/src/sourceControl/SourceControlProviderRegistry.ts +101 −7
apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts+82−0
@@ -0,0 +1,82 @@
1+import { assert, it } from "@effect/vitest";
2+import { Effect, Layer } from "effect";
3+
4+import { GitHubCli } from "../git/Services/GitHubCli.ts";
5+import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
6+import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
7+
8+const processResult = (stdout: string) => ({
9+ stdout,
10+ stderr: "",
11+ code: 0,
12+ signal: null,
13+ timedOut: false,
14+ stdoutTruncated: false,
15+ stderrTruncated: false,
16+});
17+
18+function makeRegistry(input: {
19+ readonly originUrl?: string | null;
20+ readonly remoteVerboseOutput?: string;
21+}) {
22+ const gitLayer = Layer.mock(GitVcsDriver.GitVcsDriver)({
23+ readConfigValue: (_cwd, key) =>
24+ key === "remote.origin.url" ? Effect.succeed(input.originUrl ?? null) : Effect.succeed(null),
25+ execute: () => Effect.succeed(processResult(input.remoteVerboseOutput ?? "")),
26+ });
27+
28+ return SourceControlProviderRegistry.make().pipe(
29+ Effect.provide(Layer.mergeAll(gitLayer, Layer.mock(GitHubCli)({}))),
30+ );
31+}
32+
33+it.effect("routes GitHub remotes to the GitHub provider", () =>
34+ Effect.gen(function* () {
35+ const registry = yield* makeRegistry({
36+ originUrl: "git@github.com:pingdotgg/t3code.git",
37+ });
38+
39+ const provider = yield* registry.resolve({ cwd: "/repo" });
40+
41+ assert.strictEqual(provider.kind, "github");
42+ }),
43+);
44+
45+it.effect(
46+ "detects GitLab remotes and returns an unsupported provider until one is registered",
47+ () =>
48+ Effect.gen(function* () {
49+ const registry = yield* makeRegistry({
50+ originUrl: "git@gitlab.com:group/project.git",
51+ });
52+
53+ const provider = yield* registry.resolve({ cwd: "/repo" });
54+
55+ assert.strictEqual(provider.kind, "gitlab");
56+ const error = yield* Effect.flip(
57+ provider.listChangeRequests({
58+ cwd: "/repo",
59+ headSelector: "feature/source-control",
60+ state: "open",
61+ }),
62+ );
63+
64+ assert.strictEqual(error.provider, "gitlab");
65+ }),
66+);
67+
68+it.effect("falls back to remote verbose output when origin is not configured", () =>
69+ Effect.gen(function* () {
70+ const registry = yield* makeRegistry({
71+ originUrl: null,
72+ remoteVerboseOutput: [
73+ "upstream\thttps://dev.azure.com/acme/project/_git/repo (fetch)",
74+ "upstream\thttps://dev.azure.com/acme/project/_git/repo (push)",
75+ ].join("\n"),
76+ });
77+
78+ const provider = yield* registry.resolve({ cwd: "/repo" });
79+
80+ assert.strictEqual(provider.kind, "azure-devops");
81+ }),
82+);
apps/server/src/sourceControl/SourceControlProviderRegistry.ts+101−7
@@ -1,8 +1,14 @@
1−import { Context, Effect, Layer } from "effect";
2−import type { SourceControlProviderError } from "@t3tools/contracts";
1+import { Cache, Context, Duration, Effect, Exit, Layer } from "effect";
2+import { SourceControlProviderError } from "@t3tools/contracts";
3+import type { SourceControlProviderKind } from "@t3tools/contracts";
4+import { detectGitHostingProviderFromRemoteUrl } from "@t3tools/shared/git";
35
46 import { SourceControlProvider, type SourceControlProviderShape } from "./SourceControlProvider.ts";
57 import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";
8+import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
9+
10+const PROVIDER_DETECTION_CACHE_CAPACITY = 2_048;
11+const PROVIDER_DETECTION_CACHE_TTL = Duration.seconds(5);
612
713 export interface SourceControlProviderRegistryShape {
814 readonly resolve: (input: {
@@ -15,14 +21,102 @@ export class SourceControlProviderRegistry extends Context.Service< · 6 unmodified lines
1521 SourceControlProviderRegistryShape
1622 >()("t3/source-control/SourceControlProviderRegistry") {}
1723
24+function unsupportedProvider(kind: SourceControlProviderKind): SourceControlProviderShape {
25+ const unsupported = (operation: string) =>
26+ Effect.fail(
27+ new SourceControlProviderError({
28+ provider: kind,
29+ operation,
30+ detail: `No ${kind} source control provider is registered.`,
31+ }),
32+ );
33+
34+ return SourceControlProvider.of({
35+ kind,
36+ listChangeRequests: () => unsupported("listChangeRequests"),
37+ getChangeRequest: () => unsupported("getChangeRequest"),
38+ createChangeRequest: () => unsupported("createChangeRequest"),
39+ getRepositoryCloneUrls: () => unsupported("getRepositoryCloneUrls"),
40+ getDefaultBranch: () => unsupported("getDefaultBranch"),
41+ checkoutChangeRequest: () => unsupported("checkoutChangeRequest"),
42+ });
43+}
44+
45+function providerDetectionError(operation: string, cwd: string, cause: unknown) {
46+ return new SourceControlProviderError({
47+ provider: "unknown",
48+ operation,
49+ detail: `Failed to detect source control provider for ${cwd}.`,
50+ cause,
51+ });
52+}
53+
54+function firstRemoteUrlFromVerboseOutput(output: string): string | null {
55+ for (const line of output.split("\n")) {
56+ const trimmed = line.trim();
57+ if (trimmed.length === 0) {
58+ continue;
59+ }
60+ const match = /^\S+\s+(\S+)\s+\((?:fetch|push)\)$/.exec(trimmed);
61+ const remoteUrl = match?.[1]?.trim() ?? "";
62+ if (remoteUrl.length > 0) {
63+ return remoteUrl;
64+ }
65+ }
66+ return null;
67+}
68+
1869 export const make = Effect.fn("makeSourceControlProviderRegistry")(function* () {
19− const github = yield* SourceControlProvider;
70+ const github = yield* GitHubSourceControlProvider.make();
71+ const git = yield* GitVcsDriver.GitVcsDriver;
72+ const providers: Partial<Record<SourceControlProviderKind, SourceControlProviderShape>> = {
73+ github,
74+ };
75+
76+ const detectProviderKind = Effect.fn("SourceControlProviderRegistry.detectProviderKind")(
77+ function* (cwd: string) {
78+ const originUrl = yield* git
79+ .readConfigValue(cwd, "remote.origin.url")
80+ .pipe(Effect.catch(() => Effect.succeed(null)));
81+ const remoteUrl =
82+ originUrl ??
83+ (yield* git
84+ .execute({
85+ operation: "SourceControlProviderRegistry.detectProvider.remoteVerbose",
86+ cwd,
87+ args: ["remote", "-v"],
88+ allowNonZeroExit: true,
89+ })
90+ .pipe(
91+ Effect.map((result) =>
92+ result.code === 0 ? firstRemoteUrlFromVerboseOutput(result.stdout) : null,
93+ ),
94+ Effect.mapError((error) => providerDetectionError("detectProvider", cwd, error)),
95+ ));
96+
97+ if (!remoteUrl) {
98+ return "unknown" as const;
99+ }
100+
101+ return detectGitHostingProviderFromRemoteUrl(remoteUrl)?.kind ?? "unknown";
102+ },
103+ );
104+
105+ const providerKindCache = yield* Cache.makeWith<
106+ string,
107+ SourceControlProviderKind,
108+ SourceControlProviderError
109+ >(detectProviderKind, {
110+ capacity: PROVIDER_DETECTION_CACHE_CAPACITY,
111+ timeToLive: (exit) => (Exit.isSuccess(exit) ? PROVIDER_DETECTION_CACHE_TTL : Duration.zero),
112+ });
20113
21114 return SourceControlProviderRegistry.of({
22− resolve: () => Effect.succeed(github),
115+ resolve: (input) =>
116+ Cache.get(providerKindCache, input.cwd).pipe(
117+ Effect.map((kind) => providers[kind] ?? unsupportedProvider(kind)),
118+ ),
23119 });
24120 });
25121
26−export const layer = Layer.effect(SourceControlProviderRegistry, make()).pipe(
27− Layer.provide(GitHubSourceControlProvider.layer),
28−);
122+export const layer = Layer.effect(SourceControlProviderRegistry, make());