Skip to content

Commit fcca0dc

Browse files
feat: re-wire the details page
It's so fast now I love it so much
1 parent 670a554 commit fcca0dc

File tree

8 files changed

+394
-131
lines changed

8 files changed

+394
-131
lines changed

src/lib/server/github-cache.ts

Lines changed: 289 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,50 @@
11
import { GITHUB_TOKEN, KV_REST_API_TOKEN, KV_REST_API_URL } from "$env/static/private";
22
import { Redis } from "@upstash/redis";
33
import { Octokit } from "octokit";
4+
import type { Repository as GQLRepository } from "@octokit/graphql-schema";
45
import type { Repository } from "$lib/repositories";
6+
import type { Issues, Pulls } from "$lib/types";
57
import parseChangelog from "$lib/changelog-parser";
68

9+
/**
10+
* A strict version of Extract.
11+
*
12+
* @see {@link https://github.com/sindresorhus/type-fest/issues/222#issuecomment-940597759|Original implementation}
13+
*/
14+
type ExtractStrict<T, U extends T> = U;
15+
716
export type GitHubRelease = Awaited<
817
ReturnType<InstanceType<typeof Octokit>["rest"]["repos"]["listReleases"]>
918
>["data"][number];
1019

11-
type KeyType = "releases" | "descriptions";
20+
type KeyType = "releases" | "descriptions" | "issue" | "pr";
21+
22+
export type ItemDetails = {
23+
comments: Awaited<ReturnType<Issues["listComments"]>>["data"];
24+
};
25+
26+
export type IssueDetails = ItemDetails & {
27+
info: Awaited<ReturnType<Issues["get"]>>["data"];
28+
linkedPrs: LinkedItem[];
29+
};
30+
31+
export type PullRequestDetails = ItemDetails & {
32+
info: Awaited<ReturnType<Pulls["get"]>>["data"];
33+
commits: Awaited<ReturnType<Pulls["listCommits"]>>["data"];
34+
files: Awaited<ReturnType<Pulls["listFiles"]>>["data"];
35+
linkedIssues: LinkedItem[];
36+
};
37+
38+
export type LinkedItem = {
39+
number: number;
40+
title: string;
41+
author?: {
42+
avatarUrl: string;
43+
login: string;
44+
} | null;
45+
createdAt: string;
46+
body: string;
47+
};
1248

1349
/**
1450
* The maximum items amount to get per-page
@@ -23,13 +59,17 @@ type KeyType = "releases" | "descriptions";
2359
*/
2460
const per_page = 100;
2561
/**
26-
* The TTL of the cached values, in seconds.
62+
* The TTL of the cached releases, in seconds.
63+
*/
64+
const RELEASES_TTL = 60 * 15; // 15 min
65+
/**
66+
* The TTL of the full issue/pr details, in seconds.
2767
*/
28-
const cacheTTL = 60 * 15; // 15 min
68+
const FULL_DETAILS_TTL = 60 * 60 * 2; // 2 hours
2969
/**
30-
* The TTL of the cached values, in seconds.
70+
* The TTL of the cached descriptions, in seconds.
3171
*/
32-
const longCacheTTL = 60 * 60 * 24 * 10; // 10 days
72+
const DESCRIPTIONS_TTL = 60 * 60 * 24 * 10; // 10 days
3373

3474
/**
3575
* A fetch layer to reach the GitHub API
@@ -64,11 +104,251 @@ export class GitHubCache {
64104
* @param owner the GitHub repository owner
65105
* @param repo the GitHub repository name
66106
* @param type the kind of cache to use
107+
* @param args the optional additional values to append
108+
* at the end of the key; every element will be interpolated
109+
* in a string
67110
* @returns the pure computed key
68111
* @private
69112
*/
70-
#getRepoKey(owner: string, repo: string, type: KeyType) {
71-
return `repo:${owner}/${repo}:${type}`;
113+
#getRepoKey(owner: string, repo: string, type: KeyType, ...args: unknown[]) {
114+
const strArgs = args.map(a => `:${a}`);
115+
return `repo:${owner}/${repo}:${type}${strArgs}`;
116+
}
117+
118+
/**
119+
* Get the item (issue or pr) with the given information.
120+
* Return the appropriate value if the type is defined, or
121+
* try to coerce it otherwise.
122+
*
123+
* @param owner the GitHub repository owner
124+
* @param repo the GitHub repository name
125+
* @param id the issue/pr number
126+
* @param type the item to fetch
127+
* @returns the matching or specified item, or `null` if not found
128+
*/
129+
async getItemDetails(
130+
owner: string,
131+
repo: string,
132+
id: number,
133+
type: ExtractStrict<KeyType, "issue" | "pr"> | undefined = undefined
134+
) {
135+
// Known type we assume the existence of
136+
switch (type) {
137+
case "issue":
138+
return await this.getIssueDetails(owner, repo, id);
139+
case "pr":
140+
return await this.getPullRequestDetails(owner, repo, id);
141+
}
142+
143+
// Unknown type, try to find or null otherwise
144+
try {
145+
return await this.getPullRequestDetails(owner, repo, id);
146+
} catch (err: unknown) {
147+
console.error(`Error trying to get PR details for ${owner}/${repo}: ${err}`);
148+
}
149+
150+
try {
151+
// comes last because issues will also resolve for prs
152+
return await this.getIssueDetails(owner, repo, id);
153+
} catch (err: unknown) {
154+
console.error(`Error trying to get issue details for ${owner}/${repo}: ${err}`);
155+
}
156+
157+
return null;
158+
}
159+
160+
/**
161+
* Get the issue from the specified info.
162+
*
163+
* @param owner the GitHub repository owner
164+
* @param repo the GitHub repository name
165+
* @param id the issue number
166+
* @returns the matching issue
167+
* @throws Error if the issue is not found
168+
*/
169+
async getIssueDetails(owner: string, repo: string, id: number) {
170+
const cacheKey = this.#getRepoKey(owner, repo, "issue", id);
171+
172+
const cachedDetails = await this.#redis.json.get<IssueDetails>(cacheKey);
173+
if (cachedDetails) {
174+
console.log(`Cache hit for issue details for ${cacheKey}`);
175+
return cachedDetails;
176+
}
177+
178+
console.log(`Cache miss for issue details for ${cacheKey}, fetching from the GitHub API`);
179+
180+
const [{ data: info }, { data: comments }, linkedPrs] = await Promise.all([
181+
this.#octokit.rest.issues.get({ owner, repo, issue_number: id }),
182+
this.#octokit.rest.issues.listComments({ owner, repo, issue_number: id }),
183+
this.#getLinkedPullRequests(owner, repo, id)
184+
]);
185+
186+
const details: IssueDetails = { info, comments, linkedPrs };
187+
188+
await this.#redis.json.set(cacheKey, "$", details);
189+
await this.#redis.expire(cacheKey, FULL_DETAILS_TTL);
190+
191+
return details;
192+
}
193+
194+
/**
195+
* Get the pull request from the specified info.
196+
*
197+
* @param owner the GitHub repository owner
198+
* @param repo the GitHub repository name
199+
* @param id the PR number
200+
* @returns the matching pull request
201+
* @throws Error if the PR is not found
202+
*/
203+
async getPullRequestDetails(owner: string, repo: string, id: number) {
204+
const cacheKey = this.#getRepoKey(owner, repo, "pr", id);
205+
206+
const cachedDetails = await this.#redis.json.get<PullRequestDetails>(cacheKey);
207+
if (cachedDetails) {
208+
console.log(`Cache hit for PR details for ${cacheKey}`);
209+
return cachedDetails;
210+
}
211+
212+
console.log(`Cache miss for PR details for ${id}, fetching from the GitHub API`);
213+
214+
const [{ data: info }, { data: comments }, { data: commits }, { data: files }, linkedIssues] =
215+
await Promise.all([
216+
this.#octokit.rest.pulls.get({ owner, repo, pull_number: id }),
217+
this.#octokit.rest.issues.listComments({ owner, repo, issue_number: id }),
218+
this.#octokit.rest.pulls.listCommits({ owner, repo, pull_number: id }),
219+
this.#octokit.rest.pulls.listFiles({ owner, repo, pull_number: id }),
220+
this.#getLinkedIssues(owner, repo, id)
221+
]);
222+
223+
const details: PullRequestDetails = { info, comments, commits, files, linkedIssues };
224+
225+
// Cache the result
226+
await this.#redis.json.set(cacheKey, "$", details);
227+
await this.#redis.expire(cacheKey, FULL_DETAILS_TTL);
228+
229+
return details;
230+
}
231+
232+
/**
233+
* Get the pull requests linked to the given issue number.
234+
*
235+
* @param owner the GitHub repository owner
236+
* @param repo the GitHub repository name
237+
* @param issueNumber the issue number
238+
* @returns the linked pull requests
239+
* @private
240+
*/
241+
async #getLinkedPullRequests(owner: string, repo: string, issueNumber: number) {
242+
const result = await this.#octokit.graphql<{ repository: GQLRepository }>(
243+
`
244+
query($owner: String!, $repo: String!, $issueNumber: Int!) {
245+
repository(owner: $owner, name: $repo) {
246+
issue(number: $issueNumber) {
247+
timelineItems(first: 100, itemTypes: [CONNECTED_EVENT, CROSS_REFERENCED_EVENT]) {
248+
nodes {
249+
... on ConnectedEvent {
250+
subject {
251+
... on PullRequest {
252+
number
253+
title
254+
author {
255+
avatarUrl
256+
login
257+
}
258+
createdAt
259+
body
260+
}
261+
}
262+
}
263+
... on CrossReferencedEvent {
264+
source {
265+
... on PullRequest {
266+
number
267+
title
268+
author {
269+
avatarUrl
270+
login
271+
}
272+
createdAt
273+
body
274+
}
275+
}
276+
}
277+
}
278+
}
279+
}
280+
}
281+
}
282+
`,
283+
{
284+
owner,
285+
repo,
286+
issueNumber
287+
}
288+
);
289+
290+
// Extract and deduplicate PRs
291+
const linkedPRs = new Map<number, LinkedItem>();
292+
const timelineItems = result?.repository?.issue?.timelineItems?.nodes ?? [];
293+
294+
for (const item of timelineItems) {
295+
if (!item) continue;
296+
if (!("subject" in item) && !("source" in item)) continue;
297+
const pr = item.subject || item.source;
298+
linkedPRs.set(pr.number, pr);
299+
}
300+
301+
return Array.from(linkedPRs.values());
302+
}
303+
304+
/**
305+
* Get the issues linked to the given PR number.
306+
*
307+
* @param owner the GitHub repository owner
308+
* @param repo the GitHub repository name
309+
* @param prNumber the PR number
310+
* @returns the linked issues
311+
* @private
312+
*/
313+
async #getLinkedIssues(owner: string, repo: string, prNumber: number) {
314+
const result = await this.#octokit.graphql<{ repository: GQLRepository }>(
315+
`
316+
query($owner: String!, $repo: String!, $prNumber: Int!) {
317+
repository(owner: $owner, name: $repo) {
318+
pullRequest(number: $prNumber) {
319+
closingIssuesReferences(first: 50) {
320+
nodes {
321+
number
322+
title
323+
author {
324+
login
325+
avatarUrl
326+
}
327+
createdAt
328+
body
329+
}
330+
}
331+
}
332+
}
333+
}
334+
`,
335+
{
336+
owner,
337+
repo,
338+
prNumber
339+
}
340+
);
341+
342+
// Extract and deduplicate issues
343+
const linkedIssues = new Map<number, LinkedItem>();
344+
345+
const closingIssues = result?.repository?.pullRequest?.closingIssuesReferences?.nodes ?? [];
346+
for (const issue of closingIssues) {
347+
if (!issue) continue;
348+
linkedIssues.set(issue.number, issue);
349+
}
350+
351+
return Array.from(linkedIssues.values());
72352
}
73353

74354
/**
@@ -91,7 +371,7 @@ export class GitHubCache {
91371
const releases = await this.#fetchReleases(repository);
92372

93373
await this.#redis.json.set(cacheKey, "$", releases);
94-
await this.#redis.expire(cacheKey, cacheTTL);
374+
await this.#redis.expire(cacheKey, RELEASES_TTL);
95375

96376
return releases;
97377
}
@@ -284,7 +564,7 @@ export class GitHubCache {
284564
}
285565

286566
await this.#redis.json.set(cacheKey, "$", Object.fromEntries(descriptions));
287-
await this.#redis.expire(cacheKey, longCacheTTL);
567+
await this.#redis.expire(cacheKey, DESCRIPTIONS_TTL);
288568

289569
return Object.fromEntries(descriptions);
290570
}
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
schema:
22
- https://api.github.com/graphql:
33
headers:
4-
Authorization: Bearer ${PUBLIC_GITHUB_TOKEN}
4+
Authorization: Bearer ${GITHUB_TOKEN}

src/lib/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { Octokit } from "octokit";
12
import type { GitHubRelease } from "$lib/server/github-cache";
23

34
export type Prettify<T> = {
@@ -47,3 +48,6 @@ export type RepoInfo = {
4748

4849
export const availableCategory = ["svelte", "kit", "others"] as const;
4950
export type Category = (typeof availableCategory)[number];
51+
52+
export type Issues = InstanceType<typeof Octokit>["rest"]["issues"];
53+
export type Pulls = InstanceType<typeof Octokit>["rest"]["pulls"];
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { error, redirect } from "@sveltejs/kit";
2+
import { gitHubCache } from "$lib/server/github-cache";
3+
4+
export async function load({ params }) {
5+
const { pullOrIssue: type, org, repo, id } = params;
6+
const numId = +id; // id is already validated by the route matcher
7+
8+
const item = await gitHubCache.getItemDetails(org, repo, numId);
9+
if (!item) {
10+
error(404, `${type} #${id} doesn't exist in repo ${org}/${repo}`);
11+
}
12+
13+
const realType = "commits" in item ? "pull" : "issues";
14+
if (type !== realType) {
15+
redirect(303, `/${realType}/${org}/${repo}/${id}`);
16+
}
17+
18+
return {
19+
itemMetadata: {
20+
org,
21+
repo,
22+
type: type === "issues" ? ("issue" as const) : ("pull" as const)
23+
},
24+
item
25+
};
26+
}

0 commit comments

Comments
 (0)