Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(github): github app [INS-3090] #8174

Open
wants to merge 2 commits into
base: develop
Choose a base branch
from
Open
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
2 changes: 1 addition & 1 deletion .github/workflows/release-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
```
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 @@ -27,4 +31,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: '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',
});
});

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
Loading