Skip to content

Commit 6e3b931

Browse files
feat: scaffold the feature
1 parent bddd1e4 commit 6e3b931

File tree

7 files changed

+318
-8
lines changed

7 files changed

+318
-8
lines changed

src/lib/server/github-cache.ts

Lines changed: 145 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,19 +17,28 @@ export type GitHubRelease = Awaited<
1717
ReturnType<InstanceType<typeof Octokit>["rest"]["repos"]["listReleases"]>
1818
>["data"][number];
1919

20-
type KeyType = "releases" | "descriptions" | "issue" | "pr";
20+
export type Member = Awaited<
21+
ReturnType<InstanceType<typeof Octokit>["rest"]["orgs"]["listMembers"]>
22+
>["data"][number];
23+
24+
type OwnerKeyType = "members";
25+
26+
type RepoKeyType = "releases" | "descriptions" | "issue" | "issues" | "pr" | "prs";
2127

2228
export type ItemDetails = {
2329
comments: Awaited<ReturnType<Issues["listComments"]>>["data"];
2430
};
2531

32+
export type Issue = Awaited<ReturnType<Issues["get"]>>["data"];
2633
export type IssueDetails = ItemDetails & {
27-
info: Awaited<ReturnType<Issues["get"]>>["data"];
34+
info: Issue;
2835
linkedPrs: LinkedItem[];
2936
};
3037

38+
export type PullRequest = Awaited<ReturnType<Pulls["get"]>>["data"];
39+
export type ListedPullRequest = Awaited<ReturnType<Pulls["list"]>>["data"][number];
3140
export type PullRequestDetails = ItemDetails & {
32-
info: Awaited<ReturnType<Pulls["get"]>>["data"];
41+
info: PullRequest;
3342
commits: Awaited<ReturnType<Pulls["listCommits"]>>["data"];
3443
files: Awaited<ReturnType<Pulls["listFiles"]>>["data"];
3544
linkedIssues: LinkedItem[];
@@ -70,6 +79,10 @@ const FULL_DETAILS_TTL = 60 * 60 * 2; // 2 hours
7079
* The TTL of the cached descriptions, in seconds.
7180
*/
7281
const DESCRIPTIONS_TTL = 60 * 60 * 24 * 10; // 10 days
82+
/**
83+
* The TTL of organization members, in seconds.
84+
*/
85+
const MEMBERS_TTL = 60 * 60 * 24 * 2; // 2 days
7386

7487
/**
7588
* A fetch layer to reach the GitHub API
@@ -98,6 +111,22 @@ export class GitHubCache {
98111
});
99112
}
100113

114+
/**
115+
* Generates a Redis key from the passed info.
116+
*
117+
* @param owner the GitHub repository owner
118+
* @param type the kind of cache to use
119+
* @param args the optional additional values to append
120+
* at the end of the key; every element will be interpolated
121+
* in a string
122+
* @returns the pure computed key
123+
* @private
124+
*/
125+
#getOwnerKey(owner: string, type: OwnerKeyType, ...args: unknown[]) {
126+
const strArgs = args.map(a => `:${a}`);
127+
return `owner:${owner}:${type}${strArgs}`;
128+
}
129+
101130
/**
102131
* Generates a Redis key from the passed info.
103132
*
@@ -110,7 +139,7 @@ export class GitHubCache {
110139
* @returns the pure computed key
111140
* @private
112141
*/
113-
#getRepoKey(owner: string, repo: string, type: KeyType, ...args: unknown[]) {
142+
#getRepoKey(owner: string, repo: string, type: RepoKeyType, ...args: unknown[]) {
114143
const strArgs = args.map(a => `:${a}`);
115144
return `repo:${owner}/${repo}:${type}${strArgs}`;
116145
}
@@ -130,7 +159,7 @@ export class GitHubCache {
130159
owner: string,
131160
repo: string,
132161
id: number,
133-
type: ExtractStrict<KeyType, "issue" | "pr"> | undefined = undefined
162+
type: ExtractStrict<RepoKeyType, "issue" | "pr"> | undefined = undefined
134163
) {
135164
// Known type we assume the existence of
136165
switch (type) {
@@ -575,6 +604,115 @@ export class GitHubCache {
575604
return Object.fromEntries(descriptions);
576605
}
577606

607+
/**
608+
* Get the list of members for a given organization.
609+
*
610+
* @param owner the GitHub organization name
611+
* @returns a list of members, or `undefined` if not existing
612+
*/
613+
async getOrganizationMembers(owner: string): Promise<Member[] | undefined> {
614+
const cacheKey = this.#getOwnerKey(owner, "members");
615+
616+
const cachedMembers = await this.#redis.json.get<Member[]>(cacheKey);
617+
if (cachedMembers) {
618+
console.log(`Cache hit for members of ${owner}`);
619+
return cachedMembers.length ? cachedMembers : undefined; // technically we can have a real empty list, but we ignore this case here
620+
}
621+
622+
console.log(`Cache miss for members of ${owner}, fetching from GitHub API`);
623+
624+
try {
625+
const { data: members } = await this.#octokit.rest.orgs.listPublicMembers({
626+
org: owner,
627+
per_page
628+
});
629+
630+
await this.#redis.json.set(cacheKey, "$", members);
631+
await this.#redis.expire(cacheKey, MEMBERS_TTL);
632+
633+
return members;
634+
} catch {
635+
await this.#redis.json.set(cacheKey, "$", []);
636+
await this.#redis.expire(cacheKey, MEMBERS_TTL);
637+
638+
return undefined;
639+
}
640+
}
641+
642+
/**
643+
* Get all the issues for a given GitHub repository.
644+
*
645+
* @param owner the GitHub repository owner
646+
* @param repo the GitHub repository name
647+
* @returns a list of issues, or `undefined` if not existing
648+
*/
649+
async getAllIssues(owner: string, repo: string): Promise<Issue[] | undefined> {
650+
const cacheKey = this.#getRepoKey(owner, repo, "issues");
651+
652+
const cachedIssues = await this.#redis.json.get<Issue[]>(cacheKey);
653+
if (cachedIssues) {
654+
console.log(`Cache hit for issues for ${owner}`);
655+
return cachedIssues.length ? cachedIssues : undefined;
656+
}
657+
658+
console.log(`Cache miss for issues for ${owner}`);
659+
660+
try {
661+
const { data: issues } = await this.#octokit.rest.issues.listForRepo({
662+
owner,
663+
repo,
664+
per_page
665+
});
666+
667+
await this.#redis.json.set(cacheKey, "$", issues);
668+
await this.#redis.expire(cacheKey, FULL_DETAILS_TTL);
669+
670+
return issues;
671+
} catch {
672+
await this.#redis.json.set(cacheKey, "$", []);
673+
await this.#redis.expire(cacheKey, FULL_DETAILS_TTL);
674+
675+
return undefined;
676+
}
677+
}
678+
679+
/**
680+
* Get all the pull requests for a given GitHub repository.
681+
*
682+
* @param owner the GitHub repository owner
683+
* @param repo the GitHub repository name
684+
* @returns a list of pull requests, or `undefined` if not existing
685+
*/
686+
async getAllPRs(owner: string, repo: string): Promise<ListedPullRequest[] | undefined> {
687+
const cacheKey = this.#getRepoKey(owner, repo, "prs");
688+
689+
const cachedPrs = await this.#redis.json.get<ListedPullRequest[]>(cacheKey);
690+
if (cachedPrs) {
691+
console.log(`Cache hit for PRs for ${owner}`);
692+
return cachedPrs.length ? cachedPrs : undefined;
693+
}
694+
695+
console.log(`Cache miss for PRs for PRs for ${owner}`);
696+
697+
try {
698+
const { data: prs } = await this.#octokit.rest.pulls.list({
699+
owner,
700+
repo,
701+
per_page
702+
});
703+
704+
await this.#redis.json.set(cacheKey, "$", prs);
705+
await this.#redis.expire(cacheKey, FULL_DETAILS_TTL);
706+
707+
return prs;
708+
} catch {
709+
await this.#redis.json.set(cacheKey, "$", []);
710+
await this.#redis.expire(cacheKey, FULL_DETAILS_TTL);
711+
712+
return undefined;
713+
}
714+
}
715+
578716
/**
579717
* Checks if releases are present in the cache for the
580718
* given GitHub info
@@ -586,7 +724,7 @@ export class GitHubCache {
586724
* @param type the kind of cache to target
587725
* @returns whether the repository is cached or not
588726
*/
589-
async exists(owner: string, repo: string, type: KeyType) {
727+
async exists(owner: string, repo: string, type: RepoKeyType) {
590728
const cacheKey = this.#getRepoKey(owner, repo, type);
591729
const result = await this.#redis.exists(cacheKey);
592730
return result === 1;
@@ -601,7 +739,7 @@ export class GitHubCache {
601739
* from the cache
602740
* @param type the kind of cache to target
603741
*/
604-
async deleteEntry(owner: string, repo: string, type: KeyType) {
742+
async deleteEntry(owner: string, repo: string, type: RepoKeyType) {
605743
const cacheKey = this.#getRepoKey(owner, repo, type);
606744
await this.#redis.del(cacheKey);
607745
}

src/routes/+layout.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@
147147
<!-- Navigation -->
148148
{#if !page.route.id?.startsWith("/blog")}
149149
<ul class="ml-6 hidden sm:flex">
150-
{#each [{ link: "/packages", title: "Packages" }, { link: "/blog", title: "Blog" }] as { link, title } (link)}
150+
{#each [{ link: "/packages", title: "Packages" }, { link: "/tracker", title: "Tracker" }, { link: "/blog", title: "Blog" }] as { link, title } (link)}
151151
{@const disabled = page.url.pathname.startsWith(link)}
152152
<li>
153153
<Button

src/routes/tracker/+page.server.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { redirect } from "@sveltejs/kit";
2+
import { uniqueRepos } from "$lib/repositories";
3+
4+
export function load({ url }) {
5+
// Load the first repo of our list
6+
const firstRepo = uniqueRepos[0];
7+
if (!firstRepo) redirect(307, "/");
8+
redirect(307, `${url.pathname}/${firstRepo.owner}/${firstRepo.name}`);
9+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { redirect } from "@sveltejs/kit";
2+
import { uniqueRepos } from "$lib/repositories";
3+
4+
export function load({ params }) {
5+
// Load the first repo that has the org
6+
const matchingRepo = uniqueRepos.find(
7+
({ owner }) => owner.localeCompare(params.org, undefined, { sensitivity: "base" }) === 0
8+
);
9+
if (!matchingRepo) redirect(307, "/");
10+
redirect(307, `/tracker/${matchingRepo.owner}/${matchingRepo.name}`);
11+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { error } from "@sveltejs/kit";
2+
import { gitHubCache } from "$lib/server/github-cache";
3+
4+
// source: https://docs.github.com/en/issues/tracking-your-work-with-issues/using-issues/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword
5+
const closingKeywords = [
6+
"close",
7+
"closes",
8+
"closed",
9+
"fix",
10+
"fixes",
11+
"fixed",
12+
"resolve",
13+
"resolves",
14+
"resolved"
15+
];
16+
17+
export async function load({ params }) {
18+
const members = await gitHubCache.getOrganizationMembers(params.org);
19+
if (!members) error(404, `Organization ${params.org} not found or empty`);
20+
21+
const membersNames = members.map(({ login }) => login);
22+
23+
const [unfilteredPRs, unfilteredIssues] = await Promise.all([
24+
gitHubCache.getAllPRs(params.org, params.repo),
25+
gitHubCache.getAllIssues(params.org, params.repo)
26+
]);
27+
return {
28+
prs: unfilteredPRs
29+
?.filter(({ user, body }) => {
30+
if (!membersNames.includes(user?.login ?? "")) return false;
31+
if (!body) return true;
32+
const lowerBody = body.toLowerCase();
33+
for (const keyword of closingKeywords) {
34+
if (
35+
lowerBody.includes(`${keyword} #`) ||
36+
lowerBody.includes(`${keyword} https://github.com`) ||
37+
new RegExp(`${keyword} [A-z0-9]+/[A-z0-9]+#[0-9]+`).test(lowerBody)
38+
) {
39+
return false;
40+
}
41+
}
42+
return true;
43+
})
44+
.toSorted((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
45+
.slice(0, 10),
46+
issues: unfilteredIssues
47+
?.filter(({ user }) => membersNames.includes(user?.login ?? ""))
48+
.toSorted((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
49+
.slice(0, 10)
50+
};
51+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
<script lang="ts">
2+
import { Transparent } from "svelte-exmarkdown";
3+
import { Separator } from "$lib/components/ui/separator";
4+
import MarkdownRenderer from "$lib/components/MarkdownRenderer.svelte";
5+
6+
let { data } = $props();
7+
8+
type Item = NonNullable<typeof data.issues>[number] | NonNullable<typeof data.prs>[number];
9+
10+
function isNew(date: Date) {
11+
return date.getTime() >= new Date().getTime() - 1000 * 60 * 60 * 24 * 30;
12+
}
13+
14+
function daysAgo(date: Date) {
15+
const days = Math.floor((new Date().getTime() - date.getTime()) / 1000 / 60 / 60 / 24);
16+
return new Intl.RelativeTimeFormat("en", { numeric: "auto" }).format(-days, "day");
17+
}
18+
</script>
19+
20+
<!-- snippets don't support generics: https://github.com/sveltejs/svelte/issues/15883 -->
21+
<!-- eslint-disable-next-line @typescript-eslint/no-explicit-any -->
22+
{#snippet list(title: string, items: Item[], itemToLink: (item: any) => string)}
23+
<!-- {#snippet list<T extends Item>(title: string, items: T[], itemToLink: (item: T) => string)} -->
24+
<div>
25+
<h2 class="mt-12 mb-2 text-2xl font-semibold tracking-tight">{title}</h2>
26+
{#each items as item, i (item.id)}
27+
{#if i > 0}
28+
<Separator class="my-1" />
29+
{/if}
30+
{@render listItem(item, itemToLink(item))}
31+
{/each}
32+
</div>
33+
{/snippet}
34+
35+
{#snippet listItem(item: Item, link: string)}
36+
{@const date = new Date(item.created_at)}
37+
<a
38+
href={link}
39+
class="flex flex-col rounded-xl px-4 py-3 transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-800"
40+
>
41+
<div class="flex justify-between">
42+
<span>
43+
<MarkdownRenderer markdown={item.title} inline class="text-foreground" />
44+
<span class="text-muted-foreground">#{item.number}</span>
45+
</span>
46+
<span>
47+
{#if isNew(date)}
48+
{daysAgo(date)} •
49+
{/if}
50+
{new Intl.DateTimeFormat("en", {
51+
dateStyle: "medium"
52+
}).format(date)}
53+
</span>
54+
</div>
55+
<MarkdownRenderer
56+
markdown={item.body || "_No description_"}
57+
inline
58+
parseRawHtml
59+
class="line-clamp-2 max-w-full text-base text-muted-foreground"
60+
additionalPlugins={[
61+
{
62+
renderer: {
63+
h1: "h4",
64+
h2: "h4",
65+
h3: "h4",
66+
pre: Transparent,
67+
a: Transparent,
68+
ul: Transparent
69+
}
70+
}
71+
]}
72+
/>
73+
</a>
74+
{/snippet}
75+
76+
{#if data.prs}
77+
{@render list(
78+
"Pull requests",
79+
data.prs,
80+
pr => `/pull/${pr.base.repo.owner.login}/${pr.base.repo.name}/${pr.number}`
81+
)}
82+
{/if}
83+
84+
{#if data.issues}
85+
{@render list("Issues", data.issues, issue => {
86+
const ownerSlashRepo = issue.html_url
87+
.replace("https://github.com/", "")
88+
.replace(/\/[A-z]+\/\d+$/, "");
89+
return `/issues/${ownerSlashRepo}/${issue.number}`;
90+
})}
91+
{/if}

0 commit comments

Comments
 (0)