authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 18:58:19-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 18:58:19-07:00
logd389cfd4e39ec1b76a6898a04d1b90190155b618
tree4397b89d13ba6ec9adc090e707a116d8c92b3622
parent6fc79737c3854344d05c1ac3d0dd10e143a19fa9
signature Signed by PGP key B5690EEEBB952194

[codex] Structure Azure DevOps CLI failures (#3460)

Co-authored-by: codex <codex@users.noreply.github.com>

3 files changed, 222 insertions(+), 86 deletions(-)

apps/server/src/sourceControl/AzureDevOpsCli.test.ts+54-1
...@@ -4,8 +4,9 @@ import * as Effect from "effect/Effect";...@@ -4,8 +4,9 @@ import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";6import * as Option from "effect/Option";
7import * as PlatformError from "effect/PlatformError";
7import { ChildProcessSpawner } from "effect/unstable/process";8import { ChildProcessSpawner } from "effect/unstable/process";
8import { VcsProcessExitError } from "@t3tools/contracts";9import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts";
910
10import * as VcsProcess from "../vcs/VcsProcess.ts";11import * as VcsProcess from "../vcs/VcsProcess.ts";
11import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";12import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
...@@ -345,11 +346,63 @@ describe("AzureDevOpsCli.layer", () => {...@@ -345,11 +346,63 @@ describe("AzureDevOpsCli.layer", () => {
345 const az = yield* AzureDevOpsCli.AzureDevOpsCli;346 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
346 const error = yield* az.execute({ cwd: "/repo", args: ["repos", "list"] }).pipe(Effect.flip);347 const error = yield* az.execute({ cwd: "/repo", args: ["repos", "list"] }).pipe(Effect.flip);
347348
349 assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsCommandFailedError);
350 assert.strictEqual(error.operation, "execute");
348 assert.strictEqual(error.command, "az");351 assert.strictEqual(error.command, "az");
349 assert.strictEqual(error.cwd, "/repo");352 assert.strictEqual(error.cwd, "/repo");
353 assert.strictEqual(error.argumentCount, 2);
350 assert.strictEqual(error.detail, "Azure DevOps CLI command failed.");354 assert.strictEqual(error.detail, "Azure DevOps CLI command failed.");
351 assert.strictEqual(error.cause, cause);355 assert.strictEqual(error.cause, cause);
352 assert.equal(error.message.includes("sensitive-upstream-detail"), false);356 assert.equal(error.message.includes("sensitive-upstream-detail"), false);
353 }).pipe(Effect.provide(layer)),357 }).pipe(Effect.provide(layer)),
354 );358 );
359
360 it.effect("does not report a missing working directory as a missing Azure CLI", () =>
361 Effect.gen(function* () {
362 const cwd = "/missing/repo";
363 const platformCause = PlatformError.systemError({
364 _tag: "NotFound",
365 module: "ChildProcess",
366 method: "spawn",
367 syscall: "chdir",
368 pathOrDescriptor: cwd,
369 });
370 const cause = new VcsProcessSpawnError({
371 operation: "AzureDevOpsCli.execute",
372 command: "az",
373 cwd,
374 argumentCount: 2,
375 cause: platformCause,
376 });
377 mockRun.mockReturnValueOnce(Effect.fail(cause));
378
379 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
380 const error = yield* az.execute({ cwd, args: ["repos", "list"] }).pipe(Effect.flip);
381
382 assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsCommandFailedError);
383 assert.strictEqual(error.cwd, cwd);
384 assert.strictEqual(error.cause, cause);
385 }).pipe(Effect.provide(layer)),
386 );
387
388 it.effect("keeps invalid pull request output diagnostics structured", () =>
389 Effect.gen(function* () {
390 mockRun.mockReturnValueOnce(Effect.succeed(processOutput("not-json")));
391
392 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
393 const error = yield* az.getPullRequest({ cwd: "/repo", reference: "42" }).pipe(Effect.flip);
394
395 assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsPullRequestDecodeError);
396 assert.strictEqual(error.operation, "getPullRequest");
397 assert.strictEqual(error.command, "az");
398 assert.strictEqual(error.cwd, "/repo");
399 assert.strictEqual(error.outputLength, 8);
400 assert.strictEqual(error.detail, "Azure DevOps CLI returned invalid pull request JSON.");
401 assert.exists(error.cause);
402 assert.strictEqual(
403 error.message,
404 "Azure DevOps CLI failed in getPullRequest: Azure DevOps CLI returned invalid pull request JSON.",
405 );
406 }).pipe(Effect.provide(layer)),
407 );
355});408});
apps/server/src/sourceControl/AzureDevOpsCli.ts+166-83
...@@ -1,9 +1,11 @@...@@ -1,9 +1,11 @@
1import * as Context from "effect/Context";1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";3import * as Layer from "effect/Layer";
4import * as PlatformError from "effect/PlatformError";
4import * as Result from "effect/Result";5import * as Result from "effect/Result";
5import * as Schema from "effect/Schema";6import * as Schema from "effect/Schema";
6import {7import {
8 NonNegativeInt,
7 TrimmedNonEmptyString,9 TrimmedNonEmptyString,
8 type SourceControlRepositoryVisibility,10 type SourceControlRepositoryVisibility,
9 type VcsError,11 type VcsError,
...@@ -19,16 +21,61 @@ import * as SourceControlProvider from "./SourceControlProvider.ts";...@@ -19,16 +21,61 @@ import * as SourceControlProvider from "./SourceControlProvider.ts";
1921
20const DEFAULT_TIMEOUT_MS = 30_000;22const DEFAULT_TIMEOUT_MS = 30_000;
2123
22export class AzureDevOpsCliError extends Schema.TaggedErrorClass<AzureDevOpsCliError>()(24const azureDevOpsCommandErrorFields = {
23 "AzureDevOpsCliError",25 operation: Schema.Literal("execute"),
24 {26 command: Schema.Literal("az"),
25 operation: Schema.String,27 cwd: Schema.String,
26 command: Schema.String,28 argumentCount: NonNegativeInt,
27 cwd: Schema.String,29 cause: Schema.Defect(),
28 detail: Schema.String,30};
29 cause: Schema.optional(Schema.Defect()),31
30 },32export class AzureDevOpsCliUnavailableError extends Schema.TaggedErrorClass<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.TaggedErrorClass<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 AzureDevOpsPullRequestNotFoundError extends Schema.TaggedErrorClass<AzureDevOpsPullRequestNotFoundError>()(
59 "AzureDevOpsPullRequestNotFoundError",
60 azureDevOpsCommandErrorFields,
61) {
62 get detail(): string {
63 return "Pull request not found. Check the PR number or URL and try again.";
64 }
65
66 override get message(): string {
67 return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`;
68 }
69}
70
71export class AzureDevOpsCommandFailedError extends Schema.TaggedErrorClass<AzureDevOpsCommandFailedError>()(
72 "AzureDevOpsCommandFailedError",
73 azureDevOpsCommandErrorFields,
31) {74) {
75 get detail(): string {
76 return "Azure DevOps CLI command failed.";
77 }
78
32 override get message(): string {79 override get message(): string {
33 return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`;80 return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`;
34 }81 }
...@@ -38,53 +85,109 @@ export class AzureDevOpsCliError extends Schema.TaggedErrorClass<AzureDevOpsCliE...@@ -38,53 +85,109 @@ export class AzureDevOpsCliError extends Schema.TaggedErrorClass<AzureDevOpsCliE
38 readonly operation: "execute";85 readonly operation: "execute";
39 readonly command: "az";86 readonly command: "az";
40 readonly cwd: string;87 readonly cwd: string;
88 readonly argumentCount: number;
41 },89 },
42 error: VcsError | unknown,90 cause: VcsError,
43 ): AzureDevOpsCliError {91 ): AzureDevOpsCliError {
44 const lower = errorText(error).toLowerCase();92 const fields = { ...context, cause };
45
46 if (lower.includes("command not found: az") || lower.includes("enoent")) {
47 return new AzureDevOpsCliError({
48 ...context,
49 detail:
50 "Azure CLI (`az`) with the Azure DevOps extension is required but not available on PATH.",
51 cause: error,
52 });
53 }
5493
55 if (94 if (
56 lower.includes("az devops login") ||95 cause._tag === "VcsProcessSpawnError" &&
57 lower.includes("please run az login") ||96 cause.cause instanceof PlatformError.PlatformError &&
58 lower.includes("not logged in") ||97 cause.cause.reason._tag === "NotFound" &&
59 lower.includes("authentication failed") ||98 cause.cause.reason.pathOrDescriptor !== context.cwd &&
60 lower.includes("unauthorized")99 cause.cause.reason.syscall !== "chdir"
61 ) {100 ) {
62 return new AzureDevOpsCliError({101 return new AzureDevOpsCliUnavailableError(fields);
63 ...context,
64 detail: "Azure DevOps CLI is not authenticated. Run `az devops login` and retry.",
65 cause: error,
66 });
67 }102 }
68103
69 if (104 if (cause._tag === "VcsProcessExitError") {
70 lower.includes("pull request") &&105 if (cause.failureKind === "authentication") {
71 (lower.includes("not found") || lower.includes("does not exist"))106 return new AzureDevOpsCliAuthenticationError(fields);
72 ) {107 }
73 return new AzureDevOpsCliError({108 if (cause.failureKind === "not-found") {
74 ...context,109 return new AzureDevOpsPullRequestNotFoundError(fields);
75 detail: "Pull request not found. Check the PR number or URL and try again.",110 }
76 cause: error,
77 });
78 }111 }
79112
80 return new AzureDevOpsCliError({113 return new AzureDevOpsCommandFailedError(fields);
81 ...context,114 }
82 detail: "Azure DevOps CLI command failed.",115}
83 cause: error,116
84 });117const azureDevOpsDecodeErrorFields = {
118 command: Schema.Literal("az"),
119 cwd: Schema.String,
120 outputLength: NonNegativeInt,
121 cause: Schema.Defect(),
122};
123
124export class AzureDevOpsPullRequestListDecodeError extends Schema.TaggedErrorClass<AzureDevOpsPullRequestListDecodeError>()(
125 "AzureDevOpsPullRequestListDecodeError",
126 {
127 operation: Schema.Literal("listPullRequests"),
128 ...azureDevOpsDecodeErrorFields,
129 },
130) {
131 get detail(): string {
132 return "Azure DevOps CLI returned invalid PR list JSON.";
133 }
134
135 override get message(): string {
136 return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`;
137 }
138}
139
140export class AzureDevOpsPullRequestDecodeError extends Schema.TaggedErrorClass<AzureDevOpsPullRequestDecodeError>()(
141 "AzureDevOpsPullRequestDecodeError",
142 {
143 operation: Schema.Literal("getPullRequest"),
144 ...azureDevOpsDecodeErrorFields,
145 },
146) {
147 get detail(): string {
148 return "Azure DevOps CLI returned invalid pull request JSON.";
149 }
150
151 override get message(): string {
152 return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`;
153 }
154}
155
156const AzureDevOpsRepositoryDecodeOperation = Schema.Literals([
157 "getRepositoryCloneUrls",
158 "getDefaultBranch",
159 "createRepository",
160]);
161
162export class AzureDevOpsRepositoryDecodeError extends Schema.TaggedErrorClass<AzureDevOpsRepositoryDecodeError>()(
163 "AzureDevOpsRepositoryDecodeError",
164 {
165 operation: AzureDevOpsRepositoryDecodeOperation,
166 ...azureDevOpsDecodeErrorFields,
167 },
168) {
169 get detail(): string {
170 return "Azure DevOps CLI returned invalid repository JSON.";
171 }
172
173 override get message(): string {
174 return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`;
85 }175 }
86}176}
87177
178export const AzureDevOpsCliError = Schema.Union([
179 AzureDevOpsCliUnavailableError,
180 AzureDevOpsCliAuthenticationError,
181 AzureDevOpsPullRequestNotFoundError,
182 AzureDevOpsCommandFailedError,
183 AzureDevOpsPullRequestListDecodeError,
184 AzureDevOpsPullRequestDecodeError,
185 AzureDevOpsRepositoryDecodeError,
186]);
187export type AzureDevOpsCliError = typeof AzureDevOpsCliError.Type;
188
189export const isAzureDevOpsCliError = Schema.is(AzureDevOpsCliError);
190
88export interface AzureDevOpsRepositoryCloneUrls {191export interface AzureDevOpsRepositoryCloneUrls {
89 readonly nameWithOwner: string;192 readonly nameWithOwner: string;
90 readonly url: string;193 readonly url: string;
...@@ -146,17 +249,6 @@ export class AzureDevOpsCli extends Context.Service<...@@ -146,17 +249,6 @@ export class AzureDevOpsCli extends Context.Service<
146 }249 }
147>()("t3/sourceControl/AzureDevOpsCli") {}250>()("t3/sourceControl/AzureDevOpsCli") {}
148251
149function errorText(error: VcsError | unknown): string {
150 if (typeof error === "object" && error !== null) {
151 const tag = "_tag" in error && typeof error._tag === "string" ? error._tag : "";
152 const detail = "detail" in error && typeof error.detail === "string" ? error.detail : "";
153 const message = "message" in error && typeof error.message === "string" ? error.message : "";
154 return [tag, detail, message].filter(Boolean).join("\n");
155 }
156
157 return String(error);
158}
159
160function normalizeChangeRequestId(reference: string): string {252function normalizeChangeRequestId(reference: string): string {
161 const trimmed = reference.trim().replace(/^#/, "");253 const trimmed = reference.trim().replace(/^#/, "");
162 const urlMatch = /(?:pullrequest|pull-request|pull|_pulls?)\/(\d+)(?:\D.*)?$/i.exec(trimmed);254 const urlMatch = /(?:pullrequest|pull-request|pull|_pulls?)\/(\d+)(?:\D.*)?$/i.exec(trimmed);
...@@ -225,19 +317,18 @@ function parseRepositorySpecifier(repository: string): {...@@ -225,19 +317,18 @@ function parseRepositorySpecifier(repository: string): {
225function decodeAzureDevOpsJson<S extends Schema.Top>(317function decodeAzureDevOpsJson<S extends Schema.Top>(
226 raw: string,318 raw: string,
227 schema: S,319 schema: S,
228 operation: "getRepositoryCloneUrls" | "getDefaultBranch" | "createRepository",320 operation: typeof AzureDevOpsRepositoryDecodeOperation.Type,
229 invalidDetail: string,
230 cwd: string,321 cwd: string,
231): Effect.Effect<S["Type"], AzureDevOpsCliError, S["DecodingServices"]> {322): Effect.Effect<S["Type"], AzureDevOpsRepositoryDecodeError, S["DecodingServices"]> {
232 return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe(323 return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe(
233 Effect.mapError(324 Effect.mapError(
234 (error) =>325 (cause) =>
235 new AzureDevOpsCliError({326 new AzureDevOpsRepositoryDecodeError({
236 operation,327 operation,
237 command: "az",328 command: "az",
238 cwd,329 cwd,
239 detail: invalidDetail,330 outputLength: raw.length,
240 cause: error,331 cause,
241 }),332 }),
242 ),333 ),
243 );334 );
...@@ -257,8 +348,13 @@ export const make = Effect.gen(function* () {...@@ -257,8 +348,13 @@ export const make = Effect.gen(function* () {
257 })348 })
258 .pipe(349 .pipe(
259 Effect.mapError((error) =>350 Effect.mapError((error) =>
260 AzureDevOpsCliError.fromVcsError(351 AzureDevOpsCommandFailedError.fromVcsError(
261 { operation: "execute", command: "az", cwd: input.cwd },352 {
353 operation: "execute",
354 command: "az",
355 cwd: input.cwd,
356 argumentCount: input.args.length,
357 },
262 error,358 error,
263 ),359 ),
264 ),360 ),
...@@ -297,11 +393,11 @@ export const make = Effect.gen(function* () {...@@ -297,11 +393,11 @@ export const make = Effect.gen(function* () {
297 Effect.flatMap((decoded) => {393 Effect.flatMap((decoded) => {
298 if (!Result.isSuccess(decoded)) {394 if (!Result.isSuccess(decoded)) {
299 return Effect.fail(395 return Effect.fail(
300 new AzureDevOpsCliError({396 new AzureDevOpsPullRequestListDecodeError({
301 operation: "listPullRequests",397 operation: "listPullRequests",
302 command: "az",398 command: "az",
303 cwd: input.cwd,399 cwd: input.cwd,
304 detail: "Azure DevOps CLI returned invalid PR list JSON.",400 outputLength: raw.length,
305 cause: decoded.failure,401 cause: decoded.failure,
306 }),402 }),
307 );403 );
...@@ -331,11 +427,11 @@ export const make = Effect.gen(function* () {...@@ -331,11 +427,11 @@ export const make = Effect.gen(function* () {
331 Effect.flatMap((decoded) => {427 Effect.flatMap((decoded) => {
332 if (!Result.isSuccess(decoded)) {428 if (!Result.isSuccess(decoded)) {
333 return Effect.fail(429 return Effect.fail(
334 new AzureDevOpsCliError({430 new AzureDevOpsPullRequestDecodeError({
335 operation: "getPullRequest",431 operation: "getPullRequest",
336 command: "az",432 command: "az",
337 cwd: input.cwd,433 cwd: input.cwd,
338 detail: "Azure DevOps CLI returned invalid pull request JSON.",434 outputLength: raw.length,
339 cause: decoded.failure,435 cause: decoded.failure,
340 }),436 }),
341 );437 );
...@@ -357,7 +453,6 @@ export const make = Effect.gen(function* () {...@@ -357,7 +453,6 @@ export const make = Effect.gen(function* () {
357 raw,453 raw,
358 RawAzureDevOpsRepositorySchema,454 RawAzureDevOpsRepositorySchema,
359 "getRepositoryCloneUrls",455 "getRepositoryCloneUrls",
360 "Azure DevOps CLI returned invalid repository JSON.",
361 input.cwd,456 input.cwd,
362 ),457 ),
363 ),458 ),
...@@ -383,13 +478,7 @@ export const make = Effect.gen(function* () {...@@ -383,13 +478,7 @@ export const make = Effect.gen(function* () {
383 }).pipe(478 }).pipe(
384 Effect.map((result) => result.stdout.trim()),479 Effect.map((result) => result.stdout.trim()),
385 Effect.flatMap((raw) =>480 Effect.flatMap((raw) =>
386 decodeAzureDevOpsJson(481 decodeAzureDevOpsJson(raw, RawAzureDevOpsRepositorySchema, "createRepository", input.cwd),
387 raw,
388 RawAzureDevOpsRepositorySchema,
389 "createRepository",
390 "Azure DevOps CLI returned invalid repository JSON.",
391 input.cwd,
392 ),
393 ),482 ),
394 Effect.map(normalizeRepositoryCloneUrls),483 Effect.map(normalizeRepositoryCloneUrls),
395 );484 );
...@@ -421,13 +510,7 @@ export const make = Effect.gen(function* () {...@@ -421,13 +510,7 @@ export const make = Effect.gen(function* () {
421 }).pipe(510 }).pipe(
422 Effect.map((result) => result.stdout.trim()),511 Effect.map((result) => result.stdout.trim()),
423 Effect.flatMap((raw) =>512 Effect.flatMap((raw) =>
424 decodeAzureDevOpsJson(513 decodeAzureDevOpsJson(raw, RawAzureDevOpsRepositorySchema, "getDefaultBranch", input.cwd),
425 raw,
426 RawAzureDevOpsRepositorySchema,
427 "getDefaultBranch",
428 "Azure DevOps CLI returned invalid repository JSON.",
429 input.cwd,
430 ),
431 ),514 ),
432 Effect.map((repo) => normalizeDefaultBranch(repo.defaultBranch)),515 Effect.map((repo) => normalizeDefaultBranch(repo.defaultBranch)),
433 ),516 ),
apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts+2-2
...@@ -48,11 +48,11 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests"...@@ -48,11 +48,11 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests"
4848
49it.effect("adds change-request context while retaining Azure CLI causes", () =>49it.effect("adds change-request context while retaining Azure CLI causes", () =>
50 Effect.gen(function* () {50 Effect.gen(function* () {
51 const cause = new AzureDevOpsCli.AzureDevOpsCliError({51 const cause = new AzureDevOpsCli.AzureDevOpsCommandFailedError({
52 operation: "execute",52 operation: "execute",
53 command: "az",53 command: "az",
54 cwd: "/repo",54 cwd: "/repo",
55 detail: "Azure DevOps CLI command failed.",55 argumentCount: 2,
56 cause: new Error("raw upstream detail that should remain in the cause"),56 cause: new Error("raw upstream detail that should remain in the cause"),
57 });57 });
58 const provider = yield* makeProvider({58 const provider = yield* makeProvider({