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/AzureDevOpsSourceControlProvider.ts (1 of 59 files). Show all

Unified · Split

apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts+144−0
@@ -0,0 +1,144 @@
1+import { Effect, Layer } from "effect";
2+import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts";
3+
4+import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
5+import * as SourceControlProvider from "./SourceControlProvider.ts";
6+import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts";
7+
8+function providerError(
9+ operation: string,
10+ cause: AzureDevOpsCli.AzureDevOpsCliError,
11+): SourceControlProviderError {
12+ return new SourceControlProviderError({
13+ provider: "azure-devops",
14+ operation,
15+ detail: cause.detail,
16+ cause,
17+ });
18+}
19+
20+function parseAzureAuth(input: SourceControlProviderDiscovery.SourceControlAuthProbeInput) {
21+ const account = input.stdout.trim().split(/\r?\n/)[0]?.trim();
22+
23+ if (input.exitCode !== 0) {
24+ return SourceControlProviderDiscovery.providerAuth({
25+ status: "unauthenticated",
26+ detail:
27+ SourceControlProviderDiscovery.firstSafeAuthLine(
28+ SourceControlProviderDiscovery.combinedAuthOutput(input),
29+ ) ?? "Run `az login` to authenticate Azure CLI.",
30+ });
31+ }
32+
33+ if (account && account.length > 0) {
34+ return SourceControlProviderDiscovery.providerAuth({
35+ status: "authenticated",
36+ account,
37+ host: "dev.azure.com",
38+ });
39+ }
40+
41+ return SourceControlProviderDiscovery.providerAuth({
42+ status: "unknown",
43+ host: "dev.azure.com",
44+ detail: "Azure CLI account status could not be parsed.",
45+ });
46+}
47+
48+export const discovery = {
49+ type: "cli",
50+ kind: "azure-devops",
51+ label: "Azure DevOps",
52+ executable: "az",
53+ versionArgs: ["--version"],
54+ authArgs: ["account", "show", "--query", "user.name", "-o", "tsv"],
55+ parseAuth: parseAzureAuth,
56+ installHint:
57+ "Install the Azure command-line tools (`az`), then enable Azure DevOps support with `az extension add --name azure-devops`.",
58+} satisfies SourceControlProviderDiscovery.SourceControlCliDiscoverySpec;
59+
60+function toChangeRequest(summary: {
61+ readonly number: number;
62+ readonly title: string;
63+ readonly url: string;
64+ readonly baseRefName: string;
65+ readonly headRefName: string;
66+ readonly state: "open" | "closed" | "merged";
67+ readonly updatedAt: ChangeRequest["updatedAt"];
68+}): ChangeRequest {
69+ return {
70+ provider: "azure-devops",
71+ number: summary.number,
72+ title: summary.title,
73+ url: summary.url,
74+ baseRefName: summary.baseRefName,
75+ headRefName: summary.headRefName,
76+ state: summary.state,
77+ updatedAt: summary.updatedAt,
78+ isCrossRepository: false,
79+ };
80+}
81+
82+export const make = Effect.fn("makeAzureDevOpsSourceControlProvider")(function* () {
83+ const azure = yield* AzureDevOpsCli.AzureDevOpsCli;
84+
85+ return SourceControlProvider.SourceControlProvider.of({
86+ kind: "azure-devops",
87+ listChangeRequests: (input) => {
88+ const source = SourceControlProvider.sourceControlRefFromInput(input);
89+ return azure
90+ .listPullRequests({
91+ cwd: input.cwd,
92+ headSelector: input.headSelector,
93+ ...(source ? { source } : {}),
94+ state: input.state,
95+ ...(input.limit !== undefined ? { limit: input.limit } : {}),
96+ })
97+ .pipe(
98+ Effect.map((items) => items.map(toChangeRequest)),
99+ Effect.mapError((error) => providerError("listChangeRequests", error)),
100+ );
101+ },
102+ getChangeRequest: (input) =>
103+ azure.getPullRequest(input).pipe(
104+ Effect.map(toChangeRequest),
105+ Effect.mapError((error) => providerError("getChangeRequest", error)),
106+ ),
107+ createChangeRequest: (input) => {
108+ const source = SourceControlProvider.sourceControlRefFromInput(input);
109+ return azure
110+ .createPullRequest({
111+ cwd: input.cwd,
112+ baseBranch: input.baseRefName,
113+ headSelector: input.headSelector,
114+ ...(source ? { source } : {}),
115+ ...(input.target ? { target: input.target } : {}),
116+ title: input.title,
117+ bodyFile: input.bodyFile,
118+ })
119+ .pipe(Effect.mapError((error) => providerError("createChangeRequest", error)));
120+ },
121+ getRepositoryCloneUrls: (input) =>
122+ azure
123+ .getRepositoryCloneUrls(input)
124+ .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))),
125+ createRepository: (input) =>
126+ azure
127+ .createRepository(input)
128+ .pipe(Effect.mapError((error) => providerError("createRepository", error))),
129+ getDefaultBranch: (input) =>
130+ azure
131+ .getDefaultBranch({ cwd: input.cwd })
132+ .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))),
133+ checkoutChangeRequest: (input) =>
134+ azure
135+ .checkoutPullRequest({
136+ cwd: input.cwd,
137+ reference: input.reference,
138+ ...(input.context ? { remoteName: input.context.remoteName } : {}),
139+ })
140+ .pipe(Effect.mapError((error) => providerError("checkoutChangeRequest", error))),
141+ });
142+});
143+
144+export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make());