| author | |
| committer | |
| log | 630df6b986f6e254d3c564da639de5b7b52fe95d |
| tree | 2b3762f293103612931a2411a288b395506d54a8 |
| parent | 569b3e5959c19e87fec28480c40011eae2e465dd |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>20 files changed, 1084 insertions(+), 339 deletions(-)
apps/server/src/git/GitManager.test.ts+16| ... | @@ -173,6 +173,8 @@ function runGitSyncForFakeGh(cwd: string, args: readonly string[]): void { | ... | @@ -173,6 +173,8 @@ function runGitSyncForFakeGh(cwd: string, args: readonly string[]): void { |
| 173 | } | 173 | } |
| 174 | throw new GitHubCli.GitHubCliError({ | 174 | throw new GitHubCli.GitHubCliError({ |
| 175 | operation: "execute", | 175 | operation: "execute", |
| 176 | command: "gh", | ||
| 177 | cwd, | ||
| 176 | detail: `Failed to simulate gh checkout with git ${args.join(" ")}: ${result.stderr?.trim() || "unknown error"}`, | 178 | detail: `Failed to simulate gh checkout with git ${args.join(" ")}: ${result.stderr?.trim() || "unknown error"}`, |
| 177 | }); | 179 | }); |
| 178 | } | 180 | } |
| ... | @@ -480,6 +482,8 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { | ... | @@ -480,6 +482,8 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { |
| 480 | ? error | 482 | ? error |
| 481 | : new GitHubCli.GitHubCliError({ | 483 | : new GitHubCli.GitHubCliError({ |
| 482 | operation: "execute", | 484 | operation: "execute", |
| 485 | command: "gh", | ||
| 486 | cwd: input.cwd, | ||
| 483 | detail: | 487 | detail: |
| 484 | error instanceof Error | 488 | error instanceof Error |
| 485 | ? `Failed to simulate gh checkout: ${error.message}` | 489 | ? `Failed to simulate gh checkout: ${error.message}` |
| ... | @@ -496,6 +500,8 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { | ... | @@ -496,6 +500,8 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { |
| 496 | return Effect.fail( | 500 | return Effect.fail( |
| 497 | new GitHubCli.GitHubCliError({ | 501 | new GitHubCli.GitHubCliError({ |
| 498 | operation: "execute", | 502 | operation: "execute", |
| 503 | command: "gh", | ||
| 504 | cwd: input.cwd, | ||
| 499 | detail: `Unexpected repository lookup: ${repository}`, | 505 | detail: `Unexpected repository lookup: ${repository}`, |
| 500 | }), | 506 | }), |
| 501 | ); | 507 | ); |
| ... | @@ -516,6 +522,8 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { | ... | @@ -516,6 +522,8 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { |
| 516 | return Effect.fail( | 522 | return Effect.fail( |
| 517 | new GitHubCli.GitHubCliError({ | 523 | new GitHubCli.GitHubCliError({ |
| 518 | operation: "execute", | 524 | operation: "execute", |
| 525 | command: "gh", | ||
| 526 | cwd: input.cwd, | ||
| 519 | detail: `Unexpected gh command: ${args.join(" ")}`, | 527 | detail: `Unexpected gh command: ${args.join(" ")}`, |
| 520 | }), | 528 | }), |
| 521 | ); | 529 | ); |
| ... | @@ -595,6 +603,8 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { | ... | @@ -595,6 +603,8 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { |
| 595 | Effect.fail( | 603 | Effect.fail( |
| 596 | new GitHubCli.GitHubCliError({ | 604 | new GitHubCli.GitHubCliError({ |
| 597 | operation: "createRepository", | 605 | operation: "createRepository", |
| 606 | command: "gh", | ||
| 607 | cwd: input.cwd, | ||
| 598 | detail: `Unexpected repository create: ${input.repository}`, | 608 | detail: `Unexpected repository create: ${input.repository}`, |
| 599 | }), | 609 | }), |
| 600 | ), | 610 | ), |
| ... | @@ -1333,6 +1343,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { | ... | @@ -1333,6 +1343,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 1333 | ghScenario: { | 1343 | ghScenario: { |
| 1334 | failWith: new GitHubCli.GitHubCliError({ | 1344 | failWith: new GitHubCli.GitHubCliError({ |
| 1335 | operation: "execute", | 1345 | operation: "execute", |
| 1346 | command: "gh", | ||
| 1347 | cwd: repoDir, | ||
| 1336 | detail: "GitHub CLI (`gh`) is required but not available on PATH.", | 1348 | detail: "GitHub CLI (`gh`) is required but not available on PATH.", |
| 1337 | }), | 1349 | }), |
| 1338 | }, | 1350 | }, |
| ... | @@ -2471,6 +2483,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { | ... | @@ -2471,6 +2483,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 2471 | ghScenario: { | 2483 | ghScenario: { |
| 2472 | failWith: new GitHubCli.GitHubCliError({ | 2484 | failWith: new GitHubCli.GitHubCliError({ |
| 2473 | operation: "execute", | 2485 | operation: "execute", |
| 2486 | command: "gh", | ||
| 2487 | cwd: repoDir, | ||
| 2474 | detail: "GitHub CLI (`gh`) is required but not available on PATH.", | 2488 | detail: "GitHub CLI (`gh`) is required but not available on PATH.", |
| 2475 | }), | 2489 | }), |
| 2476 | }, | 2490 | }, |
| ... | @@ -2500,6 +2514,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { | ... | @@ -2500,6 +2514,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 2500 | ghScenario: { | 2514 | ghScenario: { |
| 2501 | failWith: new GitHubCli.GitHubCliError({ | 2515 | failWith: new GitHubCli.GitHubCliError({ |
| 2502 | operation: "execute", | 2516 | operation: "execute", |
| 2517 | command: "gh", | ||
| 2518 | cwd: repoDir, | ||
| 2503 | detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.", | 2519 | detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.", |
| 2504 | }), | 2520 | }), |
| 2505 | }, | 2521 | }, |
apps/server/src/sourceControl/AzureDevOpsCli.test.ts+23| ... | @@ -5,6 +5,7 @@ import * as FileSystem from "effect/FileSystem"; | ... | @@ -5,6 +5,7 @@ import * as FileSystem from "effect/FileSystem"; |
| 5 | import * as Layer from "effect/Layer"; | 5 | import * as Layer from "effect/Layer"; |
| 6 | import * as Option from "effect/Option"; | 6 | import * as Option from "effect/Option"; |
| 7 | import { ChildProcessSpawner } from "effect/unstable/process"; | 7 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 8 | import { VcsProcessExitError } from "@t3tools/contracts"; | ||
| 8 | 9 | ||
| 9 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | 10 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 10 | import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; | 11 | import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; |
| ... | @@ -329,4 +330,26 @@ describe("AzureDevOpsCli.layer", () => { | ... | @@ -329,4 +330,26 @@ describe("AzureDevOpsCli.layer", () => { |
| 329 | }); | 330 | }); |
| 330 | }).pipe(Effect.provide(layer)), | 331 | }).pipe(Effect.provide(layer)), |
| 331 | ); | 332 | ); |
| 333 | |||
| 334 | it.effect("preserves VCS causes without copying upstream details into messages", () => | ||
| 335 | Effect.gen(function* () { | ||
| 336 | const cause = new VcsProcessExitError({ | ||
| 337 | operation: "AzureDevOpsCli.execute", | ||
| 338 | command: "az repos list --organization sensitive-upstream-detail", | ||
| 339 | cwd: "/repo", | ||
| 340 | exitCode: 1, | ||
| 341 | detail: "sensitive-upstream-detail", | ||
| 342 | }); | ||
| 343 | mockRun.mockReturnValueOnce(Effect.fail(cause)); | ||
| 344 | |||
| 345 | const az = yield* AzureDevOpsCli.AzureDevOpsCli; | ||
| 346 | const error = yield* az.execute({ cwd: "/repo", args: ["repos", "list"] }).pipe(Effect.flip); | ||
| 347 | |||
| 348 | assert.strictEqual(error.command, "az"); | ||
| 349 | assert.strictEqual(error.cwd, "/repo"); | ||
| 350 | assert.strictEqual(error.detail, "Azure DevOps CLI command failed."); | ||
| 351 | assert.strictEqual(error.cause, cause); | ||
| 352 | assert.equal(error.message.includes("sensitive-upstream-detail"), false); | ||
| 353 | }).pipe(Effect.provide(layer)), | ||
| 354 | ); | ||
| 332 | }); | 355 | }); |
apps/server/src/sourceControl/AzureDevOpsCli.ts+74-54| ... | @@ -3,7 +3,6 @@ import * as Effect from "effect/Effect"; | ... | @@ -3,7 +3,6 @@ import * as Effect from "effect/Effect"; |
| 3 | import * as Layer from "effect/Layer"; | 3 | import * as Layer from "effect/Layer"; |
| 4 | import * as Result from "effect/Result"; | 4 | import * as Result from "effect/Result"; |
| 5 | import * as Schema from "effect/Schema"; | 5 | import * as Schema from "effect/Schema"; |
| 6 | import * as SchemaIssue from "effect/SchemaIssue"; | ||
| 7 | import { | 6 | import { |
| 8 | TrimmedNonEmptyString, | 7 | TrimmedNonEmptyString, |
| 9 | type SourceControlRepositoryVisibility, | 8 | type SourceControlRepositoryVisibility, |
| ... | @@ -14,7 +13,6 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; | ... | @@ -14,7 +13,6 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 14 | import { | 13 | import { |
| 15 | decodeAzureDevOpsPullRequestJson, | 14 | decodeAzureDevOpsPullRequestJson, |
| 16 | decodeAzureDevOpsPullRequestListJson, | 15 | decodeAzureDevOpsPullRequestListJson, |
| 17 | formatAzureDevOpsJsonDecodeError, | ||
| 18 | type NormalizedAzureDevOpsPullRequestRecord, | 16 | type NormalizedAzureDevOpsPullRequestRecord, |
| 19 | } from "./azureDevOpsPullRequests.ts"; | 17 | } from "./azureDevOpsPullRequests.ts"; |
| 20 | import * as SourceControlProvider from "./SourceControlProvider.ts"; | 18 | import * as SourceControlProvider from "./SourceControlProvider.ts"; |
| ... | @@ -25,6 +23,8 @@ export class AzureDevOpsCliError extends Schema.TaggedErrorClass<AzureDevOpsCliE | ... | @@ -25,6 +23,8 @@ export class AzureDevOpsCliError extends Schema.TaggedErrorClass<AzureDevOpsCliE |
| 25 | "AzureDevOpsCliError", | 23 | "AzureDevOpsCliError", |
| 26 | { | 24 | { |
| 27 | operation: Schema.String, | 25 | operation: Schema.String, |
| 26 | command: Schema.String, | ||
| 27 | cwd: Schema.String, | ||
| 28 | detail: Schema.String, | 28 | detail: Schema.String, |
| 29 | cause: Schema.optional(Schema.Defect()), | 29 | cause: Schema.optional(Schema.Defect()), |
| 30 | }, | 30 | }, |
| ... | @@ -32,6 +32,57 @@ export class AzureDevOpsCliError extends Schema.TaggedErrorClass<AzureDevOpsCliE | ... | @@ -32,6 +32,57 @@ export class AzureDevOpsCliError extends Schema.TaggedErrorClass<AzureDevOpsCliE |
| 32 | override get message(): string { | 32 | override get message(): string { |
| 33 | return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; | 33 | return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; |
| 34 | } | 34 | } |
| 35 | |||
| 36 | static fromVcsError( | ||
| 37 | context: { | ||
| 38 | readonly operation: "execute"; | ||
| 39 | readonly command: "az"; | ||
| 40 | readonly cwd: string; | ||
| 41 | }, | ||
| 42 | error: VcsError | unknown, | ||
| 43 | ): AzureDevOpsCliError { | ||
| 44 | const lower = errorText(error).toLowerCase(); | ||
| 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 | } | ||
| 54 | |||
| 55 | if ( | ||
| 56 | lower.includes("az devops login") || | ||
| 57 | lower.includes("please run az login") || | ||
| 58 | lower.includes("not logged in") || | ||
| 59 | lower.includes("authentication failed") || | ||
| 60 | lower.includes("unauthorized") | ||
| 61 | ) { | ||
| 62 | return new AzureDevOpsCliError({ | ||
| 63 | ...context, | ||
| 64 | detail: "Azure DevOps CLI is not authenticated. Run `az devops login` and retry.", | ||
| 65 | cause: error, | ||
| 66 | }); | ||
| 67 | } | ||
| 68 | |||
| 69 | if ( | ||
| 70 | lower.includes("pull request") && | ||
| 71 | (lower.includes("not found") || lower.includes("does not exist")) | ||
| 72 | ) { | ||
| 73 | return new AzureDevOpsCliError({ | ||
| 74 | ...context, | ||
| 75 | detail: "Pull request not found. Check the PR number or URL and try again.", | ||
| 76 | cause: error, | ||
| 77 | }); | ||
| 78 | } | ||
| 79 | |||
| 80 | return new AzureDevOpsCliError({ | ||
| 81 | ...context, | ||
| 82 | detail: "Azure DevOps CLI command failed.", | ||
| 83 | cause: error, | ||
| 84 | }); | ||
| 85 | } | ||
| 35 | } | 86 | } |
| 36 | 87 | ||
| 37 | export interface AzureDevOpsRepositoryCloneUrls { | 88 | export interface AzureDevOpsRepositoryCloneUrls { |
| ... | @@ -106,54 +157,6 @@ function errorText(error: VcsError | unknown): string { | ... | @@ -106,54 +157,6 @@ function errorText(error: VcsError | unknown): string { |
| 106 | return String(error); | 157 | return String(error); |
| 107 | } | 158 | } |
| 108 | 159 | ||
| 109 | function normalizeAzureDevOpsCliError( | ||
| 110 | operation: "execute", | ||
| 111 | error: VcsError | unknown, | ||
| 112 | ): AzureDevOpsCliError { | ||
| 113 | const text = errorText(error); | ||
| 114 | const lower = text.toLowerCase(); | ||
| 115 | |||
| 116 | if (lower.includes("command not found: az") || lower.includes("enoent")) { | ||
| 117 | return new AzureDevOpsCliError({ | ||
| 118 | operation, | ||
| 119 | detail: | ||
| 120 | "Azure CLI (`az`) with the Azure DevOps extension is required but not available on PATH.", | ||
| 121 | cause: error, | ||
| 122 | }); | ||
| 123 | } | ||
| 124 | |||
| 125 | if ( | ||
| 126 | lower.includes("az devops login") || | ||
| 127 | lower.includes("please run az login") || | ||
| 128 | lower.includes("not logged in") || | ||
| 129 | lower.includes("authentication failed") || | ||
| 130 | lower.includes("unauthorized") | ||
| 131 | ) { | ||
| 132 | return new AzureDevOpsCliError({ | ||
| 133 | operation, | ||
| 134 | detail: "Azure DevOps CLI is not authenticated. Run `az devops login` and retry.", | ||
| 135 | cause: error, | ||
| 136 | }); | ||
| 137 | } | ||
| 138 | |||
| 139 | if ( | ||
| 140 | lower.includes("pull request") && | ||
| 141 | (lower.includes("not found") || lower.includes("does not exist")) | ||
| 142 | ) { | ||
| 143 | return new AzureDevOpsCliError({ | ||
| 144 | operation, | ||
| 145 | detail: "Pull request not found. Check the PR number or URL and try again.", | ||
| 146 | cause: error, | ||
| 147 | }); | ||
| 148 | } | ||
| 149 | |||
| 150 | return new AzureDevOpsCliError({ | ||
| 151 | operation, | ||
| 152 | detail: text, | ||
| 153 | cause: error, | ||
| 154 | }); | ||
| 155 | } | ||
| 156 | |||
| 157 | function normalizeChangeRequestId(reference: string): string { | 160 | function normalizeChangeRequestId(reference: string): string { |
| 158 | const trimmed = reference.trim().replace(/^#/, ""); | 161 | const trimmed = reference.trim().replace(/^#/, ""); |
| 159 | const urlMatch = /(?:pullrequest|pull-request|pull|_pulls?)\/(\d+)(?:\D.*)?$/i.exec(trimmed); | 162 | const urlMatch = /(?:pullrequest|pull-request|pull|_pulls?)\/(\d+)(?:\D.*)?$/i.exec(trimmed); |
| ... | @@ -224,13 +227,16 @@ function decodeAzureDevOpsJson<S extends Schema.Top>( | ... | @@ -224,13 +227,16 @@ function decodeAzureDevOpsJson<S extends Schema.Top>( |
| 224 | schema: S, | 227 | schema: S, |
| 225 | operation: "getRepositoryCloneUrls" | "getDefaultBranch" | "createRepository", | 228 | operation: "getRepositoryCloneUrls" | "getDefaultBranch" | "createRepository", |
| 226 | invalidDetail: string, | 229 | invalidDetail: string, |
| 230 | cwd: string, | ||
| 227 | ): Effect.Effect<S["Type"], AzureDevOpsCliError, S["DecodingServices"]> { | 231 | ): Effect.Effect<S["Type"], AzureDevOpsCliError, S["DecodingServices"]> { |
| 228 | return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( | 232 | return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( |
| 229 | Effect.mapError( | 233 | Effect.mapError( |
| 230 | (error) => | 234 | (error) => |
| 231 | new AzureDevOpsCliError({ | 235 | new AzureDevOpsCliError({ |
| 232 | operation, | 236 | operation, |
| 233 | detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`, | 237 | command: "az", |
| 238 | cwd, | ||
| 239 | detail: invalidDetail, | ||
| 234 | cause: error, | 240 | cause: error, |
| 235 | }), | 241 | }), |
| 236 | ), | 242 | ), |
| ... | @@ -249,7 +255,14 @@ export const make = Effect.gen(function* () { | ... | @@ -249,7 +255,14 @@ export const make = Effect.gen(function* () { |
| 249 | cwd: input.cwd, | 255 | cwd: input.cwd, |
| 250 | timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, | 256 | timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, |
| 251 | }) | 257 | }) |
| 252 | .pipe(Effect.mapError((error) => normalizeAzureDevOpsCliError("execute", error))); | 258 | .pipe( |
| 259 | Effect.mapError((error) => | ||
| 260 | AzureDevOpsCliError.fromVcsError( | ||
| 261 | { operation: "execute", command: "az", cwd: input.cwd }, | ||
| 262 | error, | ||
| 263 | ), | ||
| 264 | ), | ||
| 265 | ); | ||
| 253 | 266 | ||
| 254 | const executeJson = (input: Parameters<AzureDevOpsCli["Service"]["execute"]>[0]) => | 267 | const executeJson = (input: Parameters<AzureDevOpsCli["Service"]["execute"]>[0]) => |
| 255 | execute({ | 268 | execute({ |
| ... | @@ -286,7 +299,9 @@ export const make = Effect.gen(function* () { | ... | @@ -286,7 +299,9 @@ export const make = Effect.gen(function* () { |
| 286 | return Effect.fail( | 299 | return Effect.fail( |
| 287 | new AzureDevOpsCliError({ | 300 | new AzureDevOpsCliError({ |
| 288 | operation: "listPullRequests", | 301 | operation: "listPullRequests", |
| 289 | detail: `Azure DevOps CLI returned invalid PR list JSON: ${formatAzureDevOpsJsonDecodeError(decoded.failure)}`, | 302 | command: "az", |
| 303 | cwd: input.cwd, | ||
| 304 | detail: "Azure DevOps CLI returned invalid PR list JSON.", | ||
| 290 | cause: decoded.failure, | 305 | cause: decoded.failure, |
| 291 | }), | 306 | }), |
| 292 | ); | 307 | ); |
| ... | @@ -318,7 +333,9 @@ export const make = Effect.gen(function* () { | ... | @@ -318,7 +333,9 @@ export const make = Effect.gen(function* () { |
| 318 | return Effect.fail( | 333 | return Effect.fail( |
| 319 | new AzureDevOpsCliError({ | 334 | new AzureDevOpsCliError({ |
| 320 | operation: "getPullRequest", | 335 | operation: "getPullRequest", |
| 321 | detail: `Azure DevOps CLI returned invalid pull request JSON: ${formatAzureDevOpsJsonDecodeError(decoded.failure)}`, | 336 | command: "az", |
| 337 | cwd: input.cwd, | ||
| 338 | detail: "Azure DevOps CLI returned invalid pull request JSON.", | ||
| 322 | cause: decoded.failure, | 339 | cause: decoded.failure, |
| 323 | }), | 340 | }), |
| 324 | ); | 341 | ); |
| ... | @@ -341,6 +358,7 @@ export const make = Effect.gen(function* () { | ... | @@ -341,6 +358,7 @@ export const make = Effect.gen(function* () { |
| 341 | RawAzureDevOpsRepositorySchema, | 358 | RawAzureDevOpsRepositorySchema, |
| 342 | "getRepositoryCloneUrls", | 359 | "getRepositoryCloneUrls", |
| 343 | "Azure DevOps CLI returned invalid repository JSON.", | 360 | "Azure DevOps CLI returned invalid repository JSON.", |
| 361 | input.cwd, | ||
| 344 | ), | 362 | ), |
| 345 | ), | 363 | ), |
| 346 | Effect.map(normalizeRepositoryCloneUrls), | 364 | Effect.map(normalizeRepositoryCloneUrls), |
| ... | @@ -370,6 +388,7 @@ export const make = Effect.gen(function* () { | ... | @@ -370,6 +388,7 @@ export const make = Effect.gen(function* () { |
| 370 | RawAzureDevOpsRepositorySchema, | 388 | RawAzureDevOpsRepositorySchema, |
| 371 | "createRepository", | 389 | "createRepository", |
| 372 | "Azure DevOps CLI returned invalid repository JSON.", | 390 | "Azure DevOps CLI returned invalid repository JSON.", |
| 391 | input.cwd, | ||
| 373 | ), | 392 | ), |
| 374 | ), | 393 | ), |
| 375 | Effect.map(normalizeRepositoryCloneUrls), | 394 | Effect.map(normalizeRepositoryCloneUrls), |
| ... | @@ -407,6 +426,7 @@ export const make = Effect.gen(function* () { | ... | @@ -407,6 +426,7 @@ export const make = Effect.gen(function* () { |
| 407 | RawAzureDevOpsRepositorySchema, | 426 | RawAzureDevOpsRepositorySchema, |
| 408 | "getDefaultBranch", | 427 | "getDefaultBranch", |
| 409 | "Azure DevOps CLI returned invalid repository JSON.", | 428 | "Azure DevOps CLI returned invalid repository JSON.", |
| 429 | input.cwd, | ||
| 410 | ), | 430 | ), |
| 411 | ), | 431 | ), |
| 412 | Effect.map((repo) => normalizeDefaultBranch(repo.defaultBranch)), | 432 | Effect.map((repo) => normalizeDefaultBranch(repo.defaultBranch)), |
apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts+40| ... | @@ -46,6 +46,46 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests" | ... | @@ -46,6 +46,46 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests" |
| 46 | }), | 46 | }), |
| 47 | ); | 47 | ); |
| 48 | 48 | ||
| 49 | it.effect("adds change-request context while retaining Azure CLI causes", () => | ||
| 50 | Effect.gen(function* () { | ||
| 51 | const cause = new AzureDevOpsCli.AzureDevOpsCliError({ | ||
| 52 | operation: "execute", | ||
| 53 | command: "az", | ||
| 54 | cwd: "/repo", | ||
| 55 | detail: "Azure DevOps CLI command failed.", | ||
| 56 | cause: new Error("raw upstream detail that should remain in the cause"), | ||
| 57 | }); | ||
| 58 | const provider = yield* makeProvider({ | ||
| 59 | checkoutPullRequest: () => Effect.fail(cause), | ||
| 60 | }); | ||
| 61 | |||
| 62 | const error = yield* provider | ||
| 63 | .checkoutChangeRequest({ cwd: "/repo", reference: "#42" }) | ||
| 64 | .pipe(Effect.flip); | ||
| 65 | |||
| 66 | assert.deepStrictEqual( | ||
| 67 | { | ||
| 68 | provider: error.provider, | ||
| 69 | operation: error.operation, | ||
| 70 | command: error.command, | ||
| 71 | cwd: error.cwd, | ||
| 72 | reference: error.reference, | ||
| 73 | detail: error.detail, | ||
| 74 | }, | ||
| 75 | { | ||
| 76 | provider: "azure-devops", | ||
| 77 | operation: "checkoutChangeRequest", | ||
| 78 | command: "az", | ||
| 79 | cwd: "/repo", | ||
| 80 | reference: "#42", | ||
| 81 | detail: "Azure DevOps CLI command failed.", | ||
| 82 | }, | ||
| 83 | ); | ||
| 84 | assert.strictEqual(error.cause, cause); | ||
| 85 | assert.equal(error.message.includes("raw upstream detail"), false); | ||
| 86 | }), | ||
| 87 | ); | ||
| 88 | |||
| 49 | it.effect("creates Azure DevOps PRs through provider-neutral input names", () => | 89 | it.effect("creates Azure DevOps PRs through provider-neutral input names", () => |
| 50 | Effect.gen(function* () { | 90 | Effect.gen(function* () { |
| 51 | let createInput: | 91 | let createInput: |
apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts+105-25| ... | @@ -12,18 +12,6 @@ import { | ... | @@ -12,18 +12,6 @@ import { |
| 12 | type SourceControlCliDiscoverySpec, | 12 | type SourceControlCliDiscoverySpec, |
| 13 | } from "./SourceControlProviderDiscovery.ts"; | 13 | } from "./SourceControlProviderDiscovery.ts"; |
| 14 | 14 | ||
| 15 | function providerError( | ||
| 16 | operation: string, | ||
| 17 | cause: AzureDevOpsCli.AzureDevOpsCliError, | ||
| 18 | ): SourceControlProviderError { | ||
| 19 | return new SourceControlProviderError({ | ||
| 20 | provider: "azure-devops", | ||
| 21 | operation, | ||
| 22 | detail: cause.detail, | ||
| 23 | cause, | ||
| 24 | }); | ||
| 25 | } | ||
| 26 | |||
| 27 | function parseAzureAuth(input: SourceControlAuthProbeInput) { | 15 | function parseAzureAuth(input: SourceControlAuthProbeInput) { |
| 28 | const account = input.stdout.trim().split(/\r?\n/)[0]?.trim(); | 16 | const account = input.stdout.trim().split(/\r?\n/)[0]?.trim(); |
| 29 | 17 | ||
| ... | @@ -101,13 +89,39 @@ export const make = Effect.gen(function* () { | ... | @@ -101,13 +89,39 @@ export const make = Effect.gen(function* () { |
| 101 | }) | 89 | }) |
| 102 | .pipe( | 90 | .pipe( |
| 103 | Effect.map((items) => items.map(toChangeRequest)), | 91 | Effect.map((items) => items.map(toChangeRequest)), |
| 104 | Effect.mapError((error) => providerError("listChangeRequests", error)), | 92 | Effect.mapError( |
| 93 | (error) => | ||
| 94 | new SourceControlProviderError({ | ||
| 95 | provider: "azure-devops", | ||
| 96 | operation: "listChangeRequests", | ||
| 97 | command: error.command, | ||
| 98 | cwd: input.cwd, | ||
| 99 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | ||
| 100 | input.headSelector, | ||
| 101 | ), | ||
| 102 | detail: error.detail, | ||
| 103 | cause: error, | ||
| 104 | }), | ||
| 105 | ), | ||
| 105 | ); | 106 | ); |
| 106 | }, | 107 | }, |
| 107 | getChangeRequest: (input) => | 108 | getChangeRequest: (input) => |
| 108 | azure.getPullRequest(input).pipe( | 109 | azure.getPullRequest(input).pipe( |
| 109 | Effect.map(toChangeRequest), | 110 | Effect.map(toChangeRequest), |
| 110 | Effect.mapError((error) => providerError("getChangeRequest", error)), | 111 | Effect.mapError( |
| 112 | (error) => | ||
| 113 | new SourceControlProviderError({ | ||
| 114 | provider: "azure-devops", | ||
| 115 | operation: "getChangeRequest", | ||
| 116 | command: error.command, | ||
| 117 | cwd: input.cwd, | ||
| 118 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | ||
| 119 | input.reference, | ||
| 120 | ), | ||
| 121 | detail: error.detail, | ||
| 122 | cause: error, | ||
| 123 | }), | ||
| 124 | ), | ||
| 111 | ), | 125 | ), |
| 112 | createChangeRequest: (input) => { | 126 | createChangeRequest: (input) => { |
| 113 | const source = SourceControlProvider.sourceControlRefFromInput(input); | 127 | const source = SourceControlProvider.sourceControlRefFromInput(input); |
| ... | @@ -121,20 +135,71 @@ export const make = Effect.gen(function* () { | ... | @@ -121,20 +135,71 @@ export const make = Effect.gen(function* () { |
| 121 | title: input.title, | 135 | title: input.title, |
| 122 | bodyFile: input.bodyFile, | 136 | bodyFile: input.bodyFile, |
| 123 | }) | 137 | }) |
| 124 | .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))); | 138 | .pipe( |
| 139 | Effect.mapError( | ||
| 140 | (error) => | ||
| 141 | new SourceControlProviderError({ | ||
| 142 | provider: "azure-devops", | ||
| 143 | operation: "createChangeRequest", | ||
| 144 | command: error.command, | ||
| 145 | cwd: input.cwd, | ||
| 146 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | ||
| 147 | input.headSelector, | ||
| 148 | ), | ||
| 149 | detail: error.detail, | ||
| 150 | cause: error, | ||
| 151 | }), | ||
| 152 | ), | ||
| 153 | ); | ||
| 125 | }, | 154 | }, |
| 126 | getRepositoryCloneUrls: (input) => | 155 | getRepositoryCloneUrls: (input) => |
| 127 | azure | 156 | azure.getRepositoryCloneUrls(input).pipe( |
| 128 | .getRepositoryCloneUrls(input) | 157 | Effect.mapError( |
| 129 | .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), | 158 | (error) => |
| 159 | new SourceControlProviderError({ | ||
| 160 | provider: "azure-devops", | ||
| 161 | operation: "getRepositoryCloneUrls", | ||
| 162 | command: error.command, | ||
| 163 | cwd: input.cwd, | ||
| 164 | repository: SourceControlProvider.transportSafeSourceControlErrorValue( | ||
| 165 | input.repository, | ||
| 166 | ), | ||
| 167 | detail: error.detail, | ||
| 168 | cause: error, | ||
| 169 | }), | ||
| 170 | ), | ||
| 171 | ), | ||
| 130 | createRepository: (input) => | 172 | createRepository: (input) => |
| 131 | azure | 173 | azure.createRepository(input).pipe( |
| 132 | .createRepository(input) | 174 | Effect.mapError( |
| 133 | .pipe(Effect.mapError((error) => providerError("createRepository", error))), | 175 | (error) => |
| 176 | new SourceControlProviderError({ | ||
| 177 | provider: "azure-devops", | ||
| 178 | operation: "createRepository", | ||
| 179 | command: error.command, | ||
| 180 | cwd: input.cwd, | ||
| 181 | repository: SourceControlProvider.transportSafeSourceControlErrorValue( | ||
| 182 | input.repository, | ||
| 183 | ), | ||
| 184 | detail: error.detail, | ||
| 185 | cause: error, | ||
| 186 | }), | ||
| 187 | ), | ||
| 188 | ), | ||
| 134 | getDefaultBranch: (input) => | 189 | getDefaultBranch: (input) => |
| 135 | azure | 190 | azure.getDefaultBranch({ cwd: input.cwd }).pipe( |
| 136 | .getDefaultBranch({ cwd: input.cwd }) | 191 | Effect.mapError( |
| 137 | .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), | 192 | (error) => |
| 193 | new SourceControlProviderError({ | ||
| 194 | provider: "azure-devops", | ||
| 195 | operation: "getDefaultBranch", | ||
| 196 | command: error.command, | ||
| 197 | cwd: input.cwd, | ||
| 198 | detail: error.detail, | ||
| 199 | cause: error, | ||
| 200 | }), | ||
| 201 | ), | ||
| 202 | ), | ||
| 138 | checkoutChangeRequest: (input) => | 203 | checkoutChangeRequest: (input) => |
| 139 | azure | 204 | azure |
| 140 | .checkoutPullRequest({ | 205 | .checkoutPullRequest({ |
| ... | @@ -142,7 +207,22 @@ export const make = Effect.gen(function* () { | ... | @@ -142,7 +207,22 @@ export const make = Effect.gen(function* () { |
| 142 | reference: input.reference, | 207 | reference: input.reference, |
| 143 | ...(input.context !== undefined ? { remoteName: input.context.remoteName } : {}), | 208 | ...(input.context !== undefined ? { remoteName: input.context.remoteName } : {}), |
| 144 | }) | 209 | }) |
| 145 | .pipe(Effect.mapError((error) => providerError("checkoutChangeRequest", error))), | 210 | .pipe( |
| 211 | Effect.mapError( | ||
| 212 | (error) => | ||
| 213 | new SourceControlProviderError({ | ||
| 214 | provider: "azure-devops", | ||
| 215 | operation: "checkoutChangeRequest", | ||
| 216 | command: error.command, | ||
| 217 | cwd: input.cwd, | ||
| 218 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | ||
| 219 | input.reference, | ||
| 220 | ), | ||
| 221 | detail: error.detail, | ||
| 222 | cause: error, | ||
| 223 | }), | ||
| 224 | ), | ||
| 225 | ), | ||
| 146 | }); | 226 | }); |
| 147 | }); | 227 | }); |
| 148 | 228 |
apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts+39| ... | @@ -51,6 +51,45 @@ it.effect("maps Bitbucket PR summaries into provider-neutral change requests", ( | ... | @@ -51,6 +51,45 @@ it.effect("maps Bitbucket PR summaries into provider-neutral change requests", ( |
| 51 | }), | 51 | }), |
| 52 | ); | 52 | ); |
| 53 | 53 | ||
| 54 | it.effect("adds repository context while retaining Bitbucket API causes", () => | ||
| 55 | Effect.gen(function* () { | ||
| 56 | const cause = new BitbucketApi.BitbucketApiError({ | ||
| 57 | operation: "getRepository", | ||
| 58 | detail: "upstream detail that should remain in the cause", | ||
| 59 | status: 503, | ||
| 60 | cause: new Error("raw upstream failure"), | ||
| 61 | }); | ||
| 62 | const provider = yield* makeProvider({ | ||
| 63 | getRepositoryCloneUrls: () => Effect.fail(cause), | ||
| 64 | }); | ||
| 65 | |||
| 66 | const error = yield* provider | ||
| 67 | .getRepositoryCloneUrls({ cwd: "/repo", repository: "owner/repo" }) | ||
| 68 | .pipe(Effect.flip); | ||
| 69 | |||
| 70 | assert.deepStrictEqual( | ||
| 71 | { | ||
| 72 | provider: error.provider, | ||
| 73 | operation: error.operation, | ||
| 74 | command: error.command, | ||
| 75 | cwd: error.cwd, | ||
| 76 | repository: error.repository, | ||
| 77 | detail: error.detail, | ||
| 78 | }, | ||
| 79 | { | ||
| 80 | provider: "bitbucket", | ||
| 81 | operation: "getRepositoryCloneUrls", | ||
| 82 | command: undefined, | ||
| 83 | cwd: "/repo", | ||
| 84 | repository: "owner/repo", | ||
| 85 | detail: "Failed to get repository clone URLs.", | ||
| 86 | }, | ||
| 87 | ); | ||
| 88 | assert.strictEqual(error.cause, cause); | ||
| 89 | assert.equal(error.message.includes(cause.detail), false); | ||
| 90 | }), | ||
| 91 | ); | ||
| 92 | |||
| 54 | it.effect("lists Bitbucket PRs through provider-neutral input names", () => | 93 | it.effect("lists Bitbucket PRs through provider-neutral input names", () => |
| 55 | Effect.gen(function* () { | 94 | Effect.gen(function* () { |
| 56 | let listInput: Parameters<BitbucketApi.BitbucketApi["Service"]["listPullRequests"]>[0] | null = | 95 | let listInput: Parameters<BitbucketApi.BitbucketApi["Service"]["listPullRequests"]>[0] | null = |
apps/server/src/sourceControl/BitbucketSourceControlProvider.ts+98-23| ... | @@ -8,18 +8,6 @@ import type { NormalizedBitbucketPullRequestRecord } from "./bitbucketPullReques | ... | @@ -8,18 +8,6 @@ import type { NormalizedBitbucketPullRequestRecord } from "./bitbucketPullReques |
| 8 | import * as SourceControlProvider from "./SourceControlProvider.ts"; | 8 | import * as SourceControlProvider from "./SourceControlProvider.ts"; |
| 9 | import type { SourceControlApiDiscoverySpec } from "./SourceControlProviderDiscovery.ts"; | 9 | import type { SourceControlApiDiscoverySpec } from "./SourceControlProviderDiscovery.ts"; |
| 10 | 10 | ||
| 11 | function providerError( | ||
| 12 | operation: string, | ||
| 13 | cause: BitbucketApi.BitbucketApiError, | ||
| 14 | ): SourceControlProviderError { | ||
| 15 | return new SourceControlProviderError({ | ||
| 16 | provider: "bitbucket", | ||
| 17 | operation, | ||
| 18 | detail: cause.detail, | ||
| 19 | cause, | ||
| 20 | }); | ||
| 21 | } | ||
| 22 | |||
| 23 | function toChangeRequest(summary: NormalizedBitbucketPullRequestRecord): ChangeRequest { | 11 | function toChangeRequest(summary: NormalizedBitbucketPullRequestRecord): ChangeRequest { |
| 24 | return { | 12 | return { |
| 25 | provider: "bitbucket", | 13 | provider: "bitbucket", |
| ... | @@ -60,13 +48,37 @@ export const make = Effect.gen(function* () { | ... | @@ -60,13 +48,37 @@ export const make = Effect.gen(function* () { |
| 60 | }) | 48 | }) |
| 61 | .pipe( | 49 | .pipe( |
| 62 | Effect.map((items) => items.map(toChangeRequest)), | 50 | Effect.map((items) => items.map(toChangeRequest)), |
| 63 | Effect.mapError((error) => providerError("listChangeRequests", error)), | 51 | Effect.mapError( |
| 52 | (error) => | ||
| 53 | new SourceControlProviderError({ | ||
| 54 | provider: "bitbucket", | ||
| 55 | operation: "listChangeRequests", | ||
| 56 | cwd: input.cwd, | ||
| 57 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | ||
| 58 | input.headSelector, | ||
| 59 | ), | ||
| 60 | detail: "Failed to list change requests.", | ||
| 61 | cause: error, | ||
| 62 | }), | ||
| 63 | ), | ||
| 64 | ); | 64 | ); |
| 65 | }, | 65 | }, |
| 66 | getChangeRequest: (input) => | 66 | getChangeRequest: (input) => |
| 67 | bitbucket.getPullRequest(input).pipe( | 67 | bitbucket.getPullRequest(input).pipe( |
| 68 | Effect.map(toChangeRequest), | 68 | Effect.map(toChangeRequest), |
| 69 | Effect.mapError((error) => providerError("getChangeRequest", error)), | 69 | Effect.mapError( |
| 70 | (error) => | ||
| 71 | new SourceControlProviderError({ | ||
| 72 | provider: "bitbucket", | ||
| 73 | operation: "getChangeRequest", | ||
| 74 | cwd: input.cwd, | ||
| 75 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | ||
| 76 | input.reference, | ||
| 77 | ), | ||
| 78 | detail: "Failed to get change request.", | ||
| 79 | cause: error, | ||
| 80 | }), | ||
| 81 | ), | ||
| 70 | ), | 82 | ), |
| 71 | createChangeRequest: (input) => { | 83 | createChangeRequest: (input) => { |
| 72 | const source = SourceControlProvider.sourceControlRefFromInput(input); | 84 | const source = SourceControlProvider.sourceControlRefFromInput(input); |
| ... | @@ -81,23 +93,72 @@ export const make = Effect.gen(function* () { | ... | @@ -81,23 +93,72 @@ export const make = Effect.gen(function* () { |
| 81 | title: input.title, | 93 | title: input.title, |
| 82 | bodyFile: input.bodyFile, | 94 | bodyFile: input.bodyFile, |
| 83 | }) | 95 | }) |
| 84 | .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))); | 96 | .pipe( |
| 97 | Effect.mapError( | ||
| 98 | (error) => | ||
| 99 | new SourceControlProviderError({ | ||
| 100 | provider: "bitbucket", | ||
| 101 | operation: "createChangeRequest", | ||
| 102 | cwd: input.cwd, | ||
| 103 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | ||
| 104 | input.headSelector, | ||
| 105 | ), | ||
| 106 | detail: "Failed to create change request.", | ||
| 107 | cause: error, | ||
| 108 | }), | ||
| 109 | ), | ||
| 110 | ); | ||
| 85 | }, | 111 | }, |
| 86 | getRepositoryCloneUrls: (input) => | 112 | getRepositoryCloneUrls: (input) => |
| 87 | bitbucket | 113 | bitbucket.getRepositoryCloneUrls(input).pipe( |
| 88 | .getRepositoryCloneUrls(input) | 114 | Effect.mapError( |
| 89 | .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), | 115 | (error) => |
| 116 | new SourceControlProviderError({ | ||
| 117 | provider: "bitbucket", | ||
| 118 | operation: "getRepositoryCloneUrls", | ||
| 119 | cwd: input.cwd, | ||
| 120 | repository: SourceControlProvider.transportSafeSourceControlErrorValue( | ||
| 121 | input.repository, | ||
| 122 | ), | ||
| 123 | detail: "Failed to get repository clone URLs.", | ||
| 124 | cause: error, | ||
| 125 | }), | ||
| 126 | ), | ||
| 127 | ), | ||
| 90 | createRepository: (input) => | 128 | createRepository: (input) => |
| 91 | bitbucket | 129 | bitbucket.createRepository(input).pipe( |
| 92 | .createRepository(input) | 130 | Effect.mapError( |
| 93 | .pipe(Effect.mapError((error) => providerError("createRepository", error))), | 131 | (error) => |
| 132 | new SourceControlProviderError({ | ||
| 133 | provider: "bitbucket", | ||
| 134 | operation: "createRepository", | ||
| 135 | cwd: input.cwd, | ||
| 136 | repository: SourceControlProvider.transportSafeSourceControlErrorValue( | ||
| 137 | input.repository, | ||
| 138 | ), | ||
| 139 | detail: "Failed to create repository.", | ||
| 140 | cause: error, | ||
| 141 | }), | ||
| 142 | ), | ||
| 143 | ), | ||
| 94 | getDefaultBranch: (input) => | 144 | getDefaultBranch: (input) => |
| 95 | bitbucket | 145 | bitbucket |
| 96 | .getDefaultBranch({ | 146 | .getDefaultBranch({ |
| 97 | cwd: input.cwd, | 147 | cwd: input.cwd, |
| 98 | ...(input.context ? { context: input.context } : {}), | 148 | ...(input.context ? { context: input.context } : {}), |
| 99 | }) | 149 | }) |
| 100 | .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), | 150 | .pipe( |
| 151 | Effect.mapError( | ||
| 152 | (error) => | ||
| 153 | new SourceControlProviderError({ | ||
| 154 | provider: "bitbucket", | ||
| 155 | operation: "getDefaultBranch", | ||
| 156 | cwd: input.cwd, | ||
| 157 | detail: "Failed to get default branch.", | ||
| 158 | cause: error, | ||
| 159 | }), | ||
| 160 | ), | ||
| 161 | ), | ||
| 101 | checkoutChangeRequest: (input) => | 162 | checkoutChangeRequest: (input) => |
| 102 | bitbucket | 163 | bitbucket |
| 103 | .checkoutPullRequest({ | 164 | .checkoutPullRequest({ |
| ... | @@ -106,7 +167,21 @@ export const make = Effect.gen(function* () { | ... | @@ -106,7 +167,21 @@ export const make = Effect.gen(function* () { |
| 106 | reference: input.reference, | 167 | reference: input.reference, |
| 107 | ...(input.force !== undefined ? { force: input.force } : {}), | 168 | ...(input.force !== undefined ? { force: input.force } : {}), |
| 108 | }) | 169 | }) |
| 109 | .pipe(Effect.mapError((error) => providerError("checkoutChangeRequest", error))), | 170 | .pipe( |
| 171 | Effect.mapError( | ||
| 172 | (error) => | ||
| 173 | new SourceControlProviderError({ | ||
| 174 | provider: "bitbucket", | ||
| 175 | operation: "checkoutChangeRequest", | ||
| 176 | cwd: input.cwd, | ||
| 177 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | ||
| 178 | input.reference, | ||
| 179 | ), | ||
| 180 | detail: "Failed to check out change request.", | ||
| 181 | cause: error, | ||
| 182 | }), | ||
| 183 | ), | ||
| 184 | ), | ||
| 110 | }); | 185 | }); |
| 111 | }); | 186 | }); |
| 112 | 187 |
apps/server/src/sourceControl/GitHubCli.test.ts+13-12| ... | @@ -269,18 +269,15 @@ describe("GitHubCli.layer", () => { | ... | @@ -269,18 +269,15 @@ describe("GitHubCli.layer", () => { |
| 269 | 269 | ||
| 270 | it.effect("surfaces a friendly error when the pull request is not found", () => | 270 | it.effect("surfaces a friendly error when the pull request is not found", () => |
| 271 | Effect.gen(function* () { | 271 | Effect.gen(function* () { |
| 272 | mockRun.mockReturnValueOnce( | 272 | const cause = new VcsProcessExitError({ |
| 273 | Effect.fail( | 273 | operation: "GitHubCli.execute", |
| 274 | new VcsProcessExitError({ | 274 | command: "gh pr view", |
| 275 | operation: "GitHubCli.execute", | 275 | cwd: "/repo", |
| 276 | command: "gh pr view", | 276 | exitCode: 1, |
| 277 | cwd: "/repo", | 277 | detail: |
| 278 | exitCode: 1, | 278 | "GraphQL: Could not resolve to a PullRequest with the number of 4888. (repository.pullRequest)", |
| 279 | detail: | 279 | }); |
| 280 | "GraphQL: Could not resolve to a PullRequest with the number of 4888. (repository.pullRequest)", | 280 | mockRun.mockReturnValueOnce(Effect.fail(cause)); |
| 281 | }), | ||
| 282 | ), | ||
| 283 | ); | ||
| 284 | 281 | ||
| 285 | const gh = yield* GitHubCli.GitHubCli; | 282 | const gh = yield* GitHubCli.GitHubCli; |
| 286 | const error = yield* gh | 283 | const error = yield* gh |
| ... | @@ -291,6 +288,10 @@ describe("GitHubCli.layer", () => { | ... | @@ -291,6 +288,10 @@ describe("GitHubCli.layer", () => { |
| 291 | .pipe(Effect.flip); | 288 | .pipe(Effect.flip); |
| 292 | 289 | ||
| 293 | assert.equal(error.message.includes("Pull request not found"), true); | 290 | assert.equal(error.message.includes("Pull request not found"), true); |
| 291 | assert.strictEqual(error.command, "gh"); | ||
| 292 | assert.strictEqual(error.cwd, "/repo"); | ||
| 293 | assert.strictEqual(error.cause, cause); | ||
| 294 | assert.equal(error.message.includes(cause.detail), false); | ||
| 294 | }).pipe(Effect.provide(layer)), | 295 | }).pipe(Effect.provide(layer)), |
| 295 | ); | 296 | ); |
| 296 | }); | 297 | }); |
apps/server/src/sourceControl/GitHubCli.ts+72-54| ... | @@ -3,7 +3,6 @@ import * as Effect from "effect/Effect"; | ... | @@ -3,7 +3,6 @@ import * as Effect from "effect/Effect"; |
| 3 | import * as Layer from "effect/Layer"; | 3 | import * as Layer from "effect/Layer"; |
| 4 | import * as Result from "effect/Result"; | 4 | import * as Result from "effect/Result"; |
| 5 | import * as Schema from "effect/Schema"; | 5 | import * as Schema from "effect/Schema"; |
| 6 | import * as SchemaIssue from "effect/SchemaIssue"; | ||
| 7 | 6 | ||
| 8 | import { | 7 | import { |
| 9 | TrimmedNonEmptyString, | 8 | TrimmedNonEmptyString, |
| ... | @@ -15,19 +14,71 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; | ... | @@ -15,19 +14,71 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 15 | import { | 14 | import { |
| 16 | decodeGitHubPullRequestJson, | 15 | decodeGitHubPullRequestJson, |
| 17 | decodeGitHubPullRequestListJson, | 16 | decodeGitHubPullRequestListJson, |
| 18 | formatGitHubJsonDecodeError, | ||
| 19 | } from "./gitHubPullRequests.ts"; | 17 | } from "./gitHubPullRequests.ts"; |
| 20 | 18 | ||
| 21 | const DEFAULT_TIMEOUT_MS = 30_000; | 19 | const DEFAULT_TIMEOUT_MS = 30_000; |
| 22 | 20 | ||
| 23 | export class GitHubCliError extends Schema.TaggedErrorClass<GitHubCliError>()("GitHubCliError", { | 21 | export class GitHubCliError extends Schema.TaggedErrorClass<GitHubCliError>()("GitHubCliError", { |
| 24 | operation: Schema.String, | 22 | operation: Schema.String, |
| 23 | command: Schema.String, | ||
| 24 | cwd: Schema.String, | ||
| 25 | detail: Schema.String, | 25 | detail: Schema.String, |
| 26 | cause: Schema.optional(Schema.Defect()), | 26 | cause: Schema.optional(Schema.Defect()), |
| 27 | }) { | 27 | }) { |
| 28 | override get message(): string { | 28 | override get message(): string { |
| 29 | return `GitHub CLI failed in ${this.operation}: ${this.detail}`; | 29 | return `GitHub CLI failed in ${this.operation}: ${this.detail}`; |
| 30 | } | 30 | } |
| 31 | |||
| 32 | static fromVcsError( | ||
| 33 | context: { | ||
| 34 | readonly operation: "execute"; | ||
| 35 | readonly command: "gh"; | ||
| 36 | readonly cwd: string; | ||
| 37 | }, | ||
| 38 | error: VcsError | unknown, | ||
| 39 | ): GitHubCliError { | ||
| 40 | const lower = errorText(error).toLowerCase(); | ||
| 41 | |||
| 42 | if (lower.includes("command not found: gh") || lower.includes("enoent")) { | ||
| 43 | return new GitHubCliError({ | ||
| 44 | ...context, | ||
| 45 | detail: "GitHub CLI (`gh`) is required but not available on PATH.", | ||
| 46 | cause: error, | ||
| 47 | }); | ||
| 48 | } | ||
| 49 | |||
| 50 | if ( | ||
| 51 | lower.includes("authentication failed") || | ||
| 52 | lower.includes("not logged in") || | ||
| 53 | lower.includes("gh auth login") || | ||
| 54 | lower.includes("no oauth token") | ||
| 55 | ) { | ||
| 56 | return new GitHubCliError({ | ||
| 57 | ...context, | ||
| 58 | detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.", | ||
| 59 | cause: error, | ||
| 60 | }); | ||
| 61 | } | ||
| 62 | |||
| 63 | if ( | ||
| 64 | lower.includes("could not resolve to a pullrequest") || | ||
| 65 | lower.includes("repository.pullrequest") || | ||
| 66 | lower.includes("no pull requests found for branch") || | ||
| 67 | lower.includes("pull request not found") | ||
| 68 | ) { | ||
| 69 | return new GitHubCliError({ | ||
| 70 | ...context, | ||
| 71 | detail: "Pull request not found. Check the PR number or URL and try again.", | ||
| 72 | cause: error, | ||
| 73 | }); | ||
| 74 | } | ||
| 75 | |||
| 76 | return new GitHubCliError({ | ||
| 77 | ...context, | ||
| 78 | detail: "GitHub CLI command failed.", | ||
| 79 | cause: error, | ||
| 80 | }); | ||
| 81 | } | ||
| 31 | } | 82 | } |
| 32 | 83 | ||
| 33 | export interface GitHubPullRequestSummary { | 84 | export interface GitHubPullRequestSummary { |
| ... | @@ -110,54 +161,6 @@ function errorText(error: VcsError | unknown): string { | ... | @@ -110,54 +161,6 @@ function errorText(error: VcsError | unknown): string { |
| 110 | return String(error); | 161 | return String(error); |
| 111 | } | 162 | } |
| 112 | 163 | ||
| 113 | function normalizeGitHubCliError( | ||
| 114 | operation: "execute" | "stdout", | ||
| 115 | error: VcsError | unknown, | ||
| 116 | ): GitHubCliError { | ||
| 117 | const text = errorText(error); | ||
| 118 | const lower = text.toLowerCase(); | ||
| 119 | |||
| 120 | if (lower.includes("command not found: gh") || lower.includes("enoent")) { | ||
| 121 | return new GitHubCliError({ | ||
| 122 | operation, | ||
| 123 | detail: "GitHub CLI (`gh`) is required but not available on PATH.", | ||
| 124 | cause: error, | ||
| 125 | }); | ||
| 126 | } | ||
| 127 | |||
| 128 | if ( | ||
| 129 | lower.includes("authentication failed") || | ||
| 130 | lower.includes("not logged in") || | ||
| 131 | lower.includes("gh auth login") || | ||
| 132 | lower.includes("no oauth token") | ||
| 133 | ) { | ||
| 134 | return new GitHubCliError({ | ||
| 135 | operation, | ||
| 136 | detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.", | ||
| 137 | cause: error, | ||
| 138 | }); | ||
| 139 | } | ||
| 140 | |||
| 141 | if ( | ||
| 142 | lower.includes("could not resolve to a pullrequest") || | ||
| 143 | lower.includes("repository.pullrequest") || | ||
| 144 | lower.includes("no pull requests found for branch") || | ||
| 145 | lower.includes("pull request not found") | ||
| 146 | ) { | ||
| 147 | return new GitHubCliError({ | ||
| 148 | operation, | ||
| 149 | detail: "Pull request not found. Check the PR number or URL and try again.", | ||
| 150 | cause: error, | ||
| 151 | }); | ||
| 152 | } | ||
| 153 | |||
| 154 | return new GitHubCliError({ | ||
| 155 | operation, | ||
| 156 | detail: text, | ||
| 157 | cause: error, | ||
| 158 | }); | ||
| 159 | } | ||
| 160 | |||
| 161 | const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({ | 164 | const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({ |
| 162 | nameWithOwner: TrimmedNonEmptyString, | 165 | nameWithOwner: TrimmedNonEmptyString, |
| 163 | url: TrimmedNonEmptyString, | 166 | url: TrimmedNonEmptyString, |
| ... | @@ -216,13 +219,16 @@ function decodeGitHubJson<S extends Schema.Top>( | ... | @@ -216,13 +219,16 @@ function decodeGitHubJson<S extends Schema.Top>( |
| 216 | schema: S, | 219 | schema: S, |
| 217 | operation: "listOpenPullRequests" | "getPullRequest" | "getRepositoryCloneUrls", | 220 | operation: "listOpenPullRequests" | "getPullRequest" | "getRepositoryCloneUrls", |
| 218 | invalidDetail: string, | 221 | invalidDetail: string, |
| 222 | cwd: string, | ||
| 219 | ): Effect.Effect<S["Type"], GitHubCliError, S["DecodingServices"]> { | 223 | ): Effect.Effect<S["Type"], GitHubCliError, S["DecodingServices"]> { |
| 220 | return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( | 224 | return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( |
| 221 | Effect.mapError( | 225 | Effect.mapError( |
| 222 | (error) => | 226 | (error) => |
| 223 | new GitHubCliError({ | 227 | new GitHubCliError({ |
| 224 | operation, | 228 | operation, |
| 225 | detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`, | 229 | command: "gh", |
| 230 | cwd, | ||
| 231 | detail: invalidDetail, | ||
| 226 | cause: error, | 232 | cause: error, |
| 227 | }), | 233 | }), |
| 228 | ), | 234 | ), |
| ... | @@ -241,7 +247,14 @@ export const make = Effect.gen(function* () { | ... | @@ -241,7 +247,14 @@ export const make = Effect.gen(function* () { |
| 241 | cwd: input.cwd, | 247 | cwd: input.cwd, |
| 242 | timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, | 248 | timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, |
| 243 | }) | 249 | }) |
| 244 | .pipe(Effect.mapError((error) => normalizeGitHubCliError("execute", error))); | 250 | .pipe( |
| 251 | Effect.mapError((error) => | ||
| 252 | GitHubCliError.fromVcsError( | ||
| 253 | { operation: "execute", command: "gh", cwd: input.cwd }, | ||
| 254 | error, | ||
| 255 | ), | ||
| 256 | ), | ||
| 257 | ); | ||
| 245 | 258 | ||
| 246 | return GitHubCli.of({ | 259 | return GitHubCli.of({ |
| 247 | execute, | 260 | execute, |
| ... | @@ -271,7 +284,9 @@ export const make = Effect.gen(function* () { | ... | @@ -271,7 +284,9 @@ export const make = Effect.gen(function* () { |
| 271 | return Effect.fail( | 284 | return Effect.fail( |
| 272 | new GitHubCliError({ | 285 | new GitHubCliError({ |
| 273 | operation: "listOpenPullRequests", | 286 | operation: "listOpenPullRequests", |
| 274 | detail: `GitHub CLI returned invalid PR list JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`, | 287 | command: "gh", |
| 288 | cwd: input.cwd, | ||
| 289 | detail: "GitHub CLI returned invalid PR list JSON.", | ||
| 275 | cause: decoded.failure, | 290 | cause: decoded.failure, |
| 276 | }), | 291 | }), |
| 277 | ); | 292 | ); |
| ... | @@ -303,7 +318,9 @@ export const make = Effect.gen(function* () { | ... | @@ -303,7 +318,9 @@ export const make = Effect.gen(function* () { |
| 303 | return Effect.fail( | 318 | return Effect.fail( |
| 304 | new GitHubCliError({ | 319 | new GitHubCliError({ |
| 305 | operation: "getPullRequest", | 320 | operation: "getPullRequest", |
| 306 | detail: `GitHub CLI returned invalid pull request JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`, | 321 | command: "gh", |
| 322 | cwd: input.cwd, | ||
| 323 | detail: "GitHub CLI returned invalid pull request JSON.", | ||
| 307 | cause: decoded.failure, | 324 | cause: decoded.failure, |
| 308 | }), | 325 | }), |
| 309 | ); | 326 | ); |
| ... | @@ -328,6 +345,7 @@ export const make = Effect.gen(function* () { | ... | @@ -328,6 +345,7 @@ export const make = Effect.gen(function* () { |
| 328 | RawGitHubRepositoryCloneUrlsSchema, | 345 | RawGitHubRepositoryCloneUrlsSchema, |
| 329 | "getRepositoryCloneUrls", | 346 | "getRepositoryCloneUrls", |
| 330 | "GitHub CLI returned invalid repository JSON.", | 347 | "GitHub CLI returned invalid repository JSON.", |
| 348 | input.cwd, | ||
| 331 | ), | 349 | ), |
| 332 | ), | 350 | ), |
| 333 | Effect.map(normalizeRepositoryCloneUrls), | 351 | Effect.map(normalizeRepositoryCloneUrls), |
apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts+43| ... | @@ -68,6 +68,49 @@ it.effect("maps GitHub PR summaries into provider-neutral change requests", () = | ... | @@ -68,6 +68,49 @@ it.effect("maps GitHub PR summaries into provider-neutral change requests", () = |
| 68 | }), | 68 | }), |
| 69 | ); | 69 | ); |
| 70 | 70 | ||
| 71 | it.effect("adds safe request context while retaining GitHub CLI causes", () => | ||
| 72 | Effect.gen(function* () { | ||
| 73 | const cause = new GitHubCli.GitHubCliError({ | ||
| 74 | operation: "execute", | ||
| 75 | command: "gh", | ||
| 76 | cwd: "/repo", | ||
| 77 | detail: "Pull request not found. Check the PR number or URL and try again.", | ||
| 78 | cause: new Error("raw upstream detail that should remain in the cause"), | ||
| 79 | }); | ||
| 80 | const provider = yield* makeProvider({ | ||
| 81 | getPullRequest: () => Effect.fail(cause), | ||
| 82 | }); | ||
| 83 | |||
| 84 | const error = yield* provider | ||
| 85 | .getChangeRequest({ | ||
| 86 | cwd: "/repo", | ||
| 87 | reference: "https://user:secret@github.com/pingdotgg/t3code/pull/42?token=secret#diff", | ||
| 88 | }) | ||
| 89 | .pipe(Effect.flip); | ||
| 90 | |||
| 91 | assert.deepStrictEqual( | ||
| 92 | { | ||
| 93 | provider: error.provider, | ||
| 94 | operation: error.operation, | ||
| 95 | command: error.command, | ||
| 96 | cwd: error.cwd, | ||
| 97 | reference: error.reference, | ||
| 98 | detail: error.detail, | ||
| 99 | }, | ||
| 100 | { | ||
| 101 | provider: "github", | ||
| 102 | operation: "getChangeRequest", | ||
| 103 | command: "gh", | ||
| 104 | cwd: "/repo", | ||
| 105 | reference: "https://github.com/pingdotgg/t3code/pull/42", | ||
| 106 | detail: "Pull request not found. Check the PR number or URL and try again.", | ||
| 107 | }, | ||
| 108 | ); | ||
| 109 | assert.strictEqual(error.cause, cause); | ||
| 110 | assert.equal(error.message.includes("raw upstream detail"), false); | ||
| 111 | }), | ||
| 112 | ); | ||
| 113 | |||
| 71 | it.effect("uses gh json listing for non-open change request state queries", () => | 114 | it.effect("uses gh json listing for non-open change request state queries", () => |
| 72 | Effect.gen(function* () { | 115 | Effect.gen(function* () { |
| 73 | let executeArgs: ReadonlyArray<string> = []; | 116 | let executeArgs: ReadonlyArray<string> = []; |
apps/server/src/sourceControl/GitHubSourceControlProvider.ts+124-34| ... | @@ -2,7 +2,6 @@ import * as Effect from "effect/Effect"; | ... | @@ -2,7 +2,6 @@ import * as Effect from "effect/Effect"; |
| 2 | import * as Layer from "effect/Layer"; | 2 | import * as Layer from "effect/Layer"; |
| 3 | import * as Option from "effect/Option"; | 3 | import * as Option from "effect/Option"; |
| 4 | import * as Result from "effect/Result"; | 4 | import * as Result from "effect/Result"; |
| 5 | import * as Schema from "effect/Schema"; | ||
| 6 | import { | 5 | import { |
| 7 | SourceControlProviderError, | 6 | SourceControlProviderError, |
| 8 | type ChangeRequest, | 7 | type ChangeRequest, |
| ... | @@ -20,19 +19,6 @@ import { | ... | @@ -20,19 +19,6 @@ import { |
| 20 | type SourceControlAuthProbeInput, | 19 | type SourceControlAuthProbeInput, |
| 21 | type SourceControlCliDiscoverySpec, | 20 | type SourceControlCliDiscoverySpec, |
| 22 | } from "./SourceControlProviderDiscovery.ts"; | 21 | } from "./SourceControlProviderDiscovery.ts"; |
| 23 | const isSourceControlProviderError = Schema.is(SourceControlProviderError); | ||
| 24 | |||
| 25 | function providerError( | ||
| 26 | operation: string, | ||
| 27 | cause: GitHubCli.GitHubCliError, | ||
| 28 | ): SourceControlProviderError { | ||
| 29 | return new SourceControlProviderError({ | ||
| 30 | provider: "github", | ||
| 31 | operation, | ||
| 32 | detail: cause.detail, | ||
| 33 | cause, | ||
| 34 | }); | ||
| 35 | } | ||
| 36 | 22 | ||
| 37 | function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeRequest { | 23 | function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeRequest { |
| 38 | return { | 24 | return { |
| ... | @@ -122,7 +108,20 @@ export const make = Effect.gen(function* () { | ... | @@ -122,7 +108,20 @@ export const make = Effect.gen(function* () { |
| 122 | }) | 108 | }) |
| 123 | .pipe( | 109 | .pipe( |
| 124 | Effect.map((items) => items.map(toChangeRequest)), | 110 | Effect.map((items) => items.map(toChangeRequest)), |
| 125 | Effect.mapError((error) => providerError("listChangeRequests", error)), | 111 | Effect.mapError( |
| 112 | (error) => | ||
| 113 | new SourceControlProviderError({ | ||
| 114 | provider: "github", | ||
| 115 | operation: "listChangeRequests", | ||
| 116 | command: error.command, | ||
| 117 | cwd: input.cwd, | ||
| 118 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | ||
| 119 | input.headSelector, | ||
| 120 | ), | ||
| 121 | detail: error.detail, | ||
| 122 | cause: error, | ||
| 123 | }), | ||
| 124 | ), | ||
| 126 | ); | 125 | ); |
| 127 | } | 126 | } |
| 128 | 127 | ||
| ... | @@ -162,6 +161,11 @@ export const make = Effect.gen(function* () { | ... | @@ -162,6 +161,11 @@ export const make = Effect.gen(function* () { |
| 162 | new SourceControlProviderError({ | 161 | new SourceControlProviderError({ |
| 163 | provider: "github", | 162 | provider: "github", |
| 164 | operation: "listChangeRequests", | 163 | operation: "listChangeRequests", |
| 164 | command: "gh", | ||
| 165 | cwd: input.cwd, | ||
| 166 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | ||
| 167 | input.headSelector, | ||
| 168 | ), | ||
| 165 | detail: "GitHub CLI returned invalid change request JSON.", | 169 | detail: "GitHub CLI returned invalid change request JSON.", |
| 166 | cause: decoded.failure, | 170 | cause: decoded.failure, |
| 167 | }), | 171 | }), |
| ... | @@ -169,11 +173,20 @@ export const make = Effect.gen(function* () { | ... | @@ -169,11 +173,20 @@ export const make = Effect.gen(function* () { |
| 169 | ), | 173 | ), |
| 170 | ); | 174 | ); |
| 171 | }), | 175 | }), |
| 172 | Effect.mapError((error) => | 176 | Effect.catchTags({ |
| 173 | isSourceControlProviderError(error) | 177 | GitHubCliError: (error) => |
| 174 | ? error | 178 | new SourceControlProviderError({ |
| 175 | : providerError("listChangeRequests", error), | 179 | provider: "github", |
| 176 | ), | 180 | operation: "listChangeRequests", |
| 181 | command: error.command, | ||
| 182 | cwd: input.cwd, | ||
| 183 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | ||
| 184 | input.headSelector, | ||
| 185 | ), | ||
| 186 | detail: error.detail, | ||
| 187 | cause: error, | ||
| 188 | }), | ||
| 189 | }), | ||
| 177 | ); | 190 | ); |
| 178 | }; | 191 | }; |
| 179 | 192 | ||
| ... | @@ -183,7 +196,20 @@ export const make = Effect.gen(function* () { | ... | @@ -183,7 +196,20 @@ export const make = Effect.gen(function* () { |
| 183 | getChangeRequest: (input) => | 196 | getChangeRequest: (input) => |
| 184 | github.getPullRequest(input).pipe( | 197 | github.getPullRequest(input).pipe( |
| 185 | Effect.map(toChangeRequest), | 198 | Effect.map(toChangeRequest), |
| 186 | Effect.mapError((error) => providerError("getChangeRequest", error)), | 199 | Effect.mapError( |
| 200 | (error) => | ||
| 201 | new SourceControlProviderError({ | ||
| 202 | provider: "github", | ||
| 203 | operation: "getChangeRequest", | ||
| 204 | command: error.command, | ||
| 205 | cwd: input.cwd, | ||
| 206 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | ||
| 207 | input.reference, | ||
| 208 | ), | ||
| 209 | detail: error.detail, | ||
| 210 | cause: error, | ||
| 211 | }), | ||
| 212 | ), | ||
| 187 | ), | 213 | ), |
| 188 | createChangeRequest: (input) => | 214 | createChangeRequest: (input) => |
| 189 | github | 215 | github |
| ... | @@ -194,23 +220,87 @@ export const make = Effect.gen(function* () { | ... | @@ -194,23 +220,87 @@ export const make = Effect.gen(function* () { |
| 194 | title: input.title, | 220 | title: input.title, |
| 195 | bodyFile: input.bodyFile, | 221 | bodyFile: input.bodyFile, |
| 196 | }) | 222 | }) |
| 197 | .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))), | 223 | .pipe( |
| 224 | Effect.mapError( | ||
| 225 | (error) => | ||
| 226 | new SourceControlProviderError({ | ||
| 227 | provider: "github", | ||
| 228 | operation: "createChangeRequest", | ||
| 229 | command: error.command, | ||
| 230 | cwd: input.cwd, | ||
| 231 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | ||
| 232 | input.headSelector, | ||
| 233 | ), | ||
| 234 | detail: error.detail, | ||
| 235 | cause: error, | ||
| 236 | }), | ||
| 237 | ), | ||
| 238 | ), | ||
| 198 | getRepositoryCloneUrls: (input) => | 239 | getRepositoryCloneUrls: (input) => |
| 199 | github | 240 | github.getRepositoryCloneUrls(input).pipe( |
| 200 | .getRepositoryCloneUrls(input) | 241 | Effect.mapError( |
| 201 | .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), | 242 | (error) => |
| 243 | new SourceControlProviderError({ | ||
| 244 | provider: "github", | ||
| 245 | operation: "getRepositoryCloneUrls", | ||
| 246 | command: error.command, | ||
| 247 | cwd: input.cwd, | ||
| 248 | repository: SourceControlProvider.transportSafeSourceControlErrorValue( | ||
| 249 | input.repository, | ||
| 250 | ), | ||
| 251 | detail: error.detail, | ||
| 252 | cause: error, | ||
| 253 | }), | ||
| 254 | ), | ||
| 255 | ), | ||
| 202 | createRepository: (input) => | 256 | createRepository: (input) => |
| 203 | github | 257 | github.createRepository(input).pipe( |
| 204 | .createRepository(input) | 258 | Effect.mapError( |
| 205 | .pipe(Effect.mapError((error) => providerError("createRepository", error))), | 259 | (error) => |
| 260 | new SourceControlProviderError({ | ||
| 261 | provider: "github", | ||
| 262 | operation: "createRepository", | ||
| 263 | command: error.command, | ||
| 264 | cwd: input.cwd, | ||
| 265 | repository: SourceControlProvider.transportSafeSourceControlErrorValue( | ||
| 266 | input.repository, | ||
| 267 | ), | ||
| 268 | detail: error.detail, | ||
| 269 | cause: error, | ||
| 270 | }), | ||
| 271 | ), | ||
| 272 | ), | ||
| 206 | getDefaultBranch: (input) => | 273 | getDefaultBranch: (input) => |
| 207 | github | 274 | github.getDefaultBranch(input).pipe( |
| 208 | .getDefaultBranch(input) | 275 | Effect.mapError( |
| 209 | .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), | 276 | (error) => |
| 277 | new SourceControlProviderError({ | ||
| 278 | provider: "github", | ||
| 279 | operation: "getDefaultBranch", | ||
| 280 | command: error.command, | ||
| 281 | cwd: input.cwd, | ||
| 282 | detail: error.detail, | ||
| 283 | cause: error, | ||
| 284 | }), | ||
| 285 | ), | ||
| 286 | ), | ||
| 210 | checkoutChangeRequest: (input) => | 287 | checkoutChangeRequest: (input) => |
| 211 | github | 288 | github.checkoutPullRequest(input).pipe( |
| 212 | .checkoutPullRequest(input) | 289 | Effect.mapError( |
| 213 | .pipe(Effect.mapError((error) => providerError("checkoutChangeRequest", error))), | 290 | (error) => |
| 291 | new SourceControlProviderError({ | ||
| 292 | provider: "github", | ||
| 293 | operation: "checkoutChangeRequest", | ||
| 294 | command: error.command, | ||
| 295 | cwd: input.cwd, | ||
| 296 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | ||
| 297 | input.reference, | ||
| 298 | ), | ||
| 299 | detail: error.detail, | ||
| 300 | cause: error, | ||
| 301 | }), | ||
| 302 | ), | ||
| 303 | ), | ||
| 214 | }); | 304 | }); |
| 215 | }); | 305 | }); |
| 216 | 306 |
apps/server/src/sourceControl/GitLabCli.test.ts+12-11| ... | @@ -313,17 +313,14 @@ layer("GitLabCli.layer", (it) => { | ... | @@ -313,17 +313,14 @@ layer("GitLabCli.layer", (it) => { |
| 313 | 313 | ||
| 314 | it.effect("surfaces a friendly error when the merge request is not found", () => | 314 | it.effect("surfaces a friendly error when the merge request is not found", () => |
| 315 | Effect.gen(function* () { | 315 | Effect.gen(function* () { |
| 316 | mockedRun.mockReturnValueOnce( | 316 | const cause = new VcsProcessExitError({ |
| 317 | Effect.fail( | 317 | operation: "GitLabCli.execute", |
| 318 | new VcsProcessExitError({ | 318 | command: "glab mr view 4888", |
| 319 | operation: "GitLabCli.execute", | 319 | cwd: "/repo", |
| 320 | command: "glab mr view 4888", | 320 | exitCode: 1, |
| 321 | cwd: "/repo", | 321 | detail: "GET 404 merge request not found", |
| 322 | exitCode: 1, | 322 | }); |
| 323 | detail: "GET 404 merge request not found", | 323 | mockedRun.mockReturnValueOnce(Effect.fail(cause)); |
| 324 | }), | ||
| 325 | ), | ||
| 326 | ); | ||
| 327 | 324 | ||
| 328 | const error = yield* Effect.gen(function* () { | 325 | const error = yield* Effect.gen(function* () { |
| 329 | const glab = yield* GitLabCli.GitLabCli; | 326 | const glab = yield* GitLabCli.GitLabCli; |
| ... | @@ -334,6 +331,10 @@ layer("GitLabCli.layer", (it) => { | ... | @@ -334,6 +331,10 @@ layer("GitLabCli.layer", (it) => { |
| 334 | }).pipe(Effect.flip); | 331 | }).pipe(Effect.flip); |
| 335 | 332 | ||
| 336 | assert.equal(error.message.includes("Merge request not found"), true); | 333 | assert.equal(error.message.includes("Merge request not found"), true); |
| 334 | assert.strictEqual(error.command, "glab"); | ||
| 335 | assert.strictEqual(error.cwd, "/repo"); | ||
| 336 | assert.strictEqual(error.cause, cause); | ||
| 337 | assert.equal(error.message.includes(cause.detail), false); | ||
| 337 | }), | 338 | }), |
| 338 | ); | 339 | ); |
| 339 | }); | 340 | }); |
apps/server/src/sourceControl/GitLabCli.ts+90-57| ... | @@ -4,16 +4,18 @@ import * as Layer from "effect/Layer"; | ... | @@ -4,16 +4,18 @@ import * as Layer from "effect/Layer"; |
| 4 | import * as Option from "effect/Option"; | 4 | import * as Option from "effect/Option"; |
| 5 | import * as Result from "effect/Result"; | 5 | import * as Result from "effect/Result"; |
| 6 | import * as Schema from "effect/Schema"; | 6 | import * as Schema from "effect/Schema"; |
| 7 | import * as SchemaIssue from "effect/SchemaIssue"; | ||
| 8 | import type * as DateTime from "effect/DateTime"; | 7 | import type * as DateTime from "effect/DateTime"; |
| 9 | 8 | ||
| 10 | import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility } from "@t3tools/contracts"; | 9 | import { |
| 10 | TrimmedNonEmptyString, | ||
| 11 | type SourceControlRepositoryVisibility, | ||
| 12 | type VcsError, | ||
| 13 | } from "@t3tools/contracts"; | ||
| 11 | 14 | ||
| 12 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | 15 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 13 | import { | 16 | import { |
| 14 | decodeGitLabMergeRequestJson, | 17 | decodeGitLabMergeRequestJson, |
| 15 | decodeGitLabMergeRequestListJson, | 18 | decodeGitLabMergeRequestListJson, |
| 16 | formatGitLabJsonDecodeError, | ||
| 17 | } from "./gitLabMergeRequests.ts"; | 19 | } from "./gitLabMergeRequests.ts"; |
| 18 | import type * as SourceControlProvider from "./SourceControlProvider.ts"; | 20 | import type * as SourceControlProvider from "./SourceControlProvider.ts"; |
| 19 | 21 | ||
| ... | @@ -21,12 +23,64 @@ const DEFAULT_TIMEOUT_MS = 30_000; | ... | @@ -21,12 +23,64 @@ const DEFAULT_TIMEOUT_MS = 30_000; |
| 21 | 23 | ||
| 22 | export class GitLabCliError extends Schema.TaggedErrorClass<GitLabCliError>()("GitLabCliError", { | 24 | export class GitLabCliError extends Schema.TaggedErrorClass<GitLabCliError>()("GitLabCliError", { |
| 23 | operation: Schema.String, | 25 | operation: Schema.String, |
| 26 | command: Schema.String, | ||
| 27 | cwd: Schema.String, | ||
| 24 | detail: Schema.String, | 28 | detail: Schema.String, |
| 25 | cause: Schema.optional(Schema.Defect()), | 29 | cause: Schema.optional(Schema.Defect()), |
| 26 | }) { | 30 | }) { |
| 27 | override get message(): string { | 31 | override get message(): string { |
| 28 | return `GitLab CLI failed in ${this.operation}: ${this.detail}`; | 32 | return `GitLab CLI failed in ${this.operation}: ${this.detail}`; |
| 29 | } | 33 | } |
| 34 | |||
| 35 | static fromVcsError( | ||
| 36 | context: { | ||
| 37 | readonly operation: "execute"; | ||
| 38 | readonly command: "glab"; | ||
| 39 | readonly cwd: string; | ||
| 40 | }, | ||
| 41 | error: VcsError | unknown, | ||
| 42 | ): GitLabCliError { | ||
| 43 | const lower = errorText(error).toLowerCase(); | ||
| 44 | |||
| 45 | if (lower.includes("command not found: glab") || isVcsProcessSpawnError(error)) { | ||
| 46 | return new GitLabCliError({ | ||
| 47 | ...context, | ||
| 48 | detail: "GitLab CLI (`glab`) is required but not available on PATH.", | ||
| 49 | cause: error, | ||
| 50 | }); | ||
| 51 | } | ||
| 52 | |||
| 53 | if ( | ||
| 54 | lower.includes("authentication failed") || | ||
| 55 | lower.includes("not logged in") || | ||
| 56 | lower.includes("glab auth login") || | ||
| 57 | lower.includes("token") | ||
| 58 | ) { | ||
| 59 | return new GitLabCliError({ | ||
| 60 | ...context, | ||
| 61 | detail: "GitLab CLI is not authenticated. Run `glab auth login` and retry.", | ||
| 62 | cause: error, | ||
| 63 | }); | ||
| 64 | } | ||
| 65 | |||
| 66 | if ( | ||
| 67 | lower.includes("merge request not found") || | ||
| 68 | lower.includes("not found") || | ||
| 69 | lower.includes("404") | ||
| 70 | ) { | ||
| 71 | return new GitLabCliError({ | ||
| 72 | ...context, | ||
| 73 | detail: "Merge request not found. Check the MR number or URL and try again.", | ||
| 74 | cause: error, | ||
| 75 | }); | ||
| 76 | } | ||
| 77 | |||
| 78 | return new GitLabCliError({ | ||
| 79 | ...context, | ||
| 80 | detail: "GitLab CLI command failed.", | ||
| 81 | cause: error, | ||
| 82 | }); | ||
| 83 | } | ||
| 30 | } | 84 | } |
| 31 | 85 | ||
| 32 | export interface GitLabMergeRequestSummary { | 86 | export interface GitLabMergeRequestSummary { |
| ... | @@ -48,6 +102,17 @@ export interface GitLabRepositoryCloneUrls { | ... | @@ -48,6 +102,17 @@ export interface GitLabRepositoryCloneUrls { |
| 48 | readonly sshUrl: string; | 102 | readonly sshUrl: string; |
| 49 | } | 103 | } |
| 50 | 104 | ||
| 105 | function errorText(error: VcsError | unknown): string { | ||
| 106 | if (typeof error === "object" && error !== null) { | ||
| 107 | const tag = "_tag" in error && typeof error._tag === "string" ? error._tag : ""; | ||
| 108 | const detail = "detail" in error && typeof error.detail === "string" ? error.detail : ""; | ||
| 109 | const message = "message" in error && typeof error.message === "string" ? error.message : ""; | ||
| 110 | return [tag, detail, message].filter(Boolean).join("\n"); | ||
| 111 | } | ||
| 112 | |||
| 113 | return String(error); | ||
| 114 | } | ||
| 115 | |||
| 51 | export class GitLabCli extends Context.Service< | 116 | export class GitLabCli extends Context.Service< |
| 52 | GitLabCli, | 117 | GitLabCli, |
| 53 | { | 118 | { |
| ... | @@ -112,56 +177,6 @@ function isVcsProcessSpawnError(error: unknown): boolean { | ... | @@ -112,56 +177,6 @@ function isVcsProcessSpawnError(error: unknown): boolean { |
| 112 | ); | 177 | ); |
| 113 | } | 178 | } |
| 114 | 179 | ||
| 115 | function normalizeGitLabCliError(operation: "execute" | "stdout", error: unknown): GitLabCliError { | ||
| 116 | if (error instanceof Error) { | ||
| 117 | if (error.message.includes("Command not found: glab") || isVcsProcessSpawnError(error)) { | ||
| 118 | return new GitLabCliError({ | ||
| 119 | operation, | ||
| 120 | detail: "GitLab CLI (`glab`) is required but not available on PATH.", | ||
| 121 | cause: error, | ||
| 122 | }); | ||
| 123 | } | ||
| 124 | |||
| 125 | const lower = error.message.toLowerCase(); | ||
| 126 | if ( | ||
| 127 | lower.includes("authentication failed") || | ||
| 128 | lower.includes("not logged in") || | ||
| 129 | lower.includes("glab auth login") || | ||
| 130 | lower.includes("token") | ||
| 131 | ) { | ||
| 132 | return new GitLabCliError({ | ||
| 133 | operation, | ||
| 134 | detail: "GitLab CLI is not authenticated. Run `glab auth login` and retry.", | ||
| 135 | cause: error, | ||
| 136 | }); | ||
| 137 | } | ||
| 138 | |||
| 139 | if ( | ||
| 140 | lower.includes("merge request not found") || | ||
| 141 | lower.includes("not found") || | ||
| 142 | lower.includes("404") | ||
| 143 | ) { | ||
| 144 | return new GitLabCliError({ | ||
| 145 | operation, | ||
| 146 | detail: "Merge request not found. Check the MR number or URL and try again.", | ||
| 147 | cause: error, | ||
| 148 | }); | ||
| 149 | } | ||
| 150 | |||
| 151 | return new GitLabCliError({ | ||
| 152 | operation, | ||
| 153 | detail: `GitLab CLI command failed: ${error.message}`, | ||
| 154 | cause: error, | ||
| 155 | }); | ||
| 156 | } | ||
| 157 | |||
| 158 | return new GitLabCliError({ | ||
| 159 | operation, | ||
| 160 | detail: "GitLab CLI command failed.", | ||
| 161 | cause: error, | ||
| 162 | }); | ||
| 163 | } | ||
| 164 | |||
| 165 | const RawGitLabRepositoryCloneUrlsSchema = Schema.Struct({ | 180 | const RawGitLabRepositoryCloneUrlsSchema = Schema.Struct({ |
| 166 | path_with_namespace: TrimmedNonEmptyString, | 181 | path_with_namespace: TrimmedNonEmptyString, |
| 167 | web_url: TrimmedNonEmptyString, | 182 | web_url: TrimmedNonEmptyString, |
| ... | @@ -192,13 +207,16 @@ function decodeGitLabJson<S extends Schema.Top>( | ... | @@ -192,13 +207,16 @@ function decodeGitLabJson<S extends Schema.Top>( |
| 192 | schema: S, | 207 | schema: S, |
| 193 | operation: "getRepositoryCloneUrls" | "getDefaultBranch" | "createRepository", | 208 | operation: "getRepositoryCloneUrls" | "getDefaultBranch" | "createRepository", |
| 194 | invalidDetail: string, | 209 | invalidDetail: string, |
| 210 | cwd: string, | ||
| 195 | ): Effect.Effect<S["Type"], GitLabCliError, S["DecodingServices"]> { | 211 | ): Effect.Effect<S["Type"], GitLabCliError, S["DecodingServices"]> { |
| 196 | return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( | 212 | return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( |
| 197 | Effect.mapError( | 213 | Effect.mapError( |
| 198 | (error) => | 214 | (error) => |
| 199 | new GitLabCliError({ | 215 | new GitLabCliError({ |
| 200 | operation, | 216 | operation, |
| 201 | detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`, | 217 | command: "glab", |
| 218 | cwd, | ||
| 219 | detail: invalidDetail, | ||
| 202 | cause: error, | 220 | cause: error, |
| 203 | }), | 221 | }), |
| 204 | ), | 222 | ), |
| ... | @@ -274,7 +292,14 @@ export const make = Effect.gen(function* () { | ... | @@ -274,7 +292,14 @@ export const make = Effect.gen(function* () { |
| 274 | cwd: input.cwd, | 292 | cwd: input.cwd, |
| 275 | timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, | 293 | timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, |
| 276 | }) | 294 | }) |
| 277 | .pipe(Effect.mapError((error) => normalizeGitLabCliError("execute", error))); | 295 | .pipe( |
| 296 | Effect.mapError((error) => | ||
| 297 | GitLabCliError.fromVcsError( | ||
| 298 | { operation: "execute", command: "glab", cwd: input.cwd }, | ||
| 299 | error, | ||
| 300 | ), | ||
| 301 | ), | ||
| 302 | ); | ||
| 278 | 303 | ||
| 279 | return GitLabCli.of({ | 304 | return GitLabCli.of({ |
| 280 | execute, | 305 | execute, |
| ... | @@ -303,7 +328,9 @@ export const make = Effect.gen(function* () { | ... | @@ -303,7 +328,9 @@ export const make = Effect.gen(function* () { |
| 303 | return Effect.fail( | 328 | return Effect.fail( |
| 304 | new GitLabCliError({ | 329 | new GitLabCliError({ |
| 305 | operation: "listMergeRequests", | 330 | operation: "listMergeRequests", |
| 306 | detail: `GitLab CLI returned invalid MR list JSON: ${formatGitLabJsonDecodeError(decoded.failure)}`, | 331 | command: "glab", |
| 332 | cwd: input.cwd, | ||
| 333 | detail: "GitLab CLI returned invalid MR list JSON.", | ||
| 307 | cause: decoded.failure, | 334 | cause: decoded.failure, |
| 308 | }), | 335 | }), |
| 309 | ); | 336 | ); |
| ... | @@ -327,7 +354,9 @@ export const make = Effect.gen(function* () { | ... | @@ -327,7 +354,9 @@ export const make = Effect.gen(function* () { |
| 327 | return Effect.fail( | 354 | return Effect.fail( |
| 328 | new GitLabCliError({ | 355 | new GitLabCliError({ |
| 329 | operation: "getMergeRequest", | 356 | operation: "getMergeRequest", |
| 330 | detail: `GitLab CLI returned invalid merge request JSON: ${formatGitLabJsonDecodeError(decoded.failure)}`, | 357 | command: "glab", |
| 358 | cwd: input.cwd, | ||
| 359 | detail: "GitLab CLI returned invalid merge request JSON.", | ||
| 331 | cause: decoded.failure, | 360 | cause: decoded.failure, |
| 332 | }), | 361 | }), |
| 333 | ); | 362 | ); |
| ... | @@ -350,6 +379,7 @@ export const make = Effect.gen(function* () { | ... | @@ -350,6 +379,7 @@ export const make = Effect.gen(function* () { |
| 350 | RawGitLabRepositoryCloneUrlsSchema, | 379 | RawGitLabRepositoryCloneUrlsSchema, |
| 351 | "getRepositoryCloneUrls", | 380 | "getRepositoryCloneUrls", |
| 352 | "GitLab CLI returned invalid repository JSON.", | 381 | "GitLab CLI returned invalid repository JSON.", |
| 382 | input.cwd, | ||
| 353 | ), | 383 | ), |
| 354 | ), | 384 | ), |
| 355 | Effect.map(normalizeRepositoryCloneUrls), | 385 | Effect.map(normalizeRepositoryCloneUrls), |
| ... | @@ -368,6 +398,7 @@ export const make = Effect.gen(function* () { | ... | @@ -368,6 +398,7 @@ export const make = Effect.gen(function* () { |
| 368 | RawGitLabNamespaceSchema, | 398 | RawGitLabNamespaceSchema, |
| 369 | "createRepository", | 399 | "createRepository", |
| 370 | "GitLab CLI returned invalid namespace JSON.", | 400 | "GitLab CLI returned invalid namespace JSON.", |
| 401 | input.cwd, | ||
| 371 | ), | 402 | ), |
| 372 | ), | 403 | ), |
| 373 | Effect.map((namespace) => namespace.id), | 404 | Effect.map((namespace) => namespace.id), |
| ... | @@ -402,6 +433,7 @@ export const make = Effect.gen(function* () { | ... | @@ -402,6 +433,7 @@ export const make = Effect.gen(function* () { |
| 402 | RawGitLabRepositoryCloneUrlsSchema, | 433 | RawGitLabRepositoryCloneUrlsSchema, |
| 403 | "createRepository", | 434 | "createRepository", |
| 404 | "GitLab CLI returned invalid repository JSON.", | 435 | "GitLab CLI returned invalid repository JSON.", |
| 436 | input.cwd, | ||
| 405 | ), | 437 | ), |
| 406 | ), | 438 | ), |
| 407 | Effect.map(normalizeRepositoryCloneUrls), | 439 | Effect.map(normalizeRepositoryCloneUrls), |
| ... | @@ -440,6 +472,7 @@ export const make = Effect.gen(function* () { | ... | @@ -440,6 +472,7 @@ export const make = Effect.gen(function* () { |
| 440 | RawGitLabDefaultBranchSchema, | 472 | RawGitLabDefaultBranchSchema, |
| 441 | "getDefaultBranch", | 473 | "getDefaultBranch", |
| 442 | "GitLab CLI returned invalid repository JSON.", | 474 | "GitLab CLI returned invalid repository JSON.", |
| 475 | input.cwd, | ||
| 443 | ), | 476 | ), |
| 444 | ), | 477 | ), |
| 445 | Effect.map((value) => value.default_branch ?? null), | 478 | Effect.map((value) => value.default_branch ?? null), |
apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts+44| ... | @@ -52,6 +52,50 @@ it.effect("maps GitLab MR summaries into provider-neutral change requests", () = | ... | @@ -52,6 +52,50 @@ it.effect("maps GitLab MR summaries into provider-neutral change requests", () = |
| 52 | }), | 52 | }), |
| 53 | ); | 53 | ); |
| 54 | 54 | ||
| 55 | it.effect("adds repository context while retaining GitLab CLI causes", () => | ||
| 56 | Effect.gen(function* () { | ||
| 57 | const cause = new GitLabCli.GitLabCliError({ | ||
| 58 | operation: "execute", | ||
| 59 | command: "glab", | ||
| 60 | cwd: "/repo", | ||
| 61 | detail: "GitLab CLI command failed.", | ||
| 62 | cause: new Error("raw upstream detail that should remain in the cause"), | ||
| 63 | }); | ||
| 64 | const provider = yield* makeProvider({ | ||
| 65 | createRepository: () => Effect.fail(cause), | ||
| 66 | }); | ||
| 67 | |||
| 68 | const error = yield* provider | ||
| 69 | .createRepository({ | ||
| 70 | cwd: "/repo", | ||
| 71 | repository: "owner/repo", | ||
| 72 | visibility: "private", | ||
| 73 | }) | ||
| 74 | .pipe(Effect.flip); | ||
| 75 | |||
| 76 | assert.deepStrictEqual( | ||
| 77 | { | ||
| 78 | provider: error.provider, | ||
| 79 | operation: error.operation, | ||
| 80 | command: error.command, | ||
| 81 | cwd: error.cwd, | ||
| 82 | repository: error.repository, | ||
| 83 | detail: error.detail, | ||
| 84 | }, | ||
| 85 | { | ||
| 86 | provider: "gitlab", | ||
| 87 | operation: "createRepository", | ||
| 88 | command: "glab", | ||
| 89 | cwd: "/repo", | ||
| 90 | repository: "owner/repo", | ||
| 91 | detail: "GitLab CLI command failed.", | ||
| 92 | }, | ||
| 93 | ); | ||
| 94 | assert.strictEqual(error.cause, cause); | ||
| 95 | assert.equal(error.message.includes("raw upstream detail"), false); | ||
| 96 | }), | ||
| 97 | ); | ||
| 98 | |||
| 55 | it.effect("lists GitLab MRs through provider-neutral input names", () => | 99 | it.effect("lists GitLab MRs through provider-neutral input names", () => |
| 56 | Effect.gen(function* () { | 100 | Effect.gen(function* () { |
| 57 | let listInput: Parameters<GitLabCli.GitLabCli["Service"]["listMergeRequests"]>[0] | null = null; | 101 | let listInput: Parameters<GitLabCli.GitLabCli["Service"]["listMergeRequests"]>[0] | null = null; |
apps/server/src/sourceControl/GitLabSourceControlProvider.ts+105-27| ... | @@ -17,18 +17,6 @@ import { | ... | @@ -17,18 +17,6 @@ import { |
| 17 | } from "./SourceControlProviderDiscovery.ts"; | 17 | } from "./SourceControlProviderDiscovery.ts"; |
| 18 | import { findAuthenticatedGitLabHost, parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts"; | 18 | import { findAuthenticatedGitLabHost, parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts"; |
| 19 | 19 | ||
| 20 | function providerError( | ||
| 21 | operation: string, | ||
| 22 | cause: GitLabCli.GitLabCliError, | ||
| 23 | ): SourceControlProviderError { | ||
| 24 | return new SourceControlProviderError({ | ||
| 25 | provider: "gitlab", | ||
| 26 | operation, | ||
| 27 | detail: cause.detail, | ||
| 28 | cause, | ||
| 29 | }); | ||
| 30 | } | ||
| 31 | |||
| 32 | function toChangeRequest(summary: GitLabCli.GitLabMergeRequestSummary): ChangeRequest { | 20 | function toChangeRequest(summary: GitLabCli.GitLabMergeRequestSummary): ChangeRequest { |
| 33 | return { | 21 | return { |
| 34 | provider: "gitlab", | 22 | provider: "gitlab", |
| ... | @@ -129,13 +117,39 @@ export const make = Effect.gen(function* () { | ... | @@ -129,13 +117,39 @@ export const make = Effect.gen(function* () { |
| 129 | }) | 117 | }) |
| 130 | .pipe( | 118 | .pipe( |
| 131 | Effect.map((items) => items.map(toChangeRequest)), | 119 | Effect.map((items) => items.map(toChangeRequest)), |
| 132 | Effect.mapError((error) => providerError("listChangeRequests", error)), | 120 | Effect.mapError( |
| 121 | (error) => | ||
| 122 | new SourceControlProviderError({ | ||
| 123 | provider: "gitlab", | ||
| 124 | operation: "listChangeRequests", | ||
| 125 | command: error.command, | ||
| 126 | cwd: input.cwd, | ||
| 127 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | ||
| 128 | input.headSelector, | ||
| 129 | ), | ||
| 130 | detail: error.detail, | ||
| 131 | cause: error, | ||
| 132 | }), | ||
| 133 | ), | ||
| 133 | ); | 134 | ); |
| 134 | }, | 135 | }, |
| 135 | getChangeRequest: (input) => | 136 | getChangeRequest: (input) => |
| 136 | gitlab.getMergeRequest(input).pipe( | 137 | gitlab.getMergeRequest(input).pipe( |
| 137 | Effect.map(toChangeRequest), | 138 | Effect.map(toChangeRequest), |
| 138 | Effect.mapError((error) => providerError("getChangeRequest", error)), | 139 | Effect.mapError( |
| 140 | (error) => | ||
| 141 | new SourceControlProviderError({ | ||
| 142 | provider: "gitlab", | ||
| 143 | operation: "getChangeRequest", | ||
| 144 | command: error.command, | ||
| 145 | cwd: input.cwd, | ||
| 146 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | ||
| 147 | input.reference, | ||
| 148 | ), | ||
| 149 | detail: error.detail, | ||
| 150 | cause: error, | ||
| 151 | }), | ||
| 152 | ), | ||
| 139 | ), | 153 | ), |
| 140 | createChangeRequest: (input) => { | 154 | createChangeRequest: (input) => { |
| 141 | const source = SourceControlProvider.sourceControlRefFromInput(input); | 155 | const source = SourceControlProvider.sourceControlRefFromInput(input); |
| ... | @@ -149,24 +163,88 @@ export const make = Effect.gen(function* () { | ... | @@ -149,24 +163,88 @@ export const make = Effect.gen(function* () { |
| 149 | title: input.title, | 163 | title: input.title, |
| 150 | bodyFile: input.bodyFile, | 164 | bodyFile: input.bodyFile, |
| 151 | }) | 165 | }) |
| 152 | .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))); | 166 | .pipe( |
| 167 | Effect.mapError( | ||
| 168 | (error) => | ||
| 169 | new SourceControlProviderError({ | ||
| 170 | provider: "gitlab", | ||
| 171 | operation: "createChangeRequest", | ||
| 172 | command: error.command, | ||
| 173 | cwd: input.cwd, | ||
| 174 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | ||
| 175 | input.headSelector, | ||
| 176 | ), | ||
| 177 | detail: error.detail, | ||
| 178 | cause: error, | ||
| 179 | }), | ||
| 180 | ), | ||
| 181 | ); | ||
| 153 | }, | 182 | }, |
| 154 | getRepositoryCloneUrls: (input) => | 183 | getRepositoryCloneUrls: (input) => |
| 155 | gitlab | 184 | gitlab.getRepositoryCloneUrls(input).pipe( |
| 156 | .getRepositoryCloneUrls(input) | 185 | Effect.mapError( |
| 157 | .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), | 186 | (error) => |
| 187 | new SourceControlProviderError({ | ||
| 188 | provider: "gitlab", | ||
| 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 | ), | ||
| 158 | createRepository: (input) => | 200 | createRepository: (input) => |
| 159 | gitlab | 201 | gitlab.createRepository(input).pipe( |
| 160 | .createRepository(input) | 202 | Effect.mapError( |
| 161 | .pipe(Effect.mapError((error) => providerError("createRepository", error))), | 203 | (error) => |
| 204 | new SourceControlProviderError({ | ||
| 205 | provider: "gitlab", | ||
| 206 | operation: "createRepository", | ||
| 207 | command: error.command, | ||
| 208 | cwd: input.cwd, | ||
| 209 | repository: SourceControlProvider.transportSafeSourceControlErrorValue( | ||
| 210 | input.repository, | ||
| 211 | ), | ||
| 212 | detail: error.detail, | ||
| 213 | cause: error, | ||
| 214 | }), | ||
| 215 | ), | ||
| 216 | ), | ||
| 162 | getDefaultBranch: (input) => | 217 | getDefaultBranch: (input) => |
| 163 | gitlab | 218 | gitlab.getDefaultBranch(input).pipe( |
| 164 | .getDefaultBranch(input) | 219 | Effect.mapError( |
| 165 | .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), | 220 | (error) => |
| 221 | new SourceControlProviderError({ | ||
| 222 | provider: "gitlab", | ||
| 223 | operation: "getDefaultBranch", | ||
| 224 | command: error.command, | ||
| 225 | cwd: input.cwd, | ||
| 226 | detail: error.detail, | ||
| 227 | cause: error, | ||
| 228 | }), | ||
| 229 | ), | ||
| 230 | ), | ||
| 166 | checkoutChangeRequest: (input) => | 231 | checkoutChangeRequest: (input) => |
| 167 | gitlab | 232 | gitlab.checkoutMergeRequest(input).pipe( |
| 168 | .checkoutMergeRequest(input) | 233 | Effect.mapError( |
| 169 | .pipe(Effect.mapError((error) => providerError("checkoutChangeRequest", error))), | 234 | (error) => |
| 235 | new SourceControlProviderError({ | ||
| 236 | provider: "gitlab", | ||
| 237 | operation: "checkoutChangeRequest", | ||
| 238 | command: error.command, | ||
| 239 | cwd: input.cwd, | ||
| 240 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | ||
| 241 | input.reference, | ||
| 242 | ), | ||
| 243 | detail: error.detail, | ||
| 244 | cause: error, | ||
| 245 | }), | ||
| 246 | ), | ||
| 247 | ), | ||
| 170 | }); | 248 | }); |
| 171 | }); | 249 | }); |
| 172 | 250 |
apps/server/src/sourceControl/SourceControlProvider.test.ts created+19| ... | @@ -0,0 +1,19 @@ | ||
| 1 | import { assert, it } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { transportSafeSourceControlErrorValue } from "./SourceControlProvider.ts"; | ||
| 4 | |||
| 5 | it("removes URL credentials, query parameters, and fragments from error transport values", () => { | ||
| 6 | assert.strictEqual( | ||
| 7 | transportSafeSourceControlErrorValue( | ||
| 8 | "https://user:secret@example.test/org/repo/pull/42?token=secret#discussion", | ||
| 9 | ), | ||
| 10 | "https://example.test/org/repo/pull/42", | ||
| 11 | ); | ||
| 12 | }); | ||
| 13 | |||
| 14 | it("normalizes control characters and bounds error transport values", () => { | ||
| 15 | assert.strictEqual( | ||
| 16 | transportSafeSourceControlErrorValue(` owner/repo\n\t${"x".repeat(300)} `), | ||
| 17 | `owner/repo ${"x".repeat(245)}`, | ||
| 18 | ); | ||
| 19 | }); | ||
apps/server/src/sourceControl/SourceControlProvider.ts+30| ... | @@ -22,6 +22,36 @@ export interface SourceControlRefSelector { | ... | @@ -22,6 +22,36 @@ export interface SourceControlRefSelector { |
| 22 | readonly repository?: string; | 22 | readonly repository?: string; |
| 23 | } | 23 | } |
| 24 | 24 | ||
| 25 | const MAX_ERROR_TRANSPORT_VALUE_LENGTH = 256; | ||
| 26 | |||
| 27 | /** | ||
| 28 | * Sanitizes user-provided source-control identifiers before attaching them to | ||
| 29 | * contract errors. This is intentionally narrower than request validation: it | ||
| 30 | * only strips URL secrets and bounds diagnostic values sent over transport. | ||
| 31 | */ | ||
| 32 | export function transportSafeSourceControlErrorValue(value: string): string { | ||
| 33 | let printable = ""; | ||
| 34 | for (const character of value) { | ||
| 35 | const codePoint = character.codePointAt(0); | ||
| 36 | printable += codePoint !== undefined && (codePoint < 32 || codePoint === 127) ? " " : character; | ||
| 37 | } | ||
| 38 | const normalized = printable.trim().replace(/\s+/gu, " "); | ||
| 39 | |||
| 40 | let safe = normalized; | ||
| 41 | try { | ||
| 42 | const url = new URL(normalized); | ||
| 43 | url.username = ""; | ||
| 44 | url.password = ""; | ||
| 45 | url.search = ""; | ||
| 46 | url.hash = ""; | ||
| 47 | safe = url.toString(); | ||
| 48 | } catch { | ||
| 49 | // Plain repository and change-request identifiers are not URLs. | ||
| 50 | } | ||
| 51 | |||
| 52 | return safe.slice(0, MAX_ERROR_TRANSPORT_VALUE_LENGTH); | ||
| 53 | } | ||
| 54 | |||
| 25 | export function parseSourceControlOwnerRef( | 55 | export function parseSourceControlOwnerRef( |
| 26 | headSelector: string, | 56 | headSelector: string, |
| 27 | ): SourceControlRefSelector | undefined { | 57 | ): SourceControlRefSelector | undefined { |
apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts+57-13| ... | @@ -5,6 +5,7 @@ import * as Effect from "effect/Effect"; | ... | @@ -5,6 +5,7 @@ import * as Effect from "effect/Effect"; |
| 5 | import * as Layer from "effect/Layer"; | 5 | import * as Layer from "effect/Layer"; |
| 6 | import * as Option from "effect/Option"; | 6 | import * as Option from "effect/Option"; |
| 7 | import { ChildProcessSpawner } from "effect/unstable/process"; | 7 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 8 | import { VcsRepositoryDetectionError } from "@t3tools/contracts"; | ||
| 8 | 9 | ||
| 9 | import * as ServerConfig from "../config.ts"; | 10 | import * as ServerConfig from "../config.ts"; |
| 10 | import type * as VcsDriver from "../vcs/VcsDriver.ts"; | 11 | import type * as VcsDriver from "../vcs/VcsDriver.ts"; |
| ... | @@ -38,6 +39,7 @@ function makeRegistry(input: { | ... | @@ -38,6 +39,7 @@ function makeRegistry(input: { |
| 38 | readonly url: string; | 39 | readonly url: string; |
| 39 | }>; | 40 | }>; |
| 40 | readonly process?: Partial<VcsProcess.VcsProcess["Service"]>; | 41 | readonly process?: Partial<VcsProcess.VcsProcess["Service"]>; |
| 42 | readonly resolve?: VcsDriverRegistry.VcsDriverRegistry["Service"]["resolve"]; | ||
| 41 | }) { | 43 | }) { |
| 42 | const driver = { | 44 | const driver = { |
| 43 | listRemotes: () => | 45 | listRemotes: () => |
| ... | @@ -57,21 +59,23 @@ function makeRegistry(input: { | ... | @@ -57,21 +59,23 @@ function makeRegistry(input: { |
| 57 | 59 | ||
| 58 | const registryLayer = Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ | 60 | const registryLayer = Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ |
| 59 | get: () => Effect.succeed(driver as unknown as VcsDriver.VcsDriver["Service"]), | 61 | get: () => Effect.succeed(driver as unknown as VcsDriver.VcsDriver["Service"]), |
| 60 | resolve: () => | 62 | resolve: |
| 61 | Effect.succeed({ | 63 | input.resolve ?? |
| 62 | kind: "git", | 64 | (() => |
| 63 | repository: { | 65 | Effect.succeed({ |
| 64 | kind: "git", | 66 | kind: "git", |
| 65 | rootPath: "/repo", | 67 | repository: { |
| 66 | metadataPath: null, | 68 | kind: "git", |
| 67 | freshness: { | 69 | rootPath: "/repo", |
| 68 | source: "live-local" as const, | 70 | metadataPath: null, |
| 69 | observedAt: TEST_EPOCH, | 71 | freshness: { |
| 70 | expiresAt: Option.none(), | 72 | source: "live-local" as const, |
| 73 | observedAt: TEST_EPOCH, | ||
| 74 | expiresAt: Option.none(), | ||
| 75 | }, | ||
| 71 | }, | 76 | }, |
| 72 | }, | 77 | driver: driver as unknown as VcsDriver.VcsDriver["Service"], |
| 73 | driver: driver as unknown as VcsDriver.VcsDriver["Service"], | 78 | })), |
| 74 | }), | ||
| 75 | }); | 79 | }); |
| 76 | 80 | ||
| 77 | const processLayer = Layer.mock(VcsProcess.VcsProcess)({ | 81 | const processLayer = Layer.mock(VcsProcess.VcsProcess)({ |
| ... | @@ -120,6 +124,46 @@ it.effect("routes directly by provider kind for remote-first workflows", () => | ... | @@ -120,6 +124,46 @@ it.effect("routes directly by provider kind for remote-first workflows", () => |
| 120 | }), | 124 | }), |
| 121 | ); | 125 | ); |
| 122 | 126 | ||
| 127 | it.effect("includes the request cwd when an unregistered provider is used", () => | ||
| 128 | Effect.gen(function* () { | ||
| 129 | const registry = yield* makeRegistry({ remotes: [] }); | ||
| 130 | const provider = yield* registry.get("unknown"); | ||
| 131 | |||
| 132 | const error = yield* provider | ||
| 133 | .getChangeRequest({ cwd: "/repo", reference: "#42" }) | ||
| 134 | .pipe(Effect.flip); | ||
| 135 | |||
| 136 | assert.strictEqual(error.provider, "unknown"); | ||
| 137 | assert.strictEqual(error.operation, "getChangeRequest"); | ||
| 138 | assert.strictEqual(error.cwd, "/repo"); | ||
| 139 | assert.strictEqual(error.reference, "#42"); | ||
| 140 | }), | ||
| 141 | ); | ||
| 142 | |||
| 143 | it.effect("retains VCS detection failures with structured cwd context", () => | ||
| 144 | Effect.gen(function* () { | ||
| 145 | const cause = new VcsRepositoryDetectionError({ | ||
| 146 | operation: "resolve", | ||
| 147 | cwd: "/repo", | ||
| 148 | detail: "raw VCS detection failure", | ||
| 149 | cause: new Error("raw nested failure"), | ||
| 150 | }); | ||
| 151 | const registry = yield* makeRegistry({ | ||
| 152 | remotes: [], | ||
| 153 | resolve: () => Effect.fail(cause), | ||
| 154 | }); | ||
| 155 | |||
| 156 | const error = yield* registry.resolve({ cwd: "/repo" }).pipe(Effect.flip); | ||
| 157 | |||
| 158 | assert.strictEqual(error.provider, "unknown"); | ||
| 159 | assert.strictEqual(error.operation, "detectProvider"); | ||
| 160 | assert.strictEqual(error.cwd, "/repo"); | ||
| 161 | assert.strictEqual(error.detail, "Failed to detect source control provider."); | ||
| 162 | assert.strictEqual(error.cause, cause); | ||
| 163 | assert.equal(error.message.includes(cause.message), false); | ||
| 164 | }), | ||
| 165 | ); | ||
| 166 | |||
| 123 | it.effect("routes GitLab remotes to the GitLab provider", () => | 167 | it.effect("routes GitLab remotes to the GitLab provider", () => |
| 124 | Effect.gen(function* () { | 168 | Effect.gen(function* () { |
| 125 | const registry = yield* makeRegistry({ | 169 | const registry = yield* makeRegistry({ |
apps/server/src/sourceControl/SourceControlProviderRegistry.ts+76-29| ... | @@ -64,33 +64,62 @@ export class SourceControlProviderRegistry extends Context.Service< | ... | @@ -64,33 +64,62 @@ export class SourceControlProviderRegistry extends Context.Service< |
| 64 | function unsupportedProvider( | 64 | function unsupportedProvider( |
| 65 | kind: SourceControlProviderKind, | 65 | kind: SourceControlProviderKind, |
| 66 | ): SourceControlProvider.SourceControlProvider["Service"] { | 66 | ): SourceControlProvider.SourceControlProvider["Service"] { |
| 67 | const unsupported = (operation: string) => | 67 | return SourceControlProvider.SourceControlProvider.of({ |
| 68 | Effect.fail( | 68 | kind, |
| 69 | listChangeRequests: (input) => | ||
| 69 | new SourceControlProviderError({ | 70 | new SourceControlProviderError({ |
| 70 | provider: kind, | 71 | provider: kind, |
| 71 | operation, | 72 | operation: "listChangeRequests", |
| 73 | cwd: input.cwd, | ||
| 74 | detail: `No ${kind} source control provider is registered.`, | ||
| 75 | }), | ||
| 76 | getChangeRequest: (input) => | ||
| 77 | new SourceControlProviderError({ | ||
| 78 | provider: kind, | ||
| 79 | operation: "getChangeRequest", | ||
| 80 | cwd: input.cwd, | ||
| 81 | reference: SourceControlProvider.transportSafeSourceControlErrorValue(input.reference), | ||
| 82 | detail: `No ${kind} source control provider is registered.`, | ||
| 83 | }), | ||
| 84 | createChangeRequest: (input) => | ||
| 85 | new SourceControlProviderError({ | ||
| 86 | provider: kind, | ||
| 87 | operation: "createChangeRequest", | ||
| 88 | cwd: input.cwd, | ||
| 89 | reference: SourceControlProvider.transportSafeSourceControlErrorValue(input.headSelector), | ||
| 90 | detail: `No ${kind} source control provider is registered.`, | ||
| 91 | }), | ||
| 92 | getRepositoryCloneUrls: (input) => | ||
| 93 | new SourceControlProviderError({ | ||
| 94 | provider: kind, | ||
| 95 | operation: "getRepositoryCloneUrls", | ||
| 96 | cwd: input.cwd, | ||
| 97 | repository: SourceControlProvider.transportSafeSourceControlErrorValue(input.repository), | ||
| 98 | detail: `No ${kind} source control provider is registered.`, | ||
| 99 | }), | ||
| 100 | createRepository: (input) => | ||
| 101 | new SourceControlProviderError({ | ||
| 102 | provider: kind, | ||
| 103 | operation: "createRepository", | ||
| 104 | cwd: input.cwd, | ||
| 105 | repository: SourceControlProvider.transportSafeSourceControlErrorValue(input.repository), | ||
| 106 | detail: `No ${kind} source control provider is registered.`, | ||
| 107 | }), | ||
| 108 | getDefaultBranch: (input) => | ||
| 109 | new SourceControlProviderError({ | ||
| 110 | provider: kind, | ||
| 111 | operation: "getDefaultBranch", | ||
| 112 | cwd: input.cwd, | ||
| 113 | detail: `No ${kind} source control provider is registered.`, | ||
| 114 | }), | ||
| 115 | checkoutChangeRequest: (input) => | ||
| 116 | new SourceControlProviderError({ | ||
| 117 | provider: kind, | ||
| 118 | operation: "checkoutChangeRequest", | ||
| 119 | cwd: input.cwd, | ||
| 120 | reference: SourceControlProvider.transportSafeSourceControlErrorValue(input.reference), | ||
| 72 | detail: `No ${kind} source control provider is registered.`, | 121 | detail: `No ${kind} source control provider is registered.`, |
| 73 | }), | 122 | }), |
| 74 | ); | ||
| 75 | |||
| 76 | return SourceControlProvider.SourceControlProvider.of({ | ||
| 77 | kind, | ||
| 78 | listChangeRequests: () => unsupported("listChangeRequests"), | ||
| 79 | getChangeRequest: () => unsupported("getChangeRequest"), | ||
| 80 | createChangeRequest: () => unsupported("createChangeRequest"), | ||
| 81 | getRepositoryCloneUrls: () => unsupported("getRepositoryCloneUrls"), | ||
| 82 | createRepository: () => unsupported("createRepository"), | ||
| 83 | getDefaultBranch: () => unsupported("getDefaultBranch"), | ||
| 84 | checkoutChangeRequest: () => unsupported("checkoutChangeRequest"), | ||
| 85 | }); | ||
| 86 | } | ||
| 87 | |||
| 88 | function providerDetectionError(operation: string, cwd: string, cause: unknown) { | ||
| 89 | return new SourceControlProviderError({ | ||
| 90 | provider: "unknown", | ||
| 91 | operation, | ||
| 92 | detail: `Failed to detect source control provider for ${cwd}.`, | ||
| 93 | cause, | ||
| 94 | }); | 123 | }); |
| 95 | } | 124 | } |
| 96 | 125 | ||
| ... | @@ -180,12 +209,30 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit | ... | @@ -180,12 +209,30 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit |
| 180 | 209 | ||
| 181 | const detectProviderContext = Effect.fn("SourceControlProviderRegistry.detectProviderContext")( | 210 | const detectProviderContext = Effect.fn("SourceControlProviderRegistry.detectProviderContext")( |
| 182 | function* (cwd: string) { | 211 | function* (cwd: string) { |
| 183 | const handle = yield* vcsRegistry | 212 | const handle = yield* vcsRegistry.resolve({ cwd }).pipe( |
| 184 | .resolve({ cwd }) | 213 | Effect.mapError( |
| 185 | .pipe(Effect.mapError((error) => providerDetectionError("detectProvider", cwd, error))); | 214 | (error) => |
| 186 | const remotes = yield* handle.driver | 215 | new SourceControlProviderError({ |
| 187 | .listRemotes(cwd) | 216 | provider: "unknown", |
| 188 | .pipe(Effect.mapError((error) => providerDetectionError("detectProvider", cwd, error))); | 217 | operation: "detectProvider", |
| 218 | cwd, | ||
| 219 | detail: "Failed to detect source control provider.", | ||
| 220 | cause: error, | ||
| 221 | }), | ||
| 222 | ), | ||
| 223 | ); | ||
| 224 | const remotes = yield* handle.driver.listRemotes(cwd).pipe( | ||
| 225 | Effect.mapError( | ||
| 226 | (error) => | ||
| 227 | new SourceControlProviderError({ | ||
| 228 | provider: "unknown", | ||
| 229 | operation: "detectProvider", | ||
| 230 | cwd, | ||
| 231 | detail: "Failed to detect source control provider.", | ||
| 232 | cause: error, | ||
| 233 | }), | ||
| 234 | ), | ||
| 235 | ); | ||
| 189 | const context = selectProviderContext(remotes.remotes); | 236 | const context = selectProviderContext(remotes.remotes); |
| 190 | 237 | ||
| 191 | return yield* refineUnknownRemoteProvider({ | 238 | return yield* refineUnknownRemoteProvider({ |
packages/contracts/src/sourceControl.ts+4| ... | @@ -155,6 +155,10 @@ export class SourceControlProviderError extends Schema.TaggedErrorClass<SourceCo | ... | @@ -155,6 +155,10 @@ export class SourceControlProviderError extends Schema.TaggedErrorClass<SourceCo |
| 155 | { | 155 | { |
| 156 | provider: SourceControlProviderKind, | 156 | provider: SourceControlProviderKind, |
| 157 | operation: Schema.String, | 157 | operation: Schema.String, |
| 158 | cwd: Schema.String, | ||
| 159 | command: Schema.optional(Schema.String), | ||
| 160 | repository: Schema.optional(Schema.String), | ||
| 161 | reference: Schema.optional(Schema.String), | ||
| 158 | detail: Schema.String, | 162 | detail: Schema.String, |
| 159 | cause: Schema.optional(Schema.Defect()), | 163 | cause: Schema.optional(Schema.Defect()), |
| 160 | }, | 164 | }, |