From e896726bd658a09860e95c833f35a694b25a4ce5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 18:00:39 -0700 Subject: [PATCH 1/2] Structure Azure DevOps CLI failures Co-authored-by: codex --- .../src/sourceControl/AzureDevOpsCli.test.ts | 24 ++ .../src/sourceControl/AzureDevOpsCli.ts | 247 ++++++++++++------ .../AzureDevOpsSourceControlProvider.test.ts | 4 +- 3 files changed, 190 insertions(+), 85 deletions(-) diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts index 8617f14e365..481954e76c0 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts @@ -345,11 +345,35 @@ describe("AzureDevOpsCli.layer", () => { const az = yield* AzureDevOpsCli.AzureDevOpsCli; const error = yield* az.execute({ cwd: "/repo", args: ["repos", "list"] }).pipe(Effect.flip); + assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsCommandFailedError); + assert.strictEqual(error.operation, "execute"); assert.strictEqual(error.command, "az"); assert.strictEqual(error.cwd, "/repo"); + assert.strictEqual(error.argumentCount, 2); assert.strictEqual(error.detail, "Azure DevOps CLI command failed."); assert.strictEqual(error.cause, cause); assert.equal(error.message.includes("sensitive-upstream-detail"), false); }).pipe(Effect.provide(layer)), ); + + it.effect("keeps invalid pull request output diagnostics structured", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput("not-json"))); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + const error = yield* az.getPullRequest({ cwd: "/repo", reference: "42" }).pipe(Effect.flip); + + assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsPullRequestDecodeError); + assert.strictEqual(error.operation, "getPullRequest"); + assert.strictEqual(error.command, "az"); + assert.strictEqual(error.cwd, "/repo"); + assert.strictEqual(error.outputLength, 8); + assert.strictEqual(error.detail, "Azure DevOps CLI returned invalid pull request JSON."); + assert.exists(error.cause); + assert.strictEqual( + error.message, + "Azure DevOps CLI failed in getPullRequest: Azure DevOps CLI returned invalid pull request JSON.", + ); + }).pipe(Effect.provide(layer)), + ); }); diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.ts b/apps/server/src/sourceControl/AzureDevOpsCli.ts index ea0e5286872..bce82f235eb 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.ts @@ -1,9 +1,11 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import { + NonNegativeInt, TrimmedNonEmptyString, type SourceControlRepositoryVisibility, type VcsError, @@ -19,16 +21,61 @@ import * as SourceControlProvider from "./SourceControlProvider.ts"; const DEFAULT_TIMEOUT_MS = 30_000; -export class AzureDevOpsCliError extends Schema.TaggedErrorClass()( - "AzureDevOpsCliError", - { - operation: Schema.String, - command: Schema.String, - cwd: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect()), - }, +const azureDevOpsCommandErrorFields = { + operation: Schema.Literal("execute"), + command: Schema.Literal("az"), + cwd: Schema.String, + argumentCount: NonNegativeInt, + cause: Schema.Defect(), +}; + +export class AzureDevOpsCliUnavailableError extends Schema.TaggedErrorClass()( + "AzureDevOpsCliUnavailableError", + azureDevOpsCommandErrorFields, +) { + get detail(): string { + return "Azure CLI (`az`) with the Azure DevOps extension is required but not available on PATH."; + } + + override get message(): string { + return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export class AzureDevOpsCliAuthenticationError extends Schema.TaggedErrorClass()( + "AzureDevOpsCliAuthenticationError", + azureDevOpsCommandErrorFields, +) { + get detail(): string { + return "Azure DevOps CLI is not authenticated. Run `az devops login` and retry."; + } + + override get message(): string { + return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export class AzureDevOpsPullRequestNotFoundError extends Schema.TaggedErrorClass()( + "AzureDevOpsPullRequestNotFoundError", + azureDevOpsCommandErrorFields, +) { + get detail(): string { + return "Pull request not found. Check the PR number or URL and try again."; + } + + override get message(): string { + return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export class AzureDevOpsCommandFailedError extends Schema.TaggedErrorClass()( + "AzureDevOpsCommandFailedError", + azureDevOpsCommandErrorFields, ) { + get detail(): string { + return "Azure DevOps CLI command failed."; + } + override get message(): string { return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; } @@ -38,53 +85,107 @@ export class AzureDevOpsCliError extends Schema.TaggedErrorClass()( + "AzureDevOpsPullRequestListDecodeError", + { + operation: Schema.Literal("listPullRequests"), + ...azureDevOpsDecodeErrorFields, + }, +) { + get detail(): string { + return "Azure DevOps CLI returned invalid PR list JSON."; + } + + override get message(): string { + return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; + } +} + +export class AzureDevOpsPullRequestDecodeError extends Schema.TaggedErrorClass()( + "AzureDevOpsPullRequestDecodeError", + { + operation: Schema.Literal("getPullRequest"), + ...azureDevOpsDecodeErrorFields, + }, +) { + get detail(): string { + return "Azure DevOps CLI returned invalid pull request JSON."; + } + + override get message(): string { + return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; + } +} + +const AzureDevOpsRepositoryDecodeOperation = Schema.Literals([ + "getRepositoryCloneUrls", + "getDefaultBranch", + "createRepository", +]); + +export class AzureDevOpsRepositoryDecodeError extends Schema.TaggedErrorClass()( + "AzureDevOpsRepositoryDecodeError", + { + operation: AzureDevOpsRepositoryDecodeOperation, + ...azureDevOpsDecodeErrorFields, + }, +) { + get detail(): string { + return "Azure DevOps CLI returned invalid repository JSON."; + } + + override get message(): string { + return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; } } +export const AzureDevOpsCliError = Schema.Union([ + AzureDevOpsCliUnavailableError, + AzureDevOpsCliAuthenticationError, + AzureDevOpsPullRequestNotFoundError, + AzureDevOpsCommandFailedError, + AzureDevOpsPullRequestListDecodeError, + AzureDevOpsPullRequestDecodeError, + AzureDevOpsRepositoryDecodeError, +]); +export type AzureDevOpsCliError = typeof AzureDevOpsCliError.Type; + +export const isAzureDevOpsCliError = Schema.is(AzureDevOpsCliError); + export interface AzureDevOpsRepositoryCloneUrls { readonly nameWithOwner: string; readonly url: string; @@ -146,17 +247,6 @@ export class AzureDevOpsCli extends Context.Service< } >()("t3/sourceControl/AzureDevOpsCli") {} -function errorText(error: VcsError | unknown): string { - if (typeof error === "object" && error !== null) { - const tag = "_tag" in error && typeof error._tag === "string" ? error._tag : ""; - const detail = "detail" in error && typeof error.detail === "string" ? error.detail : ""; - const message = "message" in error && typeof error.message === "string" ? error.message : ""; - return [tag, detail, message].filter(Boolean).join("\n"); - } - - return String(error); -} - function normalizeChangeRequestId(reference: string): string { const trimmed = reference.trim().replace(/^#/, ""); const urlMatch = /(?:pullrequest|pull-request|pull|_pulls?)\/(\d+)(?:\D.*)?$/i.exec(trimmed); @@ -225,19 +315,18 @@ function parseRepositorySpecifier(repository: string): { function decodeAzureDevOpsJson( raw: string, schema: S, - operation: "getRepositoryCloneUrls" | "getDefaultBranch" | "createRepository", - invalidDetail: string, + operation: typeof AzureDevOpsRepositoryDecodeOperation.Type, cwd: string, -): Effect.Effect { +): Effect.Effect { return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( Effect.mapError( - (error) => - new AzureDevOpsCliError({ + (cause) => + new AzureDevOpsRepositoryDecodeError({ operation, command: "az", cwd, - detail: invalidDetail, - cause: error, + outputLength: raw.length, + cause, }), ), ); @@ -257,8 +346,13 @@ export const make = Effect.gen(function* () { }) .pipe( Effect.mapError((error) => - AzureDevOpsCliError.fromVcsError( - { operation: "execute", command: "az", cwd: input.cwd }, + AzureDevOpsCommandFailedError.fromVcsError( + { + operation: "execute", + command: "az", + cwd: input.cwd, + argumentCount: input.args.length, + }, error, ), ), @@ -297,11 +391,11 @@ export const make = Effect.gen(function* () { Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( - new AzureDevOpsCliError({ + new AzureDevOpsPullRequestListDecodeError({ operation: "listPullRequests", command: "az", cwd: input.cwd, - detail: "Azure DevOps CLI returned invalid PR list JSON.", + outputLength: raw.length, cause: decoded.failure, }), ); @@ -331,11 +425,11 @@ export const make = Effect.gen(function* () { Effect.flatMap((decoded) => { if (!Result.isSuccess(decoded)) { return Effect.fail( - new AzureDevOpsCliError({ + new AzureDevOpsPullRequestDecodeError({ operation: "getPullRequest", command: "az", cwd: input.cwd, - detail: "Azure DevOps CLI returned invalid pull request JSON.", + outputLength: raw.length, cause: decoded.failure, }), ); @@ -357,7 +451,6 @@ export const make = Effect.gen(function* () { raw, RawAzureDevOpsRepositorySchema, "getRepositoryCloneUrls", - "Azure DevOps CLI returned invalid repository JSON.", input.cwd, ), ), @@ -383,13 +476,7 @@ export const make = Effect.gen(function* () { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - decodeAzureDevOpsJson( - raw, - RawAzureDevOpsRepositorySchema, - "createRepository", - "Azure DevOps CLI returned invalid repository JSON.", - input.cwd, - ), + decodeAzureDevOpsJson(raw, RawAzureDevOpsRepositorySchema, "createRepository", input.cwd), ), Effect.map(normalizeRepositoryCloneUrls), ); @@ -421,13 +508,7 @@ export const make = Effect.gen(function* () { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - decodeAzureDevOpsJson( - raw, - RawAzureDevOpsRepositorySchema, - "getDefaultBranch", - "Azure DevOps CLI returned invalid repository JSON.", - input.cwd, - ), + decodeAzureDevOpsJson(raw, RawAzureDevOpsRepositorySchema, "getDefaultBranch", input.cwd), ), Effect.map((repo) => normalizeDefaultBranch(repo.defaultBranch)), ), diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts index 1341f4cc08d..21db25e7991 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts @@ -48,11 +48,11 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests" it.effect("adds change-request context while retaining Azure CLI causes", () => Effect.gen(function* () { - const cause = new AzureDevOpsCli.AzureDevOpsCliError({ + const cause = new AzureDevOpsCli.AzureDevOpsCommandFailedError({ operation: "execute", command: "az", cwd: "/repo", - detail: "Azure DevOps CLI command failed.", + argumentCount: 2, cause: new Error("raw upstream detail that should remain in the cause"), }); const provider = yield* makeProvider({ From 525210467f82da4990c4bb1644aedf06566f7980 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 18:30:02 -0700 Subject: [PATCH 2/2] Distinguish missing Azure CLI from missing cwd Co-authored-by: codex --- .../src/sourceControl/AzureDevOpsCli.test.ts | 31 ++++++++++++++++++- .../src/sourceControl/AzureDevOpsCli.ts | 4 ++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts index 481954e76c0..1cd4b388552 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts @@ -4,8 +4,9 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as PlatformError from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { VcsProcessExitError } from "@t3tools/contracts"; +import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; @@ -356,6 +357,34 @@ describe("AzureDevOpsCli.layer", () => { }).pipe(Effect.provide(layer)), ); + it.effect("does not report a missing working directory as a missing Azure CLI", () => + Effect.gen(function* () { + const cwd = "/missing/repo"; + const platformCause = PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + syscall: "chdir", + pathOrDescriptor: cwd, + }); + const cause = new VcsProcessSpawnError({ + operation: "AzureDevOpsCli.execute", + command: "az", + cwd, + argumentCount: 2, + cause: platformCause, + }); + mockRun.mockReturnValueOnce(Effect.fail(cause)); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + const error = yield* az.execute({ cwd, args: ["repos", "list"] }).pipe(Effect.flip); + + assert.instanceOf(error, AzureDevOpsCli.AzureDevOpsCommandFailedError); + assert.strictEqual(error.cwd, cwd); + assert.strictEqual(error.cause, cause); + }).pipe(Effect.provide(layer)), + ); + it.effect("keeps invalid pull request output diagnostics structured", () => Effect.gen(function* () { mockRun.mockReturnValueOnce(Effect.succeed(processOutput("not-json"))); diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.ts b/apps/server/src/sourceControl/AzureDevOpsCli.ts index bce82f235eb..609efe4df4c 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.ts @@ -94,7 +94,9 @@ export class AzureDevOpsCommandFailedError extends Schema.TaggedErrorClass