History

feat(source-control): add Bitbucket & Azure Devops providers (#2473)

Julius Marminge committed 6 months agov0.0.22-nightly.20260504.196

Commit 91a03e01 parent d501ebe59 files · +5834 −2327Browse files

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

Unified · Split

apps/server/src/sourceControl/SourceControlProviderDiscovery.ts+237−0
@@ -0,0 +1,237 @@
1+import type {
2+ SourceControlProviderAuth,
3+ SourceControlProviderDiscoveryItem,
4+ SourceControlProviderKind,
5+} from "@t3tools/contracts";
6+import { Effect, Option } from "effect";
7+
8+import type * as VcsProcess from "../vcs/VcsProcess.ts";
9+
10+export interface SourceControlAuthProbeInput {
11+ readonly stdout: string;
12+ readonly stderr: string;
13+ readonly exitCode: VcsProcess.VcsProcessOutput["exitCode"];
14+}
15+
16+interface SourceControlDiscoverySpecBase {
17+ readonly kind: SourceControlProviderKind;
18+ readonly label: string;
19+ readonly installHint: string;
20+}
21+
22+export type SourceControlCliDiscoverySpec = SourceControlDiscoverySpecBase & {
23+ readonly type: "cli";
24+ readonly executable: string;
25+ readonly versionArgs: ReadonlyArray<string>;
26+ readonly authArgs: ReadonlyArray<string>;
27+ readonly parseAuth: (input: SourceControlAuthProbeInput) => SourceControlProviderAuth;
28+};
29+
30+export type SourceControlApiDiscoverySpec = SourceControlDiscoverySpecBase & {
31+ readonly type: "api";
32+ readonly probeAuth: Effect.Effect<SourceControlProviderAuth, never>;
33+};
34+
35+export type SourceControlProviderDiscoverySpec =
36+ | SourceControlCliDiscoverySpec
37+ | SourceControlApiDiscoverySpec;
38+
39+interface DiscoveryProbeResult {
40+ readonly kind: SourceControlProviderKind;
41+ readonly label: string;
42+ readonly executable: string;
43+ readonly status: "available" | "missing";
44+ readonly version: Option.Option<string>;
45+ readonly installHint: string;
46+ readonly detail: Option.Option<string>;
47+}
48+
49+export function firstNonEmptyLine(text: string): Option.Option<string> {
50+ const line = text
51+ .split(/\r?\n/)
52+ .map((entry) => entry.trim())
53+ .find((entry) => entry.length > 0);
54+ return line === undefined ? Option.none() : Option.some(line);
55+}
56+
57+export function detailFromCause(cause: unknown): Option.Option<string> {
58+ if (cause instanceof Error && cause.message.trim().length > 0) {
59+ return Option.some(cause.message.trim());
60+ }
61+ return Option.none();
62+}
63+
64+function authAccount(account: string | undefined): Option.Option<string> {
65+ const trimmed = account?.trim();
66+ return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed);
67+}
68+
69+function authHost(host: string | undefined): Option.Option<string> {
70+ const trimmed = host?.trim();
71+ return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed);
72+}
73+
74+function authDetail(detail: string | undefined): Option.Option<string> {
75+ const trimmed = detail?.trim();
76+ return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed);
77+}
78+
79+export function providerAuth(input: {
80+ readonly status: SourceControlProviderAuth["status"];
81+ readonly account?: string | undefined;
82+ readonly host?: string | undefined;
83+ readonly detail?: string | undefined;
84+}): SourceControlProviderAuth {
85+ return {
86+ status: input.status,
87+ account: authAccount(input.account),
88+ host: authHost(input.host),
89+ detail: authDetail(input.detail),
90+ };
91+}
92+
93+export function unknownAuth(detail?: string): SourceControlProviderAuth {
94+ return providerAuth({ status: "unknown", detail });
95+}
96+
97+export function combinedAuthOutput(input: SourceControlAuthProbeInput): string {
98+ return [input.stdout, input.stderr].filter((entry) => entry.trim().length > 0).join("\n");
99+}
100+
101+function sanitizedAuthLines(text: string): ReadonlyArray<string> {
102+ return text
103+ .split(/\r?\n/)
104+ .map((entry) => entry.trim())
105+ .filter((entry) => entry.length > 0)
106+ .filter((entry) => !/^[-\s]*token(?:\s+scopes?)?:/iu.test(entry));
107+}
108+
109+export function firstSafeAuthLine(text: string): string | undefined {
110+ return sanitizedAuthLines(text)[0];
111+}
112+
113+export function parseCliHost(text: string): string | undefined {
114+ return sanitizedAuthLines(text)
115+ .map((line) => line.replace(/^[^a-z0-9]+/iu, ""))
116+ .find((line) => /^[a-z0-9][a-z0-9.-]*(?::\d+)?$/iu.test(line));
117+}
118+
119+export function matchFirst(text: string, patterns: ReadonlyArray<RegExp>): string | undefined {
120+ for (const pattern of patterns) {
121+ const match = pattern.exec(text);
122+ const value = match?.[1]?.trim();
123+ if (value && value.length > 0) return value;
124+ }
125+ return undefined;
126+}
127+
128+function probeCli(input: {
129+ readonly spec: SourceControlCliDiscoverySpec;
130+ readonly process: VcsProcess.VcsProcessShape;
131+ readonly cwd: string;
132+}): Effect.Effect<DiscoveryProbeResult> {
133+ return input.process
134+ .run({
135+ operation: "source-control.discovery.probe",
136+ command: input.spec.executable,
137+ args: input.spec.versionArgs,
138+ cwd: input.cwd,
139+ timeoutMs: 5_000,
140+ maxOutputBytes: 8_000,
141+ truncateOutputAtMaxBytes: true,
142+ })
143+ .pipe(
144+ Effect.map(
145+ (result) =>
146+ ({
147+ kind: input.spec.kind,
148+ label: input.spec.label,
149+ executable: input.spec.executable,
150+ status: "available" as const,
151+ version: Option.orElse(firstNonEmptyLine(result.stdout), () =>
152+ firstNonEmptyLine(result.stderr),
153+ ),
154+ installHint: input.spec.installHint,
155+ detail: Option.none<string>(),
156+ }) satisfies DiscoveryProbeResult,
157+ ),
158+ Effect.catch((cause) =>
159+ Effect.succeed({
160+ kind: input.spec.kind,
161+ label: input.spec.label,
162+ executable: input.spec.executable,
163+ status: "missing" as const,
164+ version: Option.none<string>(),
165+ installHint: input.spec.installHint,
166+ detail: detailFromCause(cause),
167+ } satisfies DiscoveryProbeResult),
168+ ),
169+ );
170+}
171+
172+export function probeSourceControlProvider(input: {
173+ readonly spec: SourceControlProviderDiscoverySpec;
174+ readonly process: VcsProcess.VcsProcessShape;
175+ readonly cwd: string;
176+}): Effect.Effect<SourceControlProviderDiscoveryItem> {
177+ if (input.spec.type === "api") {
178+ return input.spec.probeAuth.pipe(
179+ Effect.map(
180+ (auth) =>
181+ ({
182+ kind: input.spec.kind,
183+ label: input.spec.label,
184+ status: "available" as const,
185+ version: Option.none<string>(),
186+ installHint: input.spec.installHint,
187+ detail: Option.none<string>(),
188+ auth,
189+ }) satisfies SourceControlProviderDiscoveryItem,
190+ ),
191+ );
192+ }
193+
194+ const spec = input.spec;
195+
196+ return probeCli({
197+ spec,
198+ process: input.process,
199+ cwd: input.cwd,
200+ }).pipe(
201+ Effect.flatMap((item) => {
202+ if (item.status !== "available") {
203+ return Effect.succeed({
204+ ...item,
205+ auth: unknownAuth("Hosting integration command was not found on the server PATH."),
206+ } satisfies SourceControlProviderDiscoveryItem);
207+ }
208+
209+ return input.process
210+ .run({
211+ operation: "source-control.discovery.auth",
212+ command: spec.executable,
213+ args: spec.authArgs,
214+ cwd: input.cwd,
215+ allowNonZeroExit: true,
216+ timeoutMs: 5_000,
217+ maxOutputBytes: 8_000,
218+ truncateOutputAtMaxBytes: true,
219+ })
220+ .pipe(
221+ Effect.map(
222+ (result) =>
223+ ({
224+ ...item,
225+ auth: spec.parseAuth(result),
226+ }) satisfies SourceControlProviderDiscoveryItem,
227+ ),
228+ Effect.catch((cause) =>
229+ Effect.succeed({
230+ ...item,
231+ auth: unknownAuth(Option.getOrUndefined(detailFromCause(cause))),
232+ } satisfies SourceControlProviderDiscoveryItem),
233+ ),
234+ );
235+ }),
236+ );
237+}

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