diff --git a/app/composables/useFacetSelection.ts b/app/composables/useFacetSelection.ts index 368123c1ed..12817f4136 100644 --- a/app/composables/useFacetSelection.ts +++ b/app/composables/useFacetSelection.ts @@ -122,6 +122,26 @@ 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, + }, }), ) diff --git a/app/composables/usePackageComparison.ts b/app/composables/usePackageComparison.ts index 760f7ce08c..c0798bc11d 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 + github?: { + stars?: number + issues?: number + } } /** Whether this is a binary-only package (CLI without library entry points) */ isBinaryOnly?: boolean @@ -115,12 +121,13 @@ 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 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), @@ -133,6 +140,20 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter) { $fetch(`/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 @@ -179,8 +200,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 +278,7 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter) { return packagesData.value.map(pkg => { if (!pkg) return null + return computeFacetValue( facet, pkg, @@ -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 } 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 } diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 3684a1ff3a..c1ecc93cc7 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -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" } }, "values": { diff --git a/i18n/schema.json b/i18n/schema.json index 8df446904a..53d01ed4fc 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -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 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 new file mode 100644 index 0000000000..9bac317500 --- /dev/null +++ b/server/api/github/issues/[owner]/[repo].get.ts @@ -0,0 +1,55 @@ +import { CACHE_MAX_AGE_ONE_HOUR } from '#shared/utils/constants' + +interface GitHubSearchResponse { + total_count: number +} + +export interface GithubIssueCountResponse { + owner: string + repo: string + issues: number | null +} + +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 fetchGitHubWithRetries(url, { + timeout: 10000, + }) + + 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', + }) + } + }, + { + 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/server/utils/github.ts b/server/utils/github.ts new file mode 100644 index 0000000000..36f91b0f4d --- /dev/null +++ b/server/utils/github.ts @@ -0,0 +1,57 @@ +import { setTimeout } from 'node:timers/promises' + +export interface GitHubFetchOptions extends NonNullable[1]> { + 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 headers = new Headers(defaultHeaders) + for (const [key, value] of new Headers(fetchOptions.headers)) { + headers.set(key, value) + } + const response = await $fetch.raw(url, { + ...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`) +} diff --git a/shared/types/comparison.ts b/shared/types/comparison.ts index 9dfe229eb9..c7bfafdc70 100644 --- a/shared/types/comparison.ts +++ b/shared/types/comparison.ts @@ -17,6 +17,9 @@ export type ComparisonFacet = | 'totalDependencies' | 'deprecated' | 'totalLikes' + | 'githubStars' + | 'githubIssues' + | 'createdAt' /** Facet metadata for UI display */ export interface FacetInfo { @@ -56,6 +59,15 @@ export const FACET_INFO: Record> = { deprecated: { category: 'health', }, + githubStars: { + category: 'health', + }, + githubIssues: { + category: 'health', + }, + createdAt: { + category: 'health', + }, // Compatibility engines: { category: 'compatibility', 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 = { diff --git a/test/nuxt/composables/use-package-comparison.spec.ts b/test/nuxt/composables/use-package-comparison.spec.ts index bf3710c9b1..8e838f3181 100644 --- a/test/nuxt/composables/use-package-comparison.spec.ts +++ b/test/nuxt/composables/use-package-comparison.spec.ts @@ -192,4 +192,152 @@ 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('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 + .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() + }) + }) })