| 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 | 173 | } |
| 174 | 174 | throw new GitHubCli.GitHubCliError({ |
| 175 | 175 | operation: "execute", |
| 176 | command: "gh", | |
| 177 | cwd, | |
| 176 | 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 | 482 | ? error |
| 481 | 483 | : new GitHubCli.GitHubCliError({ |
| 482 | 484 | operation: "execute", |
| 485 | command: "gh", | |
| 486 | cwd: input.cwd, | |
| 483 | 487 | detail: |
| 484 | 488 | error instanceof Error |
| 485 | 489 | ? `Failed to simulate gh checkout: ${error.message}` |
| ... | ... | @@ -496,6 +500,8 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { |
| 496 | 500 | return Effect.fail( |
| 497 | 501 | new GitHubCli.GitHubCliError({ |
| 498 | 502 | operation: "execute", |
| 503 | command: "gh", | |
| 504 | cwd: input.cwd, | |
| 499 | 505 | detail: `Unexpected repository lookup: ${repository}`, |
| 500 | 506 | }), |
| 501 | 507 | ); |
| ... | ... | @@ -516,6 +522,8 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { |
| 516 | 522 | return Effect.fail( |
| 517 | 523 | new GitHubCli.GitHubCliError({ |
| 518 | 524 | operation: "execute", |
| 525 | command: "gh", | |
| 526 | cwd: input.cwd, | |
| 519 | 527 | detail: `Unexpected gh command: ${args.join(" ")}`, |
| 520 | 528 | }), |
| 521 | 529 | ); |
| ... | ... | @@ -595,6 +603,8 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { |
| 595 | 603 | Effect.fail( |
| 596 | 604 | new GitHubCli.GitHubCliError({ |
| 597 | 605 | operation: "createRepository", |
| 606 | command: "gh", | |
| 607 | cwd: input.cwd, | |
| 598 | 608 | detail: `Unexpected repository create: ${input.repository}`, |
| 599 | 609 | }), |
| 600 | 610 | ), |
| ... | ... | @@ -1333,6 +1343,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 1333 | 1343 | ghScenario: { |
| 1334 | 1344 | failWith: new GitHubCli.GitHubCliError({ |
| 1335 | 1345 | operation: "execute", |
| 1346 | command: "gh", | |
| 1347 | cwd: repoDir, | |
| 1336 | 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 | 2483 | ghScenario: { |
| 2472 | 2484 | failWith: new GitHubCli.GitHubCliError({ |
| 2473 | 2485 | operation: "execute", |
| 2486 | command: "gh", | |
| 2487 | cwd: repoDir, | |
| 2474 | 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 | 2514 | ghScenario: { |
| 2501 | 2515 | failWith: new GitHubCli.GitHubCliError({ |
| 2502 | 2516 | operation: "execute", |
| 2517 | command: "gh", | |
| 2518 | cwd: repoDir, | |
| 2503 | 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 | 5 | import * as Layer from "effect/Layer"; |
| 6 | 6 | import * as Option from "effect/Option"; |
| 7 | 7 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 8 | import { VcsProcessExitError } from "@t3tools/contracts"; | |
| 8 | 9 | |
| 9 | 10 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 10 | 11 | import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; |
| ... | ... | @@ -329,4 +330,26 @@ describe("AzureDevOpsCli.layer", () => { |
| 329 | 330 | }); |
| 330 | 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 | 3 | import * as Layer from "effect/Layer"; |
| 4 | 4 | import * as Result from "effect/Result"; |
| 5 | 5 | import * as Schema from "effect/Schema"; |
| 6 | import * as SchemaIssue from "effect/SchemaIssue"; | |
| 7 | 6 | import { |
| 8 | 7 | TrimmedNonEmptyString, |
| 9 | 8 | type SourceControlRepositoryVisibility, |
| ... | ... | @@ -14,7 +13,6 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 14 | 13 | import { |
| 15 | 14 | decodeAzureDevOpsPullRequestJson, |
| 16 | 15 | decodeAzureDevOpsPullRequestListJson, |
| 17 | formatAzureDevOpsJsonDecodeError, | |
| 18 | 16 | type NormalizedAzureDevOpsPullRequestRecord, |
| 19 | 17 | } from "./azureDevOpsPullRequests.ts"; |
| 20 | 18 | import * as SourceControlProvider from "./SourceControlProvider.ts"; |
| ... | ... | @@ -25,6 +23,8 @@ export class AzureDevOpsCliError extends Schema.TaggedErrorClass<AzureDevOpsCliE |
| 25 | 23 | "AzureDevOpsCliError", |
| 26 | 24 | { |
| 27 | 25 | operation: Schema.String, |
| 26 | command: Schema.String, | |
| 27 | cwd: Schema.String, | |
| 28 | 28 | detail: Schema.String, |
| 29 | 29 | cause: Schema.optional(Schema.Defect()), |
| 30 | 30 | }, |
| ... | ... | @@ -32,6 +32,57 @@ export class AzureDevOpsCliError extends Schema.TaggedErrorClass<AzureDevOpsCliE |
| 32 | 32 | override get message(): string { |
| 33 | 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 | 88 | export interface AzureDevOpsRepositoryCloneUrls { |
| ... | ... | @@ -106,54 +157,6 @@ function errorText(error: VcsError | unknown): string { |
| 106 | 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 | 160 | function normalizeChangeRequestId(reference: string): string { |
| 158 | 161 | const trimmed = reference.trim().replace(/^#/, ""); |
| 159 | 162 | const urlMatch = /(?:pullrequest|pull-request|pull|_pulls?)\/(\d+)(?:\D.*)?$/i.exec(trimmed); |
| ... | ... | @@ -224,13 +227,16 @@ function decodeAzureDevOpsJson<S extends Schema.Top>( |
| 224 | 227 | schema: S, |
| 225 | 228 | operation: "getRepositoryCloneUrls" | "getDefaultBranch" | "createRepository", |
| 226 | 229 | invalidDetail: string, |
| 230 | cwd: string, | |
| 227 | 231 | ): Effect.Effect<S["Type"], AzureDevOpsCliError, S["DecodingServices"]> { |
| 228 | 232 | return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( |
| 229 | 233 | Effect.mapError( |
| 230 | 234 | (error) => |
| 231 | 235 | new AzureDevOpsCliError({ |
| 232 | 236 | operation, |
| 233 | detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`, | |
| 237 | command: "az", | |
| 238 | cwd, | |
| 239 | detail: invalidDetail, | |
| 234 | 240 | cause: error, |
| 235 | 241 | }), |
| 236 | 242 | ), |
| ... | ... | @@ -249,7 +255,14 @@ export const make = Effect.gen(function* () { |
| 249 | 255 | cwd: input.cwd, |
| 250 | 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 | 267 | const executeJson = (input: Parameters<AzureDevOpsCli["Service"]["execute"]>[0]) => |
| 255 | 268 | execute({ |
| ... | ... | @@ -286,7 +299,9 @@ export const make = Effect.gen(function* () { |
| 286 | 299 | return Effect.fail( |
| 287 | 300 | new AzureDevOpsCliError({ |
| 288 | 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 | 305 | cause: decoded.failure, |
| 291 | 306 | }), |
| 292 | 307 | ); |
| ... | ... | @@ -318,7 +333,9 @@ export const make = Effect.gen(function* () { |
| 318 | 333 | return Effect.fail( |
| 319 | 334 | new AzureDevOpsCliError({ |
| 320 | 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 | 339 | cause: decoded.failure, |
| 323 | 340 | }), |
| 324 | 341 | ); |
| ... | ... | @@ -341,6 +358,7 @@ export const make = Effect.gen(function* () { |
| 341 | 358 | RawAzureDevOpsRepositorySchema, |
| 342 | 359 | "getRepositoryCloneUrls", |
| 343 | 360 | "Azure DevOps CLI returned invalid repository JSON.", |
| 361 | input.cwd, | |
| 344 | 362 | ), |
| 345 | 363 | ), |
| 346 | 364 | Effect.map(normalizeRepositoryCloneUrls), |
| ... | ... | @@ -370,6 +388,7 @@ export const make = Effect.gen(function* () { |
| 370 | 388 | RawAzureDevOpsRepositorySchema, |
| 371 | 389 | "createRepository", |
| 372 | 390 | "Azure DevOps CLI returned invalid repository JSON.", |
| 391 | input.cwd, | |
| 373 | 392 | ), |
| 374 | 393 | ), |
| 375 | 394 | Effect.map(normalizeRepositoryCloneUrls), |
| ... | ... | @@ -407,6 +426,7 @@ export const make = Effect.gen(function* () { |
| 407 | 426 | RawAzureDevOpsRepositorySchema, |
| 408 | 427 | "getDefaultBranch", |
| 409 | 428 | "Azure DevOps CLI returned invalid repository JSON.", |
| 429 | input.cwd, | |
| 410 | 430 | ), |
| 411 | 431 | ), |
| 412 | 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 | 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 | 89 | it.effect("creates Azure DevOps PRs through provider-neutral input names", () => |
| 50 | 90 | Effect.gen(function* () { |
| 51 | 91 | let createInput: |
apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts+105-25| ... | ... | @@ -12,18 +12,6 @@ import { |
| 12 | 12 | type SourceControlCliDiscoverySpec, |
| 13 | 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 | 15 | function parseAzureAuth(input: SourceControlAuthProbeInput) { |
| 28 | 16 | const account = input.stdout.trim().split(/\r?\n/)[0]?.trim(); |
| 29 | 17 | |
| ... | ... | @@ -101,13 +89,39 @@ export const make = Effect.gen(function* () { |
| 101 | 89 | }) |
| 102 | 90 | .pipe( |
| 103 | 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 | 108 | getChangeRequest: (input) => |
| 108 | 109 | azure.getPullRequest(input).pipe( |
| 109 | 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 | 126 | createChangeRequest: (input) => { |
| 113 | 127 | const source = SourceControlProvider.sourceControlRefFromInput(input); |
| ... | ... | @@ -121,20 +135,71 @@ export const make = Effect.gen(function* () { |
| 121 | 135 | title: input.title, |
| 122 | 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 | 155 | getRepositoryCloneUrls: (input) => |
| 127 | azure | |
| 128 | .getRepositoryCloneUrls(input) | |
| 129 | .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), | |
| 156 | azure.getRepositoryCloneUrls(input).pipe( | |
| 157 | Effect.mapError( | |
| 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 | 172 | createRepository: (input) => |
| 131 | azure | |
| 132 | .createRepository(input) | |
| 133 | .pipe(Effect.mapError((error) => providerError("createRepository", error))), | |
| 173 | azure.createRepository(input).pipe( | |
| 174 | Effect.mapError( | |
| 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 | 189 | getDefaultBranch: (input) => |
| 135 | azure | |
| 136 | .getDefaultBranch({ cwd: input.cwd }) | |
| 137 | .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), | |
| 190 | azure.getDefaultBranch({ cwd: input.cwd }).pipe( | |
| 191 | Effect.mapError( | |
| 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 | 203 | checkoutChangeRequest: (input) => |
| 139 | 204 | azure |
| 140 | 205 | .checkoutPullRequest({ |
| ... | ... | @@ -142,7 +207,22 @@ export const make = Effect.gen(function* () { |
| 142 | 207 | reference: input.reference, |
| 143 | 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 | 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 | 93 | it.effect("lists Bitbucket PRs through provider-neutral input names", () => |
| 55 | 94 | Effect.gen(function* () { |
| 56 | 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 | 8 | import * as SourceControlProvider from "./SourceControlProvider.ts"; |
| 9 | 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 | 11 | function toChangeRequest(summary: NormalizedBitbucketPullRequestRecord): ChangeRequest { |
| 24 | 12 | return { |
| 25 | 13 | provider: "bitbucket", |
| ... | ... | @@ -60,13 +48,37 @@ export const make = Effect.gen(function* () { |
| 60 | 48 | }) |
| 61 | 49 | .pipe( |
| 62 | 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 | 66 | getChangeRequest: (input) => |
| 67 | 67 | bitbucket.getPullRequest(input).pipe( |
| 68 | 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 | 83 | createChangeRequest: (input) => { |
| 72 | 84 | const source = SourceControlProvider.sourceControlRefFromInput(input); |
| ... | ... | @@ -81,23 +93,72 @@ export const make = Effect.gen(function* () { |
| 81 | 93 | title: input.title, |
| 82 | 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 | 112 | getRepositoryCloneUrls: (input) => |
| 87 | bitbucket | |
| 88 | .getRepositoryCloneUrls(input) | |
| 89 | .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), | |
| 113 | bitbucket.getRepositoryCloneUrls(input).pipe( | |
| 114 | Effect.mapError( | |
| 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 | 128 | createRepository: (input) => |
| 91 | bitbucket | |
| 92 | .createRepository(input) | |
| 93 | .pipe(Effect.mapError((error) => providerError("createRepository", error))), | |
| 129 | bitbucket.createRepository(input).pipe( | |
| 130 | Effect.mapError( | |
| 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 | 144 | getDefaultBranch: (input) => |
| 95 | 145 | bitbucket |
| 96 | 146 | .getDefaultBranch({ |
| 97 | 147 | cwd: input.cwd, |
| 98 | 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 | 162 | checkoutChangeRequest: (input) => |
| 102 | 163 | bitbucket |
| 103 | 164 | .checkoutPullRequest({ |
| ... | ... | @@ -106,7 +167,21 @@ export const make = Effect.gen(function* () { |
| 106 | 167 | reference: input.reference, |
| 107 | 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 | 269 | |
| 270 | 270 | it.effect("surfaces a friendly error when the pull request is not found", () => |
| 271 | 271 | Effect.gen(function* () { |
| 272 | mockRun.mockReturnValueOnce( | |
| 273 | Effect.fail( | |
| 274 | new VcsProcessExitError({ | |
| 275 | operation: "GitHubCli.execute", | |
| 276 | command: "gh pr view", | |
| 277 | cwd: "/repo", | |
| 278 | exitCode: 1, | |
| 279 | detail: | |
| 280 | "GraphQL: Could not resolve to a PullRequest with the number of 4888. (repository.pullRequest)", | |
| 281 | }), | |
| 282 | ), | |
| 283 | ); | |
| 272 | const cause = new VcsProcessExitError({ | |
| 273 | operation: "GitHubCli.execute", | |
| 274 | command: "gh pr view", | |
| 275 | cwd: "/repo", | |
| 276 | exitCode: 1, | |
| 277 | detail: | |
| 278 | "GraphQL: Could not resolve to a PullRequest with the number of 4888. (repository.pullRequest)", | |
| 279 | }); | |
| 280 | mockRun.mockReturnValueOnce(Effect.fail(cause)); | |
| 284 | 281 | |
| 285 | 282 | const gh = yield* GitHubCli.GitHubCli; |
| 286 | 283 | const error = yield* gh |
| ... | ... | @@ -291,6 +288,10 @@ describe("GitHubCli.layer", () => { |
| 291 | 288 | .pipe(Effect.flip); |
| 292 | 289 | |
| 293 | 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 | 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 | 3 | import * as Layer from "effect/Layer"; |
| 4 | 4 | import * as Result from "effect/Result"; |
| 5 | 5 | import * as Schema from "effect/Schema"; |
| 6 | import * as SchemaIssue from "effect/SchemaIssue"; | |
| 7 | 6 | |
| 8 | 7 | import { |
| 9 | 8 | TrimmedNonEmptyString, |
| ... | ... | @@ -15,19 +14,71 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 15 | 14 | import { |
| 16 | 15 | decodeGitHubPullRequestJson, |
| 17 | 16 | decodeGitHubPullRequestListJson, |
| 18 | formatGitHubJsonDecodeError, | |
| 19 | 17 | } from "./gitHubPullRequests.ts"; |
| 20 | 18 | |
| 21 | 19 | const DEFAULT_TIMEOUT_MS = 30_000; |
| 22 | 20 | |
| 23 | 21 | export class GitHubCliError extends Schema.TaggedErrorClass<GitHubCliError>()("GitHubCliError", { |
| 24 | 22 | operation: Schema.String, |
| 23 | command: Schema.String, | |
| 24 | cwd: Schema.String, | |
| 25 | 25 | detail: Schema.String, |
| 26 | 26 | cause: Schema.optional(Schema.Defect()), |
| 27 | 27 | }) { |
| 28 | 28 | override get message(): string { |
| 29 | 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 | 84 | export interface GitHubPullRequestSummary { |
| ... | ... | @@ -110,54 +161,6 @@ function errorText(error: VcsError | unknown): string { |
| 110 | 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 | 164 | const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({ |
| 162 | 165 | nameWithOwner: TrimmedNonEmptyString, |
| 163 | 166 | url: TrimmedNonEmptyString, |
| ... | ... | @@ -216,13 +219,16 @@ function decodeGitHubJson<S extends Schema.Top>( |
| 216 | 219 | schema: S, |
| 217 | 220 | operation: "listOpenPullRequests" | "getPullRequest" | "getRepositoryCloneUrls", |
| 218 | 221 | invalidDetail: string, |
| 222 | cwd: string, | |
| 219 | 223 | ): Effect.Effect<S["Type"], GitHubCliError, S["DecodingServices"]> { |
| 220 | 224 | return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( |
| 221 | 225 | Effect.mapError( |
| 222 | 226 | (error) => |
| 223 | 227 | new GitHubCliError({ |
| 224 | 228 | operation, |
| 225 | detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`, | |
| 229 | command: "gh", | |
| 230 | cwd, | |
| 231 | detail: invalidDetail, | |
| 226 | 232 | cause: error, |
| 227 | 233 | }), |
| 228 | 234 | ), |
| ... | ... | @@ -241,7 +247,14 @@ export const make = Effect.gen(function* () { |
| 241 | 247 | cwd: input.cwd, |
| 242 | 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 | 259 | return GitHubCli.of({ |
| 247 | 260 | execute, |
| ... | ... | @@ -271,7 +284,9 @@ export const make = Effect.gen(function* () { |
| 271 | 284 | return Effect.fail( |
| 272 | 285 | new GitHubCliError({ |
| 273 | 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 | 290 | cause: decoded.failure, |
| 276 | 291 | }), |
| 277 | 292 | ); |
| ... | ... | @@ -303,7 +318,9 @@ export const make = Effect.gen(function* () { |
| 303 | 318 | return Effect.fail( |
| 304 | 319 | new GitHubCliError({ |
| 305 | 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 | 324 | cause: decoded.failure, |
| 308 | 325 | }), |
| 309 | 326 | ); |
| ... | ... | @@ -328,6 +345,7 @@ export const make = Effect.gen(function* () { |
| 328 | 345 | RawGitHubRepositoryCloneUrlsSchema, |
| 329 | 346 | "getRepositoryCloneUrls", |
| 330 | 347 | "GitHub CLI returned invalid repository JSON.", |
| 348 | input.cwd, | |
| 331 | 349 | ), |
| 332 | 350 | ), |
| 333 | 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 | 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 | 114 | it.effect("uses gh json listing for non-open change request state queries", () => |
| 72 | 115 | Effect.gen(function* () { |
| 73 | 116 | let executeArgs: ReadonlyArray<string> = []; |
apps/server/src/sourceControl/GitHubSourceControlProvider.ts+124-34| ... | ... | @@ -2,7 +2,6 @@ import * as Effect from "effect/Effect"; |
| 2 | 2 | import * as Layer from "effect/Layer"; |
| 3 | 3 | import * as Option from "effect/Option"; |
| 4 | 4 | import * as Result from "effect/Result"; |
| 5 | import * as Schema from "effect/Schema"; | |
| 6 | 5 | import { |
| 7 | 6 | SourceControlProviderError, |
| 8 | 7 | type ChangeRequest, |
| ... | ... | @@ -20,19 +19,6 @@ import { |
| 20 | 19 | type SourceControlAuthProbeInput, |
| 21 | 20 | type SourceControlCliDiscoverySpec, |
| 22 | 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 | 23 | function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeRequest { |
| 38 | 24 | return { |
| ... | ... | @@ -122,7 +108,20 @@ export const make = Effect.gen(function* () { |
| 122 | 108 | }) |
| 123 | 109 | .pipe( |
| 124 | 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 | 161 | new SourceControlProviderError({ |
| 163 | 162 | provider: "github", |
| 164 | 163 | operation: "listChangeRequests", |
| 164 | command: "gh", | |
| 165 | cwd: input.cwd, | |
| 166 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | |
| 167 | input.headSelector, | |
| 168 | ), | |
| 165 | 169 | detail: "GitHub CLI returned invalid change request JSON.", |
| 166 | 170 | cause: decoded.failure, |
| 167 | 171 | }), |
| ... | ... | @@ -169,11 +173,20 @@ export const make = Effect.gen(function* () { |
| 169 | 173 | ), |
| 170 | 174 | ); |
| 171 | 175 | }), |
| 172 | Effect.mapError((error) => | |
| 173 | isSourceControlProviderError(error) | |
| 174 | ? error | |
| 175 | : providerError("listChangeRequests", error), | |
| 176 | ), | |
| 176 | Effect.catchTags({ | |
| 177 | GitHubCliError: (error) => | |
| 178 | new SourceControlProviderError({ | |
| 179 | provider: "github", | |
| 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 | 196 | getChangeRequest: (input) => |
| 184 | 197 | github.getPullRequest(input).pipe( |
| 185 | 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 | 214 | createChangeRequest: (input) => |
| 189 | 215 | github |
| ... | ... | @@ -194,23 +220,87 @@ export const make = Effect.gen(function* () { |
| 194 | 220 | title: input.title, |
| 195 | 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 | 239 | getRepositoryCloneUrls: (input) => |
| 199 | github | |
| 200 | .getRepositoryCloneUrls(input) | |
| 201 | .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), | |
| 240 | github.getRepositoryCloneUrls(input).pipe( | |
| 241 | Effect.mapError( | |
| 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 | 256 | createRepository: (input) => |
| 203 | github | |
| 204 | .createRepository(input) | |
| 205 | .pipe(Effect.mapError((error) => providerError("createRepository", error))), | |
| 257 | github.createRepository(input).pipe( | |
| 258 | Effect.mapError( | |
| 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 | 273 | getDefaultBranch: (input) => |
| 207 | github | |
| 208 | .getDefaultBranch(input) | |
| 209 | .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), | |
| 274 | github.getDefaultBranch(input).pipe( | |
| 275 | Effect.mapError( | |
| 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 | 287 | checkoutChangeRequest: (input) => |
| 211 | github | |
| 212 | .checkoutPullRequest(input) | |
| 213 | .pipe(Effect.mapError((error) => providerError("checkoutChangeRequest", error))), | |
| 288 | github.checkoutPullRequest(input).pipe( | |
| 289 | Effect.mapError( | |
| 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 | 313 | |
| 314 | 314 | it.effect("surfaces a friendly error when the merge request is not found", () => |
| 315 | 315 | Effect.gen(function* () { |
| 316 | mockedRun.mockReturnValueOnce( | |
| 317 | Effect.fail( | |
| 318 | new VcsProcessExitError({ | |
| 319 | operation: "GitLabCli.execute", | |
| 320 | command: "glab mr view 4888", | |
| 321 | cwd: "/repo", | |
| 322 | exitCode: 1, | |
| 323 | detail: "GET 404 merge request not found", | |
| 324 | }), | |
| 325 | ), | |
| 326 | ); | |
| 316 | const cause = new VcsProcessExitError({ | |
| 317 | operation: "GitLabCli.execute", | |
| 318 | command: "glab mr view 4888", | |
| 319 | cwd: "/repo", | |
| 320 | exitCode: 1, | |
| 321 | detail: "GET 404 merge request not found", | |
| 322 | }); | |
| 323 | mockedRun.mockReturnValueOnce(Effect.fail(cause)); | |
| 327 | 324 | |
| 328 | 325 | const error = yield* Effect.gen(function* () { |
| 329 | 326 | const glab = yield* GitLabCli.GitLabCli; |
| ... | ... | @@ -334,6 +331,10 @@ layer("GitLabCli.layer", (it) => { |
| 334 | 331 | }).pipe(Effect.flip); |
| 335 | 332 | |
| 336 | 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 | 4 | import * as Option from "effect/Option"; |
| 5 | 5 | import * as Result from "effect/Result"; |
| 6 | 6 | import * as Schema from "effect/Schema"; |
| 7 | import * as SchemaIssue from "effect/SchemaIssue"; | |
| 8 | 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 | 15 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 13 | 16 | import { |
| 14 | 17 | decodeGitLabMergeRequestJson, |
| 15 | 18 | decodeGitLabMergeRequestListJson, |
| 16 | formatGitLabJsonDecodeError, | |
| 17 | 19 | } from "./gitLabMergeRequests.ts"; |
| 18 | 20 | import type * as SourceControlProvider from "./SourceControlProvider.ts"; |
| 19 | 21 | |
| ... | ... | @@ -21,12 +23,64 @@ const DEFAULT_TIMEOUT_MS = 30_000; |
| 21 | 23 | |
| 22 | 24 | export class GitLabCliError extends Schema.TaggedErrorClass<GitLabCliError>()("GitLabCliError", { |
| 23 | 25 | operation: Schema.String, |
| 26 | command: Schema.String, | |
| 27 | cwd: Schema.String, | |
| 24 | 28 | detail: Schema.String, |
| 25 | 29 | cause: Schema.optional(Schema.Defect()), |
| 26 | 30 | }) { |
| 27 | 31 | override get message(): string { |
| 28 | 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 | 86 | export interface GitLabMergeRequestSummary { |
| ... | ... | @@ -48,6 +102,17 @@ export interface GitLabRepositoryCloneUrls { |
| 48 | 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 | 116 | export class GitLabCli extends Context.Service< |
| 52 | 117 | GitLabCli, |
| 53 | 118 | { |
| ... | ... | @@ -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 | 180 | const RawGitLabRepositoryCloneUrlsSchema = Schema.Struct({ |
| 166 | 181 | path_with_namespace: TrimmedNonEmptyString, |
| 167 | 182 | web_url: TrimmedNonEmptyString, |
| ... | ... | @@ -192,13 +207,16 @@ function decodeGitLabJson<S extends Schema.Top>( |
| 192 | 207 | schema: S, |
| 193 | 208 | operation: "getRepositoryCloneUrls" | "getDefaultBranch" | "createRepository", |
| 194 | 209 | invalidDetail: string, |
| 210 | cwd: string, | |
| 195 | 211 | ): Effect.Effect<S["Type"], GitLabCliError, S["DecodingServices"]> { |
| 196 | 212 | return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( |
| 197 | 213 | Effect.mapError( |
| 198 | 214 | (error) => |
| 199 | 215 | new GitLabCliError({ |
| 200 | 216 | operation, |
| 201 | detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`, | |
| 217 | command: "glab", | |
| 218 | cwd, | |
| 219 | detail: invalidDetail, | |
| 202 | 220 | cause: error, |
| 203 | 221 | }), |
| 204 | 222 | ), |
| ... | ... | @@ -274,7 +292,14 @@ export const make = Effect.gen(function* () { |
| 274 | 292 | cwd: input.cwd, |
| 275 | 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 | 304 | return GitLabCli.of({ |
| 280 | 305 | execute, |
| ... | ... | @@ -303,7 +328,9 @@ export const make = Effect.gen(function* () { |
| 303 | 328 | return Effect.fail( |
| 304 | 329 | new GitLabCliError({ |
| 305 | 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 | 334 | cause: decoded.failure, |
| 308 | 335 | }), |
| 309 | 336 | ); |
| ... | ... | @@ -327,7 +354,9 @@ export const make = Effect.gen(function* () { |
| 327 | 354 | return Effect.fail( |
| 328 | 355 | new GitLabCliError({ |
| 329 | 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 | 360 | cause: decoded.failure, |
| 332 | 361 | }), |
| 333 | 362 | ); |
| ... | ... | @@ -350,6 +379,7 @@ export const make = Effect.gen(function* () { |
| 350 | 379 | RawGitLabRepositoryCloneUrlsSchema, |
| 351 | 380 | "getRepositoryCloneUrls", |
| 352 | 381 | "GitLab CLI returned invalid repository JSON.", |
| 382 | input.cwd, | |
| 353 | 383 | ), |
| 354 | 384 | ), |
| 355 | 385 | Effect.map(normalizeRepositoryCloneUrls), |
| ... | ... | @@ -368,6 +398,7 @@ export const make = Effect.gen(function* () { |
| 368 | 398 | RawGitLabNamespaceSchema, |
| 369 | 399 | "createRepository", |
| 370 | 400 | "GitLab CLI returned invalid namespace JSON.", |
| 401 | input.cwd, | |
| 371 | 402 | ), |
| 372 | 403 | ), |
| 373 | 404 | Effect.map((namespace) => namespace.id), |
| ... | ... | @@ -402,6 +433,7 @@ export const make = Effect.gen(function* () { |
| 402 | 433 | RawGitLabRepositoryCloneUrlsSchema, |
| 403 | 434 | "createRepository", |
| 404 | 435 | "GitLab CLI returned invalid repository JSON.", |
| 436 | input.cwd, | |
| 405 | 437 | ), |
| 406 | 438 | ), |
| 407 | 439 | Effect.map(normalizeRepositoryCloneUrls), |
| ... | ... | @@ -440,6 +472,7 @@ export const make = Effect.gen(function* () { |
| 440 | 472 | RawGitLabDefaultBranchSchema, |
| 441 | 473 | "getDefaultBranch", |
| 442 | 474 | "GitLab CLI returned invalid repository JSON.", |
| 475 | input.cwd, | |
| 443 | 476 | ), |
| 444 | 477 | ), |
| 445 | 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 | 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 | 99 | it.effect("lists GitLab MRs through provider-neutral input names", () => |
| 56 | 100 | Effect.gen(function* () { |
| 57 | 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 | 17 | } from "./SourceControlProviderDiscovery.ts"; |
| 18 | 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 | 20 | function toChangeRequest(summary: GitLabCli.GitLabMergeRequestSummary): ChangeRequest { |
| 33 | 21 | return { |
| 34 | 22 | provider: "gitlab", |
| ... | ... | @@ -129,13 +117,39 @@ export const make = Effect.gen(function* () { |
| 129 | 117 | }) |
| 130 | 118 | .pipe( |
| 131 | 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 | 136 | getChangeRequest: (input) => |
| 136 | 137 | gitlab.getMergeRequest(input).pipe( |
| 137 | 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 | 154 | createChangeRequest: (input) => { |
| 141 | 155 | const source = SourceControlProvider.sourceControlRefFromInput(input); |
| ... | ... | @@ -149,24 +163,88 @@ export const make = Effect.gen(function* () { |
| 149 | 163 | title: input.title, |
| 150 | 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 | 183 | getRepositoryCloneUrls: (input) => |
| 155 | gitlab | |
| 156 | .getRepositoryCloneUrls(input) | |
| 157 | .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), | |
| 184 | gitlab.getRepositoryCloneUrls(input).pipe( | |
| 185 | Effect.mapError( | |
| 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 | 200 | createRepository: (input) => |
| 159 | gitlab | |
| 160 | .createRepository(input) | |
| 161 | .pipe(Effect.mapError((error) => providerError("createRepository", error))), | |
| 201 | gitlab.createRepository(input).pipe( | |
| 202 | Effect.mapError( | |
| 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 | 217 | getDefaultBranch: (input) => |
| 163 | gitlab | |
| 164 | .getDefaultBranch(input) | |
| 165 | .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), | |
| 218 | gitlab.getDefaultBranch(input).pipe( | |
| 219 | Effect.mapError( | |
| 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 | 231 | checkoutChangeRequest: (input) => |
| 167 | gitlab | |
| 168 | .checkoutMergeRequest(input) | |
| 169 | .pipe(Effect.mapError((error) => providerError("checkoutChangeRequest", error))), | |
| 232 | gitlab.checkoutMergeRequest(input).pipe( | |
| 233 | Effect.mapError( | |
| 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 | 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 | 55 | export function parseSourceControlOwnerRef( |
| 26 | 56 | headSelector: string, |
| 27 | 57 | ): SourceControlRefSelector | undefined { |
apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts+57-13| ... | ... | @@ -5,6 +5,7 @@ import * as Effect from "effect/Effect"; |
| 5 | 5 | import * as Layer from "effect/Layer"; |
| 6 | 6 | import * as Option from "effect/Option"; |
| 7 | 7 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 8 | import { VcsRepositoryDetectionError } from "@t3tools/contracts"; | |
| 8 | 9 | |
| 9 | 10 | import * as ServerConfig from "../config.ts"; |
| 10 | 11 | import type * as VcsDriver from "../vcs/VcsDriver.ts"; |
| ... | ... | @@ -38,6 +39,7 @@ function makeRegistry(input: { |
| 38 | 39 | readonly url: string; |
| 39 | 40 | }>; |
| 40 | 41 | readonly process?: Partial<VcsProcess.VcsProcess["Service"]>; |
| 42 | readonly resolve?: VcsDriverRegistry.VcsDriverRegistry["Service"]["resolve"]; | |
| 41 | 43 | }) { |
| 42 | 44 | const driver = { |
| 43 | 45 | listRemotes: () => |
| ... | ... | @@ -57,21 +59,23 @@ function makeRegistry(input: { |
| 57 | 59 | |
| 58 | 60 | const registryLayer = Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ |
| 59 | 61 | get: () => Effect.succeed(driver as unknown as VcsDriver.VcsDriver["Service"]), |
| 60 | resolve: () => | |
| 61 | Effect.succeed({ | |
| 62 | kind: "git", | |
| 63 | repository: { | |
| 62 | resolve: | |
| 63 | input.resolve ?? | |
| 64 | (() => | |
| 65 | Effect.succeed({ | |
| 64 | 66 | kind: "git", |
| 65 | rootPath: "/repo", | |
| 66 | metadataPath: null, | |
| 67 | freshness: { | |
| 68 | source: "live-local" as const, | |
| 69 | observedAt: TEST_EPOCH, | |
| 70 | expiresAt: Option.none(), | |
| 67 | repository: { | |
| 68 | kind: "git", | |
| 69 | rootPath: "/repo", | |
| 70 | metadataPath: null, | |
| 71 | freshness: { | |
| 72 | source: "live-local" as const, | |
| 73 | observedAt: TEST_EPOCH, | |
| 74 | expiresAt: Option.none(), | |
| 75 | }, | |
| 71 | 76 | }, |
| 72 | }, | |
| 73 | driver: driver as unknown as VcsDriver.VcsDriver["Service"], | |
| 74 | }), | |
| 77 | driver: driver as unknown as VcsDriver.VcsDriver["Service"], | |
| 78 | })), | |
| 75 | 79 | }); |
| 76 | 80 | |
| 77 | 81 | const processLayer = Layer.mock(VcsProcess.VcsProcess)({ |
| ... | ... | @@ -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 | 167 | it.effect("routes GitLab remotes to the GitLab provider", () => |
| 124 | 168 | Effect.gen(function* () { |
| 125 | 169 | const registry = yield* makeRegistry({ |
apps/server/src/sourceControl/SourceControlProviderRegistry.ts+76-29| ... | ... | @@ -64,33 +64,62 @@ export class SourceControlProviderRegistry extends Context.Service< |
| 64 | 64 | function unsupportedProvider( |
| 65 | 65 | kind: SourceControlProviderKind, |
| 66 | 66 | ): SourceControlProvider.SourceControlProvider["Service"] { |
| 67 | const unsupported = (operation: string) => | |
| 68 | Effect.fail( | |
| 67 | return SourceControlProvider.SourceControlProvider.of({ | |
| 68 | kind, | |
| 69 | listChangeRequests: (input) => | |
| 69 | 70 | new SourceControlProviderError({ |
| 70 | 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 | 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 | 209 | |
| 181 | 210 | const detectProviderContext = Effect.fn("SourceControlProviderRegistry.detectProviderContext")( |
| 182 | 211 | function* (cwd: string) { |
| 183 | const handle = yield* vcsRegistry | |
| 184 | .resolve({ cwd }) | |
| 185 | .pipe(Effect.mapError((error) => providerDetectionError("detectProvider", cwd, error))); | |
| 186 | const remotes = yield* handle.driver | |
| 187 | .listRemotes(cwd) | |
| 188 | .pipe(Effect.mapError((error) => providerDetectionError("detectProvider", cwd, error))); | |
| 212 | const handle = yield* vcsRegistry.resolve({ cwd }).pipe( | |
| 213 | Effect.mapError( | |
| 214 | (error) => | |
| 215 | new SourceControlProviderError({ | |
| 216 | provider: "unknown", | |
| 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 | 236 | const context = selectProviderContext(remotes.remotes); |
| 190 | 237 | |
| 191 | 238 | return yield* refineUnknownRemoteProvider({ |
packages/contracts/src/sourceControl.ts+4| ... | ... | @@ -155,6 +155,10 @@ export class SourceControlProviderError extends Schema.TaggedErrorClass<SourceCo |
| 155 | 155 | { |
| 156 | 156 | provider: SourceControlProviderKind, |
| 157 | 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 | 162 | detail: Schema.String, |
| 159 | 163 | cause: Schema.optional(Schema.Defect()), |
| 160 | 164 | }, |