diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 964ed3d021c1..a7fa162df4d5 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -292,6 +292,42 @@ describe("GitHubCli.layer", () => { }).pipe(Effect.provide(layer)), ); + it.effect("maps a repository resolution failure to GitHubRepositoryNotFoundError", () => + Effect.gen(function* () { + const exitError = VcsProcessExitError.fromProcessExit( + { + operation: "GitHubCli.execute", + command: "gh", + cwd: "/repo", + argumentCount: 5, + }, + { + exitCode: 1, + stderr: + "GraphQL: Could not resolve to a Repository with the name 'octocat/nope'. (repository)", + stderrTruncated: false, + }, + "repository-not-found", + ); + assert.strictEqual(exitError.detail, "Repository not found."); + mockRun.mockReturnValueOnce(Effect.fail(exitError)); + + const gh = yield* GitHubCli.GitHubCli; + const error = yield* Effect.flip( + gh.getRepositoryCloneUrls({ + cwd: "/repo", + repository: "octocat/nope", + }), + ); + + assert.strictEqual(error._tag, "GitHubRepositoryNotFoundError"); + assert.strictEqual( + error.detail, + "Repository not found. Check the owner/repo path and try again.", + ); + }).pipe(Effect.provide(layer)), + ); + it.effect("creates repositories and parses clone URLs from create output", () => Effect.gen(function* () { mockRun.mockReturnValueOnce( diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index 974574cbd20e..7b28855cb16a 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -77,6 +77,19 @@ export class GitHubPullRequestNotFoundError extends Schema.TaggedErrorClass()( + "GitHubRepositoryNotFoundError", + gitHubCliFailureFields, +) { + get detail(): string { + return "Repository not found. Check the owner/repo path and try again."; + } + + override get message(): string { + return `GitHub CLI failed in execute: ${this.detail}`; + } +} + export class GitHubCliCommandError extends Schema.TaggedErrorClass()( "GitHubCliCommandError", gitHubCliFailureFields, @@ -153,6 +166,7 @@ export const GitHubCliError = Schema.Union([ GitHubCliAuthenticationError, GitHubCliRateLimitError, GitHubPullRequestNotFoundError, + GitHubRepositoryNotFoundError, GitHubCliCommandError, GitHubPullRequestListDecodeError, GitHubChangeRequestListDecodeError, @@ -190,6 +204,9 @@ export function fromVcsError( if (error.failureKind === "not-found") { return new GitHubPullRequestNotFoundError({ ...context, cause: error }); } + if (error.failureKind === "repository-not-found") { + return new GitHubRepositoryNotFoundError({ ...context, cause: error }); + } } return new GitHubCliCommandError({ ...context, cause: error }); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 3dcc8ab826a6..6fb45cb159bc 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -253,6 +253,9 @@ export const make = Effect.gen(function* () { input.repository, ), detail: error.detail, + // GitHubCliError details are compile-time constants, so they are + // safe to show the user in place of the generic fallback. + userDetail: error.detail, cause: error, }), ), diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts index 9a9fc3360247..0c5cce099a88 100644 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -142,6 +142,7 @@ export class GitLabCliCommandError extends Schema.TaggedErrorClass { + const providerCause = new SourceControlProviderError({ + provider: "github", + operation: "getRepositoryCloneUrls", + cwd: "/workspace", + repository: "octocat/nope", + detail: "gh stderr that stays server-side", + userDetail: "Repository not found. Check the owner/repo path and try again.", + }); + const provider = makeProvider({ + getRepositoryCloneUrls: () => Effect.fail(providerCause), + }); + + return Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const error = yield* Effect.flip( + service.lookupRepository({ + provider: "github", + repository: "octocat/nope", + cwd: "/workspace", + }), + ); + + assert.strictEqual( + error.detail, + "Repository not found. Check the owner/repo path and try again.", + ); + assert.strictEqual( + error.message, + "Source control repository operation lookupRepository failed for github: Repository not found. Check the owner/repo path and try again.", + ); + assert.strictEqual(error.cause, providerCause); + }).pipe(Effect.provide(makeLayer({ provider }))); +}); + it.effect("clones a looked-up repository into the requested destination", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index 1b46369e25c4..e1aae6edc969 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -7,6 +7,7 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { + SourceControlProviderError, SourceControlRepositoryError, type SourceControlCloneRepositoryInput, type SourceControlCloneRepositoryResult, @@ -23,6 +24,7 @@ import { ServerConfig } from "../config.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError); +const isSourceControlProviderError = Schema.is(SourceControlProviderError); export class SourceControlRepositoryService extends Context.Service< SourceControlRepositoryService, @@ -46,7 +48,12 @@ function mapRepositoryError(operation: string, provider: SourceControlProviderKi : new SourceControlRepositoryError({ operation, provider, - detail: "The source control operation could not be completed.", + // Provider `detail` may quote provider output, so only the curated + // `userDetail` opt-in reaches the client; everything else stays + // behind the generic sentence. + detail: + (isSourceControlProviderError(cause) ? cause.userDetail : undefined) ?? + "The source control operation could not be completed.", cause, }), ); diff --git a/apps/server/src/vcs/VcsProcess.test.ts b/apps/server/src/vcs/VcsProcess.test.ts index bd3e5b4cdce2..6337c274c6b4 100644 --- a/apps/server/src/vcs/VcsProcess.test.ts +++ b/apps/server/src/vcs/VcsProcess.test.ts @@ -315,3 +315,26 @@ describe("VcsProcess.run", () => { }).pipe(provideLive), ); }); + +describe("classifyNonZeroExit", () => { + it("classifies a gh repository resolution failure as repository-not-found", () => { + expect( + VcsProcess.classifyNonZeroExit( + "gh", + "GraphQL: Could not resolve to a Repository with the name 'octocat/nope'. (repository)", + ), + ).toBe("repository-not-found"); + }); + + it("keeps gh pull request resolution failures as not-found", () => { + expect( + VcsProcess.classifyNonZeroExit("gh", "GraphQL: Could not resolve to a PullRequest."), + ).toBe("not-found"); + }); + + it("does not classify repository resolution failures for other commands", () => { + expect(VcsProcess.classifyNonZeroExit("git", "could not resolve to a repository")).toBe( + "command-failed", + ); + }); +}); diff --git a/apps/server/src/vcs/VcsProcess.ts b/apps/server/src/vcs/VcsProcess.ts index ec245fa13604..d52ad160dbe9 100644 --- a/apps/server/src/vcs/VcsProcess.ts +++ b/apps/server/src/vcs/VcsProcess.ts @@ -53,7 +53,7 @@ const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; -const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFailureKind => { +export const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFailureKind => { const normalized = stderr.toLowerCase(); if ( @@ -79,6 +79,10 @@ const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFai return "rate-limited"; } + if (command === "gh" && normalized.includes("could not resolve to a repository")) { + return "repository-not-found"; + } + if ( (command === "gh" && (normalized.includes("could not resolve to a pullrequest") || diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index 104aadd9161f..12023dfff2f2 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -160,6 +160,13 @@ export class SourceControlProviderError extends Schema.TaggedErrorClass