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 {
173173 }
174174 throw new GitHubCli.GitHubCliError({
175175 operation: "execute",
176 command: "gh",
177 cwd,
176178 detail: `Failed to simulate gh checkout with git ${args.join(" ")}: ${result.stderr?.trim() || "unknown error"}`,
177179 });
178180}
......@@ -480,6 +482,8 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
480482 ? error
481483 : new GitHubCli.GitHubCliError({
482484 operation: "execute",
485 command: "gh",
486 cwd: input.cwd,
483487 detail:
484488 error instanceof Error
485489 ? `Failed to simulate gh checkout: ${error.message}`
......@@ -496,6 +500,8 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
496500 return Effect.fail(
497501 new GitHubCli.GitHubCliError({
498502 operation: "execute",
503 command: "gh",
504 cwd: input.cwd,
499505 detail: `Unexpected repository lookup: ${repository}`,
500506 }),
501507 );
......@@ -516,6 +522,8 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
516522 return Effect.fail(
517523 new GitHubCli.GitHubCliError({
518524 operation: "execute",
525 command: "gh",
526 cwd: input.cwd,
519527 detail: `Unexpected gh command: ${args.join(" ")}`,
520528 }),
521529 );
......@@ -595,6 +603,8 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
595603 Effect.fail(
596604 new GitHubCli.GitHubCliError({
597605 operation: "createRepository",
606 command: "gh",
607 cwd: input.cwd,
598608 detail: `Unexpected repository create: ${input.repository}`,
599609 }),
600610 ),
......@@ -1333,6 +1343,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
13331343 ghScenario: {
13341344 failWith: new GitHubCli.GitHubCliError({
13351345 operation: "execute",
1346 command: "gh",
1347 cwd: repoDir,
13361348 detail: "GitHub CLI (`gh`) is required but not available on PATH.",
13371349 }),
13381350 },
......@@ -2471,6 +2483,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
24712483 ghScenario: {
24722484 failWith: new GitHubCli.GitHubCliError({
24732485 operation: "execute",
2486 command: "gh",
2487 cwd: repoDir,
24742488 detail: "GitHub CLI (`gh`) is required but not available on PATH.",
24752489 }),
24762490 },
......@@ -2500,6 +2514,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
25002514 ghScenario: {
25012515 failWith: new GitHubCli.GitHubCliError({
25022516 operation: "execute",
2517 command: "gh",
2518 cwd: repoDir,
25032519 detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.",
25042520 }),
25052521 },
apps/server/src/sourceControl/AzureDevOpsCli.test.ts+23
......@@ -5,6 +5,7 @@ import * as FileSystem from "effect/FileSystem";
55import * as Layer from "effect/Layer";
66import * as Option from "effect/Option";
77import { ChildProcessSpawner } from "effect/unstable/process";
8import { VcsProcessExitError } from "@t3tools/contracts";
89
910import * as VcsProcess from "../vcs/VcsProcess.ts";
1011import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
......@@ -329,4 +330,26 @@ describe("AzureDevOpsCli.layer", () => {
329330 });
330331 }).pipe(Effect.provide(layer)),
331332 );
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 );
332355});
apps/server/src/sourceControl/AzureDevOpsCli.ts+74-54
......@@ -3,7 +3,6 @@ import * as Effect from "effect/Effect";
33import * as Layer from "effect/Layer";
44import * as Result from "effect/Result";
55import * as Schema from "effect/Schema";
6import * as SchemaIssue from "effect/SchemaIssue";
76import {
87 TrimmedNonEmptyString,
98 type SourceControlRepositoryVisibility,
......@@ -14,7 +13,6 @@ import * as VcsProcess from "../vcs/VcsProcess.ts";
1413import {
1514 decodeAzureDevOpsPullRequestJson,
1615 decodeAzureDevOpsPullRequestListJson,
17 formatAzureDevOpsJsonDecodeError,
1816 type NormalizedAzureDevOpsPullRequestRecord,
1917} from "./azureDevOpsPullRequests.ts";
2018import * as SourceControlProvider from "./SourceControlProvider.ts";
......@@ -25,6 +23,8 @@ export class AzureDevOpsCliError extends Schema.TaggedErrorClass<AzureDevOpsCliE
2523 "AzureDevOpsCliError",
2624 {
2725 operation: Schema.String,
26 command: Schema.String,
27 cwd: Schema.String,
2828 detail: Schema.String,
2929 cause: Schema.optional(Schema.Defect()),
3030 },
......@@ -32,6 +32,57 @@ export class AzureDevOpsCliError extends Schema.TaggedErrorClass<AzureDevOpsCliE
3232 override get message(): string {
3333 return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`;
3434 }
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 }
3586}
3687
3788export interface AzureDevOpsRepositoryCloneUrls {
......@@ -106,54 +157,6 @@ function errorText(error: VcsError | unknown): string {
106157 return String(error);
107158}
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
157160function normalizeChangeRequestId(reference: string): string {
158161 const trimmed = reference.trim().replace(/^#/, "");
159162 const urlMatch = /(?:pullrequest|pull-request|pull|_pulls?)\/(\d+)(?:\D.*)?$/i.exec(trimmed);
......@@ -224,13 +227,16 @@ function decodeAzureDevOpsJson<S extends Schema.Top>(
224227 schema: S,
225228 operation: "getRepositoryCloneUrls" | "getDefaultBranch" | "createRepository",
226229 invalidDetail: string,
230 cwd: string,
227231): Effect.Effect<S["Type"], AzureDevOpsCliError, S["DecodingServices"]> {
228232 return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe(
229233 Effect.mapError(
230234 (error) =>
231235 new AzureDevOpsCliError({
232236 operation,
233 detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`,
237 command: "az",
238 cwd,
239 detail: invalidDetail,
234240 cause: error,
235241 }),
236242 ),
......@@ -249,7 +255,14 @@ export const make = Effect.gen(function* () {
249255 cwd: input.cwd,
250256 timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS,
251257 })
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
254267 const executeJson = (input: Parameters<AzureDevOpsCli["Service"]["execute"]>[0]) =>
255268 execute({
......@@ -286,7 +299,9 @@ export const make = Effect.gen(function* () {
286299 return Effect.fail(
287300 new AzureDevOpsCliError({
288301 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.",
290305 cause: decoded.failure,
291306 }),
292307 );
......@@ -318,7 +333,9 @@ export const make = Effect.gen(function* () {
318333 return Effect.fail(
319334 new AzureDevOpsCliError({
320335 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.",
322339 cause: decoded.failure,
323340 }),
324341 );
......@@ -341,6 +358,7 @@ export const make = Effect.gen(function* () {
341358 RawAzureDevOpsRepositorySchema,
342359 "getRepositoryCloneUrls",
343360 "Azure DevOps CLI returned invalid repository JSON.",
361 input.cwd,
344362 ),
345363 ),
346364 Effect.map(normalizeRepositoryCloneUrls),
......@@ -370,6 +388,7 @@ export const make = Effect.gen(function* () {
370388 RawAzureDevOpsRepositorySchema,
371389 "createRepository",
372390 "Azure DevOps CLI returned invalid repository JSON.",
391 input.cwd,
373392 ),
374393 ),
375394 Effect.map(normalizeRepositoryCloneUrls),
......@@ -407,6 +426,7 @@ export const make = Effect.gen(function* () {
407426 RawAzureDevOpsRepositorySchema,
408427 "getDefaultBranch",
409428 "Azure DevOps CLI returned invalid repository JSON.",
429 input.cwd,
410430 ),
411431 ),
412432 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"
4646 }),
4747);
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
4989it.effect("creates Azure DevOps PRs through provider-neutral input names", () =>
5090 Effect.gen(function* () {
5191 let createInput:
apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts+105-25
......@@ -12,18 +12,6 @@ import {
1212 type SourceControlCliDiscoverySpec,
1313} 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
2715function parseAzureAuth(input: SourceControlAuthProbeInput) {
2816 const account = input.stdout.trim().split(/\r?\n/)[0]?.trim();
2917
......@@ -101,13 +89,39 @@ export const make = Effect.gen(function* () {
10189 })
10290 .pipe(
10391 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 ),
105106 );
106107 },
107108 getChangeRequest: (input) =>
108109 azure.getPullRequest(input).pipe(
109110 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 ),
111125 ),
112126 createChangeRequest: (input) => {
113127 const source = SourceControlProvider.sourceControlRefFromInput(input);
......@@ -121,20 +135,71 @@ export const make = Effect.gen(function* () {
121135 title: input.title,
122136 bodyFile: input.bodyFile,
123137 })
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 );
125154 },
126155 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 ),
130172 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 ),
134189 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 ),
138203 checkoutChangeRequest: (input) =>
139204 azure
140205 .checkoutPullRequest({
......@@ -142,7 +207,22 @@ export const make = Effect.gen(function* () {
142207 reference: input.reference,
143208 ...(input.context !== undefined ? { remoteName: input.context.remoteName } : {}),
144209 })
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 ),
146226 });
147227});
148228
apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts+39
......@@ -51,6 +51,45 @@ it.effect("maps Bitbucket PR summaries into provider-neutral change requests", (
5151 }),
5252);
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
5493it.effect("lists Bitbucket PRs through provider-neutral input names", () =>
5594 Effect.gen(function* () {
5695 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
88import * as SourceControlProvider from "./SourceControlProvider.ts";
99import 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
2311function toChangeRequest(summary: NormalizedBitbucketPullRequestRecord): ChangeRequest {
2412 return {
2513 provider: "bitbucket",
......@@ -60,13 +48,37 @@ export const make = Effect.gen(function* () {
6048 })
6149 .pipe(
6250 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 ),
6464 );
6565 },
6666 getChangeRequest: (input) =>
6767 bitbucket.getPullRequest(input).pipe(
6868 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 ),
7082 ),
7183 createChangeRequest: (input) => {
7284 const source = SourceControlProvider.sourceControlRefFromInput(input);
......@@ -81,23 +93,72 @@ export const make = Effect.gen(function* () {
8193 title: input.title,
8294 bodyFile: input.bodyFile,
8395 })
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 );
85111 },
86112 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 ),
90128 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 ),
94144 getDefaultBranch: (input) =>
95145 bitbucket
96146 .getDefaultBranch({
97147 cwd: input.cwd,
98148 ...(input.context ? { context: input.context } : {}),
99149 })
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 ),
101162 checkoutChangeRequest: (input) =>
102163 bitbucket
103164 .checkoutPullRequest({
......@@ -106,7 +167,21 @@ export const make = Effect.gen(function* () {
106167 reference: input.reference,
107168 ...(input.force !== undefined ? { force: input.force } : {}),
108169 })
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 ),
110185 });
111186});
112187
apps/server/src/sourceControl/GitHubCli.test.ts+13-12
......@@ -269,18 +269,15 @@ describe("GitHubCli.layer", () => {
269269
270270 it.effect("surfaces a friendly error when the pull request is not found", () =>
271271 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));
284281
285282 const gh = yield* GitHubCli.GitHubCli;
286283 const error = yield* gh
......@@ -291,6 +288,10 @@ describe("GitHubCli.layer", () => {
291288 .pipe(Effect.flip);
292289
293290 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);
294295 }).pipe(Effect.provide(layer)),
295296 );
296297});
apps/server/src/sourceControl/GitHubCli.ts+72-54
......@@ -3,7 +3,6 @@ import * as Effect from "effect/Effect";
33import * as Layer from "effect/Layer";
44import * as Result from "effect/Result";
55import * as Schema from "effect/Schema";
6import * as SchemaIssue from "effect/SchemaIssue";
76
87import {
98 TrimmedNonEmptyString,
......@@ -15,19 +14,71 @@ import * as VcsProcess from "../vcs/VcsProcess.ts";
1514import {
1615 decodeGitHubPullRequestJson,
1716 decodeGitHubPullRequestListJson,
18 formatGitHubJsonDecodeError,
1917} from "./gitHubPullRequests.ts";
2018
2119const DEFAULT_TIMEOUT_MS = 30_000;
2220
2321export class GitHubCliError extends Schema.TaggedErrorClass<GitHubCliError>()("GitHubCliError", {
2422 operation: Schema.String,
23 command: Schema.String,
24 cwd: Schema.String,
2525 detail: Schema.String,
2626 cause: Schema.optional(Schema.Defect()),
2727}) {
2828 override get message(): string {
2929 return `GitHub CLI failed in ${this.operation}: ${this.detail}`;
3030 }
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 }
3182}
3283
3384export interface GitHubPullRequestSummary {
......@@ -110,54 +161,6 @@ function errorText(error: VcsError | unknown): string {
110161 return String(error);
111162}
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
161164const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({
162165 nameWithOwner: TrimmedNonEmptyString,
163166 url: TrimmedNonEmptyString,
......@@ -216,13 +219,16 @@ function decodeGitHubJson<S extends Schema.Top>(
216219 schema: S,
217220 operation: "listOpenPullRequests" | "getPullRequest" | "getRepositoryCloneUrls",
218221 invalidDetail: string,
222 cwd: string,
219223): Effect.Effect<S["Type"], GitHubCliError, S["DecodingServices"]> {
220224 return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe(
221225 Effect.mapError(
222226 (error) =>
223227 new GitHubCliError({
224228 operation,
225 detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`,
229 command: "gh",
230 cwd,
231 detail: invalidDetail,
226232 cause: error,
227233 }),
228234 ),
......@@ -241,7 +247,14 @@ export const make = Effect.gen(function* () {
241247 cwd: input.cwd,
242248 timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS,
243249 })
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
246259 return GitHubCli.of({
247260 execute,
......@@ -271,7 +284,9 @@ export const make = Effect.gen(function* () {
271284 return Effect.fail(
272285 new GitHubCliError({
273286 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.",
275290 cause: decoded.failure,
276291 }),
277292 );
......@@ -303,7 +318,9 @@ export const make = Effect.gen(function* () {
303318 return Effect.fail(
304319 new GitHubCliError({
305320 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.",
307324 cause: decoded.failure,
308325 }),
309326 );
......@@ -328,6 +345,7 @@ export const make = Effect.gen(function* () {
328345 RawGitHubRepositoryCloneUrlsSchema,
329346 "getRepositoryCloneUrls",
330347 "GitHub CLI returned invalid repository JSON.",
348 input.cwd,
331349 ),
332350 ),
333351 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", () =
6868 }),
6969);
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
71114it.effect("uses gh json listing for non-open change request state queries", () =>
72115 Effect.gen(function* () {
73116 let executeArgs: ReadonlyArray<string> = [];
apps/server/src/sourceControl/GitHubSourceControlProvider.ts+124-34
......@@ -2,7 +2,6 @@ import * as Effect from "effect/Effect";
22import * as Layer from "effect/Layer";
33import * as Option from "effect/Option";
44import * as Result from "effect/Result";
5import * as Schema from "effect/Schema";
65import {
76 SourceControlProviderError,
87 type ChangeRequest,
......@@ -20,19 +19,6 @@ import {
2019 type SourceControlAuthProbeInput,
2120 type SourceControlCliDiscoverySpec,
2221} 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
3723function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeRequest {
3824 return {
......@@ -122,7 +108,20 @@ export const make = Effect.gen(function* () {
122108 })
123109 .pipe(
124110 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 ),
126125 );
127126 }
128127
......@@ -162,6 +161,11 @@ export const make = Effect.gen(function* () {
162161 new SourceControlProviderError({
163162 provider: "github",
164163 operation: "listChangeRequests",
164 command: "gh",
165 cwd: input.cwd,
166 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
167 input.headSelector,
168 ),
165169 detail: "GitHub CLI returned invalid change request JSON.",
166170 cause: decoded.failure,
167171 }),
......@@ -169,11 +173,20 @@ export const make = Effect.gen(function* () {
169173 ),
170174 );
171175 }),
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 }),
177190 );
178191 };
179192
......@@ -183,7 +196,20 @@ export const make = Effect.gen(function* () {
183196 getChangeRequest: (input) =>
184197 github.getPullRequest(input).pipe(
185198 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 ),
187213 ),
188214 createChangeRequest: (input) =>
189215 github
......@@ -194,23 +220,87 @@ export const make = Effect.gen(function* () {
194220 title: input.title,
195221 bodyFile: input.bodyFile,
196222 })
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 ),
198239 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 ),
202256 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 ),
206273 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 ),
210287 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 ),
214304 });
215305});
216306
apps/server/src/sourceControl/GitLabCli.test.ts+12-11
......@@ -313,17 +313,14 @@ layer("GitLabCli.layer", (it) => {
313313
314314 it.effect("surfaces a friendly error when the merge request is not found", () =>
315315 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));
327324
328325 const error = yield* Effect.gen(function* () {
329326 const glab = yield* GitLabCli.GitLabCli;
......@@ -334,6 +331,10 @@ layer("GitLabCli.layer", (it) => {
334331 }).pipe(Effect.flip);
335332
336333 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);
337338 }),
338339 );
339340});
apps/server/src/sourceControl/GitLabCli.ts+90-57
......@@ -4,16 +4,18 @@ import * as Layer from "effect/Layer";
44import * as Option from "effect/Option";
55import * as Result from "effect/Result";
66import * as Schema from "effect/Schema";
7import * as SchemaIssue from "effect/SchemaIssue";
87import 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
1215import * as VcsProcess from "../vcs/VcsProcess.ts";
1316import {
1417 decodeGitLabMergeRequestJson,
1518 decodeGitLabMergeRequestListJson,
16 formatGitLabJsonDecodeError,
1719} from "./gitLabMergeRequests.ts";
1820import type * as SourceControlProvider from "./SourceControlProvider.ts";
1921
......@@ -21,12 +23,64 @@ const DEFAULT_TIMEOUT_MS = 30_000;
2123
2224export class GitLabCliError extends Schema.TaggedErrorClass<GitLabCliError>()("GitLabCliError", {
2325 operation: Schema.String,
26 command: Schema.String,
27 cwd: Schema.String,
2428 detail: Schema.String,
2529 cause: Schema.optional(Schema.Defect()),
2630}) {
2731 override get message(): string {
2832 return `GitLab CLI failed in ${this.operation}: ${this.detail}`;
2933 }
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 }
3084}
3185
3286export interface GitLabMergeRequestSummary {
......@@ -48,6 +102,17 @@ export interface GitLabRepositoryCloneUrls {
48102 readonly sshUrl: string;
49103}
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
51116export class GitLabCli extends Context.Service<
52117 GitLabCli,
53118 {
......@@ -112,56 +177,6 @@ function isVcsProcessSpawnError(error: unknown): boolean {
112177 );
113178}
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
165180const RawGitLabRepositoryCloneUrlsSchema = Schema.Struct({
166181 path_with_namespace: TrimmedNonEmptyString,
167182 web_url: TrimmedNonEmptyString,
......@@ -192,13 +207,16 @@ function decodeGitLabJson<S extends Schema.Top>(
192207 schema: S,
193208 operation: "getRepositoryCloneUrls" | "getDefaultBranch" | "createRepository",
194209 invalidDetail: string,
210 cwd: string,
195211): Effect.Effect<S["Type"], GitLabCliError, S["DecodingServices"]> {
196212 return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe(
197213 Effect.mapError(
198214 (error) =>
199215 new GitLabCliError({
200216 operation,
201 detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`,
217 command: "glab",
218 cwd,
219 detail: invalidDetail,
202220 cause: error,
203221 }),
204222 ),
......@@ -274,7 +292,14 @@ export const make = Effect.gen(function* () {
274292 cwd: input.cwd,
275293 timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS,
276294 })
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
279304 return GitLabCli.of({
280305 execute,
......@@ -303,7 +328,9 @@ export const make = Effect.gen(function* () {
303328 return Effect.fail(
304329 new GitLabCliError({
305330 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.",
307334 cause: decoded.failure,
308335 }),
309336 );
......@@ -327,7 +354,9 @@ export const make = Effect.gen(function* () {
327354 return Effect.fail(
328355 new GitLabCliError({
329356 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.",
331360 cause: decoded.failure,
332361 }),
333362 );
......@@ -350,6 +379,7 @@ export const make = Effect.gen(function* () {
350379 RawGitLabRepositoryCloneUrlsSchema,
351380 "getRepositoryCloneUrls",
352381 "GitLab CLI returned invalid repository JSON.",
382 input.cwd,
353383 ),
354384 ),
355385 Effect.map(normalizeRepositoryCloneUrls),
......@@ -368,6 +398,7 @@ export const make = Effect.gen(function* () {
368398 RawGitLabNamespaceSchema,
369399 "createRepository",
370400 "GitLab CLI returned invalid namespace JSON.",
401 input.cwd,
371402 ),
372403 ),
373404 Effect.map((namespace) => namespace.id),
......@@ -402,6 +433,7 @@ export const make = Effect.gen(function* () {
402433 RawGitLabRepositoryCloneUrlsSchema,
403434 "createRepository",
404435 "GitLab CLI returned invalid repository JSON.",
436 input.cwd,
405437 ),
406438 ),
407439 Effect.map(normalizeRepositoryCloneUrls),
......@@ -440,6 +472,7 @@ export const make = Effect.gen(function* () {
440472 RawGitLabDefaultBranchSchema,
441473 "getDefaultBranch",
442474 "GitLab CLI returned invalid repository JSON.",
475 input.cwd,
443476 ),
444477 ),
445478 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", () =
5252 }),
5353);
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
5599it.effect("lists GitLab MRs through provider-neutral input names", () =>
56100 Effect.gen(function* () {
57101 let listInput: Parameters<GitLabCli.GitLabCli["Service"]["listMergeRequests"]>[0] | null = null;
apps/server/src/sourceControl/GitLabSourceControlProvider.ts+105-27
......@@ -17,18 +17,6 @@ import {
1717} from "./SourceControlProviderDiscovery.ts";
1818import { 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
3220function toChangeRequest(summary: GitLabCli.GitLabMergeRequestSummary): ChangeRequest {
3321 return {
3422 provider: "gitlab",
......@@ -129,13 +117,39 @@ export const make = Effect.gen(function* () {
129117 })
130118 .pipe(
131119 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 ),
133134 );
134135 },
135136 getChangeRequest: (input) =>
136137 gitlab.getMergeRequest(input).pipe(
137138 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 ),
139153 ),
140154 createChangeRequest: (input) => {
141155 const source = SourceControlProvider.sourceControlRefFromInput(input);
......@@ -149,24 +163,88 @@ export const make = Effect.gen(function* () {
149163 title: input.title,
150164 bodyFile: input.bodyFile,
151165 })
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 );
153182 },
154183 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 ),
158200 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 ),
162217 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 ),
166231 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 ),
170248 });
171249});
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 {
2222 readonly repository?: string;
2323}
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
2555export function parseSourceControlOwnerRef(
2656 headSelector: string,
2757): SourceControlRefSelector | undefined {
apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts+57-13
......@@ -5,6 +5,7 @@ import * as Effect from "effect/Effect";
55import * as Layer from "effect/Layer";
66import * as Option from "effect/Option";
77import { ChildProcessSpawner } from "effect/unstable/process";
8import { VcsRepositoryDetectionError } from "@t3tools/contracts";
89
910import * as ServerConfig from "../config.ts";
1011import type * as VcsDriver from "../vcs/VcsDriver.ts";
......@@ -38,6 +39,7 @@ function makeRegistry(input: {
3839 readonly url: string;
3940 }>;
4041 readonly process?: Partial<VcsProcess.VcsProcess["Service"]>;
42 readonly resolve?: VcsDriverRegistry.VcsDriverRegistry["Service"]["resolve"];
4143}) {
4244 const driver = {
4345 listRemotes: () =>
......@@ -57,21 +59,23 @@ function makeRegistry(input: {
5759
5860 const registryLayer = Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({
5961 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({
6466 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 },
7176 },
72 },
73 driver: driver as unknown as VcsDriver.VcsDriver["Service"],
74 }),
77 driver: driver as unknown as VcsDriver.VcsDriver["Service"],
78 })),
7579 });
7680
7781 const processLayer = Layer.mock(VcsProcess.VcsProcess)({
......@@ -120,6 +124,46 @@ it.effect("routes directly by provider kind for remote-first workflows", () =>
120124 }),
121125);
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
123167it.effect("routes GitLab remotes to the GitLab provider", () =>
124168 Effect.gen(function* () {
125169 const registry = yield* makeRegistry({
apps/server/src/sourceControl/SourceControlProviderRegistry.ts+76-29
......@@ -64,33 +64,62 @@ export class SourceControlProviderRegistry extends Context.Service<
6464function unsupportedProvider(
6565 kind: SourceControlProviderKind,
6666): SourceControlProvider.SourceControlProvider["Service"] {
67 const unsupported = (operation: string) =>
68 Effect.fail(
67 return SourceControlProvider.SourceControlProvider.of({
68 kind,
69 listChangeRequests: (input) =>
6970 new SourceControlProviderError({
7071 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),
72121 detail: `No ${kind} source control provider is registered.`,
73122 }),
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,
94123 });
95124}
96125
......@@ -180,12 +209,30 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit
180209
181210 const detectProviderContext = Effect.fn("SourceControlProviderRegistry.detectProviderContext")(
182211 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 );
189236 const context = selectProviderContext(remotes.remotes);
190237
191238 return yield* refineUnknownRemoteProvider({
packages/contracts/src/sourceControl.ts+4
......@@ -155,6 +155,10 @@ export class SourceControlProviderError extends Schema.TaggedErrorClass<SourceCo
155155 {
156156 provider: SourceControlProviderKind,
157157 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),
158162 detail: Schema.String,
159163 cause: Schema.optional(Schema.Defect()),
160164 },