From 6e3b93194147bc69e0401b6a4ec6664f3a8f27aa Mon Sep 17 00:00:00 2001 From: WarningImHack3r <43064022+WarningImHack3r@users.noreply.github.com> Date: Fri, 9 May 2025 00:52:36 +0200 Subject: [PATCH 01/12] feat: scaffold the feature --- src/lib/server/github-cache.ts | 152 +++++++++++++++++- src/routes/+layout.svelte | 2 +- src/routes/tracker/+page.server.ts | 9 ++ src/routes/tracker/[org]/+page.server.ts | 11 ++ .../tracker/[org]/[repo]/+page.server.ts | 51 ++++++ src/routes/tracker/[org]/[repo]/+page.svelte | 91 +++++++++++ src/routes/tracker/[org]/[repo]/+page.ts | 10 ++ 7 files changed, 318 insertions(+), 8 deletions(-) create mode 100644 src/routes/tracker/+page.server.ts create mode 100644 src/routes/tracker/[org]/+page.server.ts create mode 100644 src/routes/tracker/[org]/[repo]/+page.server.ts create mode 100644 src/routes/tracker/[org]/[repo]/+page.svelte create mode 100644 src/routes/tracker/[org]/[repo]/+page.ts diff --git a/src/lib/server/github-cache.ts b/src/lib/server/github-cache.ts index c064090b..ec69cd5e 100644 --- a/src/lib/server/github-cache.ts +++ b/src/lib/server/github-cache.ts @@ -17,19 +17,28 @@ export type GitHubRelease = Awaited< ReturnType["rest"]["repos"]["listReleases"]> >["data"][number]; -type KeyType = "releases" | "descriptions" | "issue" | "pr"; +export type Member = Awaited< + ReturnType["rest"]["orgs"]["listMembers"]> +>["data"][number]; + +type OwnerKeyType = "members"; + +type RepoKeyType = "releases" | "descriptions" | "issue" | "issues" | "pr" | "prs"; export type ItemDetails = { comments: Awaited>["data"]; }; +export type Issue = Awaited>["data"]; export type IssueDetails = ItemDetails & { - info: Awaited>["data"]; + info: Issue; linkedPrs: LinkedItem[]; }; +export type PullRequest = Awaited>["data"]; +export type ListedPullRequest = Awaited>["data"][number]; export type PullRequestDetails = ItemDetails & { - info: Awaited>["data"]; + info: PullRequest; commits: Awaited>["data"]; files: Awaited>["data"]; linkedIssues: LinkedItem[]; @@ -70,6 +79,10 @@ const FULL_DETAILS_TTL = 60 * 60 * 2; // 2 hours * The TTL of the cached descriptions, in seconds. */ const DESCRIPTIONS_TTL = 60 * 60 * 24 * 10; // 10 days +/** + * The TTL of organization members, in seconds. + */ +const MEMBERS_TTL = 60 * 60 * 24 * 2; // 2 days /** * A fetch layer to reach the GitHub API @@ -98,6 +111,22 @@ export class GitHubCache { }); } + /** + * Generates a Redis key from the passed info. + * + * @param owner the GitHub repository owner + * @param type the kind of cache to use + * @param args the optional additional values to append + * at the end of the key; every element will be interpolated + * in a string + * @returns the pure computed key + * @private + */ + #getOwnerKey(owner: string, type: OwnerKeyType, ...args: unknown[]) { + const strArgs = args.map(a => `:${a}`); + return `owner:${owner}:${type}${strArgs}`; + } + /** * Generates a Redis key from the passed info. * @@ -110,7 +139,7 @@ export class GitHubCache { * @returns the pure computed key * @private */ - #getRepoKey(owner: string, repo: string, type: KeyType, ...args: unknown[]) { + #getRepoKey(owner: string, repo: string, type: RepoKeyType, ...args: unknown[]) { const strArgs = args.map(a => `:${a}`); return `repo:${owner}/${repo}:${type}${strArgs}`; } @@ -130,7 +159,7 @@ export class GitHubCache { owner: string, repo: string, id: number, - type: ExtractStrict | undefined = undefined + type: ExtractStrict | undefined = undefined ) { // Known type we assume the existence of switch (type) { @@ -575,6 +604,115 @@ export class GitHubCache { return Object.fromEntries(descriptions); } + /** + * Get the list of members for a given organization. + * + * @param owner the GitHub organization name + * @returns a list of members, or `undefined` if not existing + */ + async getOrganizationMembers(owner: string): Promise { + const cacheKey = this.#getOwnerKey(owner, "members"); + + const cachedMembers = await this.#redis.json.get(cacheKey); + if (cachedMembers) { + console.log(`Cache hit for members of ${owner}`); + return cachedMembers.length ? cachedMembers : undefined; // technically we can have a real empty list, but we ignore this case here + } + + console.log(`Cache miss for members of ${owner}, fetching from GitHub API`); + + try { + const { data: members } = await this.#octokit.rest.orgs.listPublicMembers({ + org: owner, + per_page + }); + + await this.#redis.json.set(cacheKey, "$", members); + await this.#redis.expire(cacheKey, MEMBERS_TTL); + + return members; + } catch { + await this.#redis.json.set(cacheKey, "$", []); + await this.#redis.expire(cacheKey, MEMBERS_TTL); + + return undefined; + } + } + + /** + * Get all the issues for a given GitHub repository. + * + * @param owner the GitHub repository owner + * @param repo the GitHub repository name + * @returns a list of issues, or `undefined` if not existing + */ + async getAllIssues(owner: string, repo: string): Promise { + const cacheKey = this.#getRepoKey(owner, repo, "issues"); + + const cachedIssues = await this.#redis.json.get(cacheKey); + if (cachedIssues) { + console.log(`Cache hit for issues for ${owner}`); + return cachedIssues.length ? cachedIssues : undefined; + } + + console.log(`Cache miss for issues for ${owner}`); + + try { + const { data: issues } = await this.#octokit.rest.issues.listForRepo({ + owner, + repo, + per_page + }); + + await this.#redis.json.set(cacheKey, "$", issues); + await this.#redis.expire(cacheKey, FULL_DETAILS_TTL); + + return issues; + } catch { + await this.#redis.json.set(cacheKey, "$", []); + await this.#redis.expire(cacheKey, FULL_DETAILS_TTL); + + return undefined; + } + } + + /** + * Get all the pull requests for a given GitHub repository. + * + * @param owner the GitHub repository owner + * @param repo the GitHub repository name + * @returns a list of pull requests, or `undefined` if not existing + */ + async getAllPRs(owner: string, repo: string): Promise { + const cacheKey = this.#getRepoKey(owner, repo, "prs"); + + const cachedPrs = await this.#redis.json.get(cacheKey); + if (cachedPrs) { + console.log(`Cache hit for PRs for ${owner}`); + return cachedPrs.length ? cachedPrs : undefined; + } + + console.log(`Cache miss for PRs for PRs for ${owner}`); + + try { + const { data: prs } = await this.#octokit.rest.pulls.list({ + owner, + repo, + per_page + }); + + await this.#redis.json.set(cacheKey, "$", prs); + await this.#redis.expire(cacheKey, FULL_DETAILS_TTL); + + return prs; + } catch { + await this.#redis.json.set(cacheKey, "$", []); + await this.#redis.expire(cacheKey, FULL_DETAILS_TTL); + + return undefined; + } + } + /** * Checks if releases are present in the cache for the * given GitHub info @@ -586,7 +724,7 @@ export class GitHubCache { * @param type the kind of cache to target * @returns whether the repository is cached or not */ - async exists(owner: string, repo: string, type: KeyType) { + async exists(owner: string, repo: string, type: RepoKeyType) { const cacheKey = this.#getRepoKey(owner, repo, type); const result = await this.#redis.exists(cacheKey); return result === 1; @@ -601,7 +739,7 @@ export class GitHubCache { * from the cache * @param type the kind of cache to target */ - async deleteEntry(owner: string, repo: string, type: KeyType) { + async deleteEntry(owner: string, repo: string, type: RepoKeyType) { const cacheKey = this.#getRepoKey(owner, repo, type); await this.#redis.del(cacheKey); } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index a254bc75..d6be9b73 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -147,7 +147,7 @@ {#if !page.route.id?.startsWith("/blog")}