Skip to content

Commit 88815cb

Browse files
feat!: add support for GH discussions (#71)
1 parent 5ca41fc commit 88815cb

File tree

16 files changed

+435
-139
lines changed

16 files changed

+435
-139
lines changed

package.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
"clsx": "^2.1.1",
4141
"eslint": "^9.26.0",
4242
"eslint-config-prettier": "^10.1.2",
43-
"eslint-plugin-svelte": "^3.5.1",
43+
"eslint-plugin-svelte": "^3.7.0",
4444
"feed": "^5.0.0",
4545
"globals": "^16.0.0",
4646
"marked": "^15.0.11",
@@ -49,14 +49,14 @@
4949
"posthog-js": "^1.240.0",
5050
"posthog-node": "^4.17.1",
5151
"prettier": "^3.5.3",
52-
"prettier-plugin-svelte": "^3.3.3",
52+
"prettier-plugin-svelte": "^3.4.0",
5353
"prettier-plugin-tailwindcss": "^0.6.11",
5454
"rehype-raw": "^7.0.0",
5555
"runed": "^0.26.0",
5656
"semver": "^7.7.1",
5757
"shiki": "^3.4.0",
58-
"svelte": "^5.28.2",
59-
"svelte-check": "^4.1.7",
58+
"svelte": "^5.30.1",
59+
"svelte-check": "^4.2.1",
6060
"svelte-exmarkdown": "^5.0.1",
6161
"svelte-meta-tags": "^4.2.0",
6262
"svelte-sonner": "https://pkg.pr.new/wobsoriano/svelte-sonner@126",
@@ -78,5 +78,5 @@
7878
]
7979
},
8080
"type": "module",
81-
"packageManager": "pnpm@10.10.0"
81+
"packageManager": "pnpm@10.11.0"
8282
}

pnpm-lock.yaml

Lines changed: 97 additions & 97 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/lib/components/GHBadge.svelte

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
GitPullRequestArrow,
99
GitPullRequestClosed,
1010
GitPullRequestDraft,
11-
type Icon
11+
type Icon,
12+
MessageSquare,
13+
MessageSquareX
1214
} from "@lucide/svelte";
1315
1416
type CommonStatus = "open" | "closed";
@@ -20,6 +22,10 @@
2022
| {
2123
type: "issue";
2224
status: CommonStatus | "solved";
25+
}
26+
| {
27+
type: "discussion";
28+
status: CommonStatus;
2329
};
2430
2531
type Props = {
@@ -86,6 +92,23 @@
8692
bgColor = "bg-purple-500";
8793
break;
8894
}
95+
break;
96+
case "discussion":
97+
switch (status) {
98+
case "open":
99+
icon = MessageSquare;
100+
label = "Open";
101+
textColor = "text-green-600";
102+
bgColor = "bg-green-600";
103+
break;
104+
case "closed":
105+
icon = MessageSquareX;
106+
label = "Closed";
107+
textColor = "text-purple-500";
108+
bgColor = "bg-purple-500";
109+
break;
110+
}
111+
break;
89112
}
90113
</script>
91114

src/lib/server/github-cache.ts

Lines changed: 147 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,15 @@ export type Member = Awaited<
2323

2424
type OwnerKeyType = "members";
2525

26-
type RepoKeyType = "releases" | "descriptions" | "issue" | "issues" | "pr" | "prs";
26+
type RepoKeyType =
27+
| "releases"
28+
| "descriptions"
29+
| "issue"
30+
| "issues"
31+
| "pr"
32+
| "prs"
33+
| "discussion"
34+
| "discussions";
2735

2836
export type ItemDetails = {
2937
comments: Awaited<ReturnType<Issues["listComments"]>>["data"];
@@ -44,6 +52,70 @@ export type PullRequestDetails = ItemDetails & {
4452
linkedIssues: LinkedItem[];
4553
};
4654

55+
export type DiscussionDetails = {
56+
info: Discussion;
57+
comments: DiscussionComment[];
58+
};
59+
60+
type AuthorAssociation =
61+
| "OWNER"
62+
| "MEMBER"
63+
| "COLLABORATOR"
64+
| "CONTRIBUTOR"
65+
| "FIRST_TIMER"
66+
| "FIRST_TIME_CONTRIBUTOR"
67+
| "MANNEQUIN"
68+
| "NONE";
69+
type TeamDiscussion = Awaited<
70+
ReturnType<InstanceType<typeof Octokit>["rest"]["teams"]["listDiscussionsInOrg"]>
71+
>["data"][number];
72+
export type Discussion = {
73+
repository_url: string;
74+
category: {
75+
id: number;
76+
node_id: string;
77+
repository_id: number;
78+
emoji: `:${string}:`;
79+
name: string;
80+
description: string;
81+
created_at: string;
82+
updated_at: string;
83+
slug: string;
84+
is_answerable: boolean;
85+
};
86+
answer_html_url: string | null;
87+
answer_chosen_at: string | null;
88+
answer_chosen_by: TeamDiscussion["author"] | null;
89+
id: number;
90+
user: TeamDiscussion["author"];
91+
labels: never[];
92+
state: "open" | "closed";
93+
state_reason: "resolved" | null;
94+
locked: boolean;
95+
comments: TeamDiscussion["comments_count"];
96+
author_association: AuthorAssociation;
97+
active_lock_reason: null;
98+
timeline_url: string;
99+
} & Pick<
100+
TeamDiscussion,
101+
"html_url" | "node_id" | "number" | "title" | "created_at" | "updated_at" | "body" | "reactions"
102+
>;
103+
type TeamDiscussionComment = Awaited<
104+
ReturnType<InstanceType<typeof Octokit>["rest"]["teams"]["listDiscussionCommentsInOrg"]>
105+
>["data"][number];
106+
export type DiscussionComment = {
107+
id: number;
108+
parent_id: number | null;
109+
child_comment_count: number;
110+
repository_url: `${string}/${string}`;
111+
discussion_id: number;
112+
author_association: AuthorAssociation;
113+
user: TeamDiscussion["author"];
114+
} & Pick<
115+
TeamDiscussionComment,
116+
"node_id" | "html_url" | "created_at" | "updated_at" | "body" | "reactions"
117+
>;
118+
47119
export type LinkedItem = {
48120
number: number;
49121
title: string;
@@ -169,6 +241,7 @@ export class GitHubCache {
169241
* 2. calls the promise to get new data if no value is found in cache
170242
* 3. store this new value back in the cache with an optional TTL before returning the value.
171243
*
244+
* @returns a currying promise than handles everything needed for requests
172245
* @private
173246
*/
174247
#processCached<RType extends Parameters<InstanceType<typeof Redis>["json"]["set"]>[2]>() {
@@ -229,14 +302,16 @@ export class GitHubCache {
229302
owner: string,
230303
repo: string,
231304
id: number,
232-
type: ExtractStrict<RepoKeyType, "issue" | "pr"> | undefined = undefined
305+
type: ExtractStrict<RepoKeyType, "issue" | "pr" | "discussions"> | undefined = undefined
233306
) {
234307
// Known type we assume the existence of
235308
switch (type) {
236309
case "issue":
237310
return await this.getIssueDetails(owner, repo, id);
238311
case "pr":
239312
return await this.getPullRequestDetails(owner, repo, id);
313+
case "discussions":
314+
return await this.getDiscussionDetails(owner, repo, id);
240315
}
241316

242317
// Unknown type, try to find or null otherwise
@@ -247,12 +322,18 @@ export class GitHubCache {
247322
}
248323

249324
try {
250-
// comes last because issues will also resolve for prs
325+
// doesn't come first because issues will also resolve for prs
251326
return await this.getIssueDetails(owner, repo, id);
252327
} catch (err: unknown) {
253328
console.error(`Error trying to get issue details for ${owner}/${repo}: ${err}`);
254329
}
255330

331+
try {
332+
return await this.getDiscussionDetails(owner, repo, id);
333+
} catch (err: unknown) {
334+
console.error(`Error trying to get discussion details for ${owner}/${repo}: ${err}`);
335+
}
336+
256337
return null;
257338
}
258339

@@ -314,6 +395,43 @@ export class GitHubCache {
314395
);
315396
}
316397

398+
/**
399+
* Get the discussion from the specified info.
400+
*
401+
* @param owner the GitHub repository owner
402+
* @param repo the GitHub repository name
403+
* @param id the discussion number
404+
* @returns the matching discussion
405+
* @throws Error if the discussion is not found
406+
*/
407+
async getDiscussionDetails(owner: string, repo: string, id: number) {
408+
return await this.#processCached<DiscussionDetails>()(
409+
this.#getRepoKey(owner, repo, "discussion", id),
410+
() =>
411+
Promise.all([
412+
this.#octokit.request("GET /repos/{owner}/{repo}/discussions/{number}", {
413+
owner,
414+
repo,
415+
number: id
416+
}),
417+
this.#octokit.paginate<DiscussionComment>(
418+
"GET /repos/{owner}/{repo}/discussions/{number}/comments",
419+
{
420+
owner,
421+
repo,
422+
number: id,
423+
per_page
424+
}
425+
)
426+
]),
427+
([{ data: discussion }, comments]) => ({
428+
info: discussion,
429+
comments
430+
}),
431+
FULL_DETAILS_TTL
432+
);
433+
}
434+
317435
/**
318436
* Get the pull requests linked to the given issue number.
319437
*
@@ -720,6 +838,32 @@ export class GitHubCache {
720838
);
721839
}
722840

841+
/**
842+
* Get all the discussions for a given GitHub repository.
843+
*
844+
* @param owner the GitHub repository owner
845+
* @param repo the GitHub repository name
846+
* @returns a list of discussions, empty if not existing
847+
*/
848+
async getAllDiscussions(owner: string, repo: string) {
849+
return await this.#processCached<Discussion[]>()(
850+
this.#getRepoKey(owner, repo, "discussions"),
851+
async () => {
852+
try {
853+
return await this.#octokit.paginate<Discussion>("GET /repos/{owner}/{repo}/discussions", {
854+
owner,
855+
repo,
856+
per_page
857+
});
858+
} catch {
859+
return [] as Discussion[];
860+
}
861+
},
862+
discussions => discussions,
863+
FULL_DETAILS_TTL
864+
);
865+
}
866+
723867
/**
724868
* Get the deprecation state of a package from its name.
725869
*

src/params/pid.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function match(param: string) {
2+
return param === "pull" || param === "issues" || param === "discussions";
3+
}

src/params/poi.ts

Lines changed: 0 additions & 3 deletions
This file was deleted.

src/routes/[pullOrIssue=poi]/[org]/[repo]/[id=number]/+page.server.ts renamed to src/routes/[pid=pid]/[org]/[repo]/[id=number]/+page.server.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,30 @@ import { error, redirect } from "@sveltejs/kit";
22
import { gitHubCache } from "$lib/server/github-cache";
33

44
export async function load({ params }) {
5-
const { pullOrIssue: type, org, repo, id } = params;
5+
const { pid: type, org, repo, id } = params;
66
const numId = +id; // id is already validated by the route matcher
77

88
const item = await gitHubCache.getItemDetails(org, repo, numId);
99
if (!item) {
1010
error(404, `${type} #${id} doesn't exist in repo ${org}/${repo}`);
1111
}
1212

13-
const realType = "commits" in item ? "pull" : "issues";
13+
const realType = "commits" in item ? "pull" : "category" in item.info ? "discussions" : "issues";
1414
if (type !== realType) {
15-
redirect(303, `/${realType}/${org}/${repo}/${id}`);
15+
redirect(307, `/${realType}/${org}/${repo}/${id}`);
1616
}
1717

1818
return {
1919
itemMetadata: {
2020
org,
2121
repo,
2222
id: numId,
23-
type: type === "issues" ? ("issue" as const) : ("pull" as const)
23+
type:
24+
type === "issues"
25+
? ("issue" as const)
26+
: type === "discussions"
27+
? ("discussion" as const)
28+
: ("pull" as const)
2429
},
2530
item
2631
};

0 commit comments

Comments
 (0)