Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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}`
},
},
)
56 changes: 56 additions & 0 deletions server/utils/github.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
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 response = await $fetch.raw(url, {
...fetchOptions,
headers: {
...defaultHeaders,
...fetchOptions.headers,
},
Comment on lines +22 to +27
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:

find . -type f -name "github.ts" | grep -E "(server|utils)" | head -5

Repository: npmx-dev/npmx.dev

Length of output: 85


🏁 Script executed:

git ls-files | grep -E "server/utils/github" | head -10

Repository: npmx-dev/npmx.dev

Length of output: 83


🏁 Script executed:

fd -t f "github.ts" server/

Repository: npmx-dev/npmx.dev

Length of output: 83


🏁 Script executed:

wc -l server/utils/github.ts

Repository: npmx-dev/npmx.dev

Length of output: 86


🏁 Script executed:

cat -n server/utils/github.ts | head -50

Repository: npmx-dev/npmx.dev

Length of output: 1665


🏁 Script executed:

rg -t ts "type NitroFetchOptions" --max-count 5

Repository: npmx-dev/npmx.dev

Length of output: 43


🏁 Script executed:

fd -t json "*.json" | xargs grep -l "nitropack" 2>/dev/null | head -3

Repository: npmx-dev/npmx.dev

Length of output: 232


🌐 Web query:

GitHub API version 2026-03-10 compatibility documentation

💡 Result:

The GitHub REST API version 2026-03-10 was released on March 10, 2026, and is now available as the latest version. It is fully supported alongside the previous version 2022-11-28, which remains supported for at least 24 months. To use it, specify the header: X-GitHub-Api-Version: 2026-03-10 Example: curl --header "X-GitHub-Api-Version: 2026-03-10" https://api.github.com/zen Requests without the header default to 2022-11-28. Official documentation is available with a version picker at https://docs.github.com/en/rest?apiVersion=2026-03-10. Select the version in the docs to view endpoint-specific details. Breaking changes in v2026-03-10 (upgrade guidance): - Remove deprecated 'rate' property from GET /rate_limit (use resources.core instead). - Remove deprecated 'permission' property from team creation requests. - Updates to "Get repository content" for submodules. - SARIF upload response changes. - Remove 'javascript' and 'typescript' from code scanning languages enum (use 'javascript-typescript'). - Various attestation endpoint changes. - Other deprecations like authorizations_url, has_downloads, etc. Full list and migration details: https://docs.github.com/en/rest/about-the-rest-api/breaking-changes?apiVersion=2026-03-10 Additive changes are available in all versions. Review breaking changes before upgrading, update the header, and test. API versions info: https://docs.github.com/en/rest/about-the-rest-api/api-versions?apiVersion=2026-03-10

Citations:


🏁 Script executed:

find . -type f -name "*.d.ts" | xargs grep -l "NitroFetchOptions" 2>/dev/null | head -3

Repository: npmx-dev/npmx.dev

Length of output: 43


🏁 Script executed:

cat -n server/utils/github.ts | sed -n '1,57p' | tail -10

Repository: npmx-dev/npmx.dev

Length of output: 369


🏁 Script executed:

cat package.json | grep -A 5 -B 5 "nitropack"

Repository: npmx-dev/npmx.dev

Length of output: 43


🏁 Script executed:

find . -path ./node_modules -prune -o -type f -name "*.d.ts" -print | xargs grep -l "export.*NitroFetchOptions" 2>/dev/null | head -5

Repository: npmx-dev/npmx.dev

Length of output: 43


🌐 Web query:

NitroFetchOptions headers type definition Nitro

💡 Result:

The NitroFetchOptions interface extends FetchOptions from ofetch and adds Nitro-specific typing for API route calls. Key definition from Nitro source discussions and type proposals: ts interface NitroFetchOptions<R extends NitroFetchRequest, M extends AvailableRouterMethod = AvailableRouterMethod> extends FetchOptions { method?: Uppercase | M; body?: HandlerBodyType<R, M>; query?: HandlerQueryType<R, M>; } The headers property inherits directly from FetchOptions, which defines it as: - headers?: MaybeRefOrGetter<Record<string, string> | [key: string, value: string][] | Headers> This allows headers to be a plain object { [key: string]: string }, array of tuples, or Headers instance, and supports reactive refs or getters. Usage example from community wrappers: ts import type { NitroFetchOptions, NitroFetchRequest } from 'nitropack' const $api = async <T = unknown, R extends NitroFetchRequest = NitroFetchRequest, O extends NitroFetchOptions = NitroFetchOptions>( url: R, options?: O ) => $fetch(url, { ...options, headers: { ...useRequestHeaders(['cookie']), ...options?.headers } }) Official Nitro docs reference $fetch options matching ofetch FetchOptions, confirming standard web Fetch API headers support with Nitro route typing enhancements. Types are auto-generated in .nitro/types/nitro-routes.d.ts for IDE inference.

Citations:


🏁 Script executed:

cat package.json

Repository: npmx-dev/npmx.dev

Length of output: 5687


Fix headers merge to support all NitroFetchOptions header types (plain objects, tuples, and Headers instances).

At lines 25–28, object spread only works reliably for plain objects. If fetchOptions.headers is a Headers instance or array of tuples (both valid HeadersInit types per NitroFetchOptions spec), entries will be lost and caller-provided headers may not be sent.

Suggested fix
-      const response = await $fetch.raw(url, {
-        ...fetchOptions,
-        headers: {
-          ...defaultHeaders,
-          ...fetchOptions.headers,
-        },
-      })
+      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,
+      })

Per coding guidelines, "Ensure you write strictly type-safe code".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/utils/github.ts` around lines 23 - 28, The current merge in the
$fetch.raw call loses headers when fetchOptions.headers is a Headers instance or
array-of-tuples; update the header merge logic used before calling $fetch.raw
(where defaultHeaders and fetchOptions.headers are combined) to normalize
HeadersInit into a single Headers and then copy entries so all forms are
supported: create a Headers from defaultHeaders, then if fetchOptions.headers is
a Headers, iterate its entries and set them, if it's an array of tuples iterate
and set, and if it's a plain object iterate its keys and set; finally pass the
resulting Headers (or a plain object built from it) into $fetch.raw so callers
using Headers, tuple arrays, or objects all have their headers preserved.

})

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