Skip to content

Commit 9b5a9c0

Browse files
feat: add github stars, issues and created at to comparison page (#2479)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 236ba7d commit 9b5a9c0

File tree

11 files changed

+413
-32
lines changed

11 files changed

+413
-32
lines changed

app/composables/useFacetSelection.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,26 @@ export function useFacetSelection(queryParam = 'facets') {
122122
chartable: false,
123123
chartable_scatter: false,
124124
},
125+
githubStars: {
126+
label: t(`compare.facets.items.githubStars.label`),
127+
description: t(`compare.facets.items.githubStars.description`),
128+
chartable: true,
129+
chartable_scatter: true,
130+
formatter: v => compactNumberFormatter.value.format(v),
131+
},
132+
githubIssues: {
133+
label: t(`compare.facets.items.githubIssues.label`),
134+
description: t(`compare.facets.items.githubIssues.description`),
135+
chartable: true,
136+
chartable_scatter: true,
137+
formatter: v => compactNumberFormatter.value.format(v),
138+
},
139+
createdAt: {
140+
label: t(`compare.facets.items.createdAt.label`),
141+
description: t(`compare.facets.items.createdAt.description`),
142+
chartable: false,
143+
chartable_scatter: false,
144+
},
125145
}),
126146
)
127147

app/composables/usePackageComparison.ts

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,14 @@ export interface PackageComparisonData {
4444
* but a maintainer was removed last week, this would show the '3 years ago' time.
4545
*/
4646
lastUpdated?: string
47+
/** Creation date of the package (ISO 8601 date-time string) */
48+
createdAt?: string
4749
engines?: { node?: string; npm?: string }
4850
deprecated?: string
51+
github?: {
52+
stars?: number
53+
issues?: number
54+
}
4955
}
5056
/** Whether this is a binary-only package (CLI without library entry points) */
5157
isBinaryOnly?: boolean
@@ -115,12 +121,13 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter<string[]>) {
115121
try {
116122
// Fetch basic package info first (required)
117123
const { data: pkgData } = await $npmRegistry<Packument>(`/${encodePackageName(name)}`)
118-
119124
const latestVersion = pkgData['dist-tags']?.latest
120125
if (!latestVersion) return null
121126

122127
// Fetch fast additional data in parallel (optional - failures are ok)
123-
const [downloads, analysis, vulns, likes] = await Promise.all([
128+
const repoInfo = parseRepositoryInfo(pkgData.repository)
129+
const isGitHub = repoInfo?.provider === 'github'
130+
const [downloads, analysis, vulns, likes, ghStars, ghIssues] = await Promise.all([
124131
$fetch<{ downloads: number }>(
125132
`https://api.npmjs.org/downloads/point/last-week/${encodePackageName(name)}`,
126133
).catch(() => null),
@@ -133,6 +140,20 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter<string[]>) {
133140
$fetch<PackageLikes>(`/api/social/likes/${encodePackageName(name)}`).catch(
134141
() => null,
135142
),
143+
isGitHub
144+
? $fetch<{ repo: { stars: number } }>(
145+
`https://ungh.cc/repos/${repoInfo.owner}/${repoInfo.repo}`,
146+
)
147+
.then(res => (typeof res?.repo?.stars === 'number' ? res.repo.stars : null))
148+
.catch(() => null)
149+
: Promise.resolve(null),
150+
isGitHub
151+
? $fetch<{ issues: number | null }>(
152+
`/api/github/issues/${repoInfo.owner}/${repoInfo.repo}`,
153+
)
154+
.then(res => (typeof res?.issues === 'number' ? res.issues : null))
155+
.catch(() => null)
156+
: Promise.resolve(null),
136157
])
137158
const versionData = pkgData.versions[latestVersion]
138159
const packageSize = versionData?.dist?.unpackedSize
@@ -179,8 +200,13 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter<string[]>) {
179200
// Use version-specific publish time, NOT time.modified (which can be
180201
// updated by metadata changes like maintainer additions)
181202
lastUpdated: pkgData.time?.[latestVersion],
203+
createdAt: pkgData.time?.created,
182204
engines: analysis?.engines,
183205
deprecated: versionData?.deprecated,
206+
github: {
207+
stars: ghStars ?? undefined,
208+
issues: ghIssues ?? undefined,
209+
},
184210
},
185211
isBinaryOnly: isBinary,
186212
totalLikes: likes?.totalLikes,
@@ -252,6 +278,7 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter<string[]>) {
252278

253279
return packagesData.value.map(pkg => {
254280
if (!pkg) return null
281+
255282
return computeFacetValue(
256283
facet,
257284
pkg,
@@ -538,6 +565,33 @@ function computeFacetValue(
538565
status: totalDepCount > 50 ? 'warning' : 'neutral',
539566
}
540567
}
568+
case 'githubStars': {
569+
const stars = data.metadata?.github?.stars
570+
if (stars == null) return null
571+
return {
572+
raw: stars,
573+
display: formatCompactNumber(stars),
574+
status: 'neutral',
575+
}
576+
}
577+
case 'githubIssues': {
578+
const issues = data.metadata?.github?.issues
579+
if (issues == null) return null
580+
return {
581+
raw: issues,
582+
display: formatCompactNumber(issues),
583+
status: 'neutral',
584+
}
585+
}
586+
case 'createdAt': {
587+
const createdAt = data.metadata?.createdAt
588+
if (!createdAt) return null
589+
return {
590+
raw: createdAt,
591+
display: createdAt,
592+
type: 'date',
593+
}
594+
}
541595
default: {
542596
return null
543597
}

app/utils/compare-scatter-chart.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,16 @@ function getNumericFacetValue(
7272
case 'lastUpdated':
7373
return toFreshnessScore(packageData.metadata?.lastUpdated)
7474

75+
case 'githubStars':
76+
return isFiniteNumber(packageData.metadata?.github?.stars)
77+
? packageData.metadata.github.stars
78+
: null
79+
80+
case 'githubIssues':
81+
return isFiniteNumber(packageData.metadata?.github?.issues)
82+
? packageData.metadata.github.issues
83+
: null
84+
7585
default:
7686
return null
7787
}

i18n/locales/en.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1326,6 +1326,18 @@
13261326
"vulnerabilities": {
13271327
"label": "Vulnerabilities",
13281328
"description": "Known security vulnerabilities"
1329+
},
1330+
"githubStars": {
1331+
"label": "GitHub Stars",
1332+
"description": "Number of stars on the GitHub repository"
1333+
},
1334+
"githubIssues": {
1335+
"label": "GitHub Issues",
1336+
"description": "Number of issues on the GitHub repository"
1337+
},
1338+
"createdAt": {
1339+
"label": "Created At",
1340+
"description": "When the package was created"
13291341
}
13301342
},
13311343
"values": {

i18n/schema.json

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3984,6 +3984,42 @@
39843984
}
39853985
},
39863986
"additionalProperties": false
3987+
},
3988+
"githubStars": {
3989+
"type": "object",
3990+
"properties": {
3991+
"label": {
3992+
"type": "string"
3993+
},
3994+
"description": {
3995+
"type": "string"
3996+
}
3997+
},
3998+
"additionalProperties": false
3999+
},
4000+
"githubIssues": {
4001+
"type": "object",
4002+
"properties": {
4003+
"label": {
4004+
"type": "string"
4005+
},
4006+
"description": {
4007+
"type": "string"
4008+
}
4009+
},
4010+
"additionalProperties": false
4011+
},
4012+
"createdAt": {
4013+
"type": "object",
4014+
"properties": {
4015+
"label": {
4016+
"type": "string"
4017+
},
4018+
"description": {
4019+
"type": "string"
4020+
}
4021+
},
4022+
"additionalProperties": false
39874023
}
39884024
},
39894025
"additionalProperties": false

server/api/github/contributors-evolution/[owner]/[repo].get.ts

Lines changed: 4 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { setTimeout } from 'node:timers/promises'
21
import { CACHE_MAX_AGE_ONE_DAY } from '#shared/utils/constants'
32

43
type GitHubContributorWeek = {
@@ -26,38 +25,13 @@ export default defineCachedEventHandler(
2625
}
2726

2827
const url = `https://api.github.com/repos/${owner}/${repo}/stats/contributors`
29-
const headers = {
30-
'User-Agent': 'npmx',
31-
'Accept': 'application/vnd.github+json',
32-
}
33-
34-
const maxAttempts = 6
35-
let delayMs = 1000
3628

3729
try {
38-
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
39-
const response = await $fetch.raw<GitHubContributorStats[]>(url, { headers })
40-
const status = response.status
41-
42-
if (status === 200) {
43-
return Array.isArray(response._data) ? response._data : []
44-
}
45-
46-
if (status === 204) {
47-
return []
48-
}
49-
50-
if (status === 202) {
51-
if (attempt === maxAttempts - 1) return []
52-
await setTimeout(delayMs)
53-
delayMs = Math.min(delayMs * 2, 16_000)
54-
continue
55-
}
56-
57-
return []
58-
}
30+
const data = await fetchGitHubWithRetries<GitHubContributorStats[]>(url, {
31+
maxAttempts: 6,
32+
})
5933

60-
return []
34+
return Array.isArray(data) ? data : []
6135
} catch {
6236
return []
6337
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { CACHE_MAX_AGE_ONE_HOUR } from '#shared/utils/constants'
2+
3+
interface GitHubSearchResponse {
4+
total_count: number
5+
}
6+
7+
export interface GithubIssueCountResponse {
8+
owner: string
9+
repo: string
10+
issues: number | null
11+
}
12+
13+
export default defineCachedEventHandler(
14+
async (event): Promise<GithubIssueCountResponse> => {
15+
const owner = getRouterParam(event, 'owner')
16+
const repo = getRouterParam(event, 'repo')
17+
18+
if (!owner || !repo) {
19+
throw createError({
20+
statusCode: 400,
21+
statusMessage: 'Owner and repo are required parameters.',
22+
})
23+
}
24+
25+
const query = `repo:${owner}/${repo} is:issue is:open`
26+
const url = `https://api.github.com/search/issues?q=${encodeURIComponent(query)}&per_page=1`
27+
28+
try {
29+
const data = await fetchGitHubWithRetries<GitHubSearchResponse>(url, {
30+
timeout: 10000,
31+
})
32+
33+
return {
34+
owner,
35+
repo,
36+
issues: typeof data?.total_count === 'number' ? data.total_count : null,
37+
}
38+
} catch {
39+
throw createError({
40+
statusCode: 500,
41+
statusMessage: 'Failed to fetch issue count from GitHub',
42+
})
43+
}
44+
},
45+
{
46+
maxAge: CACHE_MAX_AGE_ONE_HOUR,
47+
swr: true,
48+
name: 'github-issue-count',
49+
getKey: event => {
50+
const owner = getRouterParam(event, 'owner')
51+
const repo = getRouterParam(event, 'repo')
52+
return `${owner}/${repo}`
53+
},
54+
},
55+
)

server/utils/github.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { setTimeout } from 'node:timers/promises'
2+
3+
export interface GitHubFetchOptions extends NonNullable<Parameters<typeof $fetch.raw>[1]> {
4+
maxAttempts?: number
5+
}
6+
7+
export async function fetchGitHubWithRetries<T>(
8+
url: string,
9+
options: GitHubFetchOptions = {},
10+
): Promise<T | null> {
11+
const { maxAttempts = 3, ...fetchOptions } = options
12+
let delayMs = 1000
13+
14+
const defaultHeaders = {
15+
'Accept': 'application/vnd.github+json',
16+
'User-Agent': 'npmx',
17+
'X-GitHub-Api-Version': '2026-03-10',
18+
}
19+
20+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
21+
try {
22+
const headers = new Headers(defaultHeaders)
23+
for (const [key, value] of new Headers(fetchOptions.headers)) {
24+
headers.set(key, value)
25+
}
26+
const response = await $fetch.raw(url, {
27+
...fetchOptions,
28+
headers,
29+
})
30+
31+
if (response.status === 200) {
32+
return (response._data as T) ?? null
33+
}
34+
35+
if (response.status === 204) {
36+
return null
37+
}
38+
39+
if (response.status === 202) {
40+
if (attempt === maxAttempts - 1) break
41+
await setTimeout(delayMs)
42+
delayMs = Math.min(delayMs * 2, 16_000)
43+
continue
44+
}
45+
46+
break
47+
} catch (error: unknown) {
48+
if (attempt === maxAttempts - 1) {
49+
throw error
50+
}
51+
await setTimeout(delayMs)
52+
delayMs = Math.min(delayMs * 2, 16_000)
53+
}
54+
}
55+
56+
throw new Error(`Failed to fetch from GitHub after ${maxAttempts} attempts`)
57+
}

0 commit comments

Comments
 (0)