From e7edec59a189721dfd097d50f4227b20dad5d3be Mon Sep 17 00:00:00 2001 From: Ryan Willis Date: Mon, 24 Feb 2025 10:06:22 -0700 Subject: [PATCH] fix: handle GH app error cases and add read/write warnings (#8373) * fix: handle GH app error cases and add read/write warnings --- .../insomnia-smoke-test/server/github-api.ts | 4 + packages/insomnia/src/main/git-service.ts | 116 +++++++++++++++++- packages/insomnia/src/main/ipc/electron.ts | 2 + packages/insomnia/src/preload.ts | 6 +- packages/insomnia/src/sync/git/git-vcs.ts | 2 +- .../dropdowns/git-sync-dropdown.tsx | 19 ++- .../ui/components/github-app-config-link.tsx | 22 ++++ .../modals/git-project-log-modal.tsx | 2 +- .../github-repository-select.tsx | 110 +++++++---------- .../github-repository-settings-form-group.tsx | 2 +- .../components/modals/git-staging-modal.tsx | 5 +- .../insomnia/src/ui/routes/git-actions.tsx | 2 + packages/insomnia/tsconfig.json | 4 - 13 files changed, 212 insertions(+), 84 deletions(-) create mode 100644 packages/insomnia/src/ui/components/github-app-config-link.tsx diff --git a/packages/insomnia-smoke-test/server/github-api.ts b/packages/insomnia-smoke-test/server/github-api.ts index 1b92e4c44b..280b7646e0 100644 --- a/packages/insomnia-smoke-test/server/github-api.ts +++ b/packages/insomnia-smoke-test/server/github-api.ts @@ -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, + }, }, ]); }); diff --git a/packages/insomnia/src/main/git-service.ts b/packages/insomnia/src/main/git-service.ts index 7ff2cef51c..0f25188bab 100644 --- a/packages/insomnia/src/main/git-service.ts +++ b/packages/insomnia/src/main/git-service.ts @@ -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[0]) => stageChangesAction(options)); ipcMainHandle('git.unstageChanges', (_, options: Parameters[0]) => unstageChangesAction(options)); ipcMainHandle('git.diffFileLoader', (_, options: Parameters[0]) => diffFileLoader(options)); - ipcMainHandle('git.completeSignInToGitHub', (_, options: Parameters[0]) => completeSignInToGitHub(options)); + ipcMainHandle('git.initSignInToGitHub', () => initSignInToGitHub()); + ipcMainHandle('git.completeSignInToGitHub', (_, options: Parameters[0]) => completeSignInToGitHub(options)); ipcMainHandle('git.signOutOfGitHub', () => signOutOfGitHub()); - ipcMainHandle('git.completeSignInToGitLab', (_, options: Parameters[0]) => completeSignInToGitLab(options)); + ipcMainHandle('git.getGitHubRepositories', (_, options: Parameters[0]) => getGitHubRepositories(options)); + ipcMainHandle('git.getGitHubRepository', (_, options: Parameters[0]) => getGitHubRepository(options)); + ipcMainHandle('git.initSignInToGitLab', () => initSignInToGitLab()); + ipcMainHandle('git.completeSignInToGitLab', (_, options: Parameters[0]) => completeSignInToGitLab(options)); ipcMainHandle('git.signOutOfGitLab', () => signOutOfGitLab()); }; diff --git a/packages/insomnia/src/main/ipc/electron.ts b/packages/insomnia/src/main/ipc/electron.ts index 38b98f90d0..f2ff14d681 100644 --- a/packages/insomnia/src/main/ipc/electron.ts +++ b/packages/insomnia/src/main/ipc/electron.ts @@ -60,6 +60,8 @@ export type HandleChannels = | 'git.initSignInToGitHub' | 'git.completeSignInToGitHub' | 'git.signOutOfGitHub' + | 'git.getGitHubRepositories' + | 'git.getGitHubRepository' | 'git.initSignInToGitLab' | 'git.completeSignInToGitLab' | 'git.signOutOfGitLab'; diff --git a/packages/insomnia/src/preload.ts b/packages/insomnia/src/preload.ts index cd4313e27f..d2f35fbd26 100644 --- a/packages/insomnia/src/preload.ts +++ b/packages/insomnia/src/preload.ts @@ -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'] = { diff --git a/packages/insomnia/src/sync/git/git-vcs.ts b/packages/insomnia/src/sync/git/git-vcs.ts index 1081a1b9c9..9625a55ad9 100644 --- a/packages/insomnia/src/sync/git/git-vcs.ts +++ b/packages/insomnia/src/sync/git/git-vcs.ts @@ -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]; diff --git a/packages/insomnia/src/ui/components/dropdowns/git-sync-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/git-sync-dropdown.tsx index 18e0e0b9e9..adba7158e3 100644 --- a/packages/insomnia/src/ui/components/dropdowns/git-sync-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/git-sync-dropdown.tsx @@ -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 = ({ gitRepository, isInsomniaSyncEnable if (errors.length > 0) { showAlert({ title: 'Push Failed', - message: errors.join('\n'), + message: <> + {errors.join('\n')} + + , }); } - }, [gitPushFetcher.data?.errors]); + }, [gitPushFetcher.data]); useEffect(() => { const gitRepoDataErrors = @@ -104,12 +108,15 @@ export const GitSyncDropdown: FC = ({ 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 = ({ gitRepository, isInsomniaSyncEnable } else { showAlert({ title: 'Pull Failed', - message: err.message, + message: <> + {err.message} + + , + bodyClassName: 'whitespace-break-spaces', }); } diff --git a/packages/insomnia/src/ui/components/github-app-config-link.tsx b/packages/insomnia/src/ui/components/github-app-config-link.tsx new file mode 100644 index 0000000000..7b2daee419 --- /dev/null +++ b/packages/insomnia/src/ui/components/github-app-config-link.tsx @@ -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 &&

You may need to Configure the App

; +}; diff --git a/packages/insomnia/src/ui/components/modals/git-project-log-modal.tsx b/packages/insomnia/src/ui/components/modals/git-project-log-modal.tsx index 711302a17d..2d69d0f0ec 100644 --- a/packages/insomnia/src/ui/components/modals/git-project-log-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/git-project-log-modal.tsx @@ -84,7 +84,7 @@ export const GitProjectLogModal: FC = ({ onClose }) => { )} 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 => ( diff --git a/packages/insomnia/src/ui/components/modals/git-repository-settings-modal/github-repository-select.tsx b/packages/insomnia/src/ui/components/modals/git-repository-settings-modal/github-repository-select.tsx index 20be292e4b..50a055d7f2 100644 --- a/packages/insomnia/src/ui/components/modals/git-repository-settings-modal/github-repository-select.tsx +++ b/packages/insomnia/src/ui/components/modals/git-repository-settings-modal/github-repository-select.tsx @@ -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>['repos'][number]; export const GitHubRepositorySelect = ( { uri, token }: { @@ -28,61 +18,42 @@ export const GitHubRepositorySelect = ( const [loading, setLoading] = useState(false); const [repositories, setRepositories] = useState([]); const [selectedRepository, setSelectedRepository] = useState(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(); }} > - {isGitHubAppUserToken(token) &&
- Can't find a repository? - Configure the App -
}} + {isGitHubAppUserToken(token) && +
+ Can't find a repository? + Configure the App +
} + } + {cannotFindRepository &&
Repository information could not be retrieved. Please Reset and select a different repository.
} + {selectedRepository !== null && !selectedRepository.permissions.push &&
You do not have write access to this repository
} ); }; diff --git a/packages/insomnia/src/ui/components/modals/git-repository-settings-modal/github-repository-settings-form-group.tsx b/packages/insomnia/src/ui/components/modals/git-repository-settings-modal/github-repository-settings-form-group.tsx index 2d09f4e814..c7abc91760 100644 --- a/packages/insomnia/src/ui/components/modals/git-repository-settings-modal/github-repository-settings-form-group.tsx +++ b/packages/insomnia/src/ui/components/modals/git-repository-settings-modal/github-repository-settings-form-group.tsx @@ -206,7 +206,7 @@ const GitHubSignInForm = () => {
- +
{error && ( diff --git a/packages/insomnia/src/ui/components/modals/git-staging-modal.tsx b/packages/insomnia/src/ui/components/modals/git-staging-modal.tsx index 72a35f00f8..4b16ba8210 100644 --- a/packages/insomnia/src/ui/components/modals/git-staging-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/git-staging-modal.tsx @@ -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 && (

{data.errors.join('\n')} +

)} diff --git a/packages/insomnia/src/ui/routes/git-actions.tsx b/packages/insomnia/src/ui/routes/git-actions.tsx index cd36e58fc6..8e09273609 100644 --- a/packages/insomnia/src/ui/routes/git-actions.tsx +++ b/packages/insomnia/src/ui/routes/git-actions.tsx @@ -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 ({ diff --git a/packages/insomnia/tsconfig.json b/packages/insomnia/tsconfig.json index 595758837a..d9799446ec 100644 --- a/packages/insomnia/tsconfig.json +++ b/packages/insomnia/tsconfig.json @@ -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",