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 @@
1import {1import {
2 type SourceControlProviderAuth,
2 type SourceControlDiscoveryResult,3 type SourceControlDiscoveryResult,
3 type SourceControlProviderDiscoveryItem,4 type SourceControlProviderDiscoveryItem,
4 type SourceControlProviderKind,5 type SourceControlProviderKind,
@@ -24,8 +25,27 @@ type VcsProbe = DiscoveryProbe & { · 19 unmodified lines
24 25
25type ProviderProbe = DiscoveryProbe & {26type ProviderProbe = DiscoveryProbe & {
26 readonly kind: SourceControlProviderKind;27 readonly kind: SourceControlProviderKind;
28 readonly authArgs: ReadonlyArray<string>;
29 readonly parseAuth: (input: AuthProbeInput) => SourceControlProviderAuth;
27};30};
28 31
32interface AuthProbeInput {
33 readonly stdout: string;
34 readonly stderr: string;
35 readonly exitCode: VcsProcess.VcsProcessOutput["exitCode"];
36}
37
38interface 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
29const VCS_PROBES: ReadonlyArray<VcsProbe> = [49const VCS_PROBES: ReadonlyArray<VcsProbe> = [
30 {50 {
31 kind: "git",51 kind: "git",
@@ -59,6 +79,8 @@ const SOURCE_CONTROL_PROVIDER_PROBES: ReadonlyArray<ProviderProbe> = [ · 27 unmodified lines
59 label: "GitHub",79 label: "GitHub",
60 executable: "gh",80 executable: "gh",
61 versionArgs: ["--version"],81 versionArgs: ["--version"],
82 authArgs: ["auth", "status"],
83 parseAuth: parseGitHubAuth,
62 implemented: true,84 implemented: true,
63 installHint: "Install GitHub CLI with `brew install gh` or from https://cli.github.com/.",85 installHint: "Install GitHub CLI with `brew install gh` or from https://cli.github.com/.",
64 },86 },
@@ -67,6 +89,8 @@ const SOURCE_CONTROL_PROVIDER_PROBES: ReadonlyArray<ProviderProbe> = [ · 2 unmodified lines
67 label: "GitLab",89 label: "GitLab",
68 executable: "glab",90 executable: "glab",
69 versionArgs: ["--version"],91 versionArgs: ["--version"],
92 authArgs: ["auth", "status"],
93 parseAuth: parseGitLabAuth,
70 implemented: false,94 implemented: false,
71 installHint:95 installHint:
72 "Install GitLab CLI with `brew install glab` or from https://gitlab.com/gitlab-org/cli.",96 "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
76 label: "Azure DevOps",100 label: "Azure DevOps",
77 executable: "az",101 executable: "az",
78 versionArgs: ["--version"],102 versionArgs: ["--version"],
103 authArgs: ["account", "show", "--query", "user.name", "-o", "tsv"],
104 parseAuth: parseAzureAuth,
79 implemented: false,105 implemented: false,
80 installHint:106 installHint:
81 "Install Azure CLI with `brew install azure-cli`, then add Azure DevOps support with `az extension add --name azure-devops`.",107 "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
85 label: "Bitbucket",111 label: "Bitbucket",
86 executable: "bb",112 executable: "bb",
87 versionArgs: ["--version"],113 versionArgs: ["--version"],
114 authArgs: ["auth", "status"],
115 parseAuth: parseBitbucketAuth,
88 implemented: false,116 implemented: false,
89 installHint: "Install a Bitbucket CLI (`bb`) and authenticate it for your Bitbucket workspace.",117 installHint: "Install a Bitbucket CLI (`bb`) and authenticate it for your Bitbucket workspace.",
90 },118 },
@@ -105,6 +133,178 @@ function detailFromCause(cause: unknown): Option.Option<string> { · 14 unmodified lines
105 return Option.none();133 return Option.none();
106}134}
107 135
136function 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
141function 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
146function 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
151function 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
165function unknownAuth(detail?: string): SourceControlProviderAuth {
166 return providerAuth({ status: "unknown", detail });
167}
168
169function combinedAuthOutput(input: AuthProbeInput): string {
170 return [input.stdout, input.stderr].filter((entry) => entry.trim().length > 0).join("\n");
171}
172
173function 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
181function firstSafeAuthLine(text: string): string | undefined {
182 return sanitizedAuthLines(text)[0];
183}
184
185function 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
191function 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
200function 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
227function 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
255function 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
277function 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
108export interface SourceControlDiscoveryShape {308export interface SourceControlDiscoveryShape {
109 readonly discover: Effect.Effect<SourceControlDiscoveryResult>;309 readonly discover: Effect.Effect<SourceControlDiscoveryResult>;
110}310}
@@ -122,7 +322,7 @@ export const layer = Layer.effect( · 11 unmodified lines
122 322
123 const probe = <Kind extends VcsDriverKind | SourceControlProviderKind>(323 const probe = <Kind extends VcsDriverKind | SourceControlProviderKind>(
124 input: DiscoveryProbe & { readonly kind: Kind },324 input: DiscoveryProbe & { readonly kind: Kind },
125 ) =>325 ): Effect.Effect<DiscoveryProbeResult<Kind>> =>
126 process326 process
127 .run({327 .run({
128 operation: "source-control.discovery.probe",328 operation: "source-control.discovery.probe",
@@ -134,18 +334,21 @@ export const layer = Layer.effect( · 5 unmodified lines
134 truncateOutputAtMaxBytes: true,334 truncateOutputAtMaxBytes: true,
135 })335 })
136 .pipe(336 .pipe(
137 Effect.map((result) => ({337 Effect.map(
138 kind: input.kind,338 (result) =>
139 label: input.label,339 ({
140 executable: input.executable,340 kind: input.kind,
141 implemented: input.implemented,341 label: input.label,
142 status: "available" as const,342 executable: input.executable,
143 version: Option.orElse(firstNonEmptyLine(result.stdout), () =>343 implemented: input.implemented,
144 firstNonEmptyLine(result.stderr),344 status: "available" as const,
145 ),345 version: Option.orElse(firstNonEmptyLine(result.stdout), () =>
146 installHint: input.installHint,346 firstNonEmptyLine(result.stderr),
147 detail: Option.none<string>(),347 ),
148 })),348 installHint: input.installHint,
349 detail: Option.none<string>(),
350 }) satisfies DiscoveryProbeResult<Kind>,
351 ),
149 Effect.catch((cause) =>352 Effect.catch((cause) =>
150 Effect.succeed({353 Effect.succeed({
151 kind: input.kind,354 kind: input.kind,
@@ -156,10 +359,49 @@ export const layer = Layer.effect( · 4 unmodified lines
156 version: Option.none<string>(),359 version: Option.none<string>(),
157 installHint: input.installHint,360 installHint: input.installHint,
158 detail: detailFromCause(cause),361 detail: detailFromCause(cause),
159 }),362 } satisfies DiscoveryProbeResult<Kind>),
160 ),363 ),
161 );364 );
162 365
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
163 return SourceControlDiscovery.of({405 return SourceControlDiscovery.of({
164 discover: Effect.all({406 discover: Effect.all({
165 versionControlSystems: Effect.all(407 versionControlSystems: Effect.all(
@@ -167,7 +409,7 @@ export const layer = Layer.effect( · 1 unmodified line
167 { concurrency: "unbounded" },409 { concurrency: "unbounded" },
168 ),410 ),
169 sourceControlProviders: Effect.all(411 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<
171 Effect.Effect<SourceControlProviderDiscoveryItem>413 Effect.Effect<SourceControlProviderDiscoveryItem>
172 >,414 >,
173 { concurrency: "unbounded" },415 { 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