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 type { NormalizedAzureDevOpsPullRequestRecord } from "./azureDevOpsPullRequests.ts";
7import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
8import {
9 combinedAuthOutput,
10 firstSafeAuthLine,
11 providerAuth,
12 type SourceControlAuthProbeInput,
13 type SourceControlCliDiscoverySpec,
14} from "@t3tools/source-control-core/server/discovery";
15
16function parseAzureAuth(input: SourceControlAuthProbeInput) {
17 const account = input.stdout.trim().split(/\r?\n/)[0]?.trim();
18
19 if (input.exitCode !== 0) {
20 return providerAuth({
21 status: "unauthenticated",
22 detail:
23 firstSafeAuthLine(combinedAuthOutput(input)) ?? "Run `az login` to authenticate Azure CLI.",
24 });
25 }
26
27 if (account !== undefined && account.length > 0) {
28 return providerAuth({
29 status: "authenticated",
30 account,
31 host: "dev.azure.com",
32 });
33 }
34
35 return providerAuth({
36 status: "unknown",
37 host: "dev.azure.com",
38 detail: "Azure CLI account status could not be parsed.",
39 });
40}
41
42export const discovery = {
43 type: "cli",
44 kind: SourceControlProviderKind.make("azure-devops"),
45 label: "Azure DevOps",
46 executable: "az",
47 versionArgs: ["--version"],
48 authArgs: ["account", "show", "--query", "user.name", "-o", "tsv"],
49 // `az --version` starts Python and takes ~6s on Windows, exceeding the default probe timeout.
50 probeTimeoutMs: 20_000,
51 parseAuth: parseAzureAuth,
52 installHint:
53 "Install the Azure command-line tools (`az`), then enable Azure DevOps support with `az extension add --name azure-devops`.",
54} satisfies SourceControlCliDiscoverySpec;
55
56/**
57 * The repository name a remote ends in; `az` takes it decoded and finds org and project itself.
58 * Azure clone URLs carry no `.git` suffix, so a trailing `.git` is part of the name.
59 */
60function repositoryNameFromRemote(remoteUrl: string): string | null {
61 const path = URL.parse(remoteUrl.trim())?.pathname ?? remoteUrl.trim();
62 const encoded = path.replace(/\/+$/u, "").split(/[/:]/u).at(-1);
63 if (!encoded) return null;
64 let name = encoded;
65 try {
66 name = decodeURIComponent(encoded);
67 } catch {}
68 // `az` replaces any argument value starting with `@` by the contents of that local file.
69 return name.startsWith("@") ? null : name;
70}
71
72function toChangeRequest(summary: NormalizedAzureDevOpsPullRequestRecord): ChangeRequest {
73 return {
74 provider: SourceControlProviderKind.make("azure-devops"),
75 number: summary.number,
76 title: summary.title,
77 url: summary.url,
78 baseRefName: summary.baseRefName,
79 headRefName: summary.headRefName,
80 ...(summary.headSha !== undefined ? { headSha: summary.headSha } : {}),
81 state: summary.state,
82 ...(summary.isDraft === true ? { isDraft: true } : {}),
83 closedAt: summary.closedAt ?? null,
84 mergedAt: summary.mergedAt ?? null,
85 updatedAt: summary.updatedAt,
86 isCrossRepository: summary.isCrossRepository,
87 };
88}
89
90export const make = Effect.gen(function* () {
91 const azure = yield* AzureDevOpsCli.AzureDevOpsCli;
92
93 return SourceControlProvider.SourceControlProvider.of({
94 kind: SourceControlProviderKind.make("azure-devops"),
95 // Azure lists fork pull requests by source branch with no owner filter, so read a full page.
96 headBranchProbe: ({ headSelectors }) => ({
97 headSelectors: headSelectors.filter((selector) => !selector.includes(":")),
98 limit: 100,
99 }),
100 listChangeRequests: Effect.fn("AzureDevOpsSourceControlProvider.listChangeRequests")(
101 function (input) {
102 const source = SourceControlProvider.sourceControlRefFromInput(input);
103 return azure
104 .listPullRequests({
105 cwd: input.cwd,
106 headSelector: input.headSelector,
107 ...(source !== undefined ? { source } : {}),
108 state: input.state,
109 ...(input.limit !== undefined ? { limit: input.limit } : {}),
110 })
111 .pipe(
112 Effect.map((items) => items.map(toChangeRequest)),
113 Effect.mapError(
114 (error) =>
115 new SourceControlProviderError({
116 provider: SourceControlProviderKind.make("azure-devops"),
117 operation: "listChangeRequests",
118 command: error.command,
119 cwd: input.cwd,
120 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
121 input.headSelector,
122 ),
123 detail: error.detail,
124 cause: error,
125 }),
126 ),
127 );
128 },
129 ),
130 getChangeRequest: Effect.fn("AzureDevOpsSourceControlProvider.getChangeRequest")(
131 function (input) {
132 return azure.getPullRequest(input).pipe(
133 Effect.map(toChangeRequest),
134 Effect.mapError(
135 (error) =>
136 new SourceControlProviderError({
137 provider: SourceControlProviderKind.make("azure-devops"),
138 operation: "getChangeRequest",
139 command: error.command,
140 cwd: input.cwd,
141 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
142 input.reference,
143 ),
144 detail: error.detail,
145 cause: error,
146 }),
147 ),
148 );
149 },
150 ),
151 createChangeRequest: Effect.fn("AzureDevOpsSourceControlProvider.createChangeRequest")(
152 function (input) {
153 const source = SourceControlProvider.sourceControlRefFromInput(input);
154 return azure
155 .createPullRequest({
156 cwd: input.cwd,
157 baseBranch: input.baseRefName,
158 headSelector: input.headSelector,
159 ...(source !== undefined ? { source } : {}),
160 ...(input.target !== undefined ? { target: input.target } : {}),
161 title: input.title,
162 bodyFile: input.bodyFile,
163 })
164 .pipe(
165 Effect.mapError(
166 (error) =>
167 new SourceControlProviderError({
168 provider: SourceControlProviderKind.make("azure-devops"),
169 operation: "createChangeRequest",
170 command: error.command,
171 cwd: input.cwd,
172 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
173 input.headSelector,
174 ),
175 detail: error.detail,
176 cause: error,
177 }),
178 ),
179 );
180 },
181 ),
182 getRepositoryCloneUrls: Effect.fn("AzureDevOpsSourceControlProvider.getRepositoryCloneUrls")(
183 function (input) {
184 return azure.getRepositoryCloneUrls(input).pipe(
185 Effect.mapError(
186 (error) =>
187 new SourceControlProviderError({
188 provider: SourceControlProviderKind.make("azure-devops"),
189 operation: "getRepositoryCloneUrls",
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 },
201 ),
202 createRepository: Effect.fn("AzureDevOpsSourceControlProvider.createRepository")(
203 function (input) {
204 return azure.createRepository(input).pipe(
205 Effect.mapError(
206 (error) =>
207 new SourceControlProviderError({
208 provider: SourceControlProviderKind.make("azure-devops"),
209 operation: "createRepository",
210 command: error.command,
211 cwd: input.cwd,
212 repository: SourceControlProvider.transportSafeSourceControlErrorValue(
213 input.repository,
214 ),
215 detail: error.detail,
216 cause: error,
217 }),
218 ),
219 );
220 },
221 ),
222 getDefaultBranch: Effect.fn("AzureDevOpsSourceControlProvider.getDefaultBranch")(
223 function (input) {
224 const repository =
225 input.context === undefined ? null : repositoryNameFromRemote(input.context.remoteUrl);
226 if (repository === null) return Effect.succeed(null);
227 return azure.getDefaultBranch({ cwd: input.cwd, repository }).pipe(
228 Effect.mapError(
229 (error) =>
230 new SourceControlProviderError({
231 provider: SourceControlProviderKind.make("azure-devops"),
232 operation: "getDefaultBranch",
233 command: error.command,
234 cwd: input.cwd,
235 detail: error.detail,
236 cause: error,
237 }),
238 ),
239 );
240 },
241 ),
242 checkoutChangeRequest: Effect.fn("AzureDevOpsSourceControlProvider.checkoutChangeRequest")(
243 function (input) {
244 return azure
245 .checkoutPullRequest({
246 cwd: input.cwd,
247 reference: input.reference,
248 ...(input.context !== undefined ? { remoteName: input.context.remoteName } : {}),
249 })
250 .pipe(
251 Effect.mapError(
252 (error) =>
253 new SourceControlProviderError({
254 provider: SourceControlProviderKind.make("azure-devops"),
255 operation: "checkoutChangeRequest",
256 command: error.command,
257 cwd: input.cwd,
258 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
259 input.reference,
260 ),
261 detail: error.detail,
262 cause: error,
263 }),
264 ),
265 );
266 },
267 ),
268 });
269});