authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 12:32:34-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 12:32:34-07:00
log630df6b986f6e254d3c564da639de5b7b52fe95d
tree2b3762f293103612931a2411a288b395506d54a8
parent569b3e5959c19e87fec28480c40011eae2e465dd
signature Signed by PGP key B5690EEEBB952194

[codex] Enrich source-control errors (#3248)

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 ? error482 ? 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 Error488 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";
5import * as Layer from "effect/Layer";5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";6import * as Option from "effect/Option";
7import { ChildProcessSpawner } from "effect/unstable/process";7import { ChildProcessSpawner } from "effect/unstable/process";
8import { VcsProcessExitError } from "@t3tools/contracts";
89
9import * as VcsProcess from "../vcs/VcsProcess.ts";10import * as VcsProcess from "../vcs/VcsProcess.ts";
10import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";11import * 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";
3import * as Layer from "effect/Layer";3import * as Layer from "effect/Layer";
4import * as Result from "effect/Result";4import * as Result from "effect/Result";
5import * as Schema from "effect/Schema";5import * as Schema from "effect/Schema";
6import * as SchemaIssue from "effect/SchemaIssue";
7import {6import {
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";
14import {13import {
15 decodeAzureDevOpsPullRequestJson,14 decodeAzureDevOpsPullRequestJson,
16 decodeAzureDevOpsPullRequestListJson,15 decodeAzureDevOpsPullRequestListJson,
17 formatAzureDevOpsJsonDecodeError,
18 type NormalizedAzureDevOpsPullRequestRecord,16 type NormalizedAzureDevOpsPullRequestRecord,
19} from "./azureDevOpsPullRequests.ts";17} from "./azureDevOpsPullRequests.ts";
20import * as SourceControlProvider from "./SourceControlProvider.ts";18import * 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}
3687
37export interface AzureDevOpsRepositoryCloneUrls {88export 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}
108159
109function 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
157function normalizeChangeRequestId(reference: string): string {160function 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 );
253266
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);
4848
49it.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
49it.effect("creates Azure DevOps PRs through provider-neutral input names", () =>89it.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";
1414
15function 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
27function parseAzureAuth(input: SourceControlAuthProbeInput) {15function 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();
2917
...@@ -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 azure156 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 azure173 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 azure190 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 azure204 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});
148228
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);
5353
54it.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
54it.effect("lists Bitbucket PRs through provider-neutral input names", () =>93it.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
8import * as SourceControlProvider from "./SourceControlProvider.ts";8import * as SourceControlProvider from "./SourceControlProvider.ts";
9import type { SourceControlApiDiscoverySpec } from "./SourceControlProviderDiscovery.ts";9import type { SourceControlApiDiscoverySpec } from "./SourceControlProviderDiscovery.ts";
1010
11function 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
23function toChangeRequest(summary: NormalizedBitbucketPullRequestRecord): ChangeRequest {11function 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 bitbucket113 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 bitbucket129 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 bitbucket145 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 bitbucket163 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});
112187
apps/server/src/sourceControl/GitHubCli.test.ts+13-12
...@@ -269,18 +269,15 @@ describe("GitHubCli.layer", () => {...@@ -269,18 +269,15 @@ describe("GitHubCli.layer", () => {
269269
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 );
284281
285 const gh = yield* GitHubCli.GitHubCli;282 const gh = yield* GitHubCli.GitHubCli;
286 const error = yield* gh283 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);
292289
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";
3import * as Layer from "effect/Layer";3import * as Layer from "effect/Layer";
4import * as Result from "effect/Result";4import * as Result from "effect/Result";
5import * as Schema from "effect/Schema";5import * as Schema from "effect/Schema";
6import * as SchemaIssue from "effect/SchemaIssue";
76
8import {7import {
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";
15import {14import {
16 decodeGitHubPullRequestJson,15 decodeGitHubPullRequestJson,
17 decodeGitHubPullRequestListJson,16 decodeGitHubPullRequestListJson,
18 formatGitHubJsonDecodeError,
19} from "./gitHubPullRequests.ts";17} from "./gitHubPullRequests.ts";
2018
21const DEFAULT_TIMEOUT_MS = 30_000;19const DEFAULT_TIMEOUT_MS = 30_000;
2220
23export class GitHubCliError extends Schema.TaggedErrorClass<GitHubCliError>()("GitHubCliError", {21export 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}
3283
33export interface GitHubPullRequestSummary {84export 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}
112163
113function 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
161const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({164const 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 );
245258
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);
7070
71it.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
71it.effect("uses gh json listing for non-open change request state queries", () =>114it.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";
2import * as Layer from "effect/Layer";2import * as Layer from "effect/Layer";
3import * as Option from "effect/Option";3import * as Option from "effect/Option";
4import * as Result from "effect/Result";4import * as Result from "effect/Result";
5import * as Schema from "effect/Schema";
6import {5import {
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";
23const isSourceControlProviderError = Schema.is(SourceControlProviderError);
24
25function 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}
3622
37function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeRequest {23function 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 }
128127
...@@ -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 ? error178 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 };
179192
...@@ -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 github215 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 github240 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 github257 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 github274 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 github288 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});
216306
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) => {
313313
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 );
327324
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);
335332
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";
4import * as Option from "effect/Option";4import * as Option from "effect/Option";
5import * as Result from "effect/Result";5import * as Result from "effect/Result";
6import * as Schema from "effect/Schema";6import * as Schema from "effect/Schema";
7import * as SchemaIssue from "effect/SchemaIssue";
8import type * as DateTime from "effect/DateTime";7import type * as DateTime from "effect/DateTime";
98
10import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility } from "@t3tools/contracts";9import {
10 TrimmedNonEmptyString,
11 type SourceControlRepositoryVisibility,
12 type VcsError,
13} from "@t3tools/contracts";
1114
12import * as VcsProcess from "../vcs/VcsProcess.ts";15import * as VcsProcess from "../vcs/VcsProcess.ts";
13import {16import {
14 decodeGitLabMergeRequestJson,17 decodeGitLabMergeRequestJson,
15 decodeGitLabMergeRequestListJson,18 decodeGitLabMergeRequestListJson,
16 formatGitLabJsonDecodeError,
17} from "./gitLabMergeRequests.ts";19} from "./gitLabMergeRequests.ts";
18import type * as SourceControlProvider from "./SourceControlProvider.ts";20import type * as SourceControlProvider from "./SourceControlProvider.ts";
1921
...@@ -21,12 +23,64 @@ const DEFAULT_TIMEOUT_MS = 30_000;...@@ -21,12 +23,64 @@ const DEFAULT_TIMEOUT_MS = 30_000;
2123
22export class GitLabCliError extends Schema.TaggedErrorClass<GitLabCliError>()("GitLabCliError", {24export 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}
3185
32export interface GitLabMergeRequestSummary {86export 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}
50104
105function 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
51export class GitLabCli extends Context.Service<116export 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}
114179
115function 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
165const RawGitLabRepositoryCloneUrlsSchema = Schema.Struct({180const 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 );
278303
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);
5454
55it.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
55it.effect("lists GitLab MRs through provider-neutral input names", () =>99it.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";
18import { findAuthenticatedGitLabHost, parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts";18import { findAuthenticatedGitLabHost, parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts";
1919
20function 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
32function toChangeRequest(summary: GitLabCli.GitLabMergeRequestSummary): ChangeRequest {20function 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 gitlab184 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 gitlab201 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 gitlab218 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 gitlab232 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});
172250
apps/server/src/sourceControl/SourceControlProvider.test.ts created+19
...@@ -0,0 +1,19 @@
1import { assert, it } from "@effect/vitest";
2
3import { transportSafeSourceControlErrorValue } from "./SourceControlProvider.ts";
4
5it("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
14it("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}
2424
25const 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 */
32export 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
25export function parseSourceControlOwnerRef(55export 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";
5import * as Layer from "effect/Layer";5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";6import * as Option from "effect/Option";
7import { ChildProcessSpawner } from "effect/unstable/process";7import { ChildProcessSpawner } from "effect/unstable/process";
8import { VcsRepositoryDetectionError } from "@t3tools/contracts";
89
9import * as ServerConfig from "../config.ts";10import * as ServerConfig from "../config.ts";
10import type * as VcsDriver from "../vcs/VcsDriver.ts";11import 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: {
5759
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 });
7680
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);
122126
127it.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
143it.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
123it.effect("routes GitLab remotes to the GitLab provider", () =>167it.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<
64function unsupportedProvider(64function 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
88function 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}
96125
...@@ -180,12 +209,30 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit...@@ -180,12 +209,30 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit
180209
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* vcsRegistry212 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.driver215 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);
190237
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 },