diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 872a14a041d..5fb34c35904 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -271,7 +271,7 @@ jobs: When ready to publish, trigger [Publish](https://github.com/${{ github.repository }}/actions/workflows/release-publish.yml) workflow with these variables: - Release version (`version`): `${{ steps.release_version.outputs.version }}` - Alternatively, you can trigger the workflow from [Github CLI](https://cli.github.com/): + Alternatively, you can trigger the workflow from [GitHub CLI](https://cli.github.com/): ```bash gh workflow run release-publish.yml -f version=${{ steps.release_version.outputs.version }} --repo ${{ github.repository }} ``` diff --git a/.vscode/settings.json b/.vscode/settings.json index f2cf272e59c..420054b8583 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,6 +8,10 @@ "files.insertFinalNewline": true, "editor.formatOnSave": true, "editor.formatOnSaveMode": "modifications", + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "always", + "source.fixAll.ts": "always" + }, "editor.defaultFormatter": "vscode.typescript-language-features", "[json]": { "editor.defaultFormatter": "vscode.json-language-features" @@ -27,4 +31,10 @@ "upsert", "xmark" ], + "[typescriptreact]": { + "editor.defaultFormatter": "vscode.typescript-language-features" + }, + "[typescript]": { + "editor.defaultFormatter": "vscode.typescript-language-features" + }, } diff --git a/packages/insomnia-smoke-test/playwright/test.ts b/packages/insomnia-smoke-test/playwright/test.ts index b2a4917debd..cfa7a428c3a 100644 --- a/packages/insomnia-smoke-test/playwright/test.ts +++ b/packages/insomnia-smoke-test/playwright/test.ts @@ -32,6 +32,7 @@ interface EnvOptions { INSOMNIA_APP_WEBSITE_URL: string; INSOMNIA_AI_URL: string; INSOMNIA_MOCK_API_URL: string; + INSOMNIA_GITHUB_REST_API_URL: string; INSOMNIA_GITHUB_API_URL: string; INSOMNIA_GITLAB_API_URL: string; INSOMNIA_UPDATES_URL: string; @@ -72,6 +73,7 @@ export const test = baseTest.extend<{ INSOMNIA_API_URL: webServerUrl, INSOMNIA_APP_WEBSITE_URL: webServerUrl + '/website', INSOMNIA_AI_URL: webServerUrl + '/ai', + INSOMNIA_GITHUB_REST_API_URL: webServerUrl + '/github-api/rest', INSOMNIA_GITHUB_API_URL: webServerUrl + '/github-api/graphql', INSOMNIA_GITLAB_API_URL: webServerUrl + '/gitlab-api', INSOMNIA_UPDATES_URL: webServerUrl || 'https://updates.insomnia.rest', diff --git a/packages/insomnia-smoke-test/server/github-api.ts b/packages/insomnia-smoke-test/server/github-api.ts index 23632800109..f061e3b60e2 100644 --- a/packages/insomnia-smoke-test/server/github-api.ts +++ b/packages/insomnia-smoke-test/server/github-api.ts @@ -1,14 +1,32 @@ import type { Application } from 'express'; export default (app: Application) => { - app.post('/github-api/graphql', (_req, res) => { - res.status(200).send({ - data: { - viewer: { - name: 'InsomniaUser', - email: 'sleepyhead@email.com', - }, + app.get('/github-api/rest/user/repos', (_req, res) => { + res.status(200).send([ + { + id: 123456, + full_name: 'kong-test/sleepless', + clone_url: 'https://github.com/kong-test/sleepless.git', + }, + ]); + }); + + app.get('/github-api/rest/user/emails', (_req, res) => { + res.status(200).send([ + { + email: 'curtain@drape.net', + primary: true, }, + ]); + }); + + app.get('/github-api/rest/user', (_req, res) => { + res.status(200).send({ + name: 'Insomnia', + login: 'insomnia-infra', + email: null, + avatar_url: 'https://github.com/insomnia-infra.png', + url: 'https://api.github.com/users/insomnia-infra', }); }); diff --git a/packages/insomnia-smoke-test/tests/smoke/git-sync.test.ts b/packages/insomnia-smoke-test/tests/smoke/git-sync.test.ts index f0b1a6232a4..bc93394cb08 100644 --- a/packages/insomnia-smoke-test/tests/smoke/git-sync.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/git-sync.test.ts @@ -1,6 +1,6 @@ import { test } from '../../playwright/test'; -test('Clone from github', async ({ page }) => { +test('Clone from generic Git server', async ({ page }) => { // waitting for the /features api request to finish await page.waitForSelector('[data-test-git-enable="true"]'); await page.getByLabel('Clone git repository').click(); @@ -20,29 +20,29 @@ test('Sign in with GitHub', async ({ app, page }) => { await page.getByLabel('Insomnia Sync').click(); await page.getByRole('menuitemradio', { name: 'Switch to Git Repository' }).click(); - await page.getByRole('tab', { name: 'Github' }).click(); + await page.getByRole('tab', { name: 'GitHub' }).click(); // Prevent the app from opening the browser to the authorization page // and return the url that would be created by following the GitHub OAuth flow. // https://docs.github.com/en/developers/apps/building-oauth-apps/authorizing-oauth-apps#web-application-flow - const fakeGitHubOAuthWebFlow = app.evaluate(electron => { + const fakeGitHubAppOAuthWebFlow = app.evaluate(electron => { return new Promise<{ redirectUrl: string }>(resolve => { const webContents = electron.BrowserWindow.getAllWindows()?.find(w => w.title === 'Insomnia')?.webContents; // Remove all navigation listeners so that only the one we inject will run webContents?.removeAllListeners('will-navigate'); - webContents?.on('will-navigate', (event: Event, url: string) => { + webContents?.on('will-navigate' as any, (event: Event, url: string) => { event.preventDefault(); const parsedUrl = new URL(url); // We use the same state parameter that the app created to assert that we prevent CSRF const stateSearchParam = parsedUrl.searchParams.get('state') || ''; - const redirectUrl = `insomnia://oauth/github/authenticate?state=${stateSearchParam}&code=12345`; + const redirectUrl = `insomnia://oauth/github-app/authenticate?state=${stateSearchParam}&code=12345`; resolve({ redirectUrl }); }); }); }); const [{ redirectUrl }] = await Promise.all([ - fakeGitHubOAuthWebFlow, + fakeGitHubAppOAuthWebFlow, page.getByText('Authenticate with GitHub').click({ // When playwright clicks a link it waits for navigation to finish. // In our case we are stubbing the navigation and we don't want to wait for it. @@ -56,9 +56,9 @@ test('Sign in with GitHub', async ({ app, page }) => { await page.getByRole('button', { name: 'Authenticate' }).click(); - await page - .locator('input[name="uri"]') - .fill('https://github.com/insomnia/example-repo'); + await page.locator('button[id="github_repo_select_dropdown_button"]').click(); + + await page.getByLabel('kong-test/sleepless').click(); await page.locator('data-testid=git-repository-settings-modal__sync-btn').click(); }); diff --git a/packages/insomnia/src/common/constants.ts b/packages/insomnia/src/common/constants.ts index 4fe6e8d523c..f503d140c76 100644 --- a/packages/insomnia/src/common/constants.ts +++ b/packages/insomnia/src/common/constants.ts @@ -140,7 +140,8 @@ export const getUpdatesBaseURL = () => env.INSOMNIA_UPDATES_URL || 'https://upda export const getAppWebsiteBaseURL = () => env.INSOMNIA_APP_WEBSITE_URL || 'https://app.insomnia.rest'; // GitHub API -export const getGitHubGraphQLApiURL = () => env.INSOMNIA_GITHUB_API_URL || 'https://api.github.com/graphql'; +export const getGitHubRestApiUrl = () => env.INSOMNIA_GITHUB_REST_API_URL || 'https://api.github.com'; +export const getGitHubGraphQLApiURL = () => env.INSOMNIA_GITHUB_API_URL || `${getGitHubRestApiUrl()}/graphql`; // SYNC export const DEFAULT_BRANCH_NAME = 'master'; diff --git a/packages/insomnia/src/sync/git/github-oauth-provider.ts b/packages/insomnia/src/sync/git/github-oauth-provider.ts index 3111361f068..779e5e0fbc9 100644 --- a/packages/insomnia/src/sync/git/github-oauth-provider.ts +++ b/packages/insomnia/src/sync/git/github-oauth-provider.ts @@ -13,17 +13,12 @@ export const GITHUB_GRAPHQL_API_URL = getGitHubGraphQLApiURL(); */ const statesCache = new Set(); -export function generateAuthorizationUrl() { +export function generateAppAuthorizationUrl() { const state = v4(); - const scopes = ['repo', 'read:user', 'user:email']; - const scope = scopes.join(' '); - - const url = new URL(getAppWebsiteBaseURL() + '/oauth/github'); - statesCache.add(state); + const url = new URL(getAppWebsiteBaseURL() + '/oauth/github-app'); url.search = new URLSearchParams({ - scope, state, }).toString(); @@ -33,9 +28,11 @@ export function generateAuthorizationUrl() { export async function exchangeCodeForToken({ code, state, + path, }: { code: string; state: string; + path: string; }) { if (!statesCache.has(state)) { throw new Error( @@ -44,7 +41,7 @@ export async function exchangeCodeForToken({ } return insomniaFetch<{ access_token: string }>({ - path: '/v1/oauth/github', + path, method: 'POST', data: { code, diff --git a/packages/insomnia/src/ui/components/error-boundary.tsx b/packages/insomnia/src/ui/components/error-boundary.tsx index 499b95feccf..512dc86f2a4 100644 --- a/packages/insomnia/src/ui/components/error-boundary.tsx +++ b/packages/insomnia/src/ui/components/error-boundary.tsx @@ -45,7 +45,7 @@ class SingleErrorBoundary extends PureComponent { title: 'Application Error', message: (

- Failed to render {componentName}. Please report the error to our Github Issues + Failed to render {componentName}. Please report the error to our GitHub Issues

), }); diff --git a/packages/insomnia/src/ui/components/github-stars-button.tsx b/packages/insomnia/src/ui/components/github-stars-button.tsx index 9ce690c051c..5c464bc0a5f 100644 --- a/packages/insomnia/src/ui/components/github-stars-button.tsx +++ b/packages/insomnia/src/ui/components/github-stars-button.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react'; import { Link } from 'react-aria-components'; import { useMount, useMountedState } from 'react-use'; +import { getGitHubRestApiUrl } from '../../common/constants'; import { SegmentEvent } from '../analytics'; import { Icon } from './icon'; @@ -24,7 +25,7 @@ export const GitHubStarsButton = () => { return; } - fetch('https://api.github.com/repos/Kong/insomnia') + fetch(`${getGitHubRestApiUrl()}/repos/Kong/insomnia`) .then(data => data.json()) .then(info => { if (!('watchers' in info)) { 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 new file mode 100644 index 00000000000..20be292e4bd --- /dev/null +++ b/packages/insomnia/src/ui/components/modals/git-repository-settings-modal/github-repository-select.tsx @@ -0,0 +1,142 @@ +import React, { useCallback, 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 { Icon } from '../../icon'; +import { Button } from '../../themed-button'; + +// 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_'); +} + +export const GitHubRepositorySelect = ( + { uri, token }: { + uri?: string; + token: string; + }) => { + const [loading, setLoading] = useState(false); + const [repositories, setRepositories] = useState([]); + const [selectedRepository, setSelectedRepository] = useState(null); + + // 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); + } + }, [token]); + + useEffect(() => { + if (!token || uri) { + return; + } + + setLoading(true); + + fetchRepositories(); + }, [token, uri, fetchRepositories]); + + return ( + <> +

Repository

+ {uri &&
} + {loading ?
Loading repositories...
: !uri && <>
+ ({ + id: repo.clone_url, + name: repo.full_name, + }))} + onSelectionChange={(key => setSelectedRepository(repositories.find(r => r.clone_url === key) || null))} + > +
+ + + + +
+ + + className="select-none text-sm min-w-max p-2 flex flex-col overflow-y-auto focus:outline-none" + > + {item => ( + + {item.name} + + )} + + + +
+ +
+ {isGitHubAppUserToken(token) &&
+ Can't find a repository? + Configure the App +
}} + + ); +}; 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 ead4c2eef76..3f075ef1b08 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 @@ -1,17 +1,17 @@ -import type { GraphQLError } from 'graphql'; import React, { type MouseEvent, useEffect, useState } from 'react'; import { useInterval, useLocalStorage } from 'react-use'; +import { getGitHubRestApiUrl } from '../../../../common/constants'; import type { GitRepository } from '../../../../models/git-repository'; import { exchangeCodeForToken, - generateAuthorizationUrl, - GITHUB_GRAPHQL_API_URL, + generateAppAuthorizationUrl, signOut, } from '../../../../sync/git/github-oauth-provider'; import { insomniaFetch } from '../../../insomniaFetch'; import { Button } from '../../themed-button'; import { showAlert, showError } from '..'; +import { GitHubRepositorySelect } from './github-repository-select'; interface Props { uri?: string; @@ -53,24 +53,22 @@ export const GitHubRepositorySetupFormGroup = (props: Props) => { ); }; -const GitHubUserInfoQuery = ` - query getUserInfo { - viewer { - login - email - avatarUrl - url - } - } -`; - -interface GitHubUserInfoQueryResult { - viewer: { - login: string; - email: string; - avatarUrl: string; - url: string; - }; +// this interface is backward-compatible with +// existing GitHub user info stored in localStorage +interface GitHubUser { + name?: string; + login: string; + email: string | null; + avatarUrl: string; + url: string; +} + +interface GitHubUserApiResponse { + name: string; + login: string; + email: string | null; + avatar_url: string; + url: string; } const Avatar = ({ src }: { src: string }) => { @@ -120,7 +118,7 @@ const GitHubRepositoryForm = ({ }: GitHubRepositoryFormProps) => { const [error, setError] = useState(''); - const [user, setUser, removeUser] = useLocalStorage( + const [user, setUser, removeUser] = useLocalStorage( 'github-user-info', undefined ); @@ -144,38 +142,52 @@ const GitHubRepositoryForm = ({ let isMounted = true; if (token && !user) { - const parsedURL = new URL(GITHUB_GRAPHQL_API_URL); - insomniaFetch<{ data: GitHubUserInfoQueryResult; errors: GraphQLError[] }>({ - path: parsedURL.pathname + parsedURL.search, - method: 'POST', - origin: parsedURL.origin, + const fetchOptions = { headers: { - Authorization: `Bearer ${token}`, + Authorization: `token ${token}`, }, + origin: getGitHubRestApiUrl(), sessionId: '', - data: { - query: GitHubUserInfoQuery, - }, - }).then(({ data, errors }) => { - if (isMounted) { - if (errors) { - setError( - 'Something went wrong when trying to fetch info from GitHub.' - ); - } else if (data) { - setUser(data.viewer); - } - } - }) - .catch((error: unknown) => { - if (error instanceof Error) { - setError( - 'Something went wrong when trying to fetch info from GitHub.' - ); - } + }; + Promise.allSettled([ + // need both requests because the email in GET /user + // is the public profile email and may not exist + insomniaFetch<{ email: string; primary: boolean }[]>({ + ...fetchOptions, + path: '/user/emails', + method: 'GET', + }), + insomniaFetch({ + ...fetchOptions, + path: '/user', + method: 'GET', + }), + ]).then(([emailsPromise, userPromise]) => { + if (!isMounted) { + return; + } + if (userPromise.status === 'rejected') { + setError( + 'Something went wrong when trying to fetch info from GitHub.' + ); + return; + } + const userProfileEmail = userPromise.value.email ?? ''; + const email = emailsPromise.status === 'fulfilled' ? emailsPromise.value.find(e => e.primary)?.email ?? userProfileEmail : userProfileEmail; + setUser({ + ...userPromise.value, + // field renamed for backward compatibility + avatarUrl: userPromise.value.avatar_url, + email, }); + }).catch((error: unknown) => { + if (error instanceof Error) { + setError( + 'Something went wrong when trying to fetch info from GitHub.' + ); + } + }); } - return () => { isMounted = false; }; @@ -185,13 +197,20 @@ const GitHubRepositoryForm = ({
{ event.preventDefault(); + const formData = new FormData(event.currentTarget); + const uri = formData.get('uri') as string; + if (!uri) { + setError('Please select a repository'); + return; + } onSubmit({ - uri: (new FormData(event.currentTarget).get('uri') as string) ?? '', + uri, author: { - name: user?.login ?? '', + // try to use the name from the user info, but fall back to the login (username) + name: user?.name ? user?.name : user?.login ?? '', + // rfc: fall back to the email from the Insomnia session? email: user?.email ?? '', }, credentials: { @@ -202,58 +221,45 @@ const GitHubRepositoryForm = ({ }); }} > - {token && ( -
- + {user && ( +
+
+ +
+ + {user?.login} + + + {user?.email || 'Signed in'} + +
+
+
)} -
-
- -
- - {user?.login} - - - {user?.email} - -
-
- -
- + {token && } {error && (