Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 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
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
20 changes: 20 additions & 0 deletions app/composables/useFacetSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
}),
)

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"
}
},
"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
34 changes: 4 additions & 30 deletions server/api/github/contributors-evolution/[owner]/[repo].get.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { setTimeout } from 'node:timers/promises'
import { CACHE_MAX_AGE_ONE_DAY } from '#shared/utils/constants'

type GitHubContributorWeek = {
Expand Down Expand Up @@ -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<GitHubContributorStats[]>(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<GitHubContributorStats[]>(url, {
maxAttempts: 6,
})
Comment on lines +30 to +32
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.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's find the file under review
fd "contributors-evolution" -t f

Repository: npmx-dev/npmx.dev

Length of output: 43


🏁 Script executed:

# Search for the fetchGitHubWithRetries function definition
rg "fetchGitHubWithRetries" -t ts -t tsx --max-count=20 -A 5 -B 2

Repository: npmx-dev/npmx.dev

Length of output: 88


🏁 Script executed:

# Look for function implementation and type definitions
rg "function fetchGitHubWithRetries|const fetchGitHubWithRetries" -t ts -t tsx -A 15

Repository: npmx-dev/npmx.dev

Length of output: 88


🏁 Script executed:

# Find the specific file being reviewed
find . -name "*.get.ts" -path "*contributors-evolution*"

Repository: npmx-dev/npmx.dev

Length of output: 125


🏁 Script executed:

# Search for fetchGitHubWithRetries without file type flags
rg "fetchGitHubWithRetries" -A 10 -B 2

Repository: npmx-dev/npmx.dev

Length of output: 2684


🏁 Script executed:

# Also search for timeout-related configuration in GitHub API utilities
rg "timeout" --max-count=30

Repository: npmx-dev/npmx.dev

Length of output: 6996


🏁 Script executed:

# Read the full implementation of fetchGitHubWithRetries
head -100 server/utils/github.ts

Repository: npmx-dev/npmx.dev

Length of output: 1485


🏁 Script executed:

# Look for GitHubFetchOptions type definition
rg "GitHubFetchOptions" -A 8 -B 2

Repository: npmx-dev/npmx.dev

Length of output: 941


Add an explicit timeout for the GitHub call.

The fetchGitHubWithRetries function supports timeout via NitroFetchOptions. Line 30–32 configures 6 retry attempts but lacks a request timeout, leaving each attempt vulnerable to indefinite blocking. The same pattern is used elsewhere in the codebase with timeout: 10000 (see server/api/github/issues/[owner]/[repo].get.ts).

Suggested fix
       const data = await fetchGitHubWithRetries<GitHubContributorStats[]>(url, {
         maxAttempts: 6,
+        timeout: 10_000,
       })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const data = await fetchGitHubWithRetries<GitHubContributorStats[]>(url, {
maxAttempts: 6,
})
const data = await fetchGitHubWithRetries<GitHubContributorStats[]>(url, {
maxAttempts: 6,
timeout: 10_000,
})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/api/github/contributors-evolution/`[owner]/[repo].get.ts around lines
30 - 32, The fetch call to GitHub uses
fetchGitHubWithRetries<GitHubContributorStats[]> but omits a request timeout, so
update the options passed to fetchGitHubWithRetries (the call that assigns to
data) to include a timeout (e.g. timeout: 10000) alongside maxAttempts: 6; this
ensures each retry attempt will fail fast instead of potentially blocking
indefinitely. Target the invocation of fetchGitHubWithRetries in the handler for
contributors-evolution ([owner]/[repo].get.ts) and add the timeout property to
the NitroFetchOptions object.


return []
return Array.isArray(data) ? data : []
} catch {
return []
}
Expand Down
55 changes: 55 additions & 0 deletions server/api/github/issues/[owner]/[repo].get.ts
Original file line number Diff line number Diff line change
@@ -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<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`

try {
const data = await fetchGitHubWithRetries<GitHubSearchResponse>(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}`
},
},
)
57 changes: 57 additions & 0 deletions server/utils/github.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { setTimeout } from 'node:timers/promises'

export interface GitHubFetchOptions extends NonNullable<Parameters<typeof $fetch.raw>[1]> {
maxAttempts?: number
}

export async function fetchGitHubWithRetries<T>(
url: string,
options: GitHubFetchOptions = {},
): Promise<T | null> {
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`)
}
Loading
Loading