feat: add CacheStore for GitHub stats and use names instead of usernames (#405)

* feat: add CacheStore for GitHub stats and use names instead of usernames

* fix: only cache name for successful API queries

* chore: linting

* chore: refactor any with unknown and add jsdocs

* fix: use cached endpoint to avoid rate limiting issues

* fix: code style

* chore: add type assertion

* fix: use correct type

* Revert "fix: use correct type"

This reverts commit 74956c12f3.

* fix: use correct type

* fix: use URL Web API

* fix: add CONTRIBUTORS_API_ROUTE constant

---------

Co-authored-by: Derek Chen <derex1987@gmail.com>
Co-authored-by: doprz <52579214+doprz@users.noreply.github.com>
This commit is contained in:
Kabir Ramzan
2024-11-13 12:01:20 -06:00
committed by GitHub
parent 46c76b1703
commit b732a80eaa
4 changed files with 108 additions and 25 deletions

View File

@@ -0,0 +1,15 @@
import type { CachedData } from '@shared/types/CachedData';
import { createLocalStore, debugStore } from 'chrome-extension-toolkit';
interface ICacheStore {
github: Record<string, CachedData<unknown>>;
}
/**
* A store that is used for storing cached data such as GitHub contributors
*/
export const CacheStore = createLocalStore<ICacheStore>({
github: {},
});
debugStore({ cacheStore: CacheStore });

View File

@@ -0,0 +1,8 @@
/**
* Represents cached data with its fetch timestamp
* @template T The type of the cached data
*/
export type CachedData<T> = {
data: T;
dataFetched: number;
};

View File

@@ -566,7 +566,7 @@ export default function Settings(): JSX.Element {
className='text-ut-burntorange font-semibold hover:cursor-pointer' className='text-ut-burntorange font-semibold hover:cursor-pointer'
onClick={() => window.open(`https://github.com/${username}`, '_blank')} onClick={() => window.open(`https://github.com/${username}`, '_blank')}
> >
@{username} {githubStats.names[username]}
</Text> </Text>
<p className='text-sm text-gray-600'>Contributor</p> <p className='text-sm text-gray-600'>Contributor</p>
{showGitHubStats && ( {showGitHubStats && (

View File

@@ -1,4 +1,6 @@
import { Octokit } from '@octokit/rest'; import { Octokit } from '@octokit/rest';
import { CacheStore } from '@shared/storage/CacheStore';
import type { CachedData } from '@shared/types/CachedData';
// Types // Types
type TeamMember = { type TeamMember = {
@@ -20,9 +22,8 @@ type ContributorStats = {
author: { login: string }; author: { login: string };
}; };
type CachedData<T> = { type ContributorUser = {
data: T; name: string | undefined;
dataFetched: Date;
}; };
type FetchResult<T> = { type FetchResult<T> = {
@@ -36,6 +37,7 @@ type FetchResult<T> = {
const CACHE_TTL = 1 * 60 * 60 * 1000; // 1 hour in milliseconds const CACHE_TTL = 1 * 60 * 60 * 1000; // 1 hour in milliseconds
const REPO_OWNER = 'Longhorn-Developers'; const REPO_OWNER = 'Longhorn-Developers';
const REPO_NAME = 'UT-Registration-Plus'; const REPO_NAME = 'UT-Registration-Plus';
const CONTRIBUTORS_API_ROUTE = `/repos/${REPO_OWNER}/${REPO_NAME}/stats/contributors`;
export const LONGHORN_DEVELOPERS_ADMINS = [ export const LONGHORN_DEVELOPERS_ADMINS = [
{ name: 'Sriram Hariharan', role: 'Founder', githubUsername: 'sghsri' }, { name: 'Sriram Hariharan', role: 'Founder', githubUsername: 'sghsri' },
@@ -68,24 +70,41 @@ export type LD_ADMIN_GITHUB_USERNAMES = (typeof LONGHORN_DEVELOPERS_ADMINS)[numb
*/ */
export class GitHubStatsService { export class GitHubStatsService {
private octokit: Octokit; private octokit: Octokit;
// eslint-disable-next-line @typescript-eslint/no-explicit-any private cache: Record<string, CachedData<unknown>>;
private cache: Map<string, CachedData<any>>;
constructor(githubToken?: string) { constructor(githubToken?: string) {
this.octokit = githubToken ? new Octokit({ auth: githubToken }) : new Octokit(); this.octokit = githubToken ? new Octokit({ auth: githubToken }) : new Octokit();
this.cache = new Map(); this.cache = {} as Record<string, CachedData<unknown>>;
} }
private getCachedData<T>(key: string): CachedData<T> | null { private async getCachedData<T>(key: string): Promise<CachedData<T> | null> {
const cachedItem = this.cache.get(key); if (Object.keys(this.cache).length === 0) {
if (cachedItem && Date.now() - cachedItem.dataFetched.getTime() < CACHE_TTL) { const githubCache = await CacheStore.get('github');
return cachedItem; if (githubCache && typeof githubCache === 'object') {
this.cache = githubCache as Record<string, CachedData<unknown>>;
}
}
const cachedItem = this.cache[key] as CachedData<T> | undefined;
if (cachedItem) {
const timeDifference = Date.now() - cachedItem.dataFetched;
if (timeDifference < CACHE_TTL) {
return cachedItem;
}
} }
return null; return null;
} }
private setCachedData<T>(key: string, data: T): void { private async setCachedData<T>(key: string, data: T): Promise<void> {
this.cache.set(key, { data, dataFetched: new Date() }); if (Object.keys(this.cache).length === 0) {
const githubCache = await CacheStore.get('github');
if (githubCache && typeof githubCache === 'object') {
this.cache = githubCache as Record<string, CachedData<unknown>>;
}
}
this.cache[key] = { data, dataFetched: Date.now() };
await CacheStore.set('github', this.cache);
} }
private async fetchWithRetry<T>(fetchFn: () => Promise<T>, retries: number = 3, delay: number = 5000): Promise<T> { private async fetchWithRetry<T>(fetchFn: () => Promise<T>, retries: number = 3, delay: number = 5000): Promise<T> {
@@ -101,25 +120,32 @@ export class GitHubStatsService {
} }
} }
private async fetchGitHub(route: string): Promise<unknown> {
try {
const url = new URL(route, 'https://github.cachedapi.com');
const response = await fetch(url);
return await response.json();
} catch (error: unknown) {
const url = new URL(route, 'https://api.github.com');
const response = await fetch(url);
return await response.json();
}
}
private async fetchContributorStats(): Promise<FetchResult<ContributorStats[]>> { private async fetchContributorStats(): Promise<FetchResult<ContributorStats[]>> {
const cacheKey = `contributor_stats_${REPO_OWNER}_${REPO_NAME}`; const cacheKey = `contributor_stats_${REPO_OWNER}_${REPO_NAME}`;
const cachedStats = this.getCachedData<ContributorStats[]>(cacheKey); const cachedStats = await this.getCachedData<ContributorStats[]>(cacheKey);
if (cachedStats) { if (cachedStats) {
return { return {
data: cachedStats.data, data: cachedStats.data,
dataFetched: cachedStats.dataFetched, dataFetched: new Date(cachedStats.dataFetched),
lastUpdated: new Date(), lastUpdated: new Date(),
isCached: true, isCached: true,
}; };
} }
const { data } = await this.fetchWithRetry(() => const data = await this.fetchWithRetry(() => this.fetchGitHub(CONTRIBUTORS_API_ROUTE));
this.octokit.repos.getContributorsStats({
owner: REPO_OWNER,
repo: REPO_NAME,
})
);
if (Array.isArray(data)) { if (Array.isArray(data)) {
const fetchResult: FetchResult<ContributorStats[]> = { const fetchResult: FetchResult<ContributorStats[]> = {
@@ -128,21 +154,51 @@ export class GitHubStatsService {
lastUpdated: new Date(), lastUpdated: new Date(),
isCached: false, isCached: false,
}; };
this.setCachedData(cacheKey, fetchResult.data); await this.setCachedData(cacheKey, fetchResult.data);
return fetchResult; return fetchResult;
} }
throw new Error('Invalid response format'); throw new Error('Invalid response format');
} }
private async fetchContributorNames(contributors: string[]): Promise<Record<string, string>> {
const names: Record<string, string> = {};
await Promise.all(
contributors.map(async contributor => {
const cacheKey = `contributor_name_${contributor}`;
const cachedName = await this.getCachedData<string>(cacheKey);
let name = `@${contributor}`;
if (cachedName) {
name = cachedName.data;
} else {
try {
const data = (await this.fetchWithRetry(() =>
this.fetchGitHub(`/users/${contributor}`)
)) as ContributorUser;
if (data.name) {
name = data.name;
}
await this.setCachedData(cacheKey, name);
} catch (e) {
console.error(e);
}
}
names[contributor] = name;
})
);
return names;
}
private async fetchMergedPRsCount(username: string): Promise<FetchResult<number>> { private async fetchMergedPRsCount(username: string): Promise<FetchResult<number>> {
const cacheKey = `merged_prs_${username}`; const cacheKey = `merged_prs_${username}`;
const cachedCount = this.getCachedData<number>(cacheKey); const cachedCount = await this.getCachedData<number>(cacheKey);
if (cachedCount !== null) { if (cachedCount !== null) {
return { return {
data: cachedCount.data, data: cachedCount.data,
dataFetched: cachedCount.dataFetched, dataFetched: new Date(cachedCount.dataFetched),
lastUpdated: new Date(), lastUpdated: new Date(),
isCached: true, isCached: true,
}; };
@@ -158,7 +214,7 @@ export class GitHubStatsService {
lastUpdated: new Date(), lastUpdated: new Date(),
isCached: false, isCached: false,
}; };
this.setCachedData(cacheKey, fetchResult.data); await this.setCachedData(cacheKey, fetchResult.data);
return fetchResult; return fetchResult;
} }
@@ -174,6 +230,7 @@ export class GitHubStatsService {
adminGitHubStats: Record<string, GitHubStats>; adminGitHubStats: Record<string, GitHubStats>;
userGitHubStats: Record<string, GitHubStats>; userGitHubStats: Record<string, GitHubStats>;
contributors: string[]; contributors: string[];
names: Record<string, string>;
dataFetched: Date; dataFetched: Date;
lastUpdated: Date; lastUpdated: Date;
isCached: boolean; isCached: boolean;
@@ -222,10 +279,13 @@ export class GitHubStatsService {
}) })
); );
const names = await this.fetchContributorNames(contributors);
return { return {
adminGitHubStats, adminGitHubStats,
userGitHubStats, userGitHubStats,
contributors, contributors,
names,
dataFetched: oldestDataFetch, dataFetched: oldestDataFetch,
lastUpdated: new Date(), lastUpdated: new Date(),
isCached: allCached, isCached: allCached,