diff --git a/apps/desktop/src/lib/error/knownErrors.ts b/apps/desktop/src/lib/error/knownErrors.ts index 68dbf55576..15c09286f0 100644 --- a/apps/desktop/src/lib/error/knownErrors.ts +++ b/apps/desktop/src/lib/error/knownErrors.ts @@ -8,7 +8,8 @@ export enum Code { ProjectMissing = 'errors.projects.missing', SecretKeychainNotFound = 'errors.secret.keychain_notfound', MissingLoginKeychain = 'errors.secret.missing_login_keychain', - GitHubTokenExpired = 'errors.github.expired_token' + GitHubTokenExpired = 'errors.github.expired_token', + GitHubStackedPrFork = 'errors.github.stacked_pr_fork' } export const KNOWN_ERRORS: Record = { @@ -34,5 +35,10 @@ With \`seahorse\` or equivalent, create a \`Login\` password store, right click `, [Code.GitHubTokenExpired]: ` Your GitHub token appears expired, please check your settings! + `, + [Code.GitHubStackedPrFork]: ` +Stacked pull requests across forks are not supported by GitHub. + +The base branch you specified doesn't exist in your fork. When creating a stacked PR, the base branch must exist in the same repository. ` }; diff --git a/apps/desktop/src/lib/forge/github/ghQuery.ts b/apps/desktop/src/lib/forge/github/ghQuery.ts index 6cb5678e0f..a59e8c3b9e 100644 --- a/apps/desktop/src/lib/forge/github/ghQuery.ts +++ b/apps/desktop/src/lib/forge/github/ghQuery.ts @@ -86,7 +86,26 @@ export async function ghQuery< : 'GitHub API error'; const message = isErrorlike(err) ? err.message : String(err); - const code = message.startsWith('Not Found -') ? Code.GitHubTokenExpired : undefined; + + // Check for stacked PR across forks error (base field invalid) + let code: string | undefined; + if (isGitHubError(err)) { + const errors = err.response.data.errors; + if (errors instanceof Array) { + const hasInvalidBaseError = errors.some( + (error) => + error.resource === 'PullRequest' && error.field === 'base' && error.code === 'invalid' + ); + if (hasInvalidBaseError) { + code = Code.GitHubStackedPrFork; + } + } + } + + // Check for expired token + if (!code && message.startsWith('Not Found -')) { + code = Code.GitHubTokenExpired; + } return { error: { name: title, message, code } }; } @@ -137,6 +156,35 @@ function extractDomainAndAction< return undefined; } +/** + * Type for GitHub API error response structure. + */ +interface GitHubErrorResponse { + response: { + data: { + errors?: Array<{ + resource?: string; + field?: string; + code?: string; + }>; + }; + }; +} + +/** + * Typeguard for checking if an error has the GitHub error response structure. + */ +function isGitHubError(err: unknown): err is GitHubErrorResponse { + return ( + isErrorlike(err) && + 'response' in err && + typeof (err as any).response === 'object' && + (err as any).response !== null && + 'data' in (err as any).response && + typeof (err as any).response.data === 'object' + ); +} + /** * Typeguard for accessing injected `GitHubClient` dependency safely. */ diff --git a/apps/desktop/src/lib/forge/github/githubPrService.test.ts b/apps/desktop/src/lib/forge/github/githubPrService.test.ts index 122f931b94..56bb3ec192 100644 --- a/apps/desktop/src/lib/forge/github/githubPrService.test.ts +++ b/apps/desktop/src/lib/forge/github/githubPrService.test.ts @@ -1,3 +1,4 @@ +import { Code } from '$lib/error/knownErrors'; import { GitHub } from '$lib/forge/github/github'; import { setupMockGitHubApi } from '$lib/testing/mockGitHubApi.svelte'; import { type RestEndpointMethodTypes } from '@octokit/rest'; @@ -36,4 +37,75 @@ describe('GitHubPrService', () => { const pr = await service?.fetch(123); expect(pr?.title).equal(title); }); + + test('should detect stacked PR across forks error', async () => { + const mockError = { + message: 'Validation Failed', + response: { + data: { + message: 'Validation Failed', + errors: [ + { + resource: 'PullRequest', + field: 'base', + code: 'invalid' + } + ] + } + } + }; + + vi.spyOn(octokit.pulls, 'create').mockRejectedValue(mockError); + + try { + await service?.createPr({ + title: 'Test PR', + body: 'Test body', + draft: false, + baseBranchName: 'feature-branch', + upstreamName: 'my-branch' + }); + expect.fail('Should have thrown an error'); + } catch (err: any) { + expect(err.code).toBe(Code.GitHubStackedPrFork); + } + }); + + test('should detect stacked PR error among multiple validation errors', async () => { + const mockError = { + message: 'Validation Failed', + response: { + data: { + message: 'Validation Failed', + errors: [ + { + resource: 'Issue', + field: 'title', + code: 'missing' + }, + { + resource: 'PullRequest', + field: 'base', + code: 'invalid' + } + ] + } + } + }; + + vi.spyOn(octokit.pulls, 'create').mockRejectedValue(mockError); + + try { + await service?.createPr({ + title: 'Test PR', + body: 'Test body', + draft: false, + baseBranchName: 'feature-branch', + upstreamName: 'my-branch' + }); + expect.fail('Should have thrown an error'); + } catch (err: any) { + expect(err.code).toBe(Code.GitHubStackedPrFork); + } + }); }); diff --git a/packages/shared/src/lib/branches/Minimap.svelte b/packages/shared/src/lib/branches/Minimap.svelte index 0bd6a65cfb..bb67e198ab 100644 --- a/packages/shared/src/lib/branches/Minimap.svelte +++ b/packages/shared/src/lib/branches/Minimap.svelte @@ -2,11 +2,11 @@ import { goto } from '$app/navigation'; import ChangeStatus from '$lib/patches/ChangeStatus.svelte'; import { WEB_ROUTES_SERVICE } from '$lib/routing/webRoutes.svelte'; + import { inject } from '@gitbutler/core/context'; import { getBranchReview } from '@gitbutler/shared/branches/branchesPreview.svelte'; import { isFound, map } from '@gitbutler/shared/network/loadable'; import { getPatch } from '@gitbutler/shared/patches/patchCommitsPreview.svelte'; import { reactive } from '@gitbutler/shared/reactiveUtils.svelte'; - import { inject } from '@gitbutler/core/context'; import { CommitStatusBadge } from '@gitbutler/ui'; import { EXTERNAL_LINK_SERVICE,