fix: handle GH app error cases and add read/write warnings (#8373)

* fix: handle GH app error cases and add read/write warnings
This commit is contained in:
Ryan Willis
2025-02-24 10:06:22 -07:00
committed by GitHub
parent b80f68a4bd
commit e7edec59a1
13 changed files with 212 additions and 84 deletions

View File

@@ -7,6 +7,10 @@ export default (app: Application) => {
id: 123456,
full_name: 'kong-test/sleepless',
clone_url: 'https://github.com/kong-test/sleepless.git',
permissions: {
push: true,
pull: true,
},
},
]);
});

View File

@@ -422,11 +422,13 @@ export const cloneGitRepoAction = async ({
if (e instanceof Errors.HttpError) {
return {
errors: [`${e.message}, ${e.data.response}`],
gitRepository: repoSettingsPatch,
};
}
return {
errors: [e.message],
gitRepository: repoSettingsPatch,
};
}
@@ -1184,6 +1186,7 @@ export const deleteGitBranchAction = async ({
export interface PushToGitRemoteResult {
errors?: string[];
gitRepository?: GitRepository;
}
export const pushToGitRemoteAction = async ({
@@ -1206,16 +1209,18 @@ export const pushToGitRemoteAction = async ({
if (err instanceof Errors.HttpError) {
return {
errors: [`${err.message}, ${err.data.response}`],
gitRepository,
};
}
const errorMessage = err instanceof Error ? err.message : 'Unknown Error';
return { errors: [errorMessage] };
return { errors: [errorMessage], gitRepository };
}
// If nothing to push, display that to the user
if (!canPush) {
return {
errors: ['Nothing to push'],
gitRepository,
};
}
@@ -1249,11 +1254,13 @@ export const pushToGitRemoteAction = async ({
if (err instanceof Errors.PushRejectedError) {
return {
errors: [`Push Rejected, ${errorMessage}`],
gitRepository,
};
}
return {
errors: [`Error Pushing Repository, ${errorMessage}`],
gitRepository,
};
}
@@ -1660,6 +1667,101 @@ async function signOutOfGitHub() {
}
}
interface GitHubRepositoryApiResponse {
id: string;
full_name: string;
clone_url: string;
permissions: {
push: boolean;
pull: boolean;
};
}
type GitHubRepositoriesApiResponse = GitHubRepositoryApiResponse[];
const GITHUB_USER_REPOS_URL = `${getGitHubRestApiUrl()}/user/repos`;
async function getGitHubRepositories(
{ url = `${GITHUB_USER_REPOS_URL}?per_page=100`, repos = [] }:
{ url?: string; repos?: GitHubRepositoriesApiResponse }
) {
const credentials = await models.gitCredentials.getByProvider('github');
const opts = {
headers: {
Authorization: `token ${credentials?.token}`,
},
};
const response = await fetch(url, opts);
if (!response.ok) {
const raw = await response.text();
if (response.status === 401) {
return {
errors: [`User token not authorized to fetch repositories, please sign out and back in.\nResponse: ${raw}`],
repos: [],
};
}
return {
errors: [`Failed to fetch repositories from GitHub: ${response.statusText}\nResponse: ${raw}`],
repos: [],
};
}
const data = await response.json();
let pullableRepos = data.filter((repo: GitHubRepositoryApiResponse) => repo.permissions.pull);
repos.push(...pullableRepos);
const link = response.headers.get('link');
if (link && link.includes('rel="last"')) {
const last = link.match(/<([^>]+)>; rel="last"/)?.[1];
if (last) {
const lastUrl = new URL(last);
const lastPage = lastUrl.searchParams.get('page');
if (lastPage) {
const pages = Number(lastPage);
const pageList = await Promise.all(Array.from({ length: pages - 1 }, (_, i) => fetch(`${GITHUB_USER_REPOS_URL}?per_page=100&page=${i + 2}`, opts)));
for (const page of pageList) {
const pageData = await page.json();
pullableRepos = pageData.filter((repo: GitHubRepositoryApiResponse) => repo.permissions.pull);
repos.push(...pullableRepos);
}
return { repos, errors: [] };
}
}
}
if (link && link.includes('rel="next"')) {
const next = link.match(/<([^>]+)>; rel="next"/)?.[1];
if (next) {
return getGitHubRepositories({ url: next, repos });
}
}
return { repos, errors: [] };
}
async function getGitHubRepository({ uri }: { uri: string }) {
const [owner, name] = uri.replace('.git', '').split('/').slice(-2); // extracts the owner + name
const credentials = await models.gitCredentials.getByProvider('github');
const opts = {
headers: {
Authorization: `token ${credentials?.token}`,
},
};
const response = await fetch(`${getGitHubRestApiUrl()}/repos/${owner}/${name}`, opts);
if (!response.ok) {
const raw = await response.text();
return {
errors: [`Failed to fetch repository from GitHub: ${response.statusText}\nResponse: ${raw}`],
notFound: response.status === 404,
};
}
return { repo: await response.json() as GitHubRepositoryApiResponse, errors: [], notFound: false };
}
/**
* This cache stores the states that are generated for the OAuth flow.
* This is used to check if a command to exchange a code for a token has been initiated by the app or not.
@@ -1856,9 +1958,13 @@ export interface GitServiceAPI {
stageChanges: typeof stageChangesAction;
unstageChanges: typeof unstageChangesAction;
diffFileLoader: typeof diffFileLoader;
initSignInToGitHub: typeof initSignInToGitHub;
completeSignInToGitHub: typeof completeSignInToGitHub;
signOutOfGitHub: typeof signOutOfGitHub;
getGitHubRepositories: typeof getGitHubRepositories;
getGitHubRepository: typeof getGitHubRepository;
initSignInToGitLab: typeof initSignInToGitLab;
completeSignInToGitLab: typeof completeSignInToGitLab;
signOutOfGitLab: typeof signOutOfGitLab;
@@ -1888,10 +1994,14 @@ export const registerGitServiceAPI = () => {
ipcMainHandle('git.stageChanges', (_, options: Parameters<typeof stageChangesAction>[0]) => stageChangesAction(options));
ipcMainHandle('git.unstageChanges', (_, options: Parameters<typeof unstageChangesAction>[0]) => unstageChangesAction(options));
ipcMainHandle('git.diffFileLoader', (_, options: Parameters<typeof diffFileLoader>[0]) => diffFileLoader(options));
ipcMainHandle('git.completeSignInToGitHub', (_, options: Parameters<typeof completeSignInToGitHub>[0]) => completeSignInToGitHub(options));
ipcMainHandle('git.initSignInToGitHub', () => initSignInToGitHub());
ipcMainHandle('git.completeSignInToGitHub', (_, options: Parameters<typeof completeSignInToGitHub>[0]) => completeSignInToGitHub(options));
ipcMainHandle('git.signOutOfGitHub', () => signOutOfGitHub());
ipcMainHandle('git.completeSignInToGitLab', (_, options: Parameters<typeof completeSignInToGitLab>[0]) => completeSignInToGitLab(options));
ipcMainHandle('git.getGitHubRepositories', (_, options: Parameters<typeof getGitHubRepositories>[0]) => getGitHubRepositories(options));
ipcMainHandle('git.getGitHubRepository', (_, options: Parameters<typeof getGitHubRepository>[0]) => getGitHubRepository(options));
ipcMainHandle('git.initSignInToGitLab', () => initSignInToGitLab());
ipcMainHandle('git.completeSignInToGitLab', (_, options: Parameters<typeof completeSignInToGitLab>[0]) => completeSignInToGitLab(options));
ipcMainHandle('git.signOutOfGitLab', () => signOutOfGitLab());
};

View File

@@ -60,6 +60,8 @@ export type HandleChannels =
| 'git.initSignInToGitHub'
| 'git.completeSignInToGitHub'
| 'git.signOutOfGitHub'
| 'git.getGitHubRepositories'
| 'git.getGitHubRepository'
| 'git.initSignInToGitLab'
| 'git.completeSignInToGitLab'
| 'git.signOutOfGitLab';

View File

@@ -75,12 +75,16 @@ const git: GitServiceAPI = {
stageChanges: options => ipcRenderer.invoke('git.stageChanges', options),
unstageChanges: options => ipcRenderer.invoke('git.unstageChanges', options),
diffFileLoader: options => ipcRenderer.invoke('git.diffFileLoader', options),
initSignInToGitHub: () => ipcRenderer.invoke('git.initSignInToGitHub'),
completeSignInToGitHub: options => ipcRenderer.invoke('git.completeSignInToGitHub', options),
signOutOfGitHub: () => ipcRenderer.invoke('git.signOutOfGitHub'),
getGitHubRepositories: options => ipcRenderer.invoke('git.getGitHubRepositories', options),
getGitHubRepository: options => ipcRenderer.invoke('git.getGitHubRepository', options),
initSignInToGitLab: () => ipcRenderer.invoke('git.initSignInToGitLab'),
signOutOfGitLab: () => ipcRenderer.invoke('git.signOutOfGitLab'),
completeSignInToGitLab: options => ipcRenderer.invoke('git.completeSignInToGitLab', options),
signOutOfGitLab: () => ipcRenderer.invoke('git.signOutOfGitLab'),
};
const main: Window['main'] = {

View File

@@ -601,7 +601,7 @@ export class GitVCS {
url: remote.url,
});
const logs = (await this.log({ depth: 1 })) || [];
const localHead = logs[0].oid;
const localHead = logs[0]?.oid;
const remoteRefs = remoteInfo.refs || {};
const remoteHeads = remoteRefs.heads || {};
const remoteHead = remoteHeads[branch];

View File

@@ -16,6 +16,7 @@ import {
pullFromGitRemote,
type PushToGitRemoteResult,
} from '../../routes/git-actions';
import { ConfigLink } from '../github-app-config-link';
import { Icon } from '../icon';
import { showAlert, showModal } from '../modals';
import { GitBranchesModal } from '../modals/git-branches-modal';
@@ -92,10 +93,13 @@ export const GitSyncDropdown: FC<Props> = ({ gitRepository, isInsomniaSyncEnable
if (errors.length > 0) {
showAlert({
title: 'Push Failed',
message: errors.join('\n'),
message: <>
{errors.join('\n')}
<ConfigLink {...gitPushFetcher.data} />
</>,
});
}
}, [gitPushFetcher.data?.errors]);
}, [gitPushFetcher.data]);
useEffect(() => {
const gitRepoDataErrors =
@@ -104,12 +108,15 @@ export const GitSyncDropdown: FC<Props> = ({ gitRepository, isInsomniaSyncEnable
: [];
const errors = [...gitRepoDataErrors];
if (errors.length > 0) {
if (isGitRepoSettingsModalOpen) { // user just clicked 'Reset'
return;
}
showAlert({
title: 'Loading of Git Repository Failed',
message: errors.join('\n'),
});
}
}, [gitRepoDataFetcher.data]);
}, [isGitRepoSettingsModalOpen, gitRepoDataFetcher.data]);
useEffect(() => {
const errors = [...(gitCheckoutFetcher.data?.errors ?? [])];
@@ -211,7 +218,11 @@ export const GitSyncDropdown: FC<Props> = ({ gitRepository, isInsomniaSyncEnable
} else {
showAlert({
title: 'Pull Failed',
message: err.message,
message: <>
{err.message}
<ConfigLink {...{ gitRepository, errors: [err.message] }} />
</>,
bodyClassName: 'whitespace-break-spaces',
});
}

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { getAppWebsiteBaseURL } from '../../common/constants';
import type { GitRepository } from '../../models/git-repository';
import { getOauth2FormatName } from '../../sync/git/utils';
interface ConfigLinkProps {
small?: boolean;
gitRepository?: GitRepository | null;
errors?: string[];
}
export function isGitHubAppUserToken(token?: string) {
// old oauth tokens start with 'gho_' and app user tokens start with 'ghu_'
return `${token}`.startsWith('ghu_');
}
export const ConfigLink = ({ small = false, gitRepository = null, errors = [] }: ConfigLinkProps) => {
const show = gitRepository?.credentials && 'oauth2format' in gitRepository?.credentials && getOauth2FormatName(gitRepository?.credentials) === 'github' && isGitHubAppUserToken(gitRepository?.credentials.token) && errors && errors?.length > 0 && errors[0].startsWith('HTTP Error: 40');
return show && <p className={`text-${small ? 'sm' : 'md'}`}>You may need to <a className="underline text-purple-500" href={`${getAppWebsiteBaseURL()}/oauth/github-app`}>Configure the App <i className="fa-solid fa-up-right-from-square" /></a></p>;
};

View File

@@ -84,7 +84,7 @@ export const GitProjectLogModal: FC<Props> = ({ onClose }) => {
</div>
)}
className="divide divide-[--hl-sm] divide-solid"
items={log.map(logEntry => ({ id: logEntry.oid, ...logEntry }))}
items={log.filter(l => !!l).map(logEntry => ({ id: logEntry.oid, ...logEntry }))}
>
{item => (
<Row className="group focus:outline-none focus-within:bg-[--hl-xxs] transition-colors">

View File

@@ -1,24 +1,14 @@
import React, { useCallback, useEffect, useState } from 'react';
import React, { useEffect, useState } from 'react';
import { Button as ComboButton, ComboBox, Input, ListBox, ListBoxItem, Popover } from 'react-aria-components';
// import { useFetcher, useParams } from 'react-router-dom';
import { getAppWebsiteBaseURL, getGitHubRestApiUrl } from '../../../../common/constants';
import { getAppWebsiteBaseURL } from '../../../../common/constants';
import { isGitHubAppUserToken } from '../../github-app-config-link';
import { Icon } from '../../icon';
import { Button } from '../../themed-button';
import { showError } from '..';
// fragment of what we receive from the GitHub API
interface GitHubRepository {
id: string;
full_name: string;
clone_url: string;
}
const GITHUB_USER_REPOS_URL = `${getGitHubRestApiUrl()}/user/repos`;
function isGitHubAppUserToken(token: string) {
// old oauth tokens start with 'gho_' and app user tokens start with 'ghu_'
return token.startsWith('ghu_');
}
type GitHubRepository = Awaited<ReturnType<typeof window.main.git.getGitHubRepositories>>['repos'][number];
export const GitHubRepositorySelect = (
{ uri, token }: {
@@ -28,61 +18,42 @@ export const GitHubRepositorySelect = (
const [loading, setLoading] = useState(false);
const [repositories, setRepositories] = useState<GitHubRepository[]>([]);
const [selectedRepository, setSelectedRepository] = useState<GitHubRepository | null>(null);
const [cannotFindRepository, setCannotFindRepository] = useState(false);
// this method assumes that GitHub will not change how it paginates this endpoint
const fetchRepositories = useCallback(async (url: string = `${GITHUB_USER_REPOS_URL}?per_page=100`) => {
try {
const opts = {
headers: {
Authorization: `token ${token}`,
},
};
const response = await fetch(url, opts);
if (!response.ok) {
throw new Error('Failed to fetch repositories');
}
const data = await response.json();
setRepositories(repos => ([...repos, ...data]));
const link = response.headers.get('link');
if (link && link.includes('rel="last"')) {
const last = link.match(/<([^>]+)>; rel="last"/)?.[1];
if (last) {
const lastUrl = new URL(last);
const lastPage = lastUrl.searchParams.get('page');
if (lastPage) {
const pages = Number(lastPage);
const pageList = await Promise.all(Array.from({ length: pages - 1 }, (_, i) => fetch(`${GITHUB_USER_REPOS_URL}?per_page=100&page=${i + 2}`, opts)));
for (const page of pageList) {
const pageData = await page.json();
setRepositories(repos => ([...repos, ...pageData]));
setLoading(false);
}
return;
}
}
}
if (link && link.includes('rel="next"')) {
const next = link.match(/<([^>]+)>; rel="next"/)?.[1];
fetchRepositories(next);
return;
}
setLoading(false);
} catch (err) {
setLoading(false);
const getRepositories = async () => {
setLoading(true);
setRepositories([]);
const { repos, errors } = await window.main.git.getGitHubRepositories({});
if (errors.length) {
showError({
title: 'Error fetching repositories',
message: errors.join('\n'),
});
}
}, [token]);
setRepositories(repos);
setLoading(false);
};
useEffect(() => {
if (!token || uri) {
return;
}
getRepositories();
}, [token, uri]);
setLoading(true);
fetchRepositories();
}, [token, uri, fetchRepositories]);
useEffect(() => {
if (!uri) {
setCannotFindRepository(false);
return;
}
if ((!selectedRepository) && token && isGitHubAppUserToken(token)) {
(async function getRepository() {
const { repo, errors, notFound } = await window.main.git.getGitHubRepository({ uri });
setCannotFindRepository(notFound);
setSelectedRepository(errors.length ? null : repo!);
})();
}
}, [selectedRepository, token, uri]);
return (
<>
@@ -126,17 +97,20 @@ export const GitHubRepositorySelect = (
disabled={loading}
onClick={() => {
setLoading(true);
setRepositories([]);
fetchRepositories();
getRepositories();
}}
>
<Icon icon="refresh" />
</Button>
</div>
{isGitHubAppUserToken(token) && <div className="flex gap-1 text-sm">
Can't find a repository?
<a className="underline text-purple-500" href={`${getAppWebsiteBaseURL()}/oauth/github-app`}>Configure the App <i className="fa-solid fa-up-right-from-square" /></a>
</div>}</>}
{isGitHubAppUserToken(token) &&
<div className="flex gap-1 text-sm">
Can't find a repository?
<a className="underline text-purple-500" href={`${getAppWebsiteBaseURL()}/oauth/github-app`}>Configure the App <i className="fa-solid fa-up-right-from-square" /></a>
</div>}
</>}
{cannotFindRepository && <div className="text-sm text-red-500"><Icon icon="warning" /> Repository information could not be retrieved. Please <code>Reset</code> and select a different repository.</div>}
{selectedRepository !== null && !selectedRepository.permissions.push && <div className="text-sm text-orange-500 mt-2"><Icon icon="warning" /> You do not have write access to this repository</div>}
</>
);
};

View File

@@ -206,7 +206,7 @@ const GitHubSignInForm = () => {
</div>
<div className="form-row">
<input name="link" />
<Button type="submit" name="add-token">Authenticate</Button>
<Button className="bg-violet-400 bold p-2 rounded" type="submit" name="add-token">Authenticate</Button>
</div>
</label>
{error && (

View File

@@ -2,8 +2,10 @@ import React, { type FC, useEffect } from 'react';
import { Button, Dialog, GridList, GridListItem, Heading, Label, Modal, ModalOverlay, TextArea, TextField, Tooltip, TooltipTrigger } from 'react-aria-components';
import { useFetcher, useParams } from 'react-router-dom';
import type { GitRepository } from '../../../models/git-repository';
import type { GitChangesLoaderData, GitDiffResult } from '../../routes/git-actions';
import { DiffEditor } from '../diff-view-editor';
import { ConfigLink } from '../github-app-config-link';
import { Icon } from '../icon';
import { showAlert } from '.';
@@ -101,7 +103,7 @@ export const GitStagingModal: FC<{ onClose: () => void }> = ({
statusNames: {},
};
const { Form, formAction, state, data } = useFetcher<{ errors?: string[] }>();
const { Form, formAction, state, data } = useFetcher<{ errors?: string[]; gitRepository: GitRepository }>();
const isCreatingSnapshot = state === 'loading' && formAction === '/organization/:organizationId/project/:projectId/workspace/:workspaceId/git/commit';
const isPushing = state === 'loading' && formAction === '/organization/:organizationId/project/:projectId/workspace/:workspaceId/git/commit-and-push';
@@ -185,6 +187,7 @@ export const GitStagingModal: FC<{ onClose: () => void }> = ({
{data && data.errors && data.errors.length > 0 && (
<p className="bg-opacity-20 text-sm text-[--color-font-danger] p-2 rounded-sm bg-[rgba(var(--color-danger-rgb),var(--tw-bg-opacity))]">
<Icon icon="exclamation-triangle" /> {data.errors.join('\n')}
<ConfigLink small {...data} />
</p>
)}
</Form>

View File

@@ -229,6 +229,7 @@ export const resetGitRepoAction: ActionFunction = async ({ params }) => {
export interface CommitToGitRepoResult {
errors?: string[];
gitRepository?: GitRepository;
}
export const commitToGitRepoAction: ActionFunction = async ({
@@ -353,6 +354,7 @@ export const deleteGitBranchAction: ActionFunction = async ({
export interface PushToGitRemoteResult {
errors?: string[];
gitRepository?: GitRepository;
}
export const pushToGitRemoteAction: ActionFunction = async ({

View File

@@ -30,15 +30,11 @@
"include": [
".eslintrc.js",
"config",
"electron-builder.config.js",
"esbuild.main.ts",
"esbuild.sr.ts",
"package.json",
"scripts",
"send-request",
"src",
"tailwind.config.js",
"postcss.config.js",
"vite.config.ts",
"vite-plugin-electron-node-require",
"**/*.d.ts",