Open History to see the last commit
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 type VcsProcessExitFailureKind,
13} from "@t3tools/contracts";
14
15import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
16import * as HostProcess from "@t3tools/shared/HostProcess";
17import { SpawnExecutableResolution } from "@t3tools/shared/shell";
18import { AzureCliInstallLocation } from "./azureCliLauncher.ts";
19import {
20 decodeAzureDevOpsPullRequestJson,
21 decodeAzureDevOpsPullRequestListJson,
22 type NormalizedAzureDevOpsPullRequestRecord,
23} from "./azureDevOpsPullRequests.ts";
24import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
25
26const DEFAULT_TIMEOUT_MS = 30_000;
27
28const azureDevOpsCommandErrorFields = {
29 operation: Schema.Literal("execute"),
30 command: Schema.Literal("az"),
31 cwd: Schema.String,
32 argumentCount: NonNegativeInt,
33 cause: Schema.Defect(),
34};
35
36export class AzureDevOpsCliUnavailableError extends Schema.TaggedError<AzureDevOpsCliUnavailableError>()(
37 "AzureDevOpsCliUnavailableError",
38 azureDevOpsCommandErrorFields,
39) {
40 get detail(): string {
41 return "Azure CLI (`az`) with the Azure DevOps extension is required but not available on PATH.";
42 }
43
44 override get message(): string {
45 return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`;
46 }
47}
48
49export class AzureDevOpsCliAuthenticationError extends Schema.TaggedError<AzureDevOpsCliAuthenticationError>()(
50 "AzureDevOpsCliAuthenticationError",
51 azureDevOpsCommandErrorFields,
52) {
53 get detail(): string {
54 return "Azure DevOps CLI is not authenticated. Run `az devops login` and retry.";
55 }
56
57 override get message(): string {
58 return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`;
59 }
60}
61
62export class AzureDevOpsCliRateLimitError extends Schema.TaggedError<AzureDevOpsCliRateLimitError>()(
63 "AzureDevOpsCliRateLimitError",
64 azureDevOpsCommandErrorFields,
65) {
66 get detail(): string {
67 return "Azure DevOps API rate limit exceeded.";
68 }
69
70 override get message(): string {
71 return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`;
72 }
73}
74
75export class AzureDevOpsPullRequestNotFoundError extends Schema.TaggedError<AzureDevOpsPullRequestNotFoundError>()(
76 "AzureDevOpsPullRequestNotFoundError",
77 azureDevOpsCommandErrorFields,
78) {
79 get detail(): string {
80 return "Pull request not found. Check the PR number or URL and try again.";
81 }
82
83 override get message(): string {
84 return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`;
85 }
86}
87
88export class AzureDevOpsCommandFailedError extends Schema.TaggedError<AzureDevOpsCommandFailedError>()(
89 "AzureDevOpsCommandFailedError",
90 azureDevOpsCommandErrorFields,
91) {
92 get detail(): string {
93 return "Azure DevOps CLI command failed.";
94 }
95
96 override get message(): string {
97 return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`;
98 }
99
100 static fromVcsError(
101 context: {
102 readonly operation: "execute";
103 readonly command: "az";
104 readonly cwd: string;
105 readonly argumentCount: number;
106 },
107 cause: VcsError,
108 ): AzureDevOpsCliError {
109 const fields = { ...context, cause };
110
111 if (
112 cause._tag === "VcsProcessSpawnError" &&
113 cause.cause instanceof PlatformError.PlatformError &&
114 cause.cause.reason._tag === "NotFound" &&
115 cause.cause.reason.pathOrDescriptor !== context.cwd &&
116 cause.cause.reason.syscall !== "chdir"
117 ) {
118 return new AzureDevOpsCliUnavailableError(fields);
119 }
120
121 if (cause._tag === "VcsProcessExitError") {
122 if (cause.failureKind === "authentication") {
123 return new AzureDevOpsCliAuthenticationError(fields);
124 }
125 if (cause.failureKind === "rate-limited") {
126 return new AzureDevOpsCliRateLimitError(fields);
127 }
128 if (cause.failureKind === "not-found") {
129 return new AzureDevOpsPullRequestNotFoundError(fields);
130 }
131 }
132
133 return new AzureDevOpsCommandFailedError(fields);
134 }
135}
136
137const azureDevOpsDecodeErrorFields = {
138 command: Schema.Literal("az"),
139 cwd: Schema.String,
140 outputLength: NonNegativeInt,
141 cause: Schema.Defect(),
142};
143
144export class AzureDevOpsPullRequestListDecodeError extends Schema.TaggedError<AzureDevOpsPullRequestListDecodeError>()(
145 "AzureDevOpsPullRequestListDecodeError",
146 {
147 operation: Schema.Literal("listPullRequests"),
148 ...azureDevOpsDecodeErrorFields,
149 },
150) {
151 get detail(): string {
152 return "Azure DevOps CLI returned invalid PR list JSON.";
153 }
154
155 override get message(): string {
156 return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`;
157 }
158}
159
160export class AzureDevOpsPullRequestDecodeError extends Schema.TaggedError<AzureDevOpsPullRequestDecodeError>()(
161 "AzureDevOpsPullRequestDecodeError",
162 {
163 operation: Schema.Literal("getPullRequest"),
164 ...azureDevOpsDecodeErrorFields,
165 },
166) {
167 get detail(): string {
168 return "Azure DevOps CLI returned invalid pull request JSON.";
169 }
170
171 override get message(): string {
172 return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`;
173 }
174}
175
176const AzureDevOpsRepositoryDecodeOperation = Schema.Literals([
177 "getRepositoryCloneUrls",
178 "getDefaultBranch",
179 "createRepository",
180]);
181
182export class AzureDevOpsRepositoryDecodeError extends Schema.TaggedError<AzureDevOpsRepositoryDecodeError>()(
183 "AzureDevOpsRepositoryDecodeError",
184 {
185 operation: AzureDevOpsRepositoryDecodeOperation,
186 ...azureDevOpsDecodeErrorFields,
187 },
188) {
189 get detail(): string {
190 return "Azure DevOps CLI returned invalid repository JSON.";
191 }
192
193 override get message(): string {
194 return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`;
195 }
196}
197
198export const AzureDevOpsCliError = Schema.Union([
199 AzureDevOpsCliUnavailableError,
200 AzureDevOpsCliAuthenticationError,
201 AzureDevOpsCliRateLimitError,
202 AzureDevOpsPullRequestNotFoundError,
203 AzureDevOpsCommandFailedError,
204 AzureDevOpsPullRequestListDecodeError,
205 AzureDevOpsPullRequestDecodeError,
206 AzureDevOpsRepositoryDecodeError,
207]);
208export type AzureDevOpsCliError = typeof AzureDevOpsCliError.Type;
209
210export interface AzureDevOpsRepositoryCloneUrls {
211 readonly nameWithOwner: string;
212 readonly url: string;
213 readonly sshUrl: string;
214}
215
216export class AzureDevOpsCli extends Context.Service<
217 AzureDevOpsCli,
218 {
219 readonly execute: (input: {
220 readonly cwd: string;
221 readonly args: ReadonlyArray<string>;
222 readonly timeoutMs?: number;
223 readonly maxOutputBytes?: number;
224 }) => Effect.Effect<SourceControlHost.SourceControlProcessOutput, AzureDevOpsCliError>;
225
226 readonly listPullRequests: (input: {
227 readonly cwd: string;
228 readonly headSelector: string;
229 readonly source?: SourceControlProvider.SourceControlRefSelector;
230 readonly state: "open" | "closed" | "merged" | "all";
231 readonly limit?: number;
232 }) => Effect.Effect<ReadonlyArray<NormalizedAzureDevOpsPullRequestRecord>, AzureDevOpsCliError>;
233
234 readonly getPullRequest: (input: {
235 readonly cwd: string;
236 readonly reference: string;
237 }) => Effect.Effect<NormalizedAzureDevOpsPullRequestRecord, AzureDevOpsCliError>;
238
239 readonly getRepositoryCloneUrls: (input: {
240 readonly cwd: string;
241 readonly repository: string;
242 }) => Effect.Effect<AzureDevOpsRepositoryCloneUrls, AzureDevOpsCliError>;
243
244 readonly createRepository: (input: {
245 readonly cwd: string;
246 readonly repository: string;
247 readonly visibility: SourceControlRepositoryVisibility;
248 }) => Effect.Effect<AzureDevOpsRepositoryCloneUrls, AzureDevOpsCliError>;
249
250 readonly createPullRequest: (input: {
251 readonly cwd: string;
252 readonly baseBranch: string;
253 readonly headSelector: string;
254 readonly source?: SourceControlProvider.SourceControlRefSelector;
255 readonly target?: SourceControlProvider.SourceControlRefSelector;
256 readonly title: string;
257 readonly bodyFile: string;
258 }) => Effect.Effect<void, AzureDevOpsCliError>;
259
260 readonly getDefaultBranch: (input: {
261 readonly cwd: string;
262 readonly repository: string;
263 }) => Effect.Effect<string | null, AzureDevOpsCliError>;
264
265 readonly checkoutPullRequest: (input: {
266 readonly cwd: string;
267 readonly reference: string;
268 readonly remoteName?: string;
269 }) => Effect.Effect<void, AzureDevOpsCliError>;
270 }
271>()("@t3tools/source-control-azure-devops/server/AzureDevOpsCli") {}
272
273function normalizeChangeRequestId(reference: string): string {
274 const trimmed = reference.trim().replace(/^#/, "");
275 const urlMatch = /(?:pullrequest|pull-request|pull|_pulls?)\/(\d+)(?:\D.*)?$/i.exec(trimmed);
276 return urlMatch?.[1] ?? trimmed;
277}
278
279function toAzureStatus(state: "open" | "closed" | "merged" | "all"): string {
280 switch (state) {
281 case "open":
282 return "active";
283 case "closed":
284 return "abandoned";
285 case "merged":
286 return "completed";
287 case "all":
288 return "all";
289 }
290}
291
292const RawAzureDevOpsRepositorySchema = Schema.Struct({
293 name: TrimmedNonEmptyString,
294 webUrl: TrimmedNonEmptyString,
295 remoteUrl: TrimmedNonEmptyString,
296 sshUrl: TrimmedNonEmptyString,
297 project: Schema.optional(
298 Schema.Struct({
299 name: TrimmedNonEmptyString,
300 }),
301 ),
302 defaultBranch: Schema.optional(Schema.NullOr(Schema.String)),
303});
304
305function normalizeDefaultBranch(value: string | null | undefined): string | null {
306 const trimmed = value?.trim().replace(/^refs\/heads\//, "") ?? "";
307 return trimmed.length > 0 ? trimmed : null;
308}
309
310function normalizeRepositoryCloneUrls(
311 raw: Schema.Schema.Type<typeof RawAzureDevOpsRepositorySchema>,
312): AzureDevOpsRepositoryCloneUrls {
313 const projectName = raw.project?.name.trim();
314 return {
315 nameWithOwner: projectName ? `${projectName}/${raw.name}` : raw.name,
316 url: raw.remoteUrl,
317 sshUrl: raw.sshUrl,
318 };
319}
320
321function parseRepositorySpecifier(repository: string): {
322 readonly project: string | null;
323 readonly name: string;
324} {
325 const parts: Array<string> = [];
326 for (const part of repository.split("/")) {
327 const trimmed = part.trim();
328 if (trimmed.length > 0) {
329 parts.push(trimmed);
330 }
331 }
332 return {
333 project: parts.length > 1 ? (parts.at(-2) ?? null) : null,
334 name: parts.at(-1) ?? repository.trim(),
335 };
336}
337
338function decodeAzureDevOpsJson<S extends Schema.Top>(
339 raw: string,
340 schema: S,
341 operation: typeof AzureDevOpsRepositoryDecodeOperation.Type,
342 cwd: string,
343): Effect.Effect<S["Type"], AzureDevOpsRepositoryDecodeError, S["DecodingServices"]> {
344 return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe(
345 Effect.mapError(
346 (cause) =>
347 new AzureDevOpsRepositoryDecodeError({
348 operation,
349 command: "az",
350 cwd,
351 outputLength: raw.length,
352 cause,
353 }),
354 ),
355 );
356}
357
358const classifyAzFailure = (stderr: string): VcsProcessExitFailureKind | null => {
359 const normalized = stderr.toLowerCase();
360 if (normalized.includes("az devops login") || /please run '?az login'?/.test(normalized)) {
361 return "authentication";
362 }
363 // Azure DevOps reports usage-limit throttling as TF400733, often without an HTTP status.
364 if (normalized.includes("tf400733")) return "rate-limited";
365 return normalized.includes("pull request") &&
366 (normalized.includes("not found") || normalized.includes("does not exist"))
367 ? "not-found"
368 : null;
369};
370
371export interface AzureCliInvocation {
372 readonly command: string;
373 /** Arguments `az` needs before the caller's, to run the CLI in UTF-8 mode. */
374 readonly args: ReadonlyArray<string>;
375 readonly env?: NodeJS.ProcessEnv;
376}
377
378/**
379 * `-X utf8` is the only way in: the launcher's `-I` implies `-E`, so `PYTHONUTF8` and
380 * `PYTHONIOENCODING` are ignored, and the ANSI code page loses text `az` cannot encode.
381 * Resolved once when the service is built: installing `az` later needs a restart.
382 */
383export const azureCliInvocation = Effect.fn("AzureDevOpsCli.invocation")(function* () {
384 if ((yield* HostProcess.Platform) !== "win32") {
385 return { command: "az", args: [] } satisfies AzureCliInvocation;
386 }
387 const resolveExecutable = yield* SpawnExecutableResolution;
388 const launcher = resolveExecutable("az", "win32", yield* HostProcess.Environment);
389 const installFor = yield* AzureCliInstallLocation;
390 const install = launcher === undefined ? undefined : installFor(launcher);
391 // Unrecognized installs (pipx's Scripts\az.exe) keep the launcher and today's decode.
392 return install === undefined
393 ? ({ command: "az", args: [] } satisfies AzureCliInvocation)
394 : ({
395 command: install.interpreter,
396 args: ["-X", "utf8", "-IBm", "azure.cli"],
397 // What the launcher sets before running this same interpreter.
398 env: { AZ_INSTALLER: install.installer },
399 } satisfies AzureCliInvocation);
400});
401
402/** @public */
403export const make = Effect.gen(function* () {
404 const { process } = yield* SourceControlHost.SourceControlHost;
405 const invocation = yield* azureCliInvocation();
406
407 const execute: AzureDevOpsCli["Service"]["execute"] = (input) =>
408 process
409 .run({
410 operation: "AzureDevOpsCli.execute",
411 command: invocation.command,
412 args: [...invocation.args, ...input.args],
413 cwd: input.cwd,
414 ...(invocation.env === undefined ? {} : { env: invocation.env }),
415 classifyFailure: classifyAzFailure,
416 notFoundDetail: "Pull request not found.",
417 timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS,
418 ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }),
419 })
420 .pipe(
421 Effect.mapError((error) =>
422 AzureDevOpsCommandFailedError.fromVcsError(
423 {
424 operation: "execute",
425 command: "az",
426 cwd: input.cwd,
427 argumentCount: input.args.length,
428 },
429 error,
430 ),
431 ),
432 );
433
434 const executeJson = (input: Parameters<AzureDevOpsCli["Service"]["execute"]>[0]) =>
435 execute({
436 ...input,
437 args: [...input.args, "--only-show-errors", "--output", "json"],
438 });
439
440 return AzureDevOpsCli.of({
441 execute,
442 listPullRequests: Effect.fn("AzureDevOpsCli.listPullRequests")(function (input) {
443 const branch = SourceControlProvider.sourceBranch(input);
444 // az checks literal ref prefixes before constructing the request URL.
445 const refPrefix =
446 ["refs/heads/", "refs/pull/", "refs/tags/"].find((prefix) => branch.startsWith(prefix)) ??
447 "";
448 return executeJson({
449 cwd: input.cwd,
450 args: [
451 "repos",
452 "pr",
453 "list",
454 "--detect",
455 "true",
456 "--source-branch",
457 // az puts this in the query string unescaped; a `#` would cut the request.
458 refPrefix + encodeURIComponent(branch.slice(refPrefix.length)),
459 "--status",
460 toAzureStatus(input.state),
461 "--top",
462 String(input.limit ?? 20),
463 ],
464 }).pipe(
465 Effect.map((result) => result.stdout.trim()),
466 Effect.flatMap((raw) =>
467 raw.length === 0
468 ? Effect.succeed([])
469 : Effect.sync(() => decodeAzureDevOpsPullRequestListJson(raw)).pipe(
470 Effect.flatMap((decoded) => {
471 if (!Result.isSuccess(decoded)) {
472 return Effect.fail(
473 new AzureDevOpsPullRequestListDecodeError({
474 operation: "listPullRequests",
475 command: "az",
476 cwd: input.cwd,
477 outputLength: raw.length,
478 cause: decoded.failure,
479 }),
480 );
481 }
482
483 return Effect.succeed(decoded.success);
484 }),
485 ),
486 ),
487 );
488 }),
489 getPullRequest: Effect.fn("AzureDevOpsCli.getPullRequest")(function (input) {
490 return executeJson({
491 cwd: input.cwd,
492 args: [
493 "repos",
494 "pr",
495 "show",
496 "--detect",
497 "true",
498 "--id",
499 normalizeChangeRequestId(input.reference),
500 ],
501 }).pipe(
502 Effect.map((result) => result.stdout.trim()),
503 Effect.flatMap((raw) =>
504 Effect.sync(() => decodeAzureDevOpsPullRequestJson(raw)).pipe(
505 Effect.flatMap((decoded) => {
506 if (!Result.isSuccess(decoded)) {
507 return Effect.fail(
508 new AzureDevOpsPullRequestDecodeError({
509 operation: "getPullRequest",
510 command: "az",
511 cwd: input.cwd,
512 outputLength: raw.length,
513 cause: decoded.failure,
514 }),
515 );
516 }
517
518 return Effect.succeed(decoded.success);
519 }),
520 ),
521 ),
522 );
523 }),
524 getRepositoryCloneUrls: Effect.fn("AzureDevOpsCli.getRepositoryCloneUrls")(function (input) {
525 const repository = parseRepositorySpecifier(input.repository);
526 return executeJson({
527 cwd: input.cwd,
528 args: [
529 "repos",
530 "show",
531 "--detect",
532 "true",
533 "--repository",
534 repository.name,
535 ...(repository.project ? ["--project", repository.project] : []),
536 ],
537 }).pipe(
538 Effect.map((result) => result.stdout.trim()),
539 Effect.flatMap((raw) =>
540 decodeAzureDevOpsJson(
541 raw,
542 RawAzureDevOpsRepositorySchema,
543 "getRepositoryCloneUrls",
544 input.cwd,
545 ),
546 ),
547 Effect.map(normalizeRepositoryCloneUrls),
548 );
549 }),
550 createRepository: Effect.fn("AzureDevOpsCli.createRepository")(function (input) {
551 const repository = parseRepositorySpecifier(input.repository);
552 // Project/organization permissions control visibility; `az repos create` has no flag.
553 return executeJson({
554 cwd: input.cwd,
555 args: [
556 "repos",
557 "create",
558 "--detect",
559 "true",
560 "--name",
561 repository.name,
562 ...(repository.project ? ["--project", repository.project] : []),
563 ],
564 }).pipe(
565 Effect.map((result) => result.stdout.trim()),
566 Effect.flatMap((raw) =>
567 decodeAzureDevOpsJson(raw, RawAzureDevOpsRepositorySchema, "createRepository", input.cwd),
568 ),
569 Effect.map(normalizeRepositoryCloneUrls),
570 );
571 }),
572 createPullRequest: Effect.fn("AzureDevOpsCli.createPullRequest")(function (input) {
573 return execute({
574 cwd: input.cwd,
575 args: [
576 "repos",
577 "pr",
578 "create",
579 "--only-show-errors",
580 "--detect",
581 "true",
582 "--target-branch",
583 input.target?.refName ?? input.baseBranch,
584 "--source-branch",
585 SourceControlProvider.sourceBranch(input),
586 "--title",
587 input.title,
588 "--description",
589 `@${input.bodyFile}`,
590 ],
591 }).pipe(Effect.asVoid);
592 }),
593 getDefaultBranch: Effect.fn("AzureDevOpsCli.getDefaultBranch")(function (input) {
594 const repository = parseRepositorySpecifier(input.repository);
595 return executeJson({
596 cwd: input.cwd,
597 args: [
598 "repos",
599 "show",
600 "--detect",
601 "true",
602 // Joined so a name starting with `-` stays the option's value.
603 `--repository=${repository.name}`,
604 ...(repository.project ? ["--project", repository.project] : []),
605 ],
606 }).pipe(
607 Effect.map((result) => result.stdout.trim()),
608 Effect.flatMap((raw) =>
609 decodeAzureDevOpsJson(raw, RawAzureDevOpsRepositorySchema, "getDefaultBranch", input.cwd),
610 ),
611 Effect.map((repo) => normalizeDefaultBranch(repo.defaultBranch)),
612 );
613 }),
614 checkoutPullRequest: Effect.fn("AzureDevOpsCli.checkoutPullRequest")(function (input) {
615 return execute({
616 cwd: input.cwd,
617 args: [
618 "repos",
619 "pr",
620 "checkout",
621 "--only-show-errors",
622 "--detect",
623 "true",
624 "--id",
625 normalizeChangeRequestId(input.reference),
626 "--remote-name",
627 input.remoteName ?? "origin",
628 ],
629 }).pipe(Effect.asVoid);
630 }),
631 });
632});
633
634export const layer = Layer.effect(AzureDevOpsCli, make);