import { Octokit } from '@octokit/rest'; import { CacheStore } from '@shared/storage/CacheStore'; import type { CachedData } from '@shared/types/CachedData'; import type { ContributorStats, ContributorUser, FetchResult, GitHubStats, TeamMember, } from '@shared/types/GitHubStats'; // Constants const CACHE_TTL = 1 * 60 * 60 * 1000; // 1 hour in milliseconds const REPO_OWNER = 'Longhorn-Developers'; const REPO_NAME = 'UT-Registration-Plus'; const CONTRIBUTORS_API_ROUTE = `/repos/${REPO_OWNER}/${REPO_NAME}/stats/contributors`; export const LONGHORN_DEVELOPERS_ADMINS = [ { name: 'Sriram Hariharan', role: ['LHD Co-Founder', 'UTRP Founder'], githubUsername: 'sghsri' }, { name: 'Elie Soloveichik', role: ['LHD Co-Founder', 'Learning and Development Director', 'UTRP Senior SWE'], githubUsername: 'Razboy20', }, { name: 'Diego Perez', role: ['LHD Co-Founder', 'Software Engineering Director', 'UTRP Senior SWE'], githubUsername: 'doprz', }, { name: 'Isaiah Rodriguez', role: ['LHD Co-Founder', 'President and UI/UX Director'], githubUsername: 'IsaDavRod' }, { name: 'Samuel Gunter', role: ['Administrative Director', 'UTRP Co-Lead', 'UTRP Senior SWE'], githubUsername: 'Samathingamajig', }, { name: 'Derek Chen', role: ['Communications Director', 'UTRP Co-Lead', 'UTRP Senior SWE'], githubUsername: 'DereC4', }, { name: 'Kabir Ramzan', role: ['Events Director'], githubUsername: 'CMEONE' }, ] as const satisfies TeamMember[]; export const LONGHORN_DEVELOPERS_SWE = [ { name: 'Preston Cook', role: ['Software Engineer'], githubUsername: 'Preston-Cook' }, { name: 'Ethan Lanting', role: ['Software Engineer'], githubUsername: 'EthanL06' }, { name: 'Casey Charleston', role: ['Software Engineer'], githubUsername: 'caseycharleston' }, { name: 'Lukas Zenick', role: ['LHD Alumni', 'Senior Software Engineer'], githubUsername: 'Lukas-Zenick' }, { name: 'Vinson', role: ['LHD Alumni', 'Software Engineer'], githubUsername: 'vinsonzheng499' }, { name: 'Vivek', role: ['LHD Alumni', 'Software Engineer'], githubUsername: 'vivek12311' }, ] as const satisfies TeamMember[]; /** * Represents the GitHub usernames of the SWEs in the LONGHORN_DEVELOPERS_SWE array. */ export type LD_SWE_GITHUB_USERNAMES = (typeof LONGHORN_DEVELOPERS_SWE)[number]['githubUsername']; /** * Represents the GitHub usernames of the admins in the LONGHORN_DEVELOPERS_ADMINS array. */ export type LD_ADMIN_GITHUB_USERNAMES = (typeof LONGHORN_DEVELOPERS_ADMINS)[number]['githubUsername']; /** * Service for fetching GitHub statistics. */ export class GitHubStatsService { private octokit: Octokit; private cache: Record>; private storageLock: Promise = Promise.resolve(); constructor(githubToken?: string) { this.octokit = githubToken ? new Octokit({ auth: githubToken }) : new Octokit(); this.cache = {} as Record>; } private async getCachedData(key: string): Promise | null> { if (Object.keys(this.cache).length === 0) { const githubCache = await CacheStore.get('github'); if (githubCache && typeof githubCache === 'object') { this.cache = githubCache as Record>; } } const cachedItem = this.cache[key] as CachedData | undefined; if (cachedItem) { const timeDifference = Date.now() - cachedItem.dataFetched; if (timeDifference < CACHE_TTL) { return cachedItem; } } return null; } private async setCachedData(key: string, data: T, persist = true): Promise { // get the current lock const existingLock = this.storageLock; // update the lock with a new promise this.storageLock = (async () => { // wait for current lock to finish await existingLock; // ensure cache is loaded before modifying if (Object.keys(this.cache).length === 0) { const githubCache = await CacheStore.get('github'); if (githubCache && typeof githubCache === 'object') { this.cache = githubCache as Record>; } } // update local memory this.cache[key] = { data, dataFetched: Date.now() }; // only write to the physical storage API if persist is true if (persist) { await CacheStore.set('github', this.cache); } })(); return this.storageLock; } private async fetchWithRetry(fetchFn: () => Promise, retries: number = 3, delay: number = 5000): Promise { try { return await fetchFn(); // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { if (retries > 0 && error.status === 202) { await new Promise(resolve => setTimeout(resolve, delay)); return this.fetchWithRetry(fetchFn, retries - 1, delay); } throw error; } } private async fetchGitHub(route: string): Promise { 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> { const cacheKey = `contributor_stats_${REPO_OWNER}_${REPO_NAME}`; const cachedStats = await this.getCachedData(cacheKey); if (cachedStats) { return { data: cachedStats.data, dataFetched: new Date(cachedStats.dataFetched), lastUpdated: new Date(), isCached: true, }; } const data = await this.fetchWithRetry(() => this.fetchGitHub(CONTRIBUTORS_API_ROUTE)); if (Array.isArray(data)) { const fetchResult: FetchResult = { data: data as ContributorStats[], dataFetched: new Date(), lastUpdated: new Date(), isCached: false, }; await this.setCachedData(cacheKey, fetchResult.data); return fetchResult; } throw new Error('Invalid response format'); } private async fetchContributorNames(contributors: string[]): Promise> { const names: Record = {}; await Promise.all( contributors.map(async contributor => { const cacheKey = `contributor_name_${contributor}`; const cachedName = await this.getCachedData(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; } // Pass 'false' to avoid writing to disk for every single name await this.setCachedData(cacheKey, name, false); } catch (e) { console.error(e); } } names[contributor] = name; }) ); return names; } private async fetchMergedPRsCount(username: string): Promise> { const cacheKey = `merged_prs_${username}`; const cachedCount = await this.getCachedData(cacheKey); if (cachedCount !== null) { return { data: cachedCount.data, dataFetched: new Date(cachedCount.dataFetched), lastUpdated: new Date(), isCached: true, }; } const { data } = await this.octokit.search.issuesAndPullRequests({ q: `org:${REPO_OWNER} author:${username} type:pr is:merged`, }); const fetchResult: FetchResult = { data: data.total_count, dataFetched: new Date(), lastUpdated: new Date(), isCached: false, }; await this.setCachedData(cacheKey, fetchResult.data, false); return fetchResult; } private processContributorStats(stats: ContributorStats): GitHubStats { return { commits: stats.total, linesAdded: stats.weeks.reduce((total, week) => total + week.a, 0), linesDeleted: stats.weeks.reduce((total, week) => total + week.d, 0), }; } public async fetchGitHubStats(options: { includeMergedPRs?: boolean } = {}): Promise<{ adminGitHubStats: Record; userGitHubStats: Record; contributors: string[]; names: Record; dataFetched: Date; lastUpdated: Date; isCached: boolean; }> { const { includeMergedPRs = false } = options; const adminGitHubStats: Record = {}; const userGitHubStats: Record = {}; const contributors: string[] = []; let oldestDataFetch = new Date(); let newestDataFetch = new Date(0); let allCached = true; try { const contributorStatsResult = await this.fetchContributorStats(); oldestDataFetch = contributorStatsResult.dataFetched; newestDataFetch = contributorStatsResult.dataFetched; allCached = contributorStatsResult.isCached; await Promise.all( contributorStatsResult.data.map(async stat => { const { login } = stat.author; contributors.push(login); const isAdmin = LONGHORN_DEVELOPERS_ADMINS.some(admin => admin.githubUsername === login); const statsObject = isAdmin ? adminGitHubStats : userGitHubStats; statsObject[login] = this.processContributorStats(stat); if (includeMergedPRs) { try { const mergedPRsResult = await this.fetchMergedPRsCount(login); statsObject[login].mergedPRs = mergedPRsResult.data; if (mergedPRsResult.dataFetched < oldestDataFetch) { oldestDataFetch = mergedPRsResult.dataFetched; } if (mergedPRsResult.dataFetched > newestDataFetch) { newestDataFetch = mergedPRsResult.dataFetched; } allCached = allCached && mergedPRsResult.isCached; } catch (error) { console.error(`Error fetching merged PRs for ${login}:`, error); statsObject[login].mergedPRs = 0; } } }) ); const names = await this.fetchContributorNames(contributors); await CacheStore.set('github', this.cache); return { adminGitHubStats, userGitHubStats, contributors, names, dataFetched: oldestDataFetch, lastUpdated: new Date(), isCached: allCached, }; } catch (error) { console.error('Error fetching GitHub stats:', error); throw error; } } } // /** // * Runs an example that fetches GitHub stats using the GitHubStatsService. // * // * @returns A promise that resolves when the example is finished running. // * @throws If there is an error fetching the GitHub stats. // */ // async function runExample() { // // Token is now optional // // const githubToken = process.env.GITHUB_TOKEN; // const gitHubStatsService = new GitHubStatsService(); // try { // console.log('Fetching stats without merged PRs...'); // const statsWithoutPRs = await gitHubStatsService.fetchGitHubStats(); // console.log('Data fetched:', statsWithoutPRs.dataFetched.toLocaleString()); // console.log('Last updated:', statsWithoutPRs.lastUpdated.toLocaleString()); // console.log('Is cached:', statsWithoutPRs.isCached); // console.log(statsWithoutPRs); // // console.log('\nFetching stats with merged PRs...'); // // const statsWithPRs = await gitHubStatsService.fetchGitHubStats({ includeMergedPRs: true }); // // console.log('Data fetched:', statsWithPRs.dataFetched.toLocaleString()); // // console.log('Last updated:', statsWithPRs.lastUpdated.toLocaleString()); // // console.log('Is cached:', statsWithPRs.isCached); // // wait 5 seconds // // await new Promise(resolve => setTimeout(resolve, 5000)); // // console.log('\nFetching stats again (should be cached)...'); // // const cachedStats = await gitHubStatsService.fetchGitHubStats(); // // console.log('Data fetched:', cachedStats.dataFetched.toLocaleString()); // // console.log('Last updated:', cachedStats.lastUpdated.toLocaleString()); // // console.log('Is cached:', cachedStats.isCached); // } catch (error) { // console.error('Failed to fetch GitHub stats:', error); // } // } // runExample();