Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
144e806
feat: add poc for github stars, github issues & created at comparison
t128n Apr 11, 2026
e4886a5
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 11, 2026
8d2cff7
Merge branch 'main' into feat/compare
t128n Apr 12, 2026
3a99631
refactor(compare): optimize github metadata fetching and repository p…
t128n Apr 12, 2026
9c6f4d8
feat(compare): add scatter chart support and formatters for facets
t128n Apr 12, 2026
b517792
chore(i18n): update schema for facets
t128n Apr 12, 2026
0127286
fix(compare): add missing cases for scatter chart
t128n Apr 12, 2026
8434189
test(compare): add coverage for github metadata and created at facets
t128n Apr 12, 2026
3865fc1
test(compare): update facet mock data to include github and creation …
t128n Apr 12, 2026
2693e16
feat(compare): mirror contributors-evolution retry logic and timeout …
t128n Apr 12, 2026
3e4fccd
fix(compare): rename facet i18n keys to camelCase for convention cons…
t128n Apr 12, 2026
9df70fe
refactor(compare): return null for missing or malformed GitHub metrics
t128n Apr 12, 2026
f64fd04
fix(compare): remove unused formatter
t128n Apr 12, 2026
85c59be
refactor: use shared fetch logic for github api
t128n Apr 12, 2026
9cb87b1
chore: remove maxAttempts=3 as this is the default value
t128n Apr 12, 2026
e29ae1f
fix: remove type import from unlisted depndency
t128n Apr 12, 2026
2adcf4a
fix: headers merge to support all NitroFetchOptions header types
t128n Apr 13, 2026
96fdccf
Merge branch 'main' into feat/compare
t128n Apr 13, 2026
5b37d61
Merge branch 'main' into feat/compare
t128n Apr 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions app/composables/useFacetSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,27 @@ export function useFacetSelection(queryParam = 'facets') {
chartable: false,
chartable_scatter: false,
},
githubStars: {
label: t(`compare.facets.items.githubStars.label`),
description: t(`compare.facets.items.githubStars.description`),
chartable: true,
chartable_scatter: true,
formatter: v => compactNumberFormatter.value.format(v),
},
githubIssues: {
label: t(`compare.facets.items.githubIssues.label`),
description: t(`compare.facets.items.githubIssues.description`),
chartable: true,
chartable_scatter: true,
formatter: v => compactNumberFormatter.value.format(v),
},
createdAt: {
label: t(`compare.facets.items.createdAt.label`),
description: t(`compare.facets.items.createdAt.description`),
chartable: false,
chartable_scatter: false,
formatter: v => new Date(v).toLocaleDateString(),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The formatter for createdAt is unused and should be removed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in f64fd04

},
}),
)

Expand Down
58 changes: 56 additions & 2 deletions app/composables/usePackageComparison.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,14 @@ export interface PackageComparisonData {
* but a maintainer was removed last week, this would show the '3 years ago' time.
*/
lastUpdated?: string
/** Creation date of the package (ISO 8601 date-time string) */
createdAt?: string
engines?: { node?: string; npm?: string }
deprecated?: string
github?: {
stars?: number
issues?: number
}
}
/** Whether this is a binary-only package (CLI without library entry points) */
isBinaryOnly?: boolean
Expand Down Expand Up @@ -115,12 +121,13 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter<string[]>) {
try {
// Fetch basic package info first (required)
const { data: pkgData } = await $npmRegistry<Packument>(`/${encodePackageName(name)}`)

const latestVersion = pkgData['dist-tags']?.latest
if (!latestVersion) return null

// Fetch fast additional data in parallel (optional - failures are ok)
const [downloads, analysis, vulns, likes] = await Promise.all([
const repoInfo = parseRepositoryInfo(pkgData.repository)
const isGitHub = repoInfo?.provider === 'github'
const [downloads, analysis, vulns, likes, ghStars, ghIssues] = await Promise.all([
$fetch<{ downloads: number }>(
`https://api.npmjs.org/downloads/point/last-week/${encodePackageName(name)}`,
).catch(() => null),
Expand All @@ -133,6 +140,20 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter<string[]>) {
$fetch<PackageLikes>(`/api/social/likes/${encodePackageName(name)}`).catch(
() => null,
),
isGitHub
? $fetch<{ repo: { stars: number } }>(
`https://ungh.cc/repos/${repoInfo.owner}/${repoInfo.repo}`,
)
.then(res => (typeof res?.repo?.stars === 'number' ? res.repo.stars : null))
.catch(() => null)
: Promise.resolve(null),
isGitHub
? $fetch<{ issues: number | null }>(
`/api/github/issues/${repoInfo.owner}/${repoInfo.repo}`,
)
.then(res => (typeof res?.issues === 'number' ? res.issues : null))
.catch(() => null)
: Promise.resolve(null),
])
const versionData = pkgData.versions[latestVersion]
const packageSize = versionData?.dist?.unpackedSize
Expand Down Expand Up @@ -179,8 +200,13 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter<string[]>) {
// Use version-specific publish time, NOT time.modified (which can be
// updated by metadata changes like maintainer additions)
lastUpdated: pkgData.time?.[latestVersion],
createdAt: pkgData.time?.created,
engines: analysis?.engines,
deprecated: versionData?.deprecated,
github: {
stars: ghStars ?? undefined,
issues: ghIssues ?? undefined,
},
},
isBinaryOnly: isBinary,
totalLikes: likes?.totalLikes,
Expand Down Expand Up @@ -252,6 +278,7 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter<string[]>) {

return packagesData.value.map(pkg => {
if (!pkg) return null

return computeFacetValue(
facet,
pkg,
Expand Down Expand Up @@ -538,6 +565,33 @@ function computeFacetValue(
status: totalDepCount > 50 ? 'warning' : 'neutral',
}
}
case 'githubStars': {
const stars = data.metadata?.github?.stars
if (stars == null) return null
return {
raw: stars,
display: formatCompactNumber(stars),
status: 'neutral',
}
}
case 'githubIssues': {
const issues = data.metadata?.github?.issues
if (issues == null) return null
return {
raw: issues,
display: formatCompactNumber(issues),
status: 'neutral',
}
}
case 'createdAt': {
const createdAt = data.metadata?.createdAt
if (!createdAt) return null
return {
raw: createdAt,
display: createdAt,
type: 'date',
}
}
default: {
return null
}
Expand Down
10 changes: 10 additions & 0 deletions app/utils/compare-scatter-chart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,16 @@ function getNumericFacetValue(
case 'lastUpdated':
return toFreshnessScore(packageData.metadata?.lastUpdated)

case 'githubStars':
return isFiniteNumber(packageData.metadata?.github?.stars)
? packageData.metadata.github.stars
: null

case 'githubIssues':
return isFiniteNumber(packageData.metadata?.github?.issues)
? packageData.metadata.github.issues
: null

default:
return null
}
Expand Down
12 changes: 12 additions & 0 deletions i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1325,6 +1325,18 @@
"vulnerabilities": {
"label": "Vulnerabilities",
"description": "Known security vulnerabilities"
},
"githubStars": {
"label": "GitHub Stars",
"description": "Number of stars on the GitHub repository"
},
"githubIssues": {
"label": "GitHub Issues",
"description": "Number of issues on the GitHub repository"
},
"createdAt": {
"label": "Created At",
"description": "When the package was created"
Comment thread
ghostdevv marked this conversation as resolved.
}
},
"values": {
Expand Down
36 changes: 36 additions & 0 deletions i18n/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -3981,6 +3981,42 @@
}
},
"additionalProperties": false
},
"githubStars": {
"type": "object",
"properties": {
"label": {
"type": "string"
},
"description": {
"type": "string"
}
},
"additionalProperties": false
},
"githubIssues": {
"type": "object",
"properties": {
"label": {
"type": "string"
},
"description": {
"type": "string"
}
},
"additionalProperties": false
},
"createdAt": {
"type": "object",
"properties": {
"label": {
"type": "string"
},
"description": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
Expand Down
90 changes: 90 additions & 0 deletions server/api/github/issues/[owner]/[repo].get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { setTimeout } from 'node:timers/promises'
import { CACHE_MAX_AGE_ONE_HOUR } from '#shared/utils/constants'

const GITHUB_HEADERS = {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'npmx',
'X-GitHub-Api-Version': '2022-11-28',
Comment thread
alexdln marked this conversation as resolved.
Outdated
} as const

interface GitHubSearchResponse {
total_count: number
}

export interface GithubIssueCountResponse {
owner: string
repo: string
issues: number | null
}

export default defineCachedEventHandler(
async (event): Promise<GithubIssueCountResponse> => {
const owner = getRouterParam(event, 'owner')
const repo = getRouterParam(event, 'repo')

if (!owner || !repo) {
throw createError({
statusCode: 400,
statusMessage: 'Owner and repo are required parameters.',
})
}

const query = `repo:${owner}/${repo} is:issue is:open`
const url = `https://api.github.com/search/issues?q=${encodeURIComponent(query)}&per_page=1`

const maxAttempts = 3
let delayMs = 1000

for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
try {
const response = await $fetch.raw<GitHubSearchResponse>(url, {
headers: GITHUB_HEADERS,
timeout: 10000,
})

if (response.status === 200) {
return {
owner,
Comment thread
alexdln marked this conversation as resolved.
Outdated
repo,
issues:
typeof response._data?.total_count === 'number' ? response._data.total_count : null,
}
}

if (response.status === 202) {
if (attempt === maxAttempts - 1) break
await setTimeout(delayMs)
delayMs = Math.min(delayMs * 2, 16_000)
continue
}

break
} catch (error: any) {
if (attempt === maxAttempts - 1) {
throw createError({
statusCode: error.response?.status || 500,
statusMessage:
error.response?._data?.message || 'Failed to fetch issue count from GitHub',
})
}
await setTimeout(delayMs)
delayMs = Math.min(delayMs * 2, 16_000)
}
}

throw createError({
statusCode: 500,
statusMessage: 'Failed to fetch issue count from GitHub after retries',
})
},
{
maxAge: CACHE_MAX_AGE_ONE_HOUR,
swr: true,
name: 'github-issue-count',
getKey: event => {
const owner = getRouterParam(event, 'owner')
const repo = getRouterParam(event, 'repo')
return `${owner}/${repo}`
},
},
)
12 changes: 12 additions & 0 deletions shared/types/comparison.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ export type ComparisonFacet =
| 'totalDependencies'
| 'deprecated'
| 'totalLikes'
| 'githubStars'
| 'githubIssues'
| 'createdAt'

/** Facet metadata for UI display */
export interface FacetInfo {
Expand Down Expand Up @@ -56,6 +59,15 @@ export const FACET_INFO: Record<ComparisonFacet, Omit<FacetInfo, 'id'>> = {
deprecated: {
category: 'health',
},
githubStars: {
category: 'health',
},
githubIssues: {
category: 'health',
},
createdAt: {
category: 'health',
},
// Compatibility
engines: {
category: 'compatibility',
Expand Down
3 changes: 3 additions & 0 deletions test/nuxt/components/compare/FacetSelector.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ const facetLabels: Record<ComparisonFacet, { label: string; description: string
},
deprecated: { label: 'Deprecated?', description: 'Whether the package is deprecated' },
totalLikes: { label: 'Likes', description: 'Number of likes' },
githubStars: { label: 'GitHub Stars', description: 'Number of GitHub stars' },
githubIssues: { label: 'GitHub Issues', description: 'Number of open GitHub issues' },
createdAt: { label: 'Created', description: 'When the package was first created' },
}

const categoryLabels: Record<string, string> = {
Expand Down
Loading
Loading