Skip to content

Commit efc8c3e

Browse files
feat!: pr/issue tracker (#69)
1 parent c736fbf commit efc8c3e

File tree

10 files changed

+483
-24
lines changed

10 files changed

+483
-24
lines changed

src/lib/components/GHBadge.svelte

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
<script lang="ts">
2+
import type { ClassValue } from "svelte/elements";
23
import {
34
CircleCheck,
45
CircleDot,
@@ -22,38 +23,45 @@
2223
};
2324
2425
type Props = {
26+
mode?: "regular" | "minimal";
2527
type: PropsObj["type"];
2628
status: PropsObj["status"];
29+
class?: ClassValue;
2730
};
2831
29-
let { type, status }: Props = $props();
32+
let { mode = "regular", type, status, class: className = undefined }: Props = $props();
3033
3134
let icon = $state<typeof Icon>();
3235
let label = $state("");
33-
let color = $state("");
36+
let textColor = $state("");
37+
let bgColor = $state("");
3438
3539
switch (type) {
3640
case "pull":
3741
switch (status) {
3842
case "draft":
3943
icon = GitPullRequestDraft;
4044
label = "Draft";
41-
color = "bg-neutral-500";
45+
textColor = "text-neutral-500";
46+
bgColor = "bg-neutral-500";
4247
break;
4348
case "open":
4449
icon = GitPullRequestArrow;
4550
label = "Open";
46-
color = "bg-green-600";
51+
textColor = "text-green-600";
52+
bgColor = "bg-green-600";
4753
break;
4854
case "merged":
4955
icon = GitMerge;
5056
label = "Merged";
51-
color = "bg-purple-500";
57+
textColor = "text-purple-500";
58+
bgColor = "bg-purple-500";
5259
break;
5360
case "closed":
5461
icon = GitPullRequestClosed;
5562
label = "Closed";
56-
color = "bg-red-500";
63+
textColor = "text-red-500";
64+
bgColor = "bg-red-500";
5765
break;
5866
}
5967
break;
@@ -62,26 +70,34 @@
6270
case "open":
6371
icon = CircleDot;
6472
label = "Open";
65-
color = "bg-green-600";
73+
textColor = "text-green-600";
74+
bgColor = "bg-green-600";
6675
break;
6776
case "closed":
6877
icon = CircleSlash;
6978
label = "Closed";
70-
color = "bg-neutral-500";
79+
textColor = "text-neutral-500";
80+
bgColor = "bg-neutral-500";
7181
break;
7282
case "solved":
7383
icon = CircleCheck;
7484
label = "Solved";
75-
color = "bg-purple-500";
85+
textColor = "text-purple-500";
86+
bgColor = "bg-purple-500";
7687
break;
7788
}
7889
}
7990
</script>
8091

81-
<div class="flex items-center rounded-full px-4 py-2 text-white {color}">
82-
{#if icon}
83-
{@const SvelteComponent = icon}
84-
<SvelteComponent class="mr-2 size-5" />
85-
{/if}
86-
<span class="font-semibold">{label}</span>
87-
</div>
92+
{#if mode === "regular"}
93+
<div class={["flex items-center rounded-full px-4 py-2 text-white", bgColor, className]}>
94+
{#if icon}
95+
{@const SvelteComponent = icon}
96+
<SvelteComponent class="mr-2 size-5" />
97+
{/if}
98+
<span class="font-semibold">{label}</span>
99+
</div>
100+
{:else if icon}
101+
{@const Component = icon}
102+
<Component class={["size-6", textColor, className]} />
103+
{/if}

src/lib/server/github-cache.ts

Lines changed: 115 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
* The TTL for non-deprecated packages, in seconds
7588
*/
@@ -102,6 +115,22 @@ export class GitHubCache {
102115
});
103116
}
104117

118+
/**
119+
* Generates a Redis key from the passed info.
120+
*
121+
* @param owner the GitHub repository owner
122+
* @param type the kind of cache to use
123+
* @param args the optional additional values to append
124+
* at the end of the key; every element will be interpolated
125+
* in a string
126+
* @returns the pure computed key
127+
* @private
128+
*/
129+
#getOwnerKey(owner: string, type: OwnerKeyType, ...args: unknown[]) {
130+
const strArgs = args.map(a => `:${a}`).join("");
131+
return `owner:${owner}:${type}${strArgs}`;
132+
}
133+
105134
/**
106135
* Generates a Redis key from the passed info.
107136
*
@@ -114,8 +143,8 @@ export class GitHubCache {
114143
* @returns the pure computed key
115144
* @private
116145
*/
117-
#getRepoKey(owner: string, repo: string, type: KeyType, ...args: unknown[]) {
118-
const strArgs = args.map(a => `:${a}`);
146+
#getRepoKey(owner: string, repo: string, type: RepoKeyType, ...args: unknown[]) {
147+
const strArgs = args.map(a => `:${a}`).join("");
119148
return `repo:${owner}/${repo}:${type}${strArgs}`;
120149
}
121150

@@ -130,7 +159,7 @@ export class GitHubCache {
130159
* @private
131160
*/
132161
#getPackageKey(packageName: string, ...args: unknown[]) {
133-
const strArgs = args.map(a => `:${a}`);
162+
const strArgs = args.map(a => `:${a}`).join("");
134163
return `package:${packageName}${strArgs}`;
135164
}
136165

@@ -200,7 +229,7 @@ export class GitHubCache {
200229
owner: string,
201230
repo: string,
202231
id: number,
203-
type: ExtractStrict<KeyType, "issue" | "pr"> | undefined = undefined
232+
type: ExtractStrict<RepoKeyType, "issue" | "pr"> | undefined = undefined
204233
) {
205234
// Known type we assume the existence of
206235
switch (type) {
@@ -612,6 +641,85 @@ export class GitHubCache {
612641
);
613642
}
614643

644+
/**
645+
* Get the list of members for a given organization.
646+
*
647+
* @param owner the GitHub organization name
648+
* @returns a list of members, or `undefined` if not existing
649+
*/
650+
async getOrganizationMembers(owner: string) {
651+
return await this.#processCached<Member[]>()(
652+
this.#getOwnerKey(owner, "members"),
653+
async () => {
654+
try {
655+
const { data: members } = await this.#octokit.rest.orgs.listPublicMembers({
656+
org: owner,
657+
per_page
658+
});
659+
return members;
660+
} catch {
661+
return [] as Member[];
662+
}
663+
},
664+
members => members,
665+
MEMBERS_TTL
666+
);
667+
}
668+
669+
/**
670+
* Get all the issues for a given GitHub repository.
671+
*
672+
* @param owner the GitHub repository owner
673+
* @param repo the GitHub repository name
674+
* @returns a list of issues, empty if not existing
675+
*/
676+
async getAllIssues(owner: string, repo: string) {
677+
return await this.#processCached<Issue[]>()(
678+
this.#getRepoKey(owner, repo, "issues"),
679+
async () => {
680+
try {
681+
const { data: issues } = await this.#octokit.rest.issues.listForRepo({
682+
owner,
683+
repo,
684+
per_page
685+
});
686+
return issues;
687+
} catch {
688+
return [] as Issue[];
689+
}
690+
},
691+
issues => issues,
692+
FULL_DETAILS_TTL
693+
);
694+
}
695+
696+
/**
697+
* Get all the pull requests for a given GitHub repository.
698+
*
699+
* @param owner the GitHub repository owner
700+
* @param repo the GitHub repository name
701+
* @returns a list of pull requests, empty if not existing
702+
*/
703+
async getAllPRs(owner: string, repo: string) {
704+
return await this.#processCached<ListedPullRequest[]>()(
705+
this.#getRepoKey(owner, repo, "prs"),
706+
async () => {
707+
try {
708+
const { data: prs } = await this.#octokit.rest.pulls.list({
709+
owner,
710+
repo,
711+
per_page
712+
});
713+
return prs;
714+
} catch {
715+
return [] as ListedPullRequest[];
716+
}
717+
},
718+
prs => prs,
719+
FULL_DETAILS_TTL
720+
);
721+
}
722+
615723
/**
616724
* Get the deprecation state of a package from its name.
617725
*

src/routes/+layout.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@
127127
<!-- Navigation -->
128128
{#if !page.route.id?.startsWith("/blog")}
129129
<ul class="ml-6 hidden sm:flex">
130-
{#each [{ link: "/packages", title: "Packages" }, { link: "/blog", title: "Blog" }] as { link, title } (link)}
130+
{#each [{ link: "/packages", title: "Packages" }, { link: "/tracker", title: "Tracker" }, { link: "/blog", title: "Blog" }] as { link, title } (link)}
131131
{@const disabled = page.url.pathname.startsWith(link)}
132132
<li>
133133
<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: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<script lang="ts">
2+
import { page } from "$app/state";
3+
import { Menu } from "@lucide/svelte";
4+
import { uniqueRepos } from "$lib/repositories";
5+
import { Button } from "$lib/components/ui/button";
6+
import * as Sheet from "$lib/components/ui/sheet";
7+
import RepoSidePanel from "./RepoSidePanel.svelte";
8+
9+
let { children } = $props();
10+
</script>
11+
12+
{#snippet repoList()}
13+
<ul class="space-y-1">
14+
{#each uniqueRepos as repo (repo)}
15+
{@const active = repo.owner === page.params.org && repo.name === page.params.repo}
16+
<li>
17+
{#if active}
18+
<span class="font-bold underline underline-offset-2">{repo.owner}/<wbr />{repo.name}</span
19+
>
20+
{:else}
21+
<a
22+
href="../{repo.owner}/{repo.name}"
23+
class="hover:underline hover:text-primary underline-offset-2"
24+
>
25+
{repo.owner}/<wbr />{repo.name}
26+
</a>
27+
{/if}
28+
</li>
29+
{/each}
30+
</ul>
31+
{/snippet}
32+
33+
<div class="relative flex gap-8">
34+
<div class="min-w-0">
35+
{@render children()}
36+
</div>
37+
38+
<Sheet.Root>
39+
<Sheet.Trigger>
40+
{#snippet child({ props })}
41+
<Button
42+
{...props}
43+
variant="secondary"
44+
class="absolute right-0 mt-12 ml-auto lg:hidden sm:mt-16"
45+
>
46+
<Menu />
47+
<span class="sr-only">Menu</span>
48+
</Button>
49+
{/snippet}
50+
</Sheet.Trigger>
51+
<Sheet.Content class="overflow-y-auto">
52+
<Sheet.Header>
53+
<Sheet.Title>Repositories</Sheet.Title>
54+
</Sheet.Header>
55+
<RepoSidePanel headless title="Repositories" class="my-8">
56+
{@render repoList()}
57+
</RepoSidePanel>
58+
</Sheet.Content>
59+
</Sheet.Root>
60+
61+
<RepoSidePanel title="Repositories" class="mt-43 shrink-0 hidden h-fit w-80 lg:flex flex-col">
62+
{@render repoList()}
63+
</RepoSidePanel>
64+
</div>

0 commit comments

Comments
 (0)