History

Add source control discovery state

Julius Marminge committed 6 months ago

Commit b133b0d1 parent e36a15513 files · +943 −124Browse files

Showing the changes to apps/server/src/sourceControl/SourceControlDiscovery.ts (1 of 13 files). Show all

Unified · Split

apps/server/src/sourceControl/SourceControlDiscovery.ts+257−15
@@ -1,4 +1,5 @@
11 import {
2+ type SourceControlProviderAuth,
23 type SourceControlDiscoveryResult,
34 type SourceControlProviderDiscoveryItem,
45 type SourceControlProviderKind,
@@ -24,8 +25,27 @@ type VcsProbe = DiscoveryProbe & { · 19 unmodified lines
2425
2526 type ProviderProbe = DiscoveryProbe & {
2627 readonly kind: SourceControlProviderKind;
28+ readonly authArgs: ReadonlyArray<string>;
29+ readonly parseAuth: (input: AuthProbeInput) => SourceControlProviderAuth;
2730 };
2831
32+interface AuthProbeInput {
33+ readonly stdout: string;
34+ readonly stderr: string;
35+ readonly exitCode: VcsProcess.VcsProcessOutput["exitCode"];
36+}
37+
38+interface DiscoveryProbeResult<Kind extends string> {
39+ readonly kind: Kind;
40+ readonly label: string;
41+ readonly executable: string;
42+ readonly implemented: boolean;
43+ readonly status: "available" | "missing";
44+ readonly version: Option.Option<string>;
45+ readonly installHint: string;
46+ readonly detail: Option.Option<string>;
47+}
48+
2949 const VCS_PROBES: ReadonlyArray<VcsProbe> = [
3050 {
3151 kind: "git",
@@ -59,6 +79,8 @@ const SOURCE_CONTROL_PROVIDER_PROBES: ReadonlyArray<ProviderProbe> = [ · 27 unmodified lines
5979 label: "GitHub",
6080 executable: "gh",
6181 versionArgs: ["--version"],
82+ authArgs: ["auth", "status"],
83+ parseAuth: parseGitHubAuth,
6284 implemented: true,
6385 installHint: "Install GitHub CLI with `brew install gh` or from https://cli.github.com/.",
6486 },
@@ -67,6 +89,8 @@ const SOURCE_CONTROL_PROVIDER_PROBES: ReadonlyArray<ProviderProbe> = [ · 2 unmodified lines
6789 label: "GitLab",
6890 executable: "glab",
6991 versionArgs: ["--version"],
92+ authArgs: ["auth", "status"],
93+ parseAuth: parseGitLabAuth,
7094 implemented: false,
7195 installHint:
7296 "Install GitLab CLI with `brew install glab` or from https://gitlab.com/gitlab-org/cli.",
@@ -76,6 +100,8 @@ const SOURCE_CONTROL_PROVIDER_PROBES: ReadonlyArray<ProviderProbe> = [ · 3 unmodified lines
76100 label: "Azure DevOps",
77101 executable: "az",
78102 versionArgs: ["--version"],
103+ authArgs: ["account", "show", "--query", "user.name", "-o", "tsv"],
104+ parseAuth: parseAzureAuth,
79105 implemented: false,
80106 installHint:
81107 "Install Azure CLI with `brew install azure-cli`, then add Azure DevOps support with `az extension add --name azure-devops`.",
@@ -85,6 +111,8 @@ const SOURCE_CONTROL_PROVIDER_PROBES: ReadonlyArray<ProviderProbe> = [ · 3 unmodified lines
85111 label: "Bitbucket",
86112 executable: "bb",
87113 versionArgs: ["--version"],
114+ authArgs: ["auth", "status"],
115+ parseAuth: parseBitbucketAuth,
88116 implemented: false,
89117 installHint: "Install a Bitbucket CLI (`bb`) and authenticate it for your Bitbucket workspace.",
90118 },
@@ -105,6 +133,178 @@ function detailFromCause(cause: unknown): Option.Option<string> { · 14 unmodified lines
105133 return Option.none();
106134 }
107135
136+function authAccount(account: string | undefined): Option.Option<string> {
137+ const trimmed = account?.trim();
138+ return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed);
139+}
140+
141+function authHost(host: string | undefined): Option.Option<string> {
142+ const trimmed = host?.trim();
143+ return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed);
144+}
145+
146+function authDetail(detail: string | undefined): Option.Option<string> {
147+ const trimmed = detail?.trim();
148+ return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed);
149+}
150+
151+function providerAuth(input: {
152+ readonly status: SourceControlProviderAuth["status"];
153+ readonly account?: string | undefined;
154+ readonly host?: string | undefined;
155+ readonly detail?: string | undefined;
156+}): SourceControlProviderAuth {
157+ return {
158+ status: input.status,
159+ account: authAccount(input.account),
160+ host: authHost(input.host),
161+ detail: authDetail(input.detail),
162+ };
163+}
164+
165+function unknownAuth(detail?: string): SourceControlProviderAuth {
166+ return providerAuth({ status: "unknown", detail });
167+}
168+
169+function combinedAuthOutput(input: AuthProbeInput): string {
170+ return [input.stdout, input.stderr].filter((entry) => entry.trim().length > 0).join("\n");
171+}
172+
173+function sanitizedAuthLines(text: string): ReadonlyArray<string> {
174+ return text
175+ .split(/\r?\n/)
176+ .map((entry) => entry.trim())
177+ .filter((entry) => entry.length > 0)
178+ .filter((entry) => !/^[-\s]*token(?:\s+scopes?)?:/iu.test(entry));
179+}
180+
181+function firstSafeAuthLine(text: string): string | undefined {
182+ return sanitizedAuthLines(text)[0];
183+}
184+
185+function parseCliHost(text: string): string | undefined {
186+ return sanitizedAuthLines(text)
187+ .map((line) => line.replace(/^[^a-z0-9]+/iu, ""))
188+ .find((line) => /^[a-z0-9][a-z0-9.-]*(?::\d+)?$/iu.test(line));
189+}
190+
191+function matchFirst(text: string, patterns: ReadonlyArray<RegExp>): string | undefined {
192+ for (const pattern of patterns) {
193+ const match = pattern.exec(text);
194+ const value = match?.[1]?.trim();
195+ if (value && value.length > 0) return value;
196+ }
197+ return undefined;
198+}
199+
200+function parseGitHubAuth(input: AuthProbeInput): SourceControlProviderAuth {
201+ const output = combinedAuthOutput(input);
202+ const account = matchFirst(output, [
203+ /Logged in to .* account\s+([^\s(]+)/iu,
204+ /Logged in to .* as\s+([^\s(]+)/iu,
205+ ]);
206+ const host = parseCliHost(output);
207+
208+ if (input.exitCode !== 0) {
209+ return providerAuth({
210+ status: "unauthenticated",
211+ host,
212+ detail: firstSafeAuthLine(output) ?? "Run `gh auth login` to authenticate GitHub CLI.",
213+ });
214+ }
215+
216+ if (account) {
217+ return providerAuth({ status: "authenticated", account, host });
218+ }
219+
220+ return providerAuth({
221+ status: "unknown",
222+ host,
223+ detail: firstSafeAuthLine(output) ?? "GitHub CLI auth status could not be parsed.",
224+ });
225+}
226+
227+function parseGitLabAuth(input: AuthProbeInput): SourceControlProviderAuth {
228+ const output = combinedAuthOutput(input);
229+ const account = matchFirst(output, [
230+ /Logged in to .* as\s+([^\s(]+)/iu,
231+ /Logged in to .* account\s+([^\s(]+)/iu,
232+ /account:\s*([^\s(]+)/iu,
233+ ]);
234+ const host = parseCliHost(output);
235+
236+ if (input.exitCode !== 0) {
237+ return providerAuth({
238+ status: "unauthenticated",
239+ host,
240+ detail: firstSafeAuthLine(output) ?? "Run `glab auth login` to authenticate GitLab CLI.",
241+ });
242+ }
243+
244+ if (account) {
245+ return providerAuth({ status: "authenticated", account, host });
246+ }
247+
248+ return providerAuth({
249+ status: "unknown",
250+ host,
251+ detail: firstSafeAuthLine(output) ?? "GitLab CLI auth status could not be parsed.",
252+ });
253+}
254+
255+function parseAzureAuth(input: AuthProbeInput): SourceControlProviderAuth {
256+ const account = input.stdout.trim().split(/\r?\n/)[0]?.trim();
257+
258+ if (input.exitCode !== 0) {
259+ return providerAuth({
260+ status: "unauthenticated",
261+ detail:
262+ firstSafeAuthLine(combinedAuthOutput(input)) ?? "Run `az login` to authenticate Azure CLI.",
263+ });
264+ }
265+
266+ if (account && account.length > 0) {
267+ return providerAuth({ status: "authenticated", account, host: "dev.azure.com" });
268+ }
269+
270+ return providerAuth({
271+ status: "unknown",
272+ host: "dev.azure.com",
273+ detail: "Azure CLI account status could not be parsed.",
274+ });
275+}
276+
277+function parseBitbucketAuth(input: AuthProbeInput): SourceControlProviderAuth {
278+ const output = combinedAuthOutput(input);
279+ const account = matchFirst(output, [
280+ /Logged in to .* as\s+([^\s(]+)/iu,
281+ /Logged in as\s+([^\s(]+)/iu,
282+ /account:\s*([^\s(]+)/iu,
283+ /user:\s*([^\s(]+)/iu,
284+ /username:\s*([^\s(]+)/iu,
285+ ]);
286+ const host = parseCliHost(output) ?? "bitbucket.org";
287+
288+ if (input.exitCode !== 0) {
289+ return providerAuth({
290+ status: "unauthenticated",
291+ host,
292+ detail:
293+ firstSafeAuthLine(output) ?? "Authenticate the Bitbucket CLI before enabling Bitbucket.",
294+ });
295+ }
296+
297+ if (account) {
298+ return providerAuth({ status: "authenticated", account, host });
299+ }
300+
301+ return providerAuth({
302+ status: "unknown",
303+ host,
304+ detail: firstSafeAuthLine(output) ?? "Bitbucket CLI auth status could not be parsed.",
305+ });
306+}
307+
108308 export interface SourceControlDiscoveryShape {
109309 readonly discover: Effect.Effect<SourceControlDiscoveryResult>;
110310 }
@@ -122,7 +322,7 @@ export const layer = Layer.effect( · 11 unmodified lines
122322
123323 const probe = <Kind extends VcsDriverKind | SourceControlProviderKind>(
124324 input: DiscoveryProbe & { readonly kind: Kind },
125− ) =>
325+ ): Effect.Effect<DiscoveryProbeResult<Kind>> =>
126326 process
127327 .run({
128328 operation: "source-control.discovery.probe",
@@ -134,18 +334,21 @@ export const layer = Layer.effect( · 5 unmodified lines
134334 truncateOutputAtMaxBytes: true,
135335 })
136336 .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− })),
337+ Effect.map(
338+ (result) =>
339+ ({
340+ kind: input.kind,
341+ label: input.label,
342+ executable: input.executable,
343+ implemented: input.implemented,
344+ status: "available" as const,
345+ version: Option.orElse(firstNonEmptyLine(result.stdout), () =>
346+ firstNonEmptyLine(result.stderr),
347+ ),
348+ installHint: input.installHint,
349+ detail: Option.none<string>(),
350+ }) satisfies DiscoveryProbeResult<Kind>,
351+ ),
149352 Effect.catch((cause) =>
150353 Effect.succeed({
151354 kind: input.kind,
@@ -156,10 +359,49 @@ export const layer = Layer.effect( · 4 unmodified lines
156359 version: Option.none<string>(),
157360 installHint: input.installHint,
158361 detail: detailFromCause(cause),
159− }),
362+ } satisfies DiscoveryProbeResult<Kind>),
160363 ),
161364 );
162365
366+ const probeProvider = (input: ProviderProbe) =>
367+ probe(input).pipe(
368+ Effect.flatMap((item) => {
369+ if (item.status !== "available") {
370+ return Effect.succeed({
371+ ...item,
372+ auth: unknownAuth("CLI is not installed."),
373+ } satisfies SourceControlProviderDiscoveryItem);
374+ }
375+
376+ return process
377+ .run({
378+ operation: "source-control.discovery.auth",
379+ command: input.executable,
380+ args: input.authArgs,
381+ cwd: config.cwd,
382+ allowNonZeroExit: true,
383+ timeoutMs: 5_000,
384+ maxOutputBytes: 8_000,
385+ truncateOutputAtMaxBytes: true,
386+ })
387+ .pipe(
388+ Effect.map(
389+ (result) =>
390+ ({
391+ ...item,
392+ auth: input.parseAuth(result),
393+ }) satisfies SourceControlProviderDiscoveryItem,
394+ ),
395+ Effect.catch((cause) =>
396+ Effect.succeed({
397+ ...item,
398+ auth: unknownAuth(Option.getOrUndefined(detailFromCause(cause))),
399+ } satisfies SourceControlProviderDiscoveryItem),
400+ ),
401+ );
402+ }),
403+ );
404+
163405 return SourceControlDiscovery.of({
164406 discover: Effect.all({
165407 versionControlSystems: Effect.all(
@@ -167,7 +409,7 @@ export const layer = Layer.effect( · 1 unmodified line
167409 { concurrency: "unbounded" },
168410 ),
169411 sourceControlProviders: Effect.all(
170− SOURCE_CONTROL_PROVIDER_PROBES.map((entry) => probe(entry)) as ReadonlyArray<
412+ SOURCE_CONTROL_PROVIDER_PROBES.map((entry) => probeProvider(entry)) as ReadonlyArray<
171413 Effect.Effect<SourceControlProviderDiscoveryItem>
172414 >,
173415 { concurrency: "unbounded" },

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