History

Add source control settings discovery

Julius Marminge committed 6 months ago

Commit dc2e2651 parent 128b43214 files · +644 −3Browse files

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

Unified · Split

2 files
  1. apps/server/src/sourceControl/SourceControlDiscovery.test.ts +77 −0
  2. apps/server/src/sourceControl/SourceControlDiscovery.ts +178 −0
apps/server/src/sourceControl/SourceControlDiscovery.test.ts+77−0
@@ -0,0 +1,77 @@
1+import { assert, it } from "@effect/vitest";
2+import * as NodeServices from "@effect/platform-node/NodeServices";
3+import { Effect, Layer } from "effect";
4+import { ChildProcessSpawner } from "effect/unstable/process";
5+import { VcsProcessSpawnError } from "@t3tools/contracts";
6+
7+import { ServerConfig } from "../config.ts";
8+import * as VcsProcess from "../vcs/VcsProcess.ts";
9+import { SourceControlDiscovery, layer } from "./SourceControlDiscovery.ts";
10+
11+const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({
12+ exitCode: ChildProcessSpawner.ExitCode(0),
13+ stdout,
14+ stderr: "",
15+ stdoutTruncated: false,
16+ stderrTruncated: false,
17+});
18+
19+it.effect("reports implemented tools separately from locally available CLIs", () => {
20+ const testLayer = layer.pipe(
21+ Layer.provide(
22+ ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-discovery-" }),
23+ ),
24+ Layer.provide(
25+ Layer.mock(VcsProcess.VcsProcess)({
26+ run: (input) => {
27+ if (input.command === "git") {
28+ return Effect.succeed(processOutput("git version 2.51.0\n"));
29+ }
30+ if (input.command === "gh") {
31+ return Effect.succeed(processOutput("gh version 2.83.0\n"));
32+ }
33+ return Effect.fail(
34+ new VcsProcessSpawnError({
35+ operation: input.operation,
36+ command: input.command,
37+ cwd: input.cwd,
38+ cause: new Error(`${input.command} not found`),
39+ }),
40+ );
41+ },
42+ }),
43+ ),
44+ Layer.provideMerge(NodeServices.layer),
45+ );
46+
47+ return Effect.gen(function* () {
48+ const discovery = yield* SourceControlDiscovery;
49+ const result = yield* discovery.discover;
50+
51+ assert.deepStrictEqual(
52+ result.versionControlSystems.map((item) => ({
53+ kind: item.kind,
54+ implemented: item.implemented,
55+ status: item.status,
56+ })),
57+ [
58+ { kind: "git", implemented: true, status: "available" },
59+ { kind: "jj", implemented: false, status: "missing" },
60+ { kind: "sapling", implemented: false, status: "missing" },
61+ ],
62+ );
63+ assert.deepStrictEqual(
64+ result.sourceControlProviders.map((item) => ({
65+ kind: item.kind,
66+ implemented: item.implemented,
67+ status: item.status,
68+ })),
69+ [
70+ { kind: "github", implemented: true, status: "available" },
71+ { kind: "gitlab", implemented: false, status: "missing" },
72+ { kind: "azure-devops", implemented: false, status: "missing" },
73+ { kind: "bitbucket", implemented: false, status: "missing" },
74+ ],
75+ );
76+ }).pipe(Effect.provide(testLayer));
77+});
apps/server/src/sourceControl/SourceControlDiscovery.ts+178−0
@@ -0,0 +1,178 @@
1+import {
2+ type SourceControlDiscoveryResult,
3+ type SourceControlProviderDiscoveryItem,
4+ type SourceControlProviderKind,
5+ type VcsDiscoveryItem,
6+ type VcsDriverKind,
7+} from "@t3tools/contracts";
8+import { Context, Effect, Layer, Option } from "effect";
9+
10+import { ServerConfig } from "../config.ts";
11+import * as VcsProcess from "../vcs/VcsProcess.ts";
12+
13+interface DiscoveryProbe {
14+ readonly label: string;
15+ readonly executable: string;
16+ readonly versionArgs: ReadonlyArray<string>;
17+ readonly implemented: boolean;
18+ readonly installHint: string;
19+}
20+
21+type VcsProbe = DiscoveryProbe & {
22+ readonly kind: VcsDriverKind;
23+};
24+
25+type ProviderProbe = DiscoveryProbe & {
26+ readonly kind: SourceControlProviderKind;
27+};
28+
29+const VCS_PROBES: ReadonlyArray<VcsProbe> = [
30+ {
31+ kind: "git",
32+ label: "Git",
33+ executable: "git",
34+ versionArgs: ["--version"],
35+ implemented: true,
36+ installHint: "Install Git from https://git-scm.com/downloads or with your package manager.",
37+ },
38+ {
39+ kind: "jj",
40+ label: "Jujutsu",
41+ executable: "jj",
42+ versionArgs: ["--version"],
43+ implemented: false,
44+ installHint: "Install Jujutsu with `brew install jj` or from https://github.com/jj-vcs/jj.",
45+ },
46+ {
47+ kind: "sapling",
48+ label: "Sapling",
49+ executable: "sl",
50+ versionArgs: ["--version"],
51+ implemented: false,
52+ installHint: "Install Sapling (`sl`) from https://sapling-scm.com/.",
53+ },
54+];
55+
56+const SOURCE_CONTROL_PROVIDER_PROBES: ReadonlyArray<ProviderProbe> = [
57+ {
58+ kind: "github",
59+ label: "GitHub",
60+ executable: "gh",
61+ versionArgs: ["--version"],
62+ implemented: true,
63+ installHint: "Install GitHub CLI with `brew install gh` or from https://cli.github.com/.",
64+ },
65+ {
66+ kind: "gitlab",
67+ label: "GitLab",
68+ executable: "glab",
69+ versionArgs: ["--version"],
70+ implemented: false,
71+ installHint:
72+ "Install GitLab CLI with `brew install glab` or from https://gitlab.com/gitlab-org/cli.",
73+ },
74+ {
75+ kind: "azure-devops",
76+ label: "Azure DevOps",
77+ executable: "az",
78+ versionArgs: ["--version"],
79+ implemented: false,
80+ installHint:
81+ "Install Azure CLI with `brew install azure-cli`, then add Azure DevOps support with `az extension add --name azure-devops`.",
82+ },
83+ {
84+ kind: "bitbucket",
85+ label: "Bitbucket",
86+ executable: "bb",
87+ versionArgs: ["--version"],
88+ implemented: false,
89+ installHint: "Install a Bitbucket CLI (`bb`) and authenticate it for your Bitbucket workspace.",
90+ },
91+];
92+
93+function firstNonEmptyLine(text: string): Option.Option<string> {
94+ const line = text
95+ .split(/\r?\n/)
96+ .map((entry) => entry.trim())
97+ .find((entry) => entry.length > 0);
98+ return line === undefined ? Option.none() : Option.some(line);
99+}
100+
101+function detailFromCause(cause: unknown): Option.Option<string> {
102+ if (cause instanceof Error && cause.message.trim().length > 0) {
103+ return Option.some(cause.message.trim());
104+ }
105+ return Option.none();
106+}
107+
108+export interface SourceControlDiscoveryShape {
109+ readonly discover: Effect.Effect<SourceControlDiscoveryResult>;
110+}
111+
112+export class SourceControlDiscovery extends Context.Service<
113+ SourceControlDiscovery,
114+ SourceControlDiscoveryShape
115+>()("t3/source-control/SourceControlDiscovery") {}
116+
117+export const layer = Layer.effect(
118+ SourceControlDiscovery,
119+ Effect.gen(function* () {
120+ const config = yield* ServerConfig;
121+ const process = yield* VcsProcess.VcsProcess;
122+
123+ const probe = <Kind extends VcsDriverKind | SourceControlProviderKind>(
124+ input: DiscoveryProbe & { readonly kind: Kind },
125+ ) =>
126+ process
127+ .run({
128+ operation: "source-control.discovery.probe",
129+ command: input.executable,
130+ args: input.versionArgs,
131+ cwd: config.cwd,
132+ timeoutMs: 5_000,
133+ maxOutputBytes: 8_000,
134+ truncateOutputAtMaxBytes: true,
135+ })
136+ .pipe(
137+ Effect.map((result) => ({
138+ kind: input.kind,
139+ label: input.label,
140+ executable: input.executable,
141+ implemented: input.implemented,
142+ status: "available" as const,
143+ version: Option.orElse(firstNonEmptyLine(result.stdout), () =>
144+ firstNonEmptyLine(result.stderr),
145+ ),
146+ installHint: input.installHint,
147+ detail: Option.none<string>(),
148+ })),
149+ Effect.catch((cause) =>
150+ Effect.succeed({
151+ kind: input.kind,
152+ label: input.label,
153+ executable: input.executable,
154+ implemented: input.implemented,
155+ status: "missing" as const,
156+ version: Option.none<string>(),
157+ installHint: input.installHint,
158+ detail: detailFromCause(cause),
159+ }),
160+ ),
161+ );
162+
163+ return SourceControlDiscovery.of({
164+ discover: Effect.all({
165+ versionControlSystems: Effect.all(
166+ VCS_PROBES.map((entry) => probe(entry)) as ReadonlyArray<Effect.Effect<VcsDiscoveryItem>>,
167+ { concurrency: "unbounded" },
168+ ),
169+ sourceControlProviders: Effect.all(
170+ SOURCE_CONTROL_PROVIDER_PROBES.map((entry) => probe(entry)) as ReadonlyArray<
171+ Effect.Effect<SourceControlProviderDiscoveryItem>
172+ >,
173+ { concurrency: "unbounded" },
174+ ),
175+ }),
176+ });
177+ }),
178+);

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t