1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as PlatformError from "effect/PlatformError";
5import * as Result from "effect/Result";
6import * as Schema from "effect/Schema";
7import {
8 NonNegativeInt,
9 TrimmedNonEmptyString,
10 type SourceControlRepositoryVisibility,
11 type VcsError,
12} from "@t3tools/contracts";
13
14import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
15import {
16 decodeAzureDevOpsPullRequestJson,
17 decodeAzureDevOpsPullRequestListJson,
18 type NormalizedAzureDevOpsPullRequestRecord,
19} from "./azureDevOpsPullRequests.ts";
20import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
21
22const DEFAULT_TIMEOUT_MS = 30_000;
23
24const azureDevOpsCommandErrorFields = {
25 operation: Schema.Literal("execute"),
26 command: Schema.Literal("az"),
27 cwd: Schema.String,
28 argumentCount: NonNegativeInt,
29 cause: Schema.Defect(),
30};
31
32export class AzureDevOpsCliUnavailableError extends Schema.TaggedError<AzureDevOpsCliUnavailableError>()(
33 "AzureDevOpsCliUnavailableError",
34 azureDevOpsCommandErrorFields,
35) {
36 get detail(): string {
37 return "Azure CLI (`az`) with the Azure DevOps extension is required but not available on PATH.";
38 }
39
40 override get message(): string {
41 return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`;
42 }
43}
44
45export class AzureDevOpsCliAuthenticationError extends Schema.TaggedError<AzureDevOpsCliAuthenticationError>()(
46 "AzureDevOpsCliAuthenticationError",
47 azureDevOpsCommandErrorFields,
48) {
49 get detail(): string {
50 return "Azure DevOps CLI is not authenticated. Run `az devops login` and retry.";
51 }
52
53 override get message(): string {
54 return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`;
55 }
56}
57
58export class AzureDevOpsCliRateLimitError extends Schema.TaggedError<AzureDevOpsCliRateLimitError>()(
59 "AzureDevOpsCliRateLimitError",
60 azureDevOpsCommandErrorFields,
61) {
62 get detail(): string {
63 return "Azure DevOps API rate limit exceeded.";
64 }
65
66 override get message(): string {
67 return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`;
68 }
69}
70
71export class AzureDevOpsPullRequestNotFoundError extends Schema.TaggedError<AzureDevOpsPullRequestNotFoundError>()(
72 "AzureDevOpsPullRequestNotFoundError",
73 azureDevOpsCommandErrorFields,
74) {
75 get detail(): string {
76 return "Pull request not found. Check the PR number or URL and try again.";
77 }
78
79 override get message(): string {
80 return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`;
81 }
82}
83
84export class AzureDevOpsCommandFailedError extends Schema.TaggedError<AzureDevOpsCommandFailedError>()(
85 "AzureDevOpsCommandFailedError",
86 azureDevOpsCommandErrorFields,
87) {
88 get detail(): string {
89 return "Azure DevOps CLI command failed.";
90 }
91
92 override get message(): string {
93 return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`;
94 }
95
96 static fromVcsError(
97 context: {
98 readonly operation: "execute";
99 readonly command: "az";
100 readonly cwd: string;
101 readonly argumentCount: number;
102 },
103 cause: VcsError,
104 ): AzureDevOpsCliError {
105 const fields = { ...context, cause };
106
107 if (
108 cause._tag === "VcsProcessSpawnError" &&
109 cause.cause instanceof PlatformError.PlatformError &&
110 cause.cause.reason._tag === "NotFound" &&
111 cause.cause.reason.pathOrDescriptor !== context.cwd &&
112 cause.cause.reason.syscall !== "chdir"
113 ) {
114 return new AzureDevOpsCliUnavailableError(fields);
115 }
116
117 if (cause._tag === "VcsProcessExitError") {
118 if (cause.failureKind === "authentication") {
119 return new AzureDevOpsCliAuthenticationError(fields);
120 }
121 if (cause.failureKind === "rate-limited") {
122 return new AzureDevOpsCliRateLimitError(fields);
123 }
124 if (cause.failureKind === "not-found") {
125 return new AzureDevOpsPullRequestNotFoundError(fields);
126 }
127 }
128
129 return new AzureDevOpsCommandFailedError(fields);
130 }
131}
132
133const azureDevOpsDecodeErrorFields = {
134 command: Schema.Literal("az"),
135 cwd: Schema.String,
136 outputLength: NonNegativeInt,
137 cause: Schema.Defect(),
138};
139
140export class AzureDevOpsPullRequestListDecodeError extends Schema.TaggedError<AzureDevOpsPullRequestListDecodeError>()(
141 "AzureDevOpsPullRequestListDecodeError",
142 {
143 operation: Schema.Literal("listPullRequests"),
144 ...azureDevOpsDecodeErrorFields,
145 },
146) {
147 get detail(): string {
148 return "Azure DevOps CLI returned invalid PR list JSON.";
149 }
150
151 override get message(): string {
152 return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`;
153 }
154}
155
156export class AzureDevOpsPullRequestDecodeError extends Schema.TaggedError<AzureDevOpsPullRequestDecodeError>()(
157 "AzureDevOpsPullRequestDecodeError",
158 {
159 operation: Schema.Literal("getPullRequest"),
160 ...azureDevOpsDecodeErrorFields,
161 },
162) {
163 get detail(): string {
164 return "Azure DevOps CLI returned invalid pull request JSON.";
165 }
166
167 override get message(): string {
168 return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`;
169 }
170}
171
172const AzureDevOpsRepositoryDecodeOperation = Schema.Literals([
173 "getRepositoryCloneUrls",
174 "getDefaultBranch",
175 "createRepository",
176]);
177
178export class AzureDevOpsRepositoryDecodeError extends Schema.TaggedError<AzureDevOpsRepositoryDecodeError>()(
179 "AzureDevOpsRepositoryDecodeError",
180 {
181 operation: AzureDevOpsRepositoryDecodeOperation,
182 ...azureDevOpsDecodeErrorFields,
183 },
184) {
185 get detail(): string {
186 return "Azure DevOps CLI returned invalid repository JSON.";
187 }
188
189 override get message(): string {
190 return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`;
191 }
192}
193
194export const AzureDevOpsCliError = Schema.Union([
195 AzureDevOpsCliUnavailableError,
196 AzureDevOpsCliAuthenticationError,
197 AzureDevOpsCliRateLimitError,
198 AzureDevOpsPullRequestNotFoundError,
199 AzureDevOpsCommandFailedError,
200 AzureDevOpsPullRequestListDecodeError,
201 AzureDevOpsPullRequestDecodeError,
202 AzureDevOpsRepositoryDecodeError,
203]);
204export type AzureDevOpsCliError = typeof AzureDevOpsCliError.Type;
205
206export interface AzureDevOpsRepositoryCloneUrls {
207 readonly nameWithOwner: string;
208 readonly url: string;
209 readonly sshUrl: string;
210}
211
212export class AzureDevOpsCli extends Context.Service<
213 AzureDevOpsCli,
214 {
215 readonly execute: (input: {
216 readonly cwd: string;
217 readonly args: ReadonlyArray<string>;
218 readonly timeoutMs?: number;
219 readonly maxOutputBytes?: number;
220 }) => Effect.Effect<SourceControlHost.SourceControlProcessOutput, AzureDevOpsCliError>;
221
222 readonly listPullRequests: (input: {
223 readonly cwd: string;
224 readonly headSelector: string;
225 readonly source?: SourceControlProvider.SourceControlRefSelector;
226 readonly state: "open" | "closed" | "merged" | "all";
227 readonly limit?: number;
228 }) => Effect.Effect<ReadonlyArray<NormalizedAzureDevOpsPullRequestRecord>, AzureDevOpsCliError>;
229
230 readonly getPullRequest: (input: {
231 readonly cwd: string;
232 readonly reference: string;
233 }) => Effect.Effect<NormalizedAzureDevOpsPullRequestRecord, AzureDevOpsCliError>;
234
235 readonly getRepositoryCloneUrls: (input: {
236 readonly cwd: string;
237 readonly repository: string;
238 }) => Effect.Effect<AzureDevOpsRepositoryCloneUrls, AzureDevOpsCliError>;
239
240 readonly createRepository: (input: {
241 readonly cwd: string;
242 readonly repository: string;
243 readonly visibility: SourceControlRepositoryVisibility;
244 }) => Effect.Effect<AzureDevOpsRepositoryCloneUrls, AzureDevOpsCliError>;
245
246 readonly createPullRequest: (input: {
247 readonly cwd: string;
248 readonly baseBranch: string;
249 readonly headSelector: string;
250 readonly source?: SourceControlProvider.SourceControlRefSelector;
251 readonly target?: SourceControlProvider.SourceControlRefSelector;
252 readonly title: string;
253 readonly bodyFile: string;
254 }) => Effect.Effect<void, AzureDevOpsCliError>;
255
256 readonly getDefaultBranch: (input: {
257 readonly cwd: string;
258 }) => Effect.Effect<string | null, AzureDevOpsCliError>;
259
260 readonly checkoutPullRequest: (input: {
261 readonly cwd: string;
262 readonly reference: string;
263 readonly remoteName?: string;
264 }) => Effect.Effect<void, AzureDevOpsCliError>;
265 }
266>()("@t3tools/source-control-azure-devops/server/AzureDevOpsCli") {}
267
268function normalizeChangeRequestId(reference: string): string {
269 const trimmed = reference.trim().replace(/^#/, "");
270 const urlMatch = /(?:pullrequest|pull-request|pull|_pulls?)\/(\d+)(?:\D.*)?$/i.exec(trimmed);
271 return urlMatch?.[1] ?? trimmed;
272}
273
274function toAzureStatus(state: "open" | "closed" | "merged" | "all"): string {
275 switch (state) {
276 case "open":
277 return "active";
278 case "closed":
279 return "abandoned";
280 case "merged":
281 return "completed";
282 case "all":
283 return "all";
284 }
285}
286
287const RawAzureDevOpsRepositorySchema = Schema.Struct({
288 name: TrimmedNonEmptyString,
289 webUrl: TrimmedNonEmptyString,
290 remoteUrl: TrimmedNonEmptyString,
291 sshUrl: TrimmedNonEmptyString,
292 project: Schema.optional(
293 Schema.Struct({
294 name: TrimmedNonEmptyString,
295 }),
296 ),
297 defaultBranch: Schema.optional(Schema.NullOr(Schema.String)),
298});
299
300function normalizeDefaultBranch(value: string | null | undefined): string | null {
301 const trimmed = value?.trim().replace(/^refs\/heads\//, "") ?? "";
302 return trimmed.length > 0 ? trimmed : null;
303}
304
305function normalizeRepositoryCloneUrls(
306 raw: Schema.Schema.Type<typeof RawAzureDevOpsRepositorySchema>,
307): AzureDevOpsRepositoryCloneUrls {
308 const projectName = raw.project?.name.trim();
309 return {
310 nameWithOwner: projectName ? `${projectName}/${raw.name}` : raw.name,
311 url: raw.remoteUrl,
312 sshUrl: raw.sshUrl,
313 };
314}
315
316function parseRepositorySpecifier(repository: string): {
317 readonly project: string | null;
318 readonly name: string;
319} {
320 const parts: Array<string> = [];
321 for (const part of repository.split("/")) {
322 const trimmed = part.trim();
323 if (trimmed.length > 0) {
324 parts.push(trimmed);
325 }
326 }
327 return {
328 project: parts.length > 1 ? (parts.at(-2) ?? null) : null,
329 name: parts.at(-1) ?? repository.trim(),
330 };
331}
332
333function decodeAzureDevOpsJson<S extends Schema.Top>(
334 raw: string,
335 schema: S,
336 operation: typeof AzureDevOpsRepositoryDecodeOperation.Type,
337 cwd: string,
338): Effect.Effect<S["Type"], AzureDevOpsRepositoryDecodeError, S["DecodingServices"]> {
339 return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe(
340 Effect.mapError(
341 (cause) =>
342 new AzureDevOpsRepositoryDecodeError({
343 operation,
344 command: "az",
345 cwd,
346 outputLength: raw.length,
347 cause,
348 }),
349 ),
350 );
351}
352
353/** @public Service construction is part of the canonical Effect module API. */
354export const make = Effect.gen(function* () {
355 const { process } = yield* SourceControlHost.SourceControlHost;
356
357 const execute: AzureDevOpsCli["Service"]["execute"] = (input) =>
358 process
359 .run({
360 operation: "AzureDevOpsCli.execute",
361 command: "az",
362 args: input.args,
363 cwd: input.cwd,
364 timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS,
365 ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }),
366 })
367 .pipe(
368 Effect.mapError((error) =>
369 AzureDevOpsCommandFailedError.fromVcsError(
370 {
371 operation: "execute",
372 command: "az",
373 cwd: input.cwd,
374 argumentCount: input.args.length,
375 },
376 error,
377 ),
378 ),
379 );
380
381 const executeJson = (input: Parameters<AzureDevOpsCli["Service"]["execute"]>[0]) =>
382 execute({
383 ...input,
384 args: [...input.args, "--only-show-errors", "--output", "json"],
385 });
386
387 return AzureDevOpsCli.of({
388 execute,
389 listPullRequests: (input) =>
390 executeJson({
391 cwd: input.cwd,
392 args: [
393 "repos",
394 "pr",
395 "list",
396 "--detect",
397 "true",
398 "--source-branch",
399 SourceControlProvider.sourceBranch(input),
400 "--status",
401 toAzureStatus(input.state),
402 "--top",
403 String(input.limit ?? 20),
404 ],
405 }).pipe(
406 Effect.map((result) => result.stdout.trim()),
407 Effect.flatMap((raw) =>
408 raw.length === 0
409 ? Effect.succeed([])
410 : Effect.sync(() => decodeAzureDevOpsPullRequestListJson(raw)).pipe(
411 Effect.flatMap((decoded) => {
412 if (!Result.isSuccess(decoded)) {
413 return Effect.fail(
414 new AzureDevOpsPullRequestListDecodeError({
415 operation: "listPullRequests",
416 command: "az",
417 cwd: input.cwd,
418 outputLength: raw.length,
419 cause: decoded.failure,
420 }),
421 );
422 }
423
424 return Effect.succeed(decoded.success);
425 }),
426 ),
427 ),
428 ),
429 getPullRequest: (input) =>
430 executeJson({
431 cwd: input.cwd,
432 args: [
433 "repos",
434 "pr",
435 "show",
436 "--detect",
437 "true",
438 "--id",
439 normalizeChangeRequestId(input.reference),
440 ],
441 }).pipe(
442 Effect.map((result) => result.stdout.trim()),
443 Effect.flatMap((raw) =>
444 Effect.sync(() => decodeAzureDevOpsPullRequestJson(raw)).pipe(
445 Effect.flatMap((decoded) => {
446 if (!Result.isSuccess(decoded)) {
447 return Effect.fail(
448 new AzureDevOpsPullRequestDecodeError({
449 operation: "getPullRequest",
450 command: "az",
451 cwd: input.cwd,
452 outputLength: raw.length,
453 cause: decoded.failure,
454 }),
455 );
456 }
457
458 return Effect.succeed(decoded.success);
459 }),
460 ),
461 ),
462 ),
463 getRepositoryCloneUrls: (input) =>
464 executeJson({
465 cwd: input.cwd,
466 args: ["repos", "show", "--detect", "true", "--repository", input.repository],
467 }).pipe(
468 Effect.map((result) => result.stdout.trim()),
469 Effect.flatMap((raw) =>
470 decodeAzureDevOpsJson(
471 raw,
472 RawAzureDevOpsRepositorySchema,
473 "getRepositoryCloneUrls",
474 input.cwd,
475 ),
476 ),
477 Effect.map(normalizeRepositoryCloneUrls),
478 ),
479 createRepository: (input) => {
480 const repository = parseRepositorySpecifier(input.repository);
481 // Azure Repos access is governed by project/organization permissions.
482 // `az repos create` does not expose a per-repository visibility flag, so
483 // the generic source-control visibility input is intentionally not
484 // translated into CLI args for this provider.
485 return executeJson({
486 cwd: input.cwd,
487 args: [
488 "repos",
489 "create",
490 "--detect",
491 "true",
492 "--name",
493 repository.name,
494 ...(repository.project ? ["--project", repository.project] : []),
495 ],
496 }).pipe(
497 Effect.map((result) => result.stdout.trim()),
498 Effect.flatMap((raw) =>
499 decodeAzureDevOpsJson(raw, RawAzureDevOpsRepositorySchema, "createRepository", input.cwd),
500 ),
501 Effect.map(normalizeRepositoryCloneUrls),
502 );
503 },
504 createPullRequest: (input) =>
505 execute({
506 cwd: input.cwd,
507 args: [
508 "repos",
509 "pr",
510 "create",
511 "--only-show-errors",
512 "--detect",
513 "true",
514 "--target-branch",
515 input.target?.refName ?? input.baseBranch,
516 "--source-branch",
517 SourceControlProvider.sourceBranch(input),
518 "--title",
519 input.title,
520 "--description",
521 `@${input.bodyFile}`,
522 ],
523 }).pipe(Effect.asVoid),
524 getDefaultBranch: (input) =>
525 executeJson({
526 cwd: input.cwd,
527 args: ["repos", "show", "--detect", "true"],
528 }).pipe(
529 Effect.map((result) => result.stdout.trim()),
530 Effect.flatMap((raw) =>
531 decodeAzureDevOpsJson(raw, RawAzureDevOpsRepositorySchema, "getDefaultBranch", input.cwd),
532 ),
533 Effect.map((repo) => normalizeDefaultBranch(repo.defaultBranch)),
534 ),
535 checkoutPullRequest: (input) =>
536 execute({
537 cwd: input.cwd,
538 args: [
539 "repos",
540 "pr",
541 "checkout",
542 "--only-show-errors",
543 "--detect",
544 "true",
545 "--id",
546 normalizeChangeRequestId(input.reference),
547 "--remote-name",
548 input.remoteName ?? "origin",
549 ],
550 }).pipe(Effect.asVoid),
551 });
552});
553
554export const layer = Layer.effect(AzureDevOpsCli, make);