1import { SourceControlProviderKind } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts";
4
5import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
6import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
7import {
8 combinedAuthOutput,
9 firstSafeAuthLine,
10 providerAuth,
11 type SourceControlAuthProbeInput,
12 type SourceControlCliDiscoverySpec,
13} from "@t3tools/source-control-core/server/discovery";
14
15function parseAzureAuth(input: SourceControlAuthProbeInput) {
16 const account = input.stdout.trim().split(/\r?\n/)[0]?.trim();
17
18 if (input.exitCode !== 0) {
19 return providerAuth({
20 status: "unauthenticated",
21 detail:
22 firstSafeAuthLine(combinedAuthOutput(input)) ?? "Run `az login` to authenticate Azure CLI.",
23 });
24 }
25
26 if (account !== undefined && account.length > 0) {
27 return providerAuth({
28 status: "authenticated",
29 account,
30 host: "dev.azure.com",
31 });
32 }
33
34 return providerAuth({
35 status: "unknown",
36 host: "dev.azure.com",
37 detail: "Azure CLI account status could not be parsed.",
38 });
39}
40
41export const discovery = {
42 type: "cli",
43 kind: SourceControlProviderKind.make("azure-devops"),
44 label: "Azure DevOps",
45 executable: "az",
46 versionArgs: ["--version"],
47 authArgs: ["account", "show", "--query", "user.name", "-o", "tsv"],
48 // `az` boots a fresh Python interpreter on every invocation, so even `az --version`
49 // takes ~6s on Windows and overruns the default budget, leaving the provider reported
50 // as missing on machines where it is installed. `gh` and `glab` answer in ~0.3s.
51 probeTimeoutMs: 20_000,
52 parseAuth: parseAzureAuth,
53 installHint:
54 "Install the Azure command-line tools (`az`), then enable Azure DevOps support with `az extension add --name azure-devops`.",
55} satisfies SourceControlCliDiscoverySpec;
56
57function toChangeRequest(summary: {
58 readonly number: number;
59 readonly title: string;
60 readonly url: string;
61 readonly baseRefName: string;
62 readonly headRefName: string;
63 readonly state: "open" | "closed" | "merged";
64 readonly isDraft?: boolean;
65 readonly isCrossRepository: boolean;
66 readonly closedAt?: string | null;
67 readonly mergedAt?: string | null;
68 readonly updatedAt: ChangeRequest["updatedAt"];
69}): ChangeRequest {
70 return {
71 provider: SourceControlProviderKind.make("azure-devops"),
72 number: summary.number,
73 title: summary.title,
74 url: summary.url,
75 baseRefName: summary.baseRefName,
76 headRefName: summary.headRefName,
77 state: summary.state,
78 ...(summary.isDraft === true ? { isDraft: true } : {}),
79 closedAt: summary.closedAt ?? null,
80 mergedAt: summary.mergedAt ?? null,
81 updatedAt: summary.updatedAt,
82 isCrossRepository: summary.isCrossRepository,
83 };
84}
85
86export const make = Effect.gen(function* () {
87 const azure = yield* AzureDevOpsCli.AzureDevOpsCli;
88
89 return SourceControlProvider.SourceControlProvider.of({
90 kind: SourceControlProviderKind.make("azure-devops"),
91 listChangeRequests: (input) => {
92 const source = SourceControlProvider.sourceControlRefFromInput(input);
93 return azure
94 .listPullRequests({
95 cwd: input.cwd,
96 headSelector: input.headSelector,
97 ...(source !== undefined ? { source } : {}),
98 state: input.state,
99 ...(input.limit !== undefined ? { limit: input.limit } : {}),
100 })
101 .pipe(
102 Effect.map((items) => items.map(toChangeRequest)),
103 Effect.mapError(
104 (error) =>
105 new SourceControlProviderError({
106 provider: SourceControlProviderKind.make("azure-devops"),
107 operation: "listChangeRequests",
108 command: error.command,
109 cwd: input.cwd,
110 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
111 input.headSelector,
112 ),
113 detail: error.detail,
114 cause: error,
115 }),
116 ),
117 );
118 },
119 getChangeRequest: (input) =>
120 azure.getPullRequest(input).pipe(
121 Effect.map(toChangeRequest),
122 Effect.mapError(
123 (error) =>
124 new SourceControlProviderError({
125 provider: SourceControlProviderKind.make("azure-devops"),
126 operation: "getChangeRequest",
127 command: error.command,
128 cwd: input.cwd,
129 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
130 input.reference,
131 ),
132 detail: error.detail,
133 cause: error,
134 }),
135 ),
136 ),
137 createChangeRequest: (input) => {
138 const source = SourceControlProvider.sourceControlRefFromInput(input);
139 return azure
140 .createPullRequest({
141 cwd: input.cwd,
142 baseBranch: input.baseRefName,
143 headSelector: input.headSelector,
144 ...(source !== undefined ? { source } : {}),
145 ...(input.target !== undefined ? { target: input.target } : {}),
146 title: input.title,
147 bodyFile: input.bodyFile,
148 })
149 .pipe(
150 Effect.mapError(
151 (error) =>
152 new SourceControlProviderError({
153 provider: SourceControlProviderKind.make("azure-devops"),
154 operation: "createChangeRequest",
155 command: error.command,
156 cwd: input.cwd,
157 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
158 input.headSelector,
159 ),
160 detail: error.detail,
161 cause: error,
162 }),
163 ),
164 );
165 },
166 getRepositoryCloneUrls: (input) =>
167 azure.getRepositoryCloneUrls(input).pipe(
168 Effect.mapError(
169 (error) =>
170 new SourceControlProviderError({
171 provider: SourceControlProviderKind.make("azure-devops"),
172 operation: "getRepositoryCloneUrls",
173 command: error.command,
174 cwd: input.cwd,
175 repository: SourceControlProvider.transportSafeSourceControlErrorValue(
176 input.repository,
177 ),
178 detail: error.detail,
179 cause: error,
180 }),
181 ),
182 ),
183 createRepository: (input) =>
184 azure.createRepository(input).pipe(
185 Effect.mapError(
186 (error) =>
187 new SourceControlProviderError({
188 provider: SourceControlProviderKind.make("azure-devops"),
189 operation: "createRepository",
190 command: error.command,
191 cwd: input.cwd,
192 repository: SourceControlProvider.transportSafeSourceControlErrorValue(
193 input.repository,
194 ),
195 detail: error.detail,
196 cause: error,
197 }),
198 ),
199 ),
200 getDefaultBranch: (input) =>
201 azure.getDefaultBranch({ cwd: input.cwd }).pipe(
202 Effect.mapError(
203 (error) =>
204 new SourceControlProviderError({
205 provider: SourceControlProviderKind.make("azure-devops"),
206 operation: "getDefaultBranch",
207 command: error.command,
208 cwd: input.cwd,
209 detail: error.detail,
210 cause: error,
211 }),
212 ),
213 ),
214 checkoutChangeRequest: (input) =>
215 azure
216 .checkoutPullRequest({
217 cwd: input.cwd,
218 reference: input.reference,
219 ...(input.context !== undefined ? { remoteName: input.context.remoteName } : {}),
220 })
221 .pipe(
222 Effect.mapError(
223 (error) =>
224 new SourceControlProviderError({
225 provider: SourceControlProviderKind.make("azure-devops"),
226 operation: "checkoutChangeRequest",
227 command: error.command,
228 cwd: input.cwd,
229 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
230 input.reference,
231 ),
232 detail: error.detail,
233 cause: error,
234 }),
235 ),
236 ),
237 });
238});