From 144e806834fc82a1b0c2f9ff8f670cb4b9024e28 Mon Sep 17 00:00:00 2001 From: Torben Haack Date: Sun, 12 Apr 2026 00:06:31 +0200 Subject: [PATCH 01/15] feat: add poc for github stars, github issues & created at comparison --- app/composables/useFacetSelection.ts | 15 +++++ app/composables/usePackageComparison.ts | 56 ++++++++++++++++- i18n/locales/en.json | 12 ++++ i18n/schema.json | 33 ++++++++++ .../api/github/issues/[owner]/[repo].get.ts | 60 +++++++++++++++++++ shared/types/comparison.ts | 13 ++++ 6 files changed, 186 insertions(+), 3 deletions(-) create mode 100644 server/api/github/issues/[owner]/[repo].get.ts diff --git a/app/composables/useFacetSelection.ts b/app/composables/useFacetSelection.ts index 92875037e9..55cf4fedea 100644 --- a/app/composables/useFacetSelection.ts +++ b/app/composables/useFacetSelection.ts @@ -89,6 +89,21 @@ export function useFacetSelection(queryParam = 'facets') { description: t(`compare.facets.items.deprecated.description`), chartable: false, }, + githubStars: { + label: t(`compare.facets.items.github_stars.label`), + description: t(`compare.facets.items.github_stars.description`), + chartable: true, + }, + githubIssues: { + label: t(`compare.facets.items.github_issues.label`), + description: t(`compare.facets.items.github_issues.description`), + chartable: true, + }, + createdAt: { + label: t(`compare.facets.items.created_at.label`), + description: t(`compare.facets.items.created_at.description`), + chartable: false, + }, }), ) diff --git a/app/composables/usePackageComparison.ts b/app/composables/usePackageComparison.ts index 760f7ce08c..9143d61ec4 100644 --- a/app/composables/usePackageComparison.ts +++ b/app/composables/usePackageComparison.ts @@ -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 + deprecated?: string, + github?: { + stars?: number + issues?: number + } } /** Whether this is a binary-only package (CLI without library entry points) */ isBinaryOnly?: boolean @@ -115,12 +121,11 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter) { try { // Fetch basic package info first (required) const { data: pkgData } = await $npmRegistry(`/${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 [downloads, analysis, vulns, likes, ghStars, ghIssues] = await Promise.all([ $fetch<{ downloads: number }>( `https://api.npmjs.org/downloads/point/last-week/${encodePackageName(name)}`, ).catch(() => null), @@ -133,6 +138,8 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter) { $fetch(`/api/social/likes/${encodePackageName(name)}`).catch( () => null, ), + $fetch<{ repo: { stars: number } }>(`https://ungh.cc/repos/${parseRepositoryInfo(pkgData.repository)?.owner}/${parseRepositoryInfo(pkgData.repository)?.repo}`).then(res => res?.repo.stars || 0).catch(() => null), + $fetch<{issues: number}>(`/api/github/issues/${parseRepositoryInfo(pkgData.repository)?.owner}/${parseRepositoryInfo(pkgData.repository)?.repo}`).then(res => res?.issues || 0).catch(() => null), ]) const versionData = pkgData.versions[latestVersion] const packageSize = versionData?.dist?.unpackedSize @@ -179,8 +186,13 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter) { // 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, @@ -252,6 +264,7 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter) { return packagesData.value.map(pkg => { if (!pkg) return null + console.log(pkg.package.name, {pkg}) return computeFacetValue( facet, pkg, @@ -538,6 +551,43 @@ 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: stars > 1000 ? 'good' : 'neutral', + } + } + case 'githubIssues': { + const issues = data.metadata?.github?.issues + if (issues == null) return null + const stars = data.metadata?.github?.stars + const ratio = stars && issues > 0 ? issues / stars : null + return { + raw: issues, + display: formatCompactNumber(issues), + // High issues-to-stars ratio suggests the project is struggling relative to its popularity + status: ratio == null || ratio < 0.1 ? 'good' : ratio < 0.5 ? 'neutral' : 'warning', + } + } + case 'createdAt': { + const createdAt = data.metadata?.createdAt + const resolved = createdAt ? resolveNoDependencyDisplay(createdAt, t) : null + if (resolved) return { raw: 0, ...resolved } + if (!createdAt) return null + const date = new Date(createdAt) + const oneMonthAgo = new Date() + oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1) + return { + raw: date.getTime(), + display: createdAt, + // Package is rated "good" if it was created more than a month ago (not brand new) + status: date < oneMonthAgo ? 'good' : 'neutral', + type: 'date', + } + } default: { return null } diff --git a/i18n/locales/en.json b/i18n/locales/en.json index a142d1ab54..4d1d647589 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -1343,6 +1343,18 @@ "vulnerabilities": { "label": "Vulnerabilities", "description": "Known security vulnerabilities" + }, + "github_stars": { + "label": "GitHub Stars", + "description": "Number of stars on the GitHub repository" + }, + "github_issues": { + "label": "GitHub Issues", + "description": "Number of issues on the GitHub repository" + }, + "created_at": { + "label": "Created At", + "description": "When the package was created" } }, "values": { diff --git a/i18n/schema.json b/i18n/schema.json index b3ccd57f64..28780ec7e7 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -4035,6 +4035,39 @@ } }, "additionalProperties": false + }, + "github_stars": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "description": { + "type": "string" + } + } + }, + "github_issues": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "description": { + "type": "string" + } + } + }, + "created_at": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "description": { + "type": "string" + } + } } }, "additionalProperties": false diff --git a/server/api/github/issues/[owner]/[repo].get.ts b/server/api/github/issues/[owner]/[repo].get.ts new file mode 100644 index 0000000000..48e7cfb836 --- /dev/null +++ b/server/api/github/issues/[owner]/[repo].get.ts @@ -0,0 +1,60 @@ +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', +} as const + +interface GitHubSearchResponse { + total_count: number +} + +export interface GithubIssueCountResponse { + owner: string + repo: string + issues: number +} + +export default defineCachedEventHandler( + async (event): Promise => { + 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` + + try { + const data = await $fetch(url, { + headers: GITHUB_HEADERS, + }) + return { + owner, + repo, + issues: data.total_count, + } + } catch (error: any) { + throw createError({ + statusCode: error.response?.status || 500, + statusMessage: error.response?._data?.message || 'Failed to fetch issue count from GitHub', + }) + } + }, + { + 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}` + }, + }, +) diff --git a/shared/types/comparison.ts b/shared/types/comparison.ts index 9dfe229eb9..fe93a819de 100644 --- a/shared/types/comparison.ts +++ b/shared/types/comparison.ts @@ -17,6 +17,10 @@ export type ComparisonFacet = | 'totalDependencies' | 'deprecated' | 'totalLikes' + | 'githubStars' + | 'githubIssues' + | 'createdAt' + /** Facet metadata for UI display */ export interface FacetInfo { @@ -56,6 +60,15 @@ export const FACET_INFO: Record> = { deprecated: { category: 'health', }, + githubStars: { + category: 'health', + }, + githubIssues: { + category: 'health', + }, + createdAt: { + category: 'health', + }, // Compatibility engines: { category: 'compatibility', From e4886a52ca5a37ba6395caad39c56511f15fd32d Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 11 Apr 2026 22:08:52 +0000 Subject: [PATCH 02/15] [autofix.ci] apply automated fixes --- app/composables/usePackageComparison.ts | 16 ++++++++++++---- server/api/github/issues/[owner]/[repo].get.ts | 2 +- shared/types/comparison.ts | 1 - 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/app/composables/usePackageComparison.ts b/app/composables/usePackageComparison.ts index 9143d61ec4..5d5a6f7fc8 100644 --- a/app/composables/usePackageComparison.ts +++ b/app/composables/usePackageComparison.ts @@ -47,7 +47,7 @@ export interface PackageComparisonData { /** Creation date of the package (ISO 8601 date-time string) */ createdAt?: string engines?: { node?: string; npm?: string } - deprecated?: string, + deprecated?: string github?: { stars?: number issues?: number @@ -138,8 +138,16 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter) { $fetch(`/api/social/likes/${encodePackageName(name)}`).catch( () => null, ), - $fetch<{ repo: { stars: number } }>(`https://ungh.cc/repos/${parseRepositoryInfo(pkgData.repository)?.owner}/${parseRepositoryInfo(pkgData.repository)?.repo}`).then(res => res?.repo.stars || 0).catch(() => null), - $fetch<{issues: number}>(`/api/github/issues/${parseRepositoryInfo(pkgData.repository)?.owner}/${parseRepositoryInfo(pkgData.repository)?.repo}`).then(res => res?.issues || 0).catch(() => null), + $fetch<{ repo: { stars: number } }>( + `https://ungh.cc/repos/${parseRepositoryInfo(pkgData.repository)?.owner}/${parseRepositoryInfo(pkgData.repository)?.repo}`, + ) + .then(res => res?.repo.stars || 0) + .catch(() => null), + $fetch<{ issues: number }>( + `/api/github/issues/${parseRepositoryInfo(pkgData.repository)?.owner}/${parseRepositoryInfo(pkgData.repository)?.repo}`, + ) + .then(res => res?.issues || 0) + .catch(() => null), ]) const versionData = pkgData.versions[latestVersion] const packageSize = versionData?.dist?.unpackedSize @@ -264,7 +272,7 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter) { return packagesData.value.map(pkg => { if (!pkg) return null - console.log(pkg.package.name, {pkg}) + console.log(pkg.package.name, { pkg }) return computeFacetValue( facet, pkg, diff --git a/server/api/github/issues/[owner]/[repo].get.ts b/server/api/github/issues/[owner]/[repo].get.ts index 48e7cfb836..58c63ceb43 100644 --- a/server/api/github/issues/[owner]/[repo].get.ts +++ b/server/api/github/issues/[owner]/[repo].get.ts @@ -51,7 +51,7 @@ export default defineCachedEventHandler( maxAge: CACHE_MAX_AGE_ONE_HOUR, swr: true, name: 'github-issue-count', - getKey: (event) => { + getKey: event => { const owner = getRouterParam(event, 'owner') const repo = getRouterParam(event, 'repo') return `${owner}/${repo}` diff --git a/shared/types/comparison.ts b/shared/types/comparison.ts index fe93a819de..c7bfafdc70 100644 --- a/shared/types/comparison.ts +++ b/shared/types/comparison.ts @@ -21,7 +21,6 @@ export type ComparisonFacet = | 'githubIssues' | 'createdAt' - /** Facet metadata for UI display */ export interface FacetInfo { id: ComparisonFacet From 3a996315083ca9ea2a9a22857f243c424f304a8f Mon Sep 17 00:00:00 2001 From: Torben Haack Date: Sun, 12 Apr 2026 09:46:30 +0200 Subject: [PATCH 03/15] refactor(compare): optimize github metadata fetching and repository parsing --- app/composables/usePackageComparison.ts | 44 +++++++++++-------------- 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/app/composables/usePackageComparison.ts b/app/composables/usePackageComparison.ts index 5d5a6f7fc8..7f88ff44c4 100644 --- a/app/composables/usePackageComparison.ts +++ b/app/composables/usePackageComparison.ts @@ -125,6 +125,8 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter) { if (!latestVersion) return null // Fetch fast additional data in parallel (optional - failures are ok) + 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)}`, @@ -138,16 +140,20 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter) { $fetch(`/api/social/likes/${encodePackageName(name)}`).catch( () => null, ), - $fetch<{ repo: { stars: number } }>( - `https://ungh.cc/repos/${parseRepositoryInfo(pkgData.repository)?.owner}/${parseRepositoryInfo(pkgData.repository)?.repo}`, - ) - .then(res => res?.repo.stars || 0) - .catch(() => null), - $fetch<{ issues: number }>( - `/api/github/issues/${parseRepositoryInfo(pkgData.repository)?.owner}/${parseRepositoryInfo(pkgData.repository)?.repo}`, - ) - .then(res => res?.issues || 0) - .catch(() => null), + isGitHub + ? $fetch<{ repo: { stars: number } }>( + `https://ungh.cc/repos/${repoInfo.owner}/${repoInfo.repo}`, + ) + .then(res => res?.repo.stars || 0) + .catch(() => null) + : Promise.resolve(null), + isGitHub + ? $fetch<{ issues: number }>( + `/api/github/issues/${repoInfo.owner}/${repoInfo.repo}`, + ) + .then(res => res?.issues || 0) + .catch(() => null) + : Promise.resolve(null), ]) const versionData = pkgData.versions[latestVersion] const packageSize = versionData?.dist?.unpackedSize @@ -272,7 +278,7 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter) { return packagesData.value.map(pkg => { if (!pkg) return null - console.log(pkg.package.name, { pkg }) + return computeFacetValue( facet, pkg, @@ -565,34 +571,24 @@ function computeFacetValue( return { raw: stars, display: formatCompactNumber(stars), - status: stars > 1000 ? 'good' : 'neutral', + status: 'neutral', } } case 'githubIssues': { const issues = data.metadata?.github?.issues if (issues == null) return null - const stars = data.metadata?.github?.stars - const ratio = stars && issues > 0 ? issues / stars : null return { raw: issues, display: formatCompactNumber(issues), - // High issues-to-stars ratio suggests the project is struggling relative to its popularity - status: ratio == null || ratio < 0.1 ? 'good' : ratio < 0.5 ? 'neutral' : 'warning', + status: 'neutral', } } case 'createdAt': { const createdAt = data.metadata?.createdAt - const resolved = createdAt ? resolveNoDependencyDisplay(createdAt, t) : null - if (resolved) return { raw: 0, ...resolved } if (!createdAt) return null - const date = new Date(createdAt) - const oneMonthAgo = new Date() - oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1) return { - raw: date.getTime(), + raw: createdAt, display: createdAt, - // Package is rated "good" if it was created more than a month ago (not brand new) - status: date < oneMonthAgo ? 'good' : 'neutral', type: 'date', } } From 9c6f4d8abc762a5969710b50c8390f3cfae7e852 Mon Sep 17 00:00:00 2001 From: Torben Haack Date: Sun, 12 Apr 2026 09:46:49 +0200 Subject: [PATCH 04/15] feat(compare): add scatter chart support and formatters for facets --- app/composables/useFacetSelection.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/composables/useFacetSelection.ts b/app/composables/useFacetSelection.ts index 451229a067..7479fd629a 100644 --- a/app/composables/useFacetSelection.ts +++ b/app/composables/useFacetSelection.ts @@ -126,16 +126,22 @@ export function useFacetSelection(queryParam = 'facets') { label: t(`compare.facets.items.github_stars.label`), description: t(`compare.facets.items.github_stars.description`), chartable: true, + chartable_scatter: true, + formatter: v => compactNumberFormatter.value.format(v), }, githubIssues: { label: t(`compare.facets.items.github_issues.label`), description: t(`compare.facets.items.github_issues.description`), chartable: true, + chartable_scatter: true, + formatter: v => compactNumberFormatter.value.format(v), }, createdAt: { label: t(`compare.facets.items.created_at.label`), description: t(`compare.facets.items.created_at.description`), chartable: false, + chartable_scatter: false, + formatter: v => new Date(v).toLocaleDateString(), }, }), ) From b517792b82b38c029f4a795ff6532032e2ee9253 Mon Sep 17 00:00:00 2001 From: Torben Haack Date: Sun, 12 Apr 2026 09:47:10 +0200 Subject: [PATCH 05/15] chore(i18n): update schema for facets --- i18n/schema.json | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/i18n/schema.json b/i18n/schema.json index 6f4b7333f5..a446f08cae 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -3991,7 +3991,8 @@ "description": { "type": "string" } - } + }, + "additionalProperties": false }, "github_issues": { "type": "object", @@ -4002,7 +4003,8 @@ "description": { "type": "string" } - } + }, + "additionalProperties": false }, "created_at": { "type": "object", @@ -4013,7 +4015,8 @@ "description": { "type": "string" } - } + }, + "additionalProperties": false } }, "additionalProperties": false From 012728645501bee9ff033d50bb884164ab5344b6 Mon Sep 17 00:00:00 2001 From: Torben Haack Date: Sun, 12 Apr 2026 09:57:16 +0200 Subject: [PATCH 06/15] fix(compare): add missing cases for scatter chart --- app/utils/compare-scatter-chart.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/utils/compare-scatter-chart.ts b/app/utils/compare-scatter-chart.ts index 43669bcf5c..7d14f5e298 100644 --- a/app/utils/compare-scatter-chart.ts +++ b/app/utils/compare-scatter-chart.ts @@ -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 } From 84341894d3bf966c981b861309f6b5b2174b6c9f Mon Sep 17 00:00:00 2001 From: Torben Haack Date: Sun, 12 Apr 2026 10:15:08 +0200 Subject: [PATCH 07/15] test(compare): add coverage for github metadata and created at facets --- .../use-package-comparison.spec.ts | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/test/nuxt/composables/use-package-comparison.spec.ts b/test/nuxt/composables/use-package-comparison.spec.ts index bf3710c9b1..d9f9b5fe4e 100644 --- a/test/nuxt/composables/use-package-comparison.spec.ts +++ b/test/nuxt/composables/use-package-comparison.spec.ts @@ -192,4 +192,115 @@ describe('usePackageComparison', () => { expect(values[0]?.status).toBe('neutral') }) }) + + describe('github metadata', () => { + it('fetches github stars and issues when repository is on github', async () => { + const pkgName = 'github-pkg' + vi.stubGlobal( + '$fetch', + vi.fn().mockImplementation((url: string, options?: { baseURL?: string }) => { + const fullUrl = options?.baseURL ? `${options.baseURL}${url}` : url + if (fullUrl.startsWith('https://registry.npmjs.org/')) { + return Promise.resolve({ + 'name': pkgName, + 'dist-tags': { latest: '1.0.0' }, + 'repository': { type: 'git', url: 'https://github.com/owner/repo' }, + 'versions': { + '1.0.0': { dist: { unpackedSize: 1000 } }, + }, + }) + } + if (fullUrl.includes('ungh.cc/repos/owner/repo')) { + return Promise.resolve({ repo: { stars: 1500 } }) + } + if (fullUrl.includes('/api/github/issues/owner/repo')) { + return Promise.resolve({ issues: 50 }) + } + return Promise.resolve(null) + }), + ) + + const { status, getFacetValues } = await usePackageComparisonInComponent([pkgName]) + await vi.waitFor(() => { + expect(status.value).toBe('success') + }) + + const stars = getFacetValues('githubStars')[0] + const issues = getFacetValues('githubIssues')[0] + + expect(stars).toMatchObject({ raw: 1500, status: 'neutral' }) + expect(issues).toMatchObject({ raw: 50, status: 'neutral' }) + }) + + it('skips github fetches for non-github repositories', async () => { + const pkgName = 'gitlab-pkg' + const fetchMock = vi + .fn() + .mockImplementation((url: string, options?: { baseURL?: string }) => { + const fullUrl = options?.baseURL ? `${options.baseURL}${url}` : url + if (fullUrl.startsWith('https://registry.npmjs.org/')) { + return Promise.resolve({ + 'name': pkgName, + 'dist-tags': { latest: '1.0.0' }, + 'repository': { type: 'git', url: 'https://gitlab.com/owner/repo' }, + 'versions': { + '1.0.0': { dist: { unpackedSize: 1000 } }, + }, + }) + } + return Promise.resolve(null) + }) + vi.stubGlobal('$fetch', fetchMock) + + const { status, getFacetValues } = await usePackageComparisonInComponent([pkgName]) + await vi.waitFor(() => { + expect(status.value).toBe('success') + }) + + expect(fetchMock).not.toHaveBeenCalledWith(expect.stringContaining('ungh.cc')) + expect(fetchMock).not.toHaveBeenCalledWith(expect.stringContaining('/api/github/issues')) + + expect(getFacetValues('githubStars')[0]).toBeNull() + expect(getFacetValues('githubIssues')[0]).toBeNull() + }) + }) + + describe('createdAt facet', () => { + it('displays the creation date without status', async () => { + const createdDate = '2020-01-01T00:00:00.000Z' + vi.stubGlobal( + '$fetch', + vi.fn().mockImplementation((url: string, options?: { baseURL?: string }) => { + const fullUrl = options?.baseURL ? `${options.baseURL}${url}` : url + if (fullUrl.startsWith('https://registry.npmjs.org/')) { + return Promise.resolve({ + 'name': 'test-package', + 'dist-tags': { latest: '1.0.0' }, + 'time': { + 'created': createdDate, + '1.0.0': createdDate, + }, + 'versions': { + '1.0.0': { dist: { unpackedSize: 1000 } }, + }, + }) + } + return Promise.resolve(null) + }), + ) + + const { status, getFacetValues } = await usePackageComparisonInComponent(['test-package']) + await vi.waitFor(() => { + expect(status.value).toBe('success') + }) + + const value = getFacetValues('createdAt')[0] + expect(value).toMatchObject({ + raw: createdDate, + display: createdDate, + type: 'date', + }) + expect(value?.status).toBeUndefined() + }) + }) }) From 3865fc1dde63533520aa8cc61492312a81e3f9fa Mon Sep 17 00:00:00 2001 From: Torben Haack Date: Sun, 12 Apr 2026 10:20:52 +0200 Subject: [PATCH 08/15] test(compare): update facet mock data to include github and creation facets --- test/nuxt/components/compare/FacetSelector.spec.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/nuxt/components/compare/FacetSelector.spec.ts b/test/nuxt/components/compare/FacetSelector.spec.ts index 0b7595ced8..11904946c7 100644 --- a/test/nuxt/components/compare/FacetSelector.spec.ts +++ b/test/nuxt/components/compare/FacetSelector.spec.ts @@ -32,6 +32,9 @@ const facetLabels: Record = { From 2693e16f2805b8bfa54224f79d0e7d7404d81af5 Mon Sep 17 00:00:00 2001 From: Torben Haack Date: Sun, 12 Apr 2026 10:39:36 +0200 Subject: [PATCH 09/15] feat(compare): mirror contributors-evolution retry logic and timeout for issues --- .../api/github/issues/[owner]/[repo].get.ts | 55 ++++++++++++++----- 1 file changed, 42 insertions(+), 13 deletions(-) diff --git a/server/api/github/issues/[owner]/[repo].get.ts b/server/api/github/issues/[owner]/[repo].get.ts index 58c63ceb43..3de75fc7d3 100644 --- a/server/api/github/issues/[owner]/[repo].get.ts +++ b/server/api/github/issues/[owner]/[repo].get.ts @@ -1,3 +1,4 @@ +import { setTimeout } from 'node:timers/promises' import { CACHE_MAX_AGE_ONE_HOUR } from '#shared/utils/constants' const GITHUB_HEADERS = { @@ -31,21 +32,49 @@ export default defineCachedEventHandler( const query = `repo:${owner}/${repo} is:issue is:open` const url = `https://api.github.com/search/issues?q=${encodeURIComponent(query)}&per_page=1` - try { - const data = await $fetch(url, { - headers: GITHUB_HEADERS, - }) - return { - owner, - repo, - issues: data.total_count, + const maxAttempts = 3 + let delayMs = 1000 + + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + try { + const response = await $fetch.raw(url, { + headers: GITHUB_HEADERS, + timeout: 10000, + }) + + if (response.status === 200) { + return { + owner, + repo, + issues: response._data?.total_count ?? 0, + } + } + + 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) } - } catch (error: any) { - throw createError({ - statusCode: error.response?.status || 500, - statusMessage: error.response?._data?.message || 'Failed to fetch issue count from GitHub', - }) } + + throw createError({ + statusCode: 500, + statusMessage: 'Failed to fetch issue count from GitHub after retries', + }) }, { maxAge: CACHE_MAX_AGE_ONE_HOUR, From 3e4fccd31d5789e3660d92c9170e5d017033a8ee Mon Sep 17 00:00:00 2001 From: Torben Haack Date: Sun, 12 Apr 2026 10:41:25 +0200 Subject: [PATCH 10/15] fix(compare): rename facet i18n keys to camelCase for convention consistency --- app/composables/useFacetSelection.ts | 12 ++++++------ i18n/locales/en.json | 6 +++--- i18n/schema.json | 6 +++--- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/app/composables/useFacetSelection.ts b/app/composables/useFacetSelection.ts index 7479fd629a..ff7349195e 100644 --- a/app/composables/useFacetSelection.ts +++ b/app/composables/useFacetSelection.ts @@ -123,22 +123,22 @@ export function useFacetSelection(queryParam = 'facets') { chartable_scatter: false, }, githubStars: { - label: t(`compare.facets.items.github_stars.label`), - description: t(`compare.facets.items.github_stars.description`), + 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.github_issues.label`), - description: t(`compare.facets.items.github_issues.description`), + 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.created_at.label`), - description: t(`compare.facets.items.created_at.description`), + 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(), diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 826b410eb1..e1272a3b18 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -1326,15 +1326,15 @@ "label": "Vulnerabilities", "description": "Known security vulnerabilities" }, - "github_stars": { + "githubStars": { "label": "GitHub Stars", "description": "Number of stars on the GitHub repository" }, - "github_issues": { + "githubIssues": { "label": "GitHub Issues", "description": "Number of issues on the GitHub repository" }, - "created_at": { + "createdAt": { "label": "Created At", "description": "When the package was created" } diff --git a/i18n/schema.json b/i18n/schema.json index a446f08cae..53d01ed4fc 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -3982,7 +3982,7 @@ }, "additionalProperties": false }, - "github_stars": { + "githubStars": { "type": "object", "properties": { "label": { @@ -3994,7 +3994,7 @@ }, "additionalProperties": false }, - "github_issues": { + "githubIssues": { "type": "object", "properties": { "label": { @@ -4006,7 +4006,7 @@ }, "additionalProperties": false }, - "created_at": { + "createdAt": { "type": "object", "properties": { "label": { From 9df70feffbfd90424d0d8c30196aab6bb9b0b187 Mon Sep 17 00:00:00 2001 From: Torben Haack Date: Sun, 12 Apr 2026 10:46:22 +0200 Subject: [PATCH 11/15] refactor(compare): return null for missing or malformed GitHub metrics --- app/composables/usePackageComparison.ts | 6 +-- .../api/github/issues/[owner]/[repo].get.ts | 5 ++- .../use-package-comparison.spec.ts | 37 +++++++++++++++++++ 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/app/composables/usePackageComparison.ts b/app/composables/usePackageComparison.ts index 7f88ff44c4..c0798bc11d 100644 --- a/app/composables/usePackageComparison.ts +++ b/app/composables/usePackageComparison.ts @@ -144,14 +144,14 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter) { ? $fetch<{ repo: { stars: number } }>( `https://ungh.cc/repos/${repoInfo.owner}/${repoInfo.repo}`, ) - .then(res => res?.repo.stars || 0) + .then(res => (typeof res?.repo?.stars === 'number' ? res.repo.stars : null)) .catch(() => null) : Promise.resolve(null), isGitHub - ? $fetch<{ issues: number }>( + ? $fetch<{ issues: number | null }>( `/api/github/issues/${repoInfo.owner}/${repoInfo.repo}`, ) - .then(res => res?.issues || 0) + .then(res => (typeof res?.issues === 'number' ? res.issues : null)) .catch(() => null) : Promise.resolve(null), ]) diff --git a/server/api/github/issues/[owner]/[repo].get.ts b/server/api/github/issues/[owner]/[repo].get.ts index 3de75fc7d3..cbb419d11b 100644 --- a/server/api/github/issues/[owner]/[repo].get.ts +++ b/server/api/github/issues/[owner]/[repo].get.ts @@ -14,7 +14,7 @@ interface GitHubSearchResponse { export interface GithubIssueCountResponse { owner: string repo: string - issues: number + issues: number | null } export default defineCachedEventHandler( @@ -46,7 +46,8 @@ export default defineCachedEventHandler( return { owner, repo, - issues: response._data?.total_count ?? 0, + issues: + typeof response._data?.total_count === 'number' ? response._data.total_count : null, } } diff --git a/test/nuxt/composables/use-package-comparison.spec.ts b/test/nuxt/composables/use-package-comparison.spec.ts index d9f9b5fe4e..8e838f3181 100644 --- a/test/nuxt/composables/use-package-comparison.spec.ts +++ b/test/nuxt/composables/use-package-comparison.spec.ts @@ -232,6 +232,43 @@ describe('usePackageComparison', () => { expect(issues).toMatchObject({ raw: 50, status: 'neutral' }) }) + it('returns null for missing or non-numeric github metrics', async () => { + const pkgName = 'missing-metrics-pkg' + vi.stubGlobal( + '$fetch', + vi.fn().mockImplementation((url: string, options?: { baseURL?: string }) => { + const fullUrl = options?.baseURL ? `${options.baseURL}${url}` : url + if (fullUrl.startsWith('https://registry.npmjs.org/')) { + return Promise.resolve({ + 'name': pkgName, + 'dist-tags': { latest: '1.0.0' }, + 'repository': { type: 'git', url: 'https://github.com/owner/repo' }, + 'versions': { + '1.0.0': { dist: { unpackedSize: 1000 } }, + }, + }) + } + if (fullUrl.includes('ungh.cc/repos/owner/repo')) { + // Return malformed data (stars missing) + return Promise.resolve({ repo: {} }) + } + if (fullUrl.includes('/api/github/issues/owner/repo')) { + // Return non-numeric data + return Promise.resolve({ issues: 'not-a-number' }) + } + return Promise.resolve(null) + }), + ) + + const { status, getFacetValues } = await usePackageComparisonInComponent([pkgName]) + await vi.waitFor(() => { + expect(status.value).toBe('success') + }) + + expect(getFacetValues('githubStars')[0]).toBeNull() + expect(getFacetValues('githubIssues')[0]).toBeNull() + }) + it('skips github fetches for non-github repositories', async () => { const pkgName = 'gitlab-pkg' const fetchMock = vi From f64fd0479992791459406a08a2e6007f0f38087c Mon Sep 17 00:00:00 2001 From: Torben Haack Date: Sun, 12 Apr 2026 11:29:57 +0200 Subject: [PATCH 12/15] fix(compare): remove unused formatter --- app/composables/useFacetSelection.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/app/composables/useFacetSelection.ts b/app/composables/useFacetSelection.ts index ff7349195e..12817f4136 100644 --- a/app/composables/useFacetSelection.ts +++ b/app/composables/useFacetSelection.ts @@ -141,7 +141,6 @@ export function useFacetSelection(queryParam = 'facets') { description: t(`compare.facets.items.createdAt.description`), chartable: false, chartable_scatter: false, - formatter: v => new Date(v).toLocaleDateString(), }, }), ) From 85c59bed8b9009ae9d76e3308191bf1c7b9c6ce1 Mon Sep 17 00:00:00 2001 From: Torben Haack Date: Sun, 12 Apr 2026 14:38:10 +0200 Subject: [PATCH 13/15] refactor: use shared fetch logic for github api --- .../[owner]/[repo].get.ts | 34 ++-------- .../api/github/issues/[owner]/[repo].get.ts | 62 +++++-------------- server/utils/github.ts | 57 +++++++++++++++++ 3 files changed, 75 insertions(+), 78 deletions(-) create mode 100644 server/utils/github.ts diff --git a/server/api/github/contributors-evolution/[owner]/[repo].get.ts b/server/api/github/contributors-evolution/[owner]/[repo].get.ts index 47cde6df85..27a13c8252 100644 --- a/server/api/github/contributors-evolution/[owner]/[repo].get.ts +++ b/server/api/github/contributors-evolution/[owner]/[repo].get.ts @@ -1,4 +1,3 @@ -import { setTimeout } from 'node:timers/promises' import { CACHE_MAX_AGE_ONE_DAY } from '#shared/utils/constants' type GitHubContributorWeek = { @@ -26,38 +25,13 @@ export default defineCachedEventHandler( } const url = `https://api.github.com/repos/${owner}/${repo}/stats/contributors` - const headers = { - 'User-Agent': 'npmx', - 'Accept': 'application/vnd.github+json', - } - - const maxAttempts = 6 - let delayMs = 1000 try { - for (let attempt = 0; attempt < maxAttempts; attempt += 1) { - const response = await $fetch.raw(url, { headers }) - const status = response.status - - if (status === 200) { - return Array.isArray(response._data) ? response._data : [] - } - - if (status === 204) { - return [] - } - - if (status === 202) { - if (attempt === maxAttempts - 1) return [] - await setTimeout(delayMs) - delayMs = Math.min(delayMs * 2, 16_000) - continue - } - - return [] - } + const data = await fetchGitHubWithRetries(url, { + maxAttempts: 6, + }) - return [] + return Array.isArray(data) ? data : [] } catch { return [] } diff --git a/server/api/github/issues/[owner]/[repo].get.ts b/server/api/github/issues/[owner]/[repo].get.ts index cbb419d11b..003043b864 100644 --- a/server/api/github/issues/[owner]/[repo].get.ts +++ b/server/api/github/issues/[owner]/[repo].get.ts @@ -1,12 +1,5 @@ -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', -} as const - interface GitHubSearchResponse { total_count: number } @@ -32,50 +25,23 @@ export default defineCachedEventHandler( 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(url, { - headers: GITHUB_HEADERS, - timeout: 10000, - }) - - if (response.status === 200) { - return { - owner, - 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 - } + try { + const data = await fetchGitHubWithRetries(url, { + maxAttempts: 3, + timeout: 10000, + }) - 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) + return { + owner, + repo, + issues: typeof data?.total_count === 'number' ? data.total_count : null, } + } catch { + throw createError({ + statusCode: 500, + statusMessage: 'Failed to fetch issue count from GitHub', + }) } - - throw createError({ - statusCode: 500, - statusMessage: 'Failed to fetch issue count from GitHub after retries', - }) }, { maxAge: CACHE_MAX_AGE_ONE_HOUR, diff --git a/server/utils/github.ts b/server/utils/github.ts new file mode 100644 index 0000000000..1a6d4bf56c --- /dev/null +++ b/server/utils/github.ts @@ -0,0 +1,57 @@ +import { setTimeout } from 'node:timers/promises' +import type { NitroFetchOptions } from 'nitropack' + +export interface GitHubFetchOptions extends NitroFetchOptions { + maxAttempts?: number +} + +export async function fetchGitHubWithRetries( + url: string, + options: GitHubFetchOptions = {}, +): Promise { + const { maxAttempts = 3, ...fetchOptions } = options + let delayMs = 1000 + + const defaultHeaders = { + 'Accept': 'application/vnd.github+json', + 'User-Agent': 'npmx', + 'X-GitHub-Api-Version': '2026-03-10', + } + + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + try { + const response = await $fetch.raw(url, { + ...fetchOptions, + headers: { + ...defaultHeaders, + ...fetchOptions.headers, + }, + }) + + if (response.status === 200) { + return (response._data as T) ?? null + } + + if (response.status === 204) { + return null + } + + if (response.status === 202) { + if (attempt === maxAttempts - 1) break + await setTimeout(delayMs) + delayMs = Math.min(delayMs * 2, 16_000) + continue + } + + break + } catch (error: unknown) { + if (attempt === maxAttempts - 1) { + throw error + } + await setTimeout(delayMs) + delayMs = Math.min(delayMs * 2, 16_000) + } + } + + throw new Error(`Failed to fetch from GitHub after ${maxAttempts} attempts`) +} From 9cb87b10adf406171d6dcd19697cdbaf330ee7a5 Mon Sep 17 00:00:00 2001 From: Torben Haack Date: Sun, 12 Apr 2026 14:40:04 +0200 Subject: [PATCH 14/15] chore: remove maxAttempts=3 as this is the default value --- server/api/github/issues/[owner]/[repo].get.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/server/api/github/issues/[owner]/[repo].get.ts b/server/api/github/issues/[owner]/[repo].get.ts index 003043b864..9bac317500 100644 --- a/server/api/github/issues/[owner]/[repo].get.ts +++ b/server/api/github/issues/[owner]/[repo].get.ts @@ -27,7 +27,6 @@ export default defineCachedEventHandler( try { const data = await fetchGitHubWithRetries(url, { - maxAttempts: 3, timeout: 10000, }) From e29ae1f8669af46520ea01b4fc364e918d3d5f1a Mon Sep 17 00:00:00 2001 From: Torben Haack Date: Sun, 12 Apr 2026 15:00:00 +0200 Subject: [PATCH 15/15] fix: remove type import from unlisted depndency --- server/utils/github.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/server/utils/github.ts b/server/utils/github.ts index 1a6d4bf56c..0c49ec6101 100644 --- a/server/utils/github.ts +++ b/server/utils/github.ts @@ -1,7 +1,6 @@ import { setTimeout } from 'node:timers/promises' -import type { NitroFetchOptions } from 'nitropack' -export interface GitHubFetchOptions extends NitroFetchOptions { +export interface GitHubFetchOptions extends NonNullable[1]> { maxAttempts?: number }