11import { GITHUB_TOKEN , KV_REST_API_TOKEN , KV_REST_API_URL } from "$env/static/private" ;
22import { Redis } from "@upstash/redis" ;
33import { Octokit } from "octokit" ;
4+ import type { Repository as GQLRepository } from "@octokit/graphql-schema" ;
45import type { Repository } from "$lib/repositories" ;
6+ import type { Issues , Pulls } from "$lib/types" ;
57import 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+
716export 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 */
2460const 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 }
0 commit comments