Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion apps/desktop/src/lib/error/knownErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
Expand All @@ -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.
`
};
50 changes: 49 additions & 1 deletion apps/desktop/src/lib/forge/github/ghQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } };
}
Expand Down Expand Up @@ -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.
*/
Expand Down
72 changes: 72 additions & 0 deletions apps/desktop/src/lib/forge/github/githubPrService.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
}
});
});
2 changes: 1 addition & 1 deletion packages/shared/src/lib/branches/Minimap.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Check failure on line 5 in packages/shared/src/lib/branches/Minimap.svelte

View workflow job for this annotation

GitHub Actions / lint-node

`@gitbutler/core/context` import should occur after import of `@gitbutler/shared/reactiveUtils.svelte`
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,
Expand Down
Loading