Skip to content

Commit

Permalink
Cache Focus View data (#30)
Browse files Browse the repository at this point in the history
* Add caching to data fetched in the extension popup

* Invalidate focus view cache when a PR link is clicked on
  • Loading branch information
jdgarcia authored Apr 19, 2024
1 parent 43b49c0 commit b6bca22
Show file tree
Hide file tree
Showing 7 changed files with 124 additions and 40 deletions.
2 changes: 1 addition & 1 deletion scripts/makeManifest.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const manifestBase = {
48: 'icons/gk-grey-48.png',
128: 'icons/gk-grey-128.png',
},
permissions: ['cookies', 'scripting', 'webNavigation'],
permissions: ['cookies', 'scripting', 'storage', 'webNavigation'],
host_permissions: [
'*://*.github.com/*',
'*://*.gitlab.com/*',
Expand Down
62 changes: 42 additions & 20 deletions src/gkApi.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { cookies } from 'webextension-polyfill';
import { cookies, storage } from 'webextension-polyfill';
import { checkOrigins } from './permissions-helper';
import { updateExtensionIcon } from './shared';
import { DefaultCacheTimeMinutes, sessionCachedFetch, updateExtensionIcon } from './shared';
import type { Provider, ProviderConnection, ProviderToken, User } from './types';

declare const MODE: 'production' | 'development' | 'none';
Expand All @@ -9,10 +9,16 @@ const gkApiUrl = MODE === 'production' ? 'https://api.gitkraken.dev' : 'https://
const accessTokenCookieUrl = 'https://gitkraken.dev';
const accessTokenCookieName = MODE === 'production' ? 'accessToken' : 'devAccessToken';

const onLoggedOut = () => {
void updateExtensionIcon(false);
void storage.session.clear();
};

const getAccessToken = async () => {
// Check if the user has granted permission to GitKraken.dev
if (!(await checkOrigins(['gitkraken.dev']))) {
// If not, just assume we're logged out
onLoggedOut();
return undefined;
}

Expand All @@ -22,29 +28,42 @@ const getAccessToken = async () => {
name: accessTokenCookieName,
});

if (!cookie?.value) {
onLoggedOut();
}

return cookie?.value;
};

export const fetchUser = async () => {
const token = await getAccessToken();
if (!token) {
onLoggedOut();
return null;
}

const res = await fetch(`${gkApiUrl}/user`, {
headers: {
Authorization: `Bearer ${token}`,
},
// Since the user object is unlikely to change, we can cache it for much longer than other data
const user = await sessionCachedFetch('user', 60 * 12 /* 12 hours */, async () => {
const res = await fetch(`${gkApiUrl}/user`, {
headers: {
Authorization: `Bearer ${token}`,
},
});

if (!res.ok) {
return null;
}

return res.json() as Promise<User>;
});

if (!res.ok) {
if (!user) {
onLoggedOut();
return null;
}

void updateExtensionIcon(true);

const user = await res.json();
return user as User;
return user;
};

export const logoutUser = async () => {
Expand All @@ -70,6 +89,7 @@ export const logoutUser = async () => {
name: accessTokenCookieName,
});

await storage.session.clear();
await updateExtensionIcon(false);
};

Expand All @@ -79,18 +99,20 @@ export const fetchProviderConnections = async () => {
return null;
}

const res = await fetch(`${gkApiUrl}/v1/provider-tokens/`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
return sessionCachedFetch('providerConnections', DefaultCacheTimeMinutes, async () => {
const res = await fetch(`${gkApiUrl}/v1/provider-tokens/`, {
headers: {
Authorization: `Bearer ${token}`,
},
});

if (!res.ok) {
return null;
}
if (!res.ok) {
return null;
}

const payload = await res.json();
return payload.data as ProviderConnection[];
const payload = await res.json();
return payload.data as ProviderConnection[];
});
};

export const fetchProviderToken = async (provider: Provider) => {
Expand Down
8 changes: 6 additions & 2 deletions src/hosts/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ export function injectionScope(url: string) {
});

this._domObserver = new MutationObserver((mutationRecord: MutationRecord[]) => {
const portalchange = mutationRecord.find(e => e.target && (e.target as HTMLElement).id === '__primerPortalRoot__');
const portalchange = mutationRecord.find(
e => e.target && (e.target as HTMLElement).id === '__primerPortalRoot__',
);
if (portalchange) {
if (this._domTimer != null) {
return;
Expand Down Expand Up @@ -312,7 +314,9 @@ export function injectionScope(url: string) {
}

if (redirectUrl === null) {
redirectUrl = new URL(`${target}://eamodio.gitlens/link/r/${repoId}/b/${prBranch.join('/')}`);
redirectUrl = new URL(
`${target}://eamodio.gitlens/link/r/${repoId}/b/${prBranch.join('/')}`,
);
}

redirectUrl.searchParams.set('pr', prNumber);
Expand Down
4 changes: 3 additions & 1 deletion src/hosts/gitlab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,9 @@ export function injectionScope(url: string) {
}

if (redirectUrl === null) {
redirectUrl = new URL(`${target}://eamodio.gitlens/link/r/${repoId}/b/${prBranch.join('/')}`);
redirectUrl = new URL(
`${target}://eamodio.gitlens/link/r/${repoId}/b/${prBranch.join('/')}`,
);
}

redirectUrl.searchParams.set('pr', prNumber);
Expand Down
50 changes: 36 additions & 14 deletions src/popup/components/FocusView.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,26 @@
import type { GitPullRequest, PullRequestBucket } from '@gitkraken/provider-apis';
import { GitHub, GitProviderUtils } from '@gitkraken/provider-apis';
import React, { useEffect, useState } from 'react';
import { storage } from 'webextension-polyfill';
import { fetchProviderToken } from '../../gkApi';
import { DefaultCacheTimeMinutes, sessionCachedFetch } from '../../shared';

const PullRequestRow = ({ pullRequest }: { pullRequest: GitPullRequest }) => {
return (
<div className="pull-request">
<div className="pull-request-title truncate">{pullRequest.title}</div>
<div className="repository-name text-secondary truncate">{pullRequest.repository.name}</div>
<a className="pull-request-number text-link" href={pullRequest.url || undefined} target="_blank">
<a
className="pull-request-number text-link"
href={pullRequest.url || undefined}
target="_blank"
onClick={() => {
// Since there is a decent chance that the PR will be acted upon after the user clicks on it,
// invalidate the cache so that the PR shows up in the appropriate bucket (or not at all) the
// next time the popup is opened.
void storage.session.remove('focusViewData');
}}
>
#{pullRequest.number}
</a>
{/* <a>
Expand Down Expand Up @@ -39,24 +51,34 @@ export const FocusView = () => {

useEffect(() => {
const loadData = async () => {
const githubToken = await fetchProviderToken('github');
if (!githubToken) {
setLoadingPullRequests(false);
return;
}
const focusViewData = await sessionCachedFetch('focusViewData', DefaultCacheTimeMinutes, async () => {
const githubToken = await fetchProviderToken('github');
if (!githubToken) {
return null;
}

const providerClient = new GitHub({ token: githubToken.accessToken });
const { data: providerUser } = await providerClient.getCurrentUser();
if (!providerUser.username) {
return null;
}

const { data: pullRequests } = await providerClient.getPullRequestsAssociatedWithUser({
username: providerUser.username,
});

const providerClient = new GitHub({ token: githubToken.accessToken });
const { data: providerUser } = await providerClient.getCurrentUser();
if (!providerUser.username) {
return { providerUser: providerUser, pullRequests: pullRequests };
});

if (!focusViewData) {
setLoadingPullRequests(false);
return;
}

const { data: pullRequests } = await providerClient.getPullRequestsAssociatedWithUser({
username: providerUser.username,
});

const bucketsMap = GitProviderUtils.groupPullRequestsIntoBuckets(pullRequests, providerUser);
const bucketsMap = GitProviderUtils.groupPullRequestsIntoBuckets(
focusViewData.pullRequests,
focusViewData.providerUser,
);
const buckets = Object.values(bucketsMap)
.filter(bucket => bucket.pullRequests.length)
.sort((a, b) => a.priority - b.priority);
Expand Down
31 changes: 29 additions & 2 deletions src/shared.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { action } from 'webextension-polyfill';
import { action, storage } from 'webextension-polyfill';
import { fetchProviderConnections } from './gkApi';
import type { CacheContext, EnterpriseProviderConnection, ProviderConnection } from './types';
import type {
CacheContext,
CachedFetchResponse,
EnterpriseProviderConnection,
ProviderConnection,
SessionCacheKey,
} from './types';

declare const MODE: 'production' | 'development' | 'none';

Expand Down Expand Up @@ -90,3 +96,24 @@ export async function getEnterpriseConnections(context: CacheContext) {
return enterpriseConnections;
});
}

export const DefaultCacheTimeMinutes = 30;

export const sessionCachedFetch = async <T>(
key: SessionCacheKey,
cacheTimeMinutes: number,
fetchFn: () => Promise<T>,
) => {
const sessionStorage = await storage.session.get(key);
const data = sessionStorage[key] as CachedFetchResponse<T> | undefined;
if (data && data.timestamp > Date.now() - cacheTimeMinutes * 60 * 1000) {
return data.data;
}

const newData = await fetchFn();
if (newData) {
await storage.session.set({ [key]: { data: newData, timestamp: Date.now() } });
}

return newData;
};
7 changes: 7 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,10 @@ export type EnterpriseProviderConnection = ProviderConnection & Required<Pick<Pr
export interface CacheContext {
enterpriseConnectionsCache?: EnterpriseProviderConnection[];
}

export type SessionCacheKey = 'user' | 'providerConnections' | 'focusViewData';

export type CachedFetchResponse<T> = {
data: T;
timestamp: number;
};

0 comments on commit b6bca22

Please sign in to comment.