feat: settings page (#260)

* feat: setup settings page boilerplate

* feat: split view into halves

* feat: add preview for Customization Options section

* feat: add OptionStore logic and LD icon

* feat: add courseStatusChips functionality

* feat: migrate experimental settings to proper settings

* feat: center Preview children and add className override

* feat: add GitHub stats

* feat: open GitHub user profile onclick

* feat: get user GitHub stats

* feat: refactor into useGitHubStats hook

* feat: toggle GitHub stats when the user presses the 'S' key

* chore: update title

* fix: remove extra file

* feat: refactor and add DialogProvider

* fix: import

* test: this commit has issues

* fix: no schedule bug

* fix: longhorn developers icon not rendering in prod builds

* feat(pr-review): fix UI and comment out experimental code

* chore: run lint and prettier

* feat: add responsive design

* feat: use @octokit/rest and fix GitHub stats
This commit is contained in:
doprz
2024-10-10 18:05:19 -05:00
committed by GitHub
parent d73615e281
commit 7a5c3a2e62
23 changed files with 1758 additions and 661 deletions

View File

@@ -0,0 +1,266 @@
import { Octokit } from '@octokit/rest';
// Types
type TeamMember = {
name: string;
role: string;
githubUsername: string;
};
type GitHubStats = {
commits: number;
linesAdded: number;
linesDeleted: number;
mergedPRs?: number;
};
type ContributorStats = {
total: number;
weeks: { w: number; a: number; d: number; c: number }[];
author: { login: string };
};
type CachedData<T> = {
data: T;
dataFetched: Date;
};
type FetchResult<T> = {
data: T;
dataFetched: Date;
lastUpdated: Date;
isCached: boolean;
};
// Constants
const CACHE_TTL = 1 * 60 * 60 * 1000; // 1 hour in milliseconds
const REPO_OWNER = 'Longhorn-Developers';
const REPO_NAME = 'UT-Registration-Plus';
export const LONGHORN_DEVELOPERS_ADMINS = [
{ name: 'Sriram Hariharan', role: 'Founder', githubUsername: 'sghsri' },
{ name: 'Elie Soloveichik', role: 'Senior Software Engineer', githubUsername: 'Razboy20' },
{ name: 'Diego Perez', role: 'Senior Software Engineer', githubUsername: 'doprz' },
{ name: 'Lukas Zenick', role: 'Senior Software Engineer', githubUsername: 'Lukas-Zenick' },
{ name: 'Isaiah Rodriguez', role: 'Chief Design Officer', githubUsername: 'IsaDavRod' },
] as const satisfies TeamMember[];
/**
* 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;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private cache: Map<string, CachedData<any>>;
constructor(githubToken?: string) {
this.octokit = githubToken ? new Octokit({ auth: githubToken }) : new Octokit();
this.cache = new Map();
}
private getCachedData<T>(key: string): CachedData<T> | null {
const cachedItem = this.cache.get(key);
if (cachedItem && Date.now() - cachedItem.dataFetched.getTime() < CACHE_TTL) {
return cachedItem;
}
return null;
}
private setCachedData<T>(key: string, data: T): void {
this.cache.set(key, { data, dataFetched: new Date() });
}
private async fetchWithRetry<T>(fetchFn: () => Promise<T>, retries: number = 3, delay: number = 5000): Promise<T> {
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 fetchContributorStats(): Promise<FetchResult<ContributorStats[]>> {
const cacheKey = `contributor_stats_${REPO_OWNER}_${REPO_NAME}`;
const cachedStats = this.getCachedData<ContributorStats[]>(cacheKey);
if (cachedStats) {
return {
data: cachedStats.data,
dataFetched: cachedStats.dataFetched,
lastUpdated: new Date(),
isCached: true,
};
}
const { data } = await this.fetchWithRetry(() =>
this.octokit.repos.getContributorsStats({
owner: REPO_OWNER,
repo: REPO_NAME,
})
);
if (Array.isArray(data)) {
const fetchResult: FetchResult<ContributorStats[]> = {
data: data as ContributorStats[],
dataFetched: new Date(),
lastUpdated: new Date(),
isCached: false,
};
this.setCachedData(cacheKey, fetchResult.data);
return fetchResult;
}
throw new Error('Invalid response format');
}
private async fetchMergedPRsCount(username: string): Promise<FetchResult<number>> {
const cacheKey = `merged_prs_${username}`;
const cachedCount = this.getCachedData<number>(cacheKey);
if (cachedCount !== null) {
return {
data: cachedCount.data,
dataFetched: 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<number> = {
data: data.total_count,
dataFetched: new Date(),
lastUpdated: new Date(),
isCached: false,
};
this.setCachedData(cacheKey, fetchResult.data);
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<string, GitHubStats>;
userGitHubStats: Record<string, GitHubStats>;
contributors: string[];
dataFetched: Date;
lastUpdated: Date;
isCached: boolean;
}> {
const { includeMergedPRs = false } = options;
const adminGitHubStats: Record<string, GitHubStats> = {};
const userGitHubStats: Record<string, GitHubStats> = {};
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;
}
}
})
);
return {
adminGitHubStats,
userGitHubStats,
contributors,
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();