Skip to content

Commit

Permalink
feat(github): github app [INS-3090] (#8254)
Browse files Browse the repository at this point in the history
  • Loading branch information
ryan-willis authored and filfreire committed Dec 17, 2024
1 parent 9af247b commit 2a9ffac
Show file tree
Hide file tree
Showing 12 changed files with 326 additions and 134 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/release-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,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 }}
```
Expand Down
10 changes: 10 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -31,4 +35,10 @@
"upsert",
"xmark"
],
"[typescriptreact]": {
"editor.defaultFormatter": "vscode.typescript-language-features"
},
"[typescript]": {
"editor.defaultFormatter": "vscode.typescript-language-features"
},
}
2 changes: 2 additions & 0 deletions packages/insomnia-smoke-test/playwright/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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',
Expand Down
32 changes: 25 additions & 7 deletions packages/insomnia-smoke-test/server/github-api.ts
Original file line number Diff line number Diff line change
@@ -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: '[email protected]',
},
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: '[email protected]',
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',
});
});

Expand Down
18 changes: 9 additions & 9 deletions packages/insomnia-smoke-test/tests/smoke/git-sync.test.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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.
Expand All @@ -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();
});
3 changes: 2 additions & 1 deletion packages/insomnia/src/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
13 changes: 5 additions & 8 deletions packages/insomnia/src/sync/git/github-oauth-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,12 @@ export const GITHUB_GRAPHQL_API_URL = getGitHubGraphQLApiURL();
*/
const statesCache = new Set<string>();

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();

Expand All @@ -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(
Expand All @@ -44,7 +41,7 @@ export async function exchangeCodeForToken({
}

return insomniaFetch<{ access_token: string }>({
path: '/v1/oauth/github',
path,
method: 'POST',
data: {
code,
Expand Down
2 changes: 1 addition & 1 deletion packages/insomnia/src/ui/components/error-boundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ class SingleErrorBoundary extends PureComponent<Props, State> {
title: 'Application Error',
message: (
<p>
Failed to render {componentName}. Please report the error to <a href="https://github.com/Kong/insomnia/issues">our Github Issues</a>
Failed to render {componentName}. Please report the error to <a href="https://github.com/Kong/insomnia/issues">our GitHub Issues</a>
</p>
),
});
Expand Down
3 changes: 2 additions & 1 deletion packages/insomnia/src/ui/components/github-stars-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<GitHubRepository[]>([]);
const [selectedRepository, setSelectedRepository] = useState<GitHubRepository | null>(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 (
<>
<h2 className="font-bold">Repository</h2>
{uri && <div className='form-control form-control--outlined'><input className="form-control" disabled defaultValue={uri} /></div>}
{loading ? <div>Loading repositories... <Icon icon="spinner" className="animate-spin" /></div> : !uri && <><div className="flex flex-row items-center gap-2">
<ComboBox
aria-label="Repositories"
allowsCustomValue={false}
className="flex-[1]"
defaultItems={repositories.map(repo => ({
id: repo.clone_url,
name: repo.full_name,
}))}
onSelectionChange={(key => setSelectedRepository(repositories.find(r => r.clone_url === key) || null))}
>
<div className='my-2 flex items-center gap-2 group rounded-sm border border-solid border-[--hl-sm] bg-[--color-bg] text-[--color-font] focus:outline-none focus:ring-1 focus:ring-[--hl-md] transition-colors'>
<Input aria-label='Repository Search' placeholder='Find a repository...' className="py-1 placeholder:italic w-full pl-2 pr-7 " />
<ComboButton id="github_repo_select_dropdown_button" type="button" className="!border-none m-2 aspect-square gap-2 truncate flex items-center justify-center aria-pressed:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-sm">
<Icon icon="caret-down" className='w-5 flex-shrink-0' />
</ComboButton>
</div>
<Popover className="min-w-max border grid grid-flow-col overflow-hidden divide-x divide-solid divide-[--hl-md] select-none text-sm border-solid border-[--hl-sm] shadow-lg bg-[--color-bg] rounded-md focus:outline-none" placement='bottom start' offset={8}>
<ListBox<{ id: string; name: string }>
className="select-none text-sm min-w-max p-2 flex flex-col overflow-y-auto focus:outline-none"
>
{item => (
<ListBoxItem
textValue={item.name}
className="aria-disabled:opacity-30 aria-selected:bg-[--hl-sm] rounded aria-disabled:cursor-not-allowed flex gap-2 px-[--padding-md] aria-selected:font-bold items-center text-[--color-font] h-[--line-height-xs] w-full text-md whitespace-nowrap bg-transparent hover:bg-[--hl-sm] disabled:cursor-not-allowed focus:bg-[--hl-xs] data-[focused]:bg-[--hl-xs] focus:outline-none transition-colors"
>
<span className='truncate'>{item.name}</span>
</ListBoxItem>
)}
</ListBox>
</Popover>
<input type="hidden" name="uri" value={selectedRepository?.clone_url || uri || ''} />
</ComboBox>
<Button
type="button"
disabled={loading}
onClick={() => {
setLoading(true);
setRepositories([]);
fetchRepositories();
}}
>
<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>}</>}
</>
);
};
Loading

0 comments on commit 2a9ffac

Please sign in to comment.