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 @@
1import { assert, it } from "@effect/vitest";
2import { Effect, Layer } from "effect";
3
4import { GitHubCli } from "../git/Services/GitHubCli.ts";
5import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
6import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
7
8const processResult = (stdout: string) => ({
9 stdout,
10 stderr: "",
11 code: 0,
12 signal: null,
13 timedOut: false,
14 stdoutTruncated: false,
15 stderrTruncated: false,
16});
17
18function 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
33it.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
45it.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
68it.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 @@
1import { Context, Effect, Layer } from "effect";1import { Cache, Context, Duration, Effect, Exit, Layer } from "effect";
2import type { SourceControlProviderError } from "@t3tools/contracts";2import { SourceControlProviderError } from "@t3tools/contracts";
3import type { SourceControlProviderKind } from "@t3tools/contracts";
4import { detectGitHostingProviderFromRemoteUrl } from "@t3tools/shared/git";
3 5
4import { SourceControlProvider, type SourceControlProviderShape } from "./SourceControlProvider.ts";6import { SourceControlProvider, type SourceControlProviderShape } from "./SourceControlProvider.ts";
5import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";7import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";
8import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
9
10const PROVIDER_DETECTION_CACHE_CAPACITY = 2_048;
11const PROVIDER_DETECTION_CACHE_TTL = Duration.seconds(5);
6 12
7export interface SourceControlProviderRegistryShape {13export interface SourceControlProviderRegistryShape {
8 readonly resolve: (input: {14 readonly resolve: (input: {
@@ -15,14 +21,102 @@ export class SourceControlProviderRegistry extends Context.Service< · 6 unmodified lines
15 SourceControlProviderRegistryShape21 SourceControlProviderRegistryShape
16>()("t3/source-control/SourceControlProviderRegistry") {}22>()("t3/source-control/SourceControlProviderRegistry") {}
17 23
24function 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
45function 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
54function 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
18export const make = Effect.fn("makeSourceControlProviderRegistry")(function* () {69export 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 });
20 113
21 return SourceControlProviderRegistry.of({114 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 ),
23 });119 });
24});120});
25 121
26export const layer = Layer.effect(SourceControlProviderRegistry, make()).pipe(122export const layer = Layer.effect(SourceControlProviderRegistry, make());
27 Layer.provide(GitHubSourceControlProvider.layer),
28);