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:
15
src/shared/storage/CacheStore.ts
Normal file
15
src/shared/storage/CacheStore.ts
Normal 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 });
|
||||||
8
src/shared/types/CachedData.ts
Normal file
8
src/shared/types/CachedData.ts
Normal 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;
|
||||||
|
};
|
||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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');
|
||||||
|
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 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,
|
||||||
|
|||||||
Reference in New Issue
Block a user