diff --git a/app/composables/usePackageComparison.ts b/app/composables/usePackageComparison.ts index 4ff4de7dbf..23c430886e 100644 --- a/app/composables/usePackageComparison.ts +++ b/app/composables/usePackageComparison.ts @@ -1,6 +1,7 @@ import { NO_DEPENDENCY_ID } from '#shared/utils/compare' import { parsePackageSpec } from '#shared/utils/parse-package-param' import { getDependencyCount } from '~/utils/npm/dependency-count' +import { getRepoMeta } from '#shared/utils/repository-meta' /** * Special display values for the "no dependency" column. @@ -71,6 +72,7 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter) { const compactNumberFormatter = useCompactNumberFormatter() const bytesFormatter = useBytesFormatter() const packages = computed(() => toValue(packageNames)) + const cachedFetch = useCachedFetch() // Cache of fetched data by package name (source of truth) const cache = shallowRef(new Map()) @@ -144,7 +146,7 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter) { // Fetch fast additional data in parallel (optional - failures are ok) const repoInfo = parseRepositoryInfo(pkg.repository) const isGitHub = repoInfo?.provider === 'github' - const [downloads, analysis, vulns, likes, ghRepo, ghIssues] = await Promise.all([ + const [downloads, analysis, vulns, likes, repoMeta, ghIssues] = await Promise.all([ // Download counts are per-package, not per-version. $fetch<{ downloads: number }>( `https://api.npmjs.org/downloads/point/last-week/${encodePackageName(name)}`, @@ -159,9 +161,7 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter) { () => null, ), isGitHub - ? $fetch<{ repo: { stars?: number; forks?: number } }>( - `https://ungh.cc/repos/${repoInfo.owner}/${repoInfo.repo}`, - ).catch(() => null) + ? getRepoMeta(cachedFetch, repoInfo).catch(() => null) : Promise.resolve(null), isGitHub ? $fetch<{ issues: number | null }>( @@ -216,8 +216,8 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter) { engines: analysis?.engines, deprecated: versionData.deprecated, github: { - stars: typeof ghRepo?.repo?.stars === 'number' ? ghRepo.repo.stars : undefined, - forks: typeof ghRepo?.repo?.forks === 'number' ? ghRepo.repo.forks : undefined, + stars: repoMeta?.stars, + forks: repoMeta?.forks, issues: ghIssues ?? undefined, }, }, diff --git a/app/composables/useRepoMeta.ts b/app/composables/useRepoMeta.ts index 3e0138e6de..6196373c59 100644 --- a/app/composables/useRepoMeta.ts +++ b/app/composables/useRepoMeta.ts @@ -34,10 +34,8 @@ export function useRepoMeta(repositoryUrl: MaybeRefOrGetter meta.value?.stars ?? 0), forks: computed(() => meta.value?.forks ?? 0), - watchers: computed(() => meta.value?.watchers ?? 0), forksLink: computed(() => meta.value?.links.forks ?? null), - watchersLink: computed(() => meta.value?.links.watchers ?? null), repoLink: computed(() => meta.value?.links.repo ?? null), pending, diff --git a/package.json b/package.json index eec341f71b..a7069137cd 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "diff": "^9.0.0", "fast-npm-meta": "2.2.0", "focus-trap": "^8.0.0", + "forges": "https://pkg.pr.new/danielroe/forges@beed311", "gray-matter": "4.0.3", "hls.js": "1.7.0", "ipaddr.js": "2.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 28bd763086..ee7903e647 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -198,6 +198,9 @@ importers: focus-trap: specifier: ^8.0.0 version: 8.2.2 + forges: + specifier: https://pkg.pr.new/danielroe/forges@beed311 + version: https://pkg.pr.new/danielroe/forges@beed311 gray-matter: specifier: 4.0.3 version: 4.0.3 @@ -7058,6 +7061,11 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + forges@https://pkg.pr.new/danielroe/forges@beed311: + resolution: {integrity: sha512-P9WfBsvKYYgDYChL0v3iL5aAajPLIIcDbADejgX1Xrhk+UfFTgV6mp3D6sqrxitlkarQIGmJFFMNBd31BtMubA==, tarball: https://pkg.pr.new/danielroe/forges@beed311} + version: 0.3.1 + engines: {node: '>=22.18'} + formatly@0.11.4: resolution: {integrity: sha512-TeCC10lP8KCgoX+Fe5lryILIT0/Dy+nP0OSVnsuB5CwXoLuOdqxCYlPFF1inR8S/zTy1ec7TjhS2TeRKWzzImA==} engines: {node: '>=18.14.0'} @@ -18846,6 +18854,8 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + forges@https://pkg.pr.new/danielroe/forges@beed311: {} + formatly@0.11.4: dependencies: fd-package-json: 2.0.0 diff --git a/server/api/atproto/tangled-stats/[owner]/[...repo].get.ts b/server/api/atproto/tangled-stats/[owner]/[...repo].get.ts new file mode 100644 index 0000000000..1870510fb4 --- /dev/null +++ b/server/api/atproto/tangled-stats/[owner]/[...repo].get.ts @@ -0,0 +1,31 @@ +import { CACHE_MAX_AGE_ONE_MINUTE } from '#shared/utils/constants' +import { createForgeProvider, toForgeRepoRef } from '#shared/utils/forges' +import type { TangledStats } from '#shared/utils/repository-meta' +import { guardedFetch } from '#server/utils/guarded-fetch' + +// Proxied because Tangled reads go to each author's PDS and knot, whose hosts +// can be anything, so the browser's CSP cannot allow them. +export default defineCachedEventHandler( + async (event): Promise => { + const owner = getRouterParam(event, 'owner') + const repo = getRouterParam(event, 'repo') + if (!owner || !repo) { + throw createError({ statusCode: 400, message: 'repository not provided' }) + } + + const ref = { provider: 'tangled' as const, owner, repo } + const provider = await createForgeProvider(ref, { fetch: guardedFetch }) + if (!provider) { + throw createError({ statusCode: 500, message: 'Tangled is not supported' }) + } + + const meta = await provider.repos.get(toForgeRepoRef(ref)) + return { + stars: meta.stars, + forks: meta.forks, + } + }, + { + maxAge: CACHE_MAX_AGE_ONE_MINUTE * 10, + }, +) diff --git a/server/api/atproto/tangled-stats/[owner]/[...repo].ts b/server/api/atproto/tangled-stats/[owner]/[...repo].ts deleted file mode 100644 index 0b8e698c60..0000000000 --- a/server/api/atproto/tangled-stats/[owner]/[...repo].ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { CachedFetchFunction } from '#shared/utils/fetch-cache-config' - -export default defineCachedEventHandler( - async event => { - let owner = getRouterParam(event, 'owner') - let repo = getRouterParam(event, 'repo') - - let cachedFetch: CachedFetchFunction - if (event.context.cachedFetch) { - cachedFetch = event.context.cachedFetch - } else { - // Fallback: return a function that uses regular $fetch - // (shouldn't happen in normal operation) - cachedFetch = async ( - url: string, - options: Parameters[1] = {}, - _ttl?: number, - ): Promise> => { - const data = (await $fetch(url, options)) as T - return { data, isStale: false, cachedAt: null } - } - } - - try { - // Tangled doesn't have a public JSON API, but we can scrape the star count - // from the HTML page (it's in the hx-post URL as countHint=N) - const { data: html } = await cachedFetch( - `https://tangled.org/${owner}/${repo}`, - { - headers: { 'User-Agent': 'npmx', 'Accept': 'text/html' }, - }, - CACHE_MAX_AGE_ONE_MINUTE * 10, - ) - // Extracts the at-uri used in atproto - const atUriMatch = html.match(/data-star-subject-at="([^"]+)"/) - // Extract star count from: hx-post="/star?subject=...&countHint=23" - const starMatch = html.match(/countHint=(\d+)/) - //We'll set the stars from tangled's repo page and may override it with constellation if successful - let stars = starMatch?.[1] ? parseInt(starMatch[1], 10) : 0 - let forks = 0 - const atUri = atUriMatch?.[1] - - if (atUri) { - try { - const constellation = new Constellation(cachedFetch) - //Get counts of records that reference this repo in the atmosphere using constellation - const { data: allLinks } = await constellation.getAllLinks(atUri) - stars = allLinks.links['sh.tangled.feed.star']?.['.subject']?.distinct_dids ?? stars - forks = allLinks.links['sh.tangled.repo']?.['.source']?.distinct_dids ?? 0 - } catch { - //failing silently since this is just an enhancement to the information already showing - } - } - - return { - stars, - forks, - } - } catch { - return { - stars: 0, - forks: 0, - } - } - }, - { - maxAge: CACHE_MAX_AGE_ONE_MINUTE * 10, - }, -) diff --git a/server/api/changelog/md/[provider]/[owner]/[repo]/[...path].get.ts b/server/api/changelog/md/[provider]/[owner]/[repo]/[...path].get.ts index 139dbb119b..7054722f74 100644 --- a/server/api/changelog/md/[provider]/[owner]/[repo]/[...path].get.ts +++ b/server/api/changelog/md/[provider]/[owner]/[repo]/[...path].get.ts @@ -1,16 +1,6 @@ -import { resolveURL } from 'ufo' import * as v from 'valibot' -import { getBaseFileUrl } from '~~/server/utils/changelog/baseFileUrl' -import { - createBitbucketRepoInfo, - createForgejoRepoInfo, - createGiteaRepoInfo, - createGiteeRepoInfo, - createGithubRepoInfo, - createGitLabRepoInfo, - createSourcehutRepoInfo, - createTangledRepoInfo, -} from '~~/server/utils/changelog/mdRepoInfo' +import { getFileUrls } from '~~/server/utils/changelog/fileUrls' +import { createRepoInfo } from '~~/server/utils/changelog/mdRepoInfo' import { validateHostWithValibot } from '~~/server/utils/changelog/validateHost' import { ERROR_CHANGELOG_FILE_FAILED, @@ -21,7 +11,8 @@ export default defineCachedEventHandler( async event => { const provider = getRouterParam(event, 'provider') as ProviderId const repo = getRouterParam(event, 'repo') - const owner = getRouterParam(event, 'owner') + // A GitLab subgroup owner arrives encoded as one segment (`group%2Fsub`). + const owner = getRouterParam(event, 'owner', { decode: true }) const path = getRouterParam(event, 'path') const rawQuery = getQuery(event) @@ -42,21 +33,17 @@ export default defineCachedEventHandler( } try { - const baseUrl = getBaseFileUrl({ - owner, - provider: provider as ProviderId, - repo, - host, - }) - const mdRepoInfo = getRepoInfo(provider, owner, repo, host, path) + const ref = { provider: provider as ProviderId, owner, repo, host } + const urls = getFileUrls(ref, path) + const mdRepoInfo = createRepoInfo(ref, path) - if (!baseUrl || !mdRepoInfo) { + if (!urls || !mdRepoInfo) { throw createError({ status: 404, statusMessage: ERROR_CHANGELOG_NOT_FOUND, }) } - const data = await $fetch(resolveURL(baseUrl.raw, path), { + const data = await $fetch(urls.raw, { headers: { 'User-Agent': 'npmx.dev', }, @@ -100,32 +87,3 @@ export default defineCachedEventHandler( shouldBypassCache: () => import.meta.dev, }, ) - -function getRepoInfo( - provider: ProviderId, - owner: string, - repo: string, - host: string | undefined, - path?: string, -) { - switch (provider) { - case 'github': - return createGithubRepoInfo(owner, repo, path) - case 'codeberg': - return createForgejoRepoInfo('codeberg.org', owner, repo, path) - case 'forgejo': - return createForgejoRepoInfo(host ?? 'codeberg.org', owner, repo, path) - case 'gitlab': - return createGitLabRepoInfo(host ?? 'gitlab.com', owner, repo, path) - case 'tangled': - return createTangledRepoInfo(owner, repo, path) - case 'gitea': - return createGiteaRepoInfo(host ?? 'gitea.com', owner, repo, path) - case 'bitbucket': - return createBitbucketRepoInfo(owner, repo, path) - case 'sourcehut': - return createSourcehutRepoInfo(owner, repo, path) - case 'gitee': - return createGiteeRepoInfo(owner, repo, path) - } -} diff --git a/server/api/changelog/releases/[provider]/[owner]/[repo].get.ts b/server/api/changelog/releases/[provider]/[owner]/[repo].get.ts index 30fbcc3bab..5e0edf0f13 100644 --- a/server/api/changelog/releases/[provider]/[owner]/[repo].get.ts +++ b/server/api/changelog/releases/[provider]/[owner]/[repo].get.ts @@ -1,4 +1,4 @@ -import type { ProviderId } from '~~/shared/utils/git-providers' +import type { ProviderId, RepoRef } from '~~/shared/utils/git-providers' import type { ReleaseData } from '~~/shared/types/changelog' import * as v from 'valibot' import { FetchError } from 'ofetch' @@ -6,28 +6,20 @@ import { ERROR_CHANGELOG_RELEASES_FAILED, ERROR_THROW_INCOMPLETE_PARAM, } from '~~/shared/utils/constants' -import { - GithubReleaseCollectionSchama, - ForgejoReleaseCollectionSchema, - GitlabReleaseCollectionSchema, - GiteaReleaseCollectionSchema, - GiteeReleaseCollectionSchema, -} from '~~/shared/schemas/changelog/release' +import { GithubReleaseCollectionSchama } from '~~/shared/schemas/changelog/release' +import { createForgeProvider, toForgeRepoRef } from '~~/shared/utils/forges' import { changelogRenderer } from '~~/server/utils/changelog/markdown' -import { - createForgejoRepoInfo, - createGiteaRepoInfo, - createGithubRepoInfo, -} from '~~/server/utils/changelog/mdRepoInfo' +import { createRepoInfo } from '~~/server/utils/changelog/mdRepoInfo' import { validateHostWithValibot } from '~~/server/utils/changelog/validateHost' -const TIMEOUT_TIME = 15_000 +const TIMEOUT_MS = 15_000 export default defineCachedEventHandler( async event => { const provider = getRouterParam(event, 'provider') as ProviderId const repo = getRouterParam(event, 'repo') - const owner = getRouterParam(event, 'owner') + // A GitLab subgroup owner arrives encoded as one segment (`group%2Fsub`). + const owner = getRouterParam(event, 'owner', { decode: true }) if (!repo || !provider || !owner) { throw createError({ @@ -39,26 +31,14 @@ export default defineCachedEventHandler( const { host } = v.parse(v.object({ host: validateHostWithValibot(provider) }), rawQuery) try { - switch (provider) { - case 'github': - return await getReleasesFromGithub(owner, repo) - case 'codeberg': - return await getReleasesFromForgejo(owner, repo, 'codeberg.org') - case 'forgejo': - return await getReleasesFromForgejo(owner, repo, host) - case 'gitlab': - return await getReleasesFromGitlab(owner, repo, host) - case 'gitea': - return await getReleasesFromGitea(owner, repo, host) - case 'gitee': - return await getReleasesFromGitee(owner, repo) - - default: - throw createError({ - status: 404, - statusMessage: ERROR_CHANGELOG_NOT_FOUND, - }) + // GitHub is read through ungh.cc to stay clear of its anonymous rate limits; + // TODO(serhalp): use `forges` once it can read GitHub through ungh. + if (provider === 'github') { + return await getReleasesFromGithub(owner, repo) } + // No disconnect signal: a cached handler cannot tell a client leaving from + // an SWR revalidation, and aborting would kill the refresh for everyone. + return await getReleasesViaForges(provider, owner, repo, host || undefined) } catch (error) { handleApiError(error, { statusCode: 500, @@ -98,12 +78,19 @@ async function getReleasesFromGithub(owner: string, repo: string) { retry: 3, // only 403 has been added, others status codes are defaults from ofetch retryStatusCodes: [403, 408, 409, 425, 429, 500, 502, 503, 504], - timeout: TIMEOUT_TIME, + timeout: TIMEOUT_MS, }) const { releases } = v.parse(GithubReleaseCollectionSchama, data) - const render = await changelogRenderer(createGithubRepoInfo(owner, repo)) + const repoInfo = createRepoInfo({ provider: 'github', owner, repo }) + if (!repoInfo) { + throw createError({ + status: 404, + statusMessage: ERROR_CHANGELOG_NOT_FOUND, + }) + } + const render = await changelogRenderer(repoInfo) return releases.map(r => { const { html, toc } = render(r.markdown, r.id) @@ -132,118 +119,43 @@ async function getReleasesFromGithub(owner: string, repo: string) { } } -async function getReleasesFromForgejo(owner: string, repo: string, host: string) { - const data = await $fetch(`https://${host}/api/v1/repos/${owner}/${repo}/releases?draft=false`, { - headers: { - 'User-Agent': 'npmx.dev', - }, - timeout: TIMEOUT_TIME, - }) - const releases = v.parse(ForgejoReleaseCollectionSchema, data) - - const render = await changelogRenderer(createForgejoRepoInfo(host, owner, repo)) - - return releases.map(r => { - const { html, toc } = render(r.body, r.id) - return { - id: r.id, - html: html?.replace(/(?)\n/g, '
') ?? null, - title: r.name || r.tag_name, - prerelease: r.prerelease, - toc, - link: r.html_url, - publishedAt: r.published_at, - draft: r.draft, - tag: r.tag_name, - } satisfies ReleaseData - }) -} - -async function getReleasesFromGitlab(owner: string, repo: string, host: string) { - owner = decodeURIComponent(owner) - - const repoPath = encodeURIComponent(`${owner}/${repo}`) - - const data = await $fetch(`https://${host}/api/v4/projects/${repoPath}/releases`, { - headers: { - 'User-Agent': 'npmx.dev', - }, - timeout: TIMEOUT_TIME, - }) - - const releases = v.parse(GitlabReleaseCollectionSchema, data) - - const render = await changelogRenderer(createGitLabRepoInfo(host, owner, repo)) - - return releases.map(r => { - const { html, toc } = render(r.description, r.commit.short_id) - return { - id: r.commit.short_id, - html: html?.replace(/(?)\n/g, '
') ?? null, - title: r.name || r.tag_name, - prerelease: r.upcoming_release, - toc, - publishedAt: r.released_at, - // oxlint-disable-next-line no-underscore-dangle - link: r._links.self, - tag: r.tag_name, - } satisfies ReleaseData - }) -} - -async function getReleasesFromGitea(owner: string, repo: string, host: string) { - const data = await $fetch(`https://${host}/api/v1/repos/${owner}/${repo}/releases?draft=false`, { - headers: { - 'User-Agent': 'npmx.dev', - }, - timeout: TIMEOUT_TIME, - }) - const releases = v.parse(GiteaReleaseCollectionSchema, data) - - const render = await changelogRenderer(createGiteaRepoInfo(host, owner, repo)) - - return releases.map(r => { - const { html, toc } = render(r.body, r.id) - return { - id: r.id, - html: html?.replace(/(?)\n/g, '
') ?? null, - title: r.name || r.tag_name, - prerelease: r.prerelease, - toc, - link: r.html_url, - publishedAt: r.published_at, - draft: r.draft, - tag: r.tag_name, - } satisfies ReleaseData - }) -} - -async function getReleasesFromGitee(owner: string, repo: string) { - const data = await $fetch( - `https://gitee.com/api/v5/repos/${owner}/${repo}/releases?per_page=30`, - { - headers: { - 'User-Agent': 'npmx.dev', - }, - timeout: TIMEOUT_TIME, - }, - ) - - const releases = v.parse(GiteeReleaseCollectionSchema, data) +async function getReleasesViaForges( + provider: ProviderId, + owner: string, + repo: string, + host?: string, +) { + const ref: RepoRef = { provider, owner, repo, host } + const forgeProvider = await createForgeProvider(ref, { timeout: TIMEOUT_MS }) + if (!forgeProvider?.can('releases.list')) { + throw createError({ + status: 404, + statusMessage: ERROR_CHANGELOG_NOT_FOUND, + }) + } - const render = await changelogRenderer(createGiteeRepoInfo(owner, repo)) + const { items: releases } = await forgeProvider.releases.list(toForgeRepoRef(ref)).page() + const repoInfo = createRepoInfo(ref) + if (!repoInfo) { + throw createError({ + status: 404, + statusMessage: ERROR_CHANGELOG_NOT_FOUND, + }) + } + const render = await changelogRenderer(repoInfo) - return releases.map(r => { - const { html, toc } = render(r.body, r.id) + return releases.map(release => { + const { html, toc } = render(release.body ?? null, release.ref.id) return { - id: r.id, + id: release.ref.id, html: html?.replace(/(?)\n/g, '
') ?? null, - title: r.name || r.tag_name, - prerelease: r.prerelease, + title: release.name || release.tag, + draft: release.isDraft, + prerelease: release.isPrerelease, toc, - link: `https://gitee.com/${owner}/${repo}/releases/tag/${r.tag_name}`, - publishedAt: r.created_at, - tag: r.tag_name, + publishedAt: release.publishedAt?.toISOString(), + link: release.url ?? forgeProvider.urlFor({ release: release.ref }) ?? '', + tag: release.tag, } satisfies ReleaseData }) } diff --git a/server/api/changelog/releases/[provider]/[owner]/[repo]/raw/[tag].get.ts b/server/api/changelog/releases/[provider]/[owner]/[repo]/raw/[tag].get.ts index 4b623d2124..3f13f78d56 100644 --- a/server/api/changelog/releases/[provider]/[owner]/[repo]/raw/[tag].get.ts +++ b/server/api/changelog/releases/[provider]/[owner]/[repo]/raw/[tag].get.ts @@ -1,22 +1,18 @@ -import type { ProviderId } from '~~/shared/utils/git-providers' +import type { ProviderId, RepoRef } from '~~/shared/utils/git-providers' import * as v from 'valibot' import { ERROR_THROW_INCOMPLETE_PARAM } from '~~/shared/utils/constants' -import { - ForgejoReleaseSchama, - GiteaReleaseSchema, - GiteeReleaseSchema, - GithubReleaseCollectionSchama, - GitlabReleaseSchame, -} from '~~/shared/schemas/changelog/release' +import { GithubReleaseCollectionSchama } from '~~/shared/schemas/changelog/release' +import { createForgeProvider, toForgeRepoRef } from '~~/shared/utils/forges' import { validateHostWithValibot } from '~~/server/utils/changelog/validateHost' -const TIMEOUT_TIME = 15_000 +const TIMEOUT_MS = 15_000 export default defineCachedEventHandler( async event => { const provider = getRouterParam(event, 'provider') as ProviderId const repo = getRouterParam(event, 'repo') - const owner = getRouterParam(event, 'owner') + // A GitLab subgroup owner arrives encoded as one segment (`group%2Fsub`). + const owner = getRouterParam(event, 'owner', { decode: true }) let tag = getRouterParam(event, 'tag') const rawQuery = getQuery(event) @@ -30,30 +26,18 @@ export default defineCachedEventHandler( } // nuxt does decode a tag except for `/` tag = decodeURIComponent(tag) - const encodedTag = encodeURIComponent(tag) setHeader(event, 'content-type', 'text/markdown') try { - switch (provider) { - case 'github': - return getMarkdownFromGithub(owner, repo, tag, encodedTag) - case 'codeberg': - return await getMarkdownFromForgejo(owner, repo, encodedTag, 'codeberg.org') - case 'forgejo': - return await getMarkdownFromForgejo(owner, repo, encodedTag, host) - case 'gitlab': - return await getMarkdownFromGitlab(owner, repo, encodedTag, host) - case 'gitea': - return await getMarkdownFromGitea(owner, repo, encodedTag, host) - case 'gitee': - return await getMarkdownFromGitee(owner, repo, encodedTag) - default: - throw createError({ - status: 404, - statusMessage: ERROR_CHANGELOG_NOT_FOUND, - }) + // GitHub is read directly (with an ungh fallback) to stay clear of rate + // limits; TODO(serhalp): use `forges` once it can read GitHub through ungh. + if (provider === 'github') { + return getMarkdownFromGithub(owner, repo, tag) } + // No disconnect signal: a cached handler cannot tell a client leaving from + // an SWR revalidation, and aborting would kill the refresh for everyone. + return await getMarkdownViaForges(provider, owner, repo, tag, host || undefined) } catch (error) { handleApiError(error, { statusCode: 500, @@ -80,44 +64,36 @@ export default defineCachedEventHandler( }, ) -async function getMarkdownFromGithub(owner: string, repo: string, tag: string, encodedTag: string) { - // ungh does not yet have an endpoint to get the release by tag, we will first attempt to get it from the github api directly +async function getMarkdownFromGithub(owner: string, repo: string, tag: string) { + // ungh cannot read a release by tag, so read it through `forges` and fall back + // to the ungh releases list when GitHub refuses (most likely rate-limited). // https://github.com/unjs/ungh/pull/162 - - const responseGithub = await $fetch( - `https://api.github.com/repos/${owner}/${repo}/releases/tags/${encodedTag}`, - { - headers: { - 'Accept': 'application/vnd.github+json', - 'User-Agent': 'npmx.dev', - }, - ignoreResponseError: true, - timeout: TIMEOUT_TIME, - }, - ).catch(() => null) - - const parsed = v.safeParse(v.object({ body: v.string() }), responseGithub) - - if (parsed.success) { - return parsed.output.body + const ref: RepoRef = { provider: 'github', owner, repo } + + try { + const forgeProvider = await createForgeProvider(ref, { timeout: TIMEOUT_MS }) + if (forgeProvider?.can('releases.getByTag')) { + const release = await forgeProvider.releases.getByTag(toForgeRepoRef(ref), tag) + if (typeof release.body === 'string') { + return release.body + } + } + } catch { + // fall back to ungh below } - // via github api didn't work, most likely due to rate limit - // trying ungh releases list instead; - const responseUngh = await $fetch(`https://ungh.cc/repos/${owner}/${repo}/releases`, { headers: { 'Accept': '*/*', 'User-Agent': 'npmx.dev', }, - timeout: TIMEOUT_TIME, + timeout: TIMEOUT_MS, }) const { releases } = v.parse(GithubReleaseCollectionSchama, responseUngh) - const decodedTag = tag for (const release of releases) { - if (release.tag == decodedTag) { + if (release.tag == tag) { return release.markdown } } @@ -128,83 +104,23 @@ async function getMarkdownFromGithub(owner: string, repo: string, tag: string, e }) } -async function getMarkdownFromForgejo( - owner: string, - repo: string, - /** tag should be encoded */ - tag: string, - host: string = 'codeberg.org', -) { - const data = await $fetch(`https://${host}/api/v1/repos/${owner}/${repo}/releases/tags/${tag}`, { - headers: { - 'User-Agent': 'npmx.dev', - }, - timeout: TIMEOUT_TIME, - }) - - const release = v.parse(ForgejoReleaseSchama, data) - - return release.body -} - -async function getMarkdownFromGitlab( +/** Release markdown for every provider `forges` covers. */ +async function getMarkdownViaForges( + provider: ProviderId, owner: string, repo: string, - /** tag should be encoded */ tag: string, - host: string = 'gitlab.com', + host?: string, ) { - owner = decodeURIComponent(owner) - - const repoPath = encodeURIComponent(`${owner}/${repo}`) - const data = await $fetch(`https://${host}/api/v4/projects/${repoPath}/releases/${tag}`, { - headers: { - 'User-Agent': 'npmx.dev', - }, - timeout: TIMEOUT_TIME, - }) - - const release = v.parse(GitlabReleaseSchame, data) - - return release.description -} - -async function getMarkdownFromGitea( - owner: string, - repo: string, - /** tag should be encoded */ - tag: string, - host: string = 'gitea.com', -) { - const data = await $fetch(`https://${host}/api/v1/repos/${owner}/${repo}/releases/tags/${tag}`, { - headers: { - 'User-Agent': 'npmx.dev', - }, - timeout: TIMEOUT_TIME, - }) - - const release = v.parse(GiteaReleaseSchema, data) - - return release.body -} - -async function getMarkdownFromGitee( - owner: string, - repo: string, - /** tag should be encoded */ - tag: string, -) { - const data = await $fetch( - `https://gitee.com/api/v5/repos/${owner}/${repo}/releases/tags/${tag}`, - { - headers: { - 'User-Agent': 'npmx.dev', - }, - timeout: TIMEOUT_TIME, - }, - ) - - const release = v.parse(GiteeReleaseSchema, data) + const ref: RepoRef = { provider, owner, repo, host } + const forgeProvider = await createForgeProvider(ref, { timeout: TIMEOUT_MS }) + if (!forgeProvider?.can('releases.getByTag')) { + throw createError({ + status: 404, + statusMessage: ERROR_CHANGELOG_NOT_FOUND, + }) + } - return release.body + const release = await forgeProvider.releases.getByTag(toForgeRepoRef(ref), tag) + return release.body ?? '' } diff --git a/server/api/contributors.get.ts b/server/api/contributors.get.ts index e77714b9d4..9c0d4414ec 100644 --- a/server/api/contributors.get.ts +++ b/server/api/contributors.get.ts @@ -1,3 +1,7 @@ +import type { ForgeProvider, RepoRef as ForgeRepoRef } from 'forges' +import type { RepoRef } from '~~/shared/utils/git-providers' +import { createForgeProvider, toForgeRepoRef } from '~~/shared/utils/forges' + export type Role = 'steward' | 'maintainer' | 'contributor' | 'core' export interface GitHubContributor { @@ -10,8 +14,6 @@ export interface GitHubContributor { sponsors_url: string | null } -type GitHubAPIContributor = Omit - // Fallback when no GitHub token is available (e.g. preview environments). // Only stewards are shown as maintainers; everyone else is a contributor. const FALLBACK_STEWARDS = new Set(['danielroe', 'patak-cat']) @@ -22,39 +24,44 @@ interface TeamMembers { maintainer: Set } -async function fetchTeamMembers(token: string): Promise { - const teams: Record = { - steward: 'stewards', - core: 'core', - maintainer: 'maintainers', - } +const ORG = 'npmx-dev' - try { - const result: TeamMembers = { steward: new Set(), maintainer: new Set(), core: new Set() } - - for (const [role, slug] of Object.entries(teams) as [keyof TeamMembers, string][]) { - const response = await fetch( - `https://api.github.com/orgs/npmx-dev/teams/${slug}/members?per_page=100`, - { - headers: { - 'Accept': 'application/vnd.github.v3+json', - 'Authorization': `Bearer ${token}`, - 'User-Agent': 'npmx', - }, - }, - ) +const TEAM_SLUGS: Record = { + steward: 'stewards', + core: 'core', + maintainer: 'maintainers', +} - if (!response.ok) { - console.warn(`Failed to fetch ${slug} team members: ${response.status}`) - return null - } +const orgRef: ForgeRepoRef = { + forge: 'github', + instance: 'github.com', + owner: ORG, + name: '', + kind: 'namespace', +} +const repoRef: RepoRef = { provider: 'github', owner: ORG, repo: 'npmx.dev' } + +async function collect(iterable: AsyncIterable): Promise { + const items: T[] = [] + for await (const item of iterable) { + items.push(item) + } + return items +} - const members = (await response.json()) as { login: string }[] - for (const member of members) { - result[role].add(member.login) +async function fetchTeamMembers(provider: ForgeProvider): Promise { + try { + const result: TeamMembers = { steward: new Set(), core: new Set(), maintainer: new Set() } + for (const [role, slug] of Object.entries(TEAM_SLUGS) as [keyof TeamMembers, string][]) { + for await (const actor of provider.orgs.teamMembers({ + forge: 'github', + instance: 'github.com', + org: orgRef, + slug, + })) { + result[role].add(actor.login) } } - return result } catch (error) { console.warn('Failed to fetch team members from GitHub:', error) @@ -62,52 +69,17 @@ async function fetchTeamMembers(token: string): Promise { } } -/** - * Batch-query GitHub GraphQL API to check which users have sponsors enabled. - * Returns a Set of logins that have a sponsors listing. - */ -async function fetchSponsorable(token: string, logins: string[]): Promise> { - if (logins.length === 0) return new Set() - - // Build aliased GraphQL query: user0: user(login: "x") { hasSponsorsListing login } - const fragments = logins.map( - (login, i) => `user${i}: user(login: "${login}") { hasSponsorsListing login }`, +async function fetchSponsorable(provider: ForgeProvider, logins: string[]): Promise> { + // One failed login (e.g. a renamed account) must not hide the others' links. + const results = await Promise.all( + logins.map(login => + provider.users.sponsorable(login).catch(error => { + console.warn(`Failed to fetch sponsors info for ${login}:`, error) + return false + }), + ), ) - const query = `{ ${fragments.join('\n')} }` - - try { - const response = await fetch('https://api.github.com/graphql', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json', - 'User-Agent': 'npmx', - }, - body: JSON.stringify({ query }), - }) - - if (!response.ok) { - console.warn(`Failed to fetch sponsors info: ${response.status}`) - return new Set() - } - - const json = (await response.json()) as { - data?: Record - } - - const sponsorable = new Set() - if (json.data) { - for (const user of Object.values(json.data)) { - if (user?.hasSponsorsListing) { - sponsorable.add(user.login) - } - } - } - return sponsorable - } catch (error) { - console.warn('Failed to fetch sponsors info:', error) - return new Set() - } + return new Set(logins.filter((_, i) => results[i])) } function getRoleInfo(login: string, teams: TeamMembers): { role: Role; order: number } { @@ -120,76 +92,48 @@ function getRoleInfo(login: string, teams: TeamMembers): { role: Role; order: nu export default defineCachedEventHandler( async (): Promise => { const githubToken = useRuntimeConfig().github.orgToken + const provider = await createForgeProvider(repoRef, { token: githubToken }) - // Fetch team members dynamically if token is available; otherwise, use fallback - const teams: TeamMembers = await (async () => { - if (githubToken) { - const fetched = await fetchTeamMembers(githubToken) - if (fetched) return fetched - } - return { steward: FALLBACK_STEWARDS, maintainer: new Set(), core: new Set() } - })() - - const allContributors: GitHubAPIContributor[] = [] - let page = 1 - const perPage = 100 - - while (true) { - const response = await fetch( - `https://api.github.com/repos/npmx-dev/npmx.dev/contributors?per_page=${perPage}&page=${page}`, - { - headers: { - 'Accept': 'application/vnd.github.v3+json', - 'User-Agent': 'npmx', - ...(githubToken && { Authorization: `Bearer ${githubToken}` }), - }, - }, - ) - - if (!response.ok) { - throw createError({ - statusCode: response.status, - message: 'Failed to fetch contributors', - }) - } - - const contributors = (await response.json()) as GitHubAPIContributor[] - - if (contributors.length === 0) { - break - } - - allContributors.push(...contributors) - - if (contributors.length < perPage) { - break - } + if (!provider) { + throw createError({ statusCode: 500, message: 'Failed to read contributors' }) + } - page++ + // Without a token, only the fallback stewards are known. + const fallback: TeamMembers = { + steward: FALLBACK_STEWARDS, + maintainer: new Set(), + core: new Set(), } + const teams: TeamMembers = githubToken + ? ((await fetchTeamMembers(provider)) ?? fallback) + : fallback - const filtered = allContributors.filter(c => !c.login.includes('[bot]')) + const contributors = ( + await collect(provider.repos.contributors(toForgeRepoRef(repoRef))) + ).filter(c => c.actor?.login && !c.actor.login.includes('[bot]')) - // Identify maintainers (stewards + maintainers) and check their sponsors status - const maintainerLogins = filtered + const maintainerLogins = contributors + .map(c => c.actor!.login) .filter( - c => teams.steward.has(c.login) || teams.core.has(c.login) || teams.maintainer.has(c.login), + login => teams.steward.has(login) || teams.core.has(login) || teams.maintainer.has(login), ) - .map(c => c.login) const sponsorable = githubToken - ? await fetchSponsorable(githubToken, maintainerLogins) + ? await fetchSponsorable(provider, maintainerLogins) : new Set() - return filtered + return contributors .map(c => { - const { role, order } = getRoleInfo(c.login, teams) - const sponsors_url = sponsorable.has(c.login) - ? `https://github.com/sponsors/${c.login}` - : null - const contributor = Object.assign(c, { role, sponsors_url }) as GitHubContributor & { - sponsors_url: string | null - role: Role + const login = c.actor!.login + const { role, order } = getRoleInfo(login, teams) + const contributor: GitHubContributor = { + login, + id: Number(c.actor!.id), + avatar_url: c.actor!.avatarUrl ?? '', + html_url: c.actor!.url ?? `https://github.com/${login}`, + contributions: c.contributions, + role, + sponsors_url: sponsorable.has(login) ? `https://github.com/sponsors/${login}` : null, } return { contributor, order } }) diff --git a/server/api/github/contributors-evolution/[owner]/[repo].get.ts b/server/api/github/contributors-evolution/[owner]/[repo].get.ts index 27a13c8252..dd70510b40 100644 --- a/server/api/github/contributors-evolution/[owner]/[repo].get.ts +++ b/server/api/github/contributors-evolution/[owner]/[repo].get.ts @@ -1,4 +1,5 @@ import { CACHE_MAX_AGE_ONE_DAY } from '#shared/utils/constants' +import { createForgeProvider, toForgeRepoRef } from '~~/shared/utils/forges' type GitHubContributorWeek = { w: number @@ -19,19 +20,30 @@ export default defineCachedEventHandler( if (!owner || !repo) { throw createError({ - status: 400, + statusCode: 400, message: 'repository not provided', }) } - const url = `https://api.github.com/repos/${owner}/${repo}/stats/contributors` + const ref = { provider: 'github' as const, owner, repo } try { - const data = await fetchGitHubWithRetries(url, { - maxAttempts: 6, - }) + const provider = await createForgeProvider(ref) + + if (!provider?.can('repos.contributorStats')) { + return [] + } - return Array.isArray(data) ? data : [] + const stats = await provider.repos.contributorStats(toForgeRepoRef(ref)) + return stats.map(stat => ({ + total: stat.commits, + weeks: stat.weeks.map(week => ({ + w: Math.floor(week.week.getTime() / 1000), + a: week.additions, + d: week.deletions, + c: week.commits, + })), + })) satisfies GitHubContributorStats[] } catch { return [] } diff --git a/server/api/github/issues/[owner]/[repo].get.ts b/server/api/github/issues/[owner]/[repo].get.ts index 9bac317500..133fba663c 100644 --- a/server/api/github/issues/[owner]/[repo].get.ts +++ b/server/api/github/issues/[owner]/[repo].get.ts @@ -1,8 +1,5 @@ import { CACHE_MAX_AGE_ONE_HOUR } from '#shared/utils/constants' - -interface GitHubSearchResponse { - total_count: number -} +import { createForgeProvider, toForgeRepoRef } from '~~/shared/utils/forges' export interface GithubIssueCountResponse { owner: string @@ -22,19 +19,28 @@ 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 ref = { provider: 'github' as const, owner, repo } + const provider = await createForgeProvider(ref) - try { - const data = await fetchGitHubWithRetries(url, { - timeout: 10000, + if (!provider) { + throw createError({ + statusCode: 500, + statusMessage: 'Failed to fetch issue count from GitHub', }) + } + + try { + // The search API reports the total, so a one-item page is enough. + const page = await provider.search + .threads({ + repo: toForgeRepoRef(ref), + kind: 'issue', + state: 'open', + perPage: 1, + }) + .page() - return { - owner, - repo, - issues: typeof data?.total_count === 'number' ? data.total_count : null, - } + return { owner, repo, issues: page.total ?? null } } catch { throw createError({ statusCode: 500, diff --git a/server/api/registry/file/[...pkg].get.ts b/server/api/registry/file/[...pkg].get.ts index da250d9026..2a717331b1 100644 --- a/server/api/registry/file/[...pkg].get.ts +++ b/server/api/registry/file/[...pkg].get.ts @@ -1,5 +1,6 @@ import * as v from 'valibot' import type { InternalImportsMap, PackageExportsMap } from '#server/utils/import-resolver' +import { publishedRevision } from '#server/utils/readme-loaders' import { PackageFileQuerySchema } from '#shared/schemas/package' import type { ReadmeResponse } from '#shared/types/readme' import { @@ -186,7 +187,12 @@ export default defineCachedEventHandler( try { const packageData = await fetchNpmPackage(rawPackageName) const repoInfo = parseRepositoryInfo(packageData.repository) - markdownHtml = await renderReadmeHtml(content, rawPackageName, repoInfo) + markdownHtml = await renderReadmeHtml( + content, + rawPackageName, + repoInfo, + publishedRevision(packageData, version), + ) } catch { markdownHtml = undefined } diff --git a/server/api/registry/readme/[...pkg].get.ts b/server/api/registry/readme/[...pkg].get.ts index 771f040351..f2f6232421 100644 --- a/server/api/registry/readme/[...pkg].get.ts +++ b/server/api/registry/readme/[...pkg].get.ts @@ -14,13 +14,14 @@ export default defineCachedEventHandler( async event => { try { const packagePath = getRouterParam(event, 'pkg') ?? '' - const { packageName, markdown, repoInfo } = await resolvePackageReadmeSource(packagePath) + const { packageName, markdown, repoInfo, revision } = + await resolvePackageReadmeSource(packagePath) if (!markdown) { return { html: '', mdExists: false, playgroundLinks: [], toc: [] } } - return await renderReadmeHtml(markdown, packageName, repoInfo) + return await renderReadmeHtml(markdown, packageName, repoInfo, revision) } catch (error: unknown) { handleApiError(error, { statusCode: 502, diff --git a/server/api/repo-stats.get.ts b/server/api/repo-stats.get.ts index 0b998d1794..0f80a5d9de 100644 --- a/server/api/repo-stats.get.ts +++ b/server/api/repo-stats.get.ts @@ -1,14 +1,7 @@ import { CACHE_MAX_AGE_ONE_HOUR } from '#shared/utils/constants' +import { createForgeProvider, toForgeRepoRef } from '~~/shared/utils/forges' const REPO = 'npmx-dev/npmx.dev' -const GITHUB_HEADERS = { - 'Accept': 'application/vnd.github.v3+json', - 'User-Agent': 'npmx', -} as const - -interface GitHubSearchResponse { - total_count: number -} export interface RepoStats { contributors: number @@ -18,17 +11,38 @@ export interface RepoStats { export default defineCachedEventHandler( async (): Promise => { - const [contributorsCount, commitsCount, prsCount] = await Promise.all([ - fetchPageCount(`https://api.github.com/repos/${REPO}/contributors?per_page=1&anon=false`), - fetchPageCount(`https://api.github.com/repos/${REPO}/commits?per_page=1`), - fetchSearchCount('issues', `repo:${REPO} is:pr is:merged`), - ]) + const ref = { provider: 'github' as const, owner: 'npmx-dev', repo: 'npmx.dev' } + const provider = await createForgeProvider(ref) - return { - contributors: contributorsCount, - commits: commitsCount, - pullRequests: prsCount, + if (!provider) { + throw createError({ statusCode: 500, message: `Failed to read ${REPO}` }) } + + const target = toForgeRepoRef(ref) + + // Each count comes from the total on a one-item page. + const [contributors, commits, pullRequests] = await Promise.all([ + provider.repos + .contributors(target, { perPage: 1 }) + .page() + .then(page => page.total ?? 0), + provider.commits + .list(target, { perPage: 1 }) + .page() + .then(page => page.total ?? 0), + provider.search + .threads({ + repo: target, + kind: 'pull_request', + state: 'closed', + queryRaw: 'is:merged', + perPage: 1, + }) + .page() + .then(page => page.total ?? 0), + ]) + + return { contributors, commits, pullRequests } }, { maxAge: CACHE_MAX_AGE_ONE_HOUR, @@ -37,44 +51,3 @@ export default defineCachedEventHandler( getKey: () => 'repo-stats', }, ) - -/** - * Count items by requesting a single result and reading the last page - * number from the Link header. - */ -async function fetchPageCount(url: string): Promise { - const response = await fetch(url, { headers: GITHUB_HEADERS }) - - if (!response.ok) { - throw createError({ statusCode: response.status, message: `Failed to fetch ${url}` }) - } - - const link = response.headers.get('link') - if (link) { - const match = link.match(/[?&]page=(\d+)>;\s*rel="last"/) - if (match?.[1]) { - return Number.parseInt(match[1], 10) - } - } - - // No Link header means only one page — count the response body - const body = (await response.json()) as unknown[] - return body.length -} - -/** - * Use the GitHub search API to get a total_count for issues/PRs. - */ -async function fetchSearchCount(type: 'issues', query: string): Promise { - const response = await fetch( - `https://api.github.com/search/${type}?q=${encodeURIComponent(query)}&per_page=1`, - { headers: GITHUB_HEADERS }, - ) - - if (!response.ok) { - throw createError({ statusCode: response.status, message: `Failed to fetch ${type} count` }) - } - - const data = (await response.json()) as GitHubSearchResponse - return data.total_count -} diff --git a/server/utils/changelog/baseFileUrl.ts b/server/utils/changelog/baseFileUrl.ts deleted file mode 100644 index 0d90efa086..0000000000 --- a/server/utils/changelog/baseFileUrl.ts +++ /dev/null @@ -1,65 +0,0 @@ -export interface RepoFileUrl { - raw: string - blob: string -} - -export function getBaseFileUrl(ref: RepoRef): RepoFileUrl | null { - switch (ref.provider) { - case 'github': - return { - raw: `https://raw.githubusercontent.com/${ref.owner}/${ref.repo}/HEAD`, - blob: `https://github.com/${ref.owner}/${ref.repo}/blob/HEAD`, - } - case 'codeberg': - return { - blob: `https://codeberg.org/${ref.owner}/${ref.repo}/src/branch/HEAD`, - raw: `https://codeberg.org/${ref.owner}/${ref.repo}/raw/branch/HEAD`, - } - case 'forgejo': { - return { - blob: `https://${ref.host}/${ref.owner}/${ref.repo}/src/branch/HEAD`, - raw: `https://${ref.host}/${ref.owner}/${ref.repo}/raw/branch/HEAD`, - } - } - case 'gitea': { - // although similar to forgejo, we keep these separate because they're separately maintained projects - const host = ref.host ?? 'gitea.com' - return { - blob: `https://${host}/${ref.owner}/${ref.repo}/src/branch/HEAD`, - raw: `https://${host}/${ref.owner}/${ref.repo}/raw/branch/HEAD`, - } - } - case 'gitlab': { - const host = ref.host || 'gitlab.com' - return { - blob: `https://${host}/${ref.owner}/${ref.repo}/-/blob/HEAD`, - raw: `https://${host}/${ref.owner}/${ref.repo}/-/raw/HEAD`, - } - } - case 'tangled': { - return { - blob: `https://tangled.org/${ref.owner}/${ref.repo}/blob/HEAD`, - raw: `https://tangled.org/${ref.owner}/${ref.repo}/raw/HEAD`, - } - } - case 'bitbucket': { - return { - blob: `https://bitbucket.org/${ref.owner}/${ref.repo}/src/HEAD`, - raw: `https://bitbucket.org/${ref.owner}/${ref.repo}/raw/HEAD`, - } - } - case 'sourcehut': { - return { - blob: `https://git.sr.ht/${ref.owner}/${ref.repo}/tree/HEAD/item`, - raw: `https://git.sr.ht/${ref.owner}/${ref.repo}/blob/HEAD`, - } - } - case 'gitee': { - return { - blob: `https://gitee.com/${ref.owner}/${ref.repo}/blob/HEAD`, - raw: `https://gitee.com/${ref.owner}/${ref.repo}/raw/HEAD`, - } - } - } - return null -} diff --git a/server/utils/changelog/detectChangelog.ts b/server/utils/changelog/detectChangelog.ts index 15193dceaf..642385033d 100644 --- a/server/utils/changelog/detectChangelog.ts +++ b/server/utils/changelog/detectChangelog.ts @@ -1,16 +1,13 @@ +import type { Release } from 'forges' import type { ChangelogMarkdownInfo, ChangelogInfo } from '~~/shared/types/changelog' import type { ExtendedPackageJson } from '~~/shared/utils/package-analysis' import { type RepoRef, parseRepositoryInfo } from '~~/shared/utils/git-providers' -import { type RepoFileUrl, getBaseFileUrl } from './baseFileUrl' +import { getFileUrls } from './fileUrls' +import { ForgeApiError, RateLimitedError } from 'forges' import { FetchError } from 'ofetch' import { ERROR_CHANGELOG_NOT_FOUND, ERROR_UNGH_API_KEY_EXHAUSTED } from '~~/shared/utils/constants' -import { - GithubReleaseSchama, - ForgejoReleaseSchama, - GitlabReleaseSchame, - GiteaReleaseSchema, - GiteeReleaseSchema, -} from '~~/shared/schemas/changelog/release' +import { GithubReleaseSchama } from '~~/shared/schemas/changelog/release' +import { createForgeProvider, toForgeRepoRef } from '~~/shared/utils/forges' import { resolveURL } from 'ufo' import * as v from 'valibot' @@ -18,6 +15,15 @@ type SafeResult = [R, null] | [null, E] const TIMEOUT = 15_000 +/** + * The `owner/repo` slug a changelog URL carries. An owner that is itself a path, + * as a GitLab subgroup is, has to be encoded or it splits into two segments. + */ +function repoSlug(ref: RepoRef): `${string}/${string}` { + const owner = ref.owner.includes('/') ? encodeURIComponent(ref.owner) : ref.owner + return `${owner}/${ref.repo}` +} + /** * Detect whether changelogs/releases are available for this package * @@ -60,24 +66,14 @@ async function checkReleases( directory?: string, ): Promise> { try { - switch (ref.provider) { - case 'github': { - return [await checkLatestGithubRelease(ref, directory), null] - } - case 'codeberg': - case 'forgejo': { - return [await checkLatestForgejoRelease(ref, directory), null] - } - case 'gitlab': { - return [await checkLatestGitlabRelease(ref, directory), null] - } - case 'gitea': { - return [await checkLatestGiteaRelease(ref, directory), null] - } - case 'gitee': { - return [await checkLatestGiteeRelease(ref, directory), null] - } - } + // GitHub is read through ungh; every other forge goes through `forges`, which + // answers `false` for the forges that have no releases. + return [ + ref.provider === 'github' + ? await checkLatestGithubRelease(ref, directory) + : await checkLatestRelease(ref, directory), + null, + ] } catch (error) { if (error instanceof Error) { return [null, error] @@ -99,18 +95,13 @@ const CHANGELOG_FILENAMES = ['changelog', 'releases', 'changes', 'history', 'new const checkChangelogFile = defineCachedFunction( async function (ref: RepoRef, directory?: string): Promise { - const baseUrl = getBaseFileUrl(ref) - if (!baseUrl) { - return false - } - if (directory) { - const inDir = await checkFiles(ref, baseUrl, directory) + const inDir = await checkFiles(ref, directory) if (inDir) { return inDir } } - return checkFiles(ref, baseUrl) + return checkFiles(ref) }, { swr: true, @@ -128,22 +119,26 @@ const checkChangelogFile = defineCachedFunction( }, ) -async function checkFiles(ref: RepoRef, baseUrl: RepoFileUrl, dir?: string) { +async function checkFiles(ref: RepoRef, dir?: string) { for (const fileName of CHANGELOG_FILENAMES) { - // - const exists = await $fetch(resolveURL(baseUrl.raw, dir ?? '', fileName), { + const path = resolveURL(dir ?? '', fileName) + const urls = getFileUrls(ref, path) + if (!urls) { + return false + } + + const exists = await $fetch(urls.raw, { headers: { // GitHub API requires User-Agent 'User-Agent': 'npmx.dev', }, timeout: TIMEOUT, - method: ref.provider != 'tangled' ? 'HEAD' : 'GET', // we just need to know if it exists or not, tangled doesn't support HEAD + method: 'HEAD', }) .then(() => true) .catch(e => { if (e instanceof FetchError) { - // early throw in case the providers is having troubles, is rate limiting us or doesn't allow us - // in case of not being available or timeout we also early return as the provider might struggle + // An outage, timeout or rate limit is not "absent": let the caller degrade. if (e.statusCode == null || e.statusCode >= 500) { throw e } @@ -151,22 +146,19 @@ async function checkFiles(ref: RepoRef, baseUrl: RepoFileUrl, dir?: string) { case 401: case 403: case 429: - case undefined: - case null: throw e } } return false }) - const owner = ref.provider == 'gitlab' ? encodeURIComponent(ref.owner) : ref.owner if (exists) { return { type: 'md', provider: ref.provider, - path: resolveURL(dir ?? '', fileName), - repo: `${owner}/${ref.repo}`, - link: resolveURL(baseUrl.blob, dir ?? '', fileName), + path, + repo: repoSlug(ref), + link: urls.blob, host: ref.host, } satisfies ChangelogMarkdownInfo } @@ -186,7 +178,7 @@ const CACHE_OPTIONS_CHECK_RELEASE: Parameters< // name is defined at the function as the provider shouldBypassCache: () => import.meta.dev, getKey: (ref, directory) => { - const base = [ref.host ?? '', ref.owner, ref.repo] + const base = [ref.provider, ref.host ?? '', ref.owner, ref.repo] if (directory) { base.push(directory.replaceAll('/', '_')) } @@ -198,6 +190,7 @@ const MD_REGEX = /(?<=\[.*?(changelog|releases|changes|history|news)\.md.*?\]\() const ROOT_ONLY_REGEX = /^\/?[^/]+$/ // this function doesn't use `defineCachedFunction` due to ungh also caching the results from github and I don't want to many cache layers +// TODO(serhalp): refactor to use `forges` once it can read GitHub through ungh. async function checkLatestGithubRelease( ref: RepoRef, directory?: string, @@ -227,7 +220,7 @@ async function checkLatestGithubRelease( return { provider: ref.provider, type: 'release', - repo: `${ref.owner}/${ref.repo}`, + repo: repoSlug(ref), link: `https://github.com/${ref.owner}/${ref.repo}/releases`, } } @@ -248,7 +241,7 @@ async function checkLatestGithubRelease( provider: ref.provider, type: 'md', path, - repo: `${ref.owner}/${ref.repo}`, + repo: repoSlug(ref), link: matchedChangelog, } } catch (e) { @@ -273,258 +266,69 @@ async function checkLatestGithubRelease( } } -// codeberg / forgejo +/** A repository that does not exist, or one this credential cannot read, has no release changelog. */ +const UNREADABLE_RELEASE_STATUSES = new Set([401, 403, 404]) -const checkLatestForgejoRelease = defineCachedFunction( +const checkLatestRelease = defineCachedFunction( async function (ref: RepoRef, directory?: string): Promise { - try { - const host = ref.host ?? 'codeberg.org' - - const response = await $fetch( - `https://${host}/api/v1/repos/${ref.owner}/${ref.repo}/releases/latest`, - { - headers: { - 'User-Agent': 'npmx.dev', - 'accept': 'application/json', - }, - timeout: TIMEOUT, - }, - ) - - const release = v.parse(ForgejoReleaseSchama, response) - - const matchedChangelog = release.body.match(MD_REGEX)?.at(0) - - // /src/branch/ can be similar to /blob/ - if (!matchedChangelog || !matchedChangelog.includes('/src/branch/')) { - return { - type: 'release', - link: `https://${host}/${ref.owner}/${ref.repo}/releases`, - provider: ref.provider, - repo: `${ref.owner}/${ref.repo}`, - host: ref.host, - } - } - - const path = matchedChangelog.replace(/^.*\/src\/branch\/[^/]+\//i, '') - if ( - directory && - !( - path.startsWith(directory.endsWith('/') ? directory : `${directory}/`) || - ROOT_ONLY_REGEX.test(path) - ) - ) { - return false - } - return { - provider: ref.provider, - type: 'md', - path, - repo: `${ref.owner}/${ref.repo}`, - link: matchedChangelog, - host: ref.host, - } - } catch (e) { - if (e instanceof FetchError && e.statusCode == 404) { - return false - } - throw e + const provider = await createForgeProvider(ref) + if (!provider?.can('releases.latest')) { + return false } - }, - { - name: 'forgejo', - ...CACHE_OPTIONS_CHECK_RELEASE, - }, -) -// gitlab -const checkLatestGitlabRelease = defineCachedFunction( - async function (ref: RepoRef, directory?: string): Promise { + let release: Release | undefined try { - const host = ref.host ?? 'gitlab.com' - const repoPath = encodeURIComponent(`${ref.owner}/${ref.repo}`) - - const response = await $fetch( - `https://${host}/api/v4/projects/${repoPath}/releases/permalink/latest`, - { - headers: { - 'User-Agent': 'npmx.dev', - 'accept': 'application/json', - }, - timeout: TIMEOUT, - }, - ) - const release = v.parse(GitlabReleaseSchame, response) - - const matchedChangelog = release.description?.match(MD_REGEX)?.at(0) - - if (!matchedChangelog || !matchedChangelog.includes('/-/blob/')) { - return { - type: 'release', - // I encode both just to be sure - link: `https://${host}/${ref.owner}/${ref.repo}/-/releases`, - provider: ref.provider, - repo: `${encodeURIComponent(ref.owner)}/${ref.repo}`, - host: ref.host, - } - } - - const path = matchedChangelog.replace(/^.*\/-\/blob\/[^/]+\//i, '') - - if ( - directory && - !( - path.startsWith(directory.endsWith('/') ? directory : `${directory}/`) || - ROOT_ONLY_REGEX.test(path) - ) - ) { + release = await provider.releases.latest(toForgeRepoRef(ref)) + } catch (error) { + // A rate limit is not "no release"; let the caller degrade instead. + if (error instanceof RateLimitedError) throw error + if (error instanceof ForgeApiError && UNREADABLE_RELEASE_STATUSES.has(error.status)) { return false } - - return { - provider: ref.provider, - type: 'md', - path, - repo: `${encodeURIComponent(ref.owner)}/${ref.repo}`, - link: matchedChangelog, - host: ref.host, - } - } catch (e) { - if ( - e instanceof FetchError && - // if a repo doesn't use releases, gitlab can sometimes also return 403, 401 is checked just in case - (e.statusCode == 404 || e.statusCode == 403 || e.statusCode == 401) - ) { - return false - } - throw e + throw error } - }, - { - name: 'gitlab', - ...CACHE_OPTIONS_CHECK_RELEASE, - }, -) - -// gitea -const checkLatestGiteaRelease = defineCachedFunction( - async function (ref: RepoRef, directory?: string): Promise { - try { - const host = ref.host ?? 'gitea.com' - - const response = await $fetch( - `https://${host}/api/v1/repos/${ref.owner}/${ref.repo}/releases/latest`, - { - headers: { - 'User-Agent': 'npmx.dev', - 'accept': 'application/json', - }, - timeout: TIMEOUT, - }, - ) - const release = v.parse(GiteaReleaseSchema, response) - - const matchedChangelog = release.body.match(MD_REGEX)?.at(0) + if (!release) { + return false + } - // /src/branch/ can be similar to /blob/ - if (!matchedChangelog || !matchedChangelog.includes('/src/branch/')) { - return { - type: 'release', - link: `https://${host}/${ref.owner}/${ref.repo}/releases`, - provider: ref.provider, - repo: `${ref.owner}/${ref.repo}`, - host: ref.host, - } - } + const matchedChangelog = release.body?.match(MD_REGEX)?.at(0) + // `parseUrl` reads the file path from the link, so no per-forge URL pattern is needed. + const file = matchedChangelog ? provider.parseUrl(matchedChangelog)?.file : undefined - const path = matchedChangelog.replace(/^.*\/src\/branch\/[^/]+\//i, '') - if ( - directory && - !( - path.startsWith(directory.endsWith('/') ? directory : `${directory}/`) || - ROOT_ONLY_REGEX.test(path) - ) - ) { - return false - } + if (!matchedChangelog || !file) { + const link = provider.urlFor({ repo: toForgeRepoRef(ref), section: 'releases' }) + if (!link) return false return { provider: ref.provider, - type: 'md', - path, - repo: `${ref.owner}/${ref.repo}`, - link: matchedChangelog, + type: 'release', + repo: repoSlug(ref), + link, host: ref.host, } - } catch (e) { - if (e instanceof FetchError && e.statusCode == 404) { - return false - } - throw e } - }, - { - name: 'Gitea', - ...CACHE_OPTIONS_CHECK_RELEASE, - }, -) -const checkLatestGiteeRelease = defineCachedFunction( - async function (ref: RepoRef, directory?: string): Promise { - try { - const response = await $fetch( - `https://gitee.com/api/v5/repos/${ref.owner}/${ref.repo}/releases/latest`, - { - headers: { - 'User-Agent': 'npmx.dev', - 'accept': 'application/json', - }, - timeout: TIMEOUT, - }, + if ( + directory && + !( + file.path.startsWith(directory.endsWith('/') ? directory : `${directory}/`) || + ROOT_ONLY_REGEX.test(file.path) ) + ) { + return false + } - const release = v.parse(GiteeReleaseSchema, response) - - const matchedChangelog = release.body.match(MD_REGEX)?.at(0) - - // if no changelog.md or the url doesn't contain /blob/ - if (!matchedChangelog || !matchedChangelog.includes('/blob/')) { - return { - provider: ref.provider, - type: 'release', - repo: `${ref.owner}/${ref.repo}`, - link: `https://gitee.com/${ref.owner}/${ref.repo}/releases`, - } - } - - const path = matchedChangelog.replace(/^.*\/blob\/[^/]+\//i, '') - - // makes sure that the correct directory is matched - if ( - directory && - !( - path.startsWith(directory.endsWith('/') ? directory : `${directory}/`) || - ROOT_ONLY_REGEX.test(path) - ) - ) { - return false - } - return { - provider: ref.provider, - type: 'md', - path, - repo: `${ref.owner}/${ref.repo}`, - link: matchedChangelog, - } - } catch (e) { - if (e instanceof FetchError && e.statusCode == 404) { - return false - } - throw e + return { + provider: ref.provider, + type: 'md', + path: file.path, + repo: repoSlug(ref), + link: matchedChangelog, + host: ref.host, } }, { - name: 'gitee', + name: 'forges', ...CACHE_OPTIONS_CHECK_RELEASE, }, ) diff --git a/server/utils/changelog/fileUrls.ts b/server/utils/changelog/fileUrls.ts new file mode 100644 index 0000000000..ae38c0a3ea --- /dev/null +++ b/server/utils/changelog/fileUrls.ts @@ -0,0 +1,16 @@ +import type { RepoRef } from '~~/shared/utils/git-providers' +import { forgeWeb, toForgeRepoRef } from '~~/shared/utils/forges' + +export interface RepoFileUrl { + raw: string + blob: string +} + +/** The raw and blob URLs of one file, built by `forges`, or `null` where the forge has no such page. */ +export function getFileUrls(ref: RepoRef, path: string): RepoFileUrl | null { + const web = forgeWeb(ref) + const repo = toForgeRepoRef(ref) + const raw = web?.urlFor({ raw: { repo, path } }) + const blob = web?.urlFor({ file: { repo, path } }) + return raw && blob ? { raw, blob } : null +} diff --git a/server/utils/changelog/markdown.ts b/server/utils/changelog/markdown.ts index 319c6d32ba..df2746db93 100644 --- a/server/utils/changelog/markdown.ts +++ b/server/utils/changelog/markdown.ts @@ -1,3 +1,5 @@ +import type { RepoRef as ForgeRepoRef } from 'forges' +import type { ForgeWeb } from 'forges/web' import type { IOptions } from 'sanitize-html' import { type ProcessImageUrlFn, @@ -18,7 +20,7 @@ import { } from '../mdKit' import { slugify } from '#shared/utils/html' import { Marked } from 'marked' -import { hasProtocol, joinRelativeURL, joinURL, parseFilename, parseURL } from 'ufo' +import { hasProtocol, joinRelativeURL, joinURL, parseFilename } from 'ufo' import { convertToEmoji } from '#shared/utils/emoji' import sanitize from 'sanitize-html' import { ALLOWED_ATTR } from '../mdKit' @@ -107,32 +109,14 @@ export async function changelogRenderer(mdRepoInfo: MarkdownRepoInfo) { } export interface MarkdownRepoInfo { - /** base url for the host */ - hostBaseUrl: string + /** The forge's web links and the ref its references are read against. */ + forge: { web: ForgeWeb; repo: ForgeRepoRef } /** Raw file URL base (e.g., https://raw.githubusercontent.com/owner/repo/HEAD) */ rawBaseUrl: string /** Blob/rendered file URL base (e.g., https://github.com/owner/repo/blob/HEAD) */ blobBaseUrl: string /** path to the markdown file, can't start with / */ path?: string - /** the base url of repository commit */ - commitBaseUrl: string - /** base url for a repository issue */ - issueBaseUrl?: string - /** the text char that indicates an issue */ - issueChar?: keyof typeof issuePrRegexes - /** custom regex in case the git provider's issues uses a different format */ - issueRegex?: RegExp - /** base url for a repository pull/merge request */ - prBaseUrl?: string - /** - * the text char that indicates a pull/merge request - * - * if it's the same as issueChar, than links will be parsed as issues and repo host is reponsible to redirect to pull/merge request - x*/ - prChar?: keyof typeof issuePrRegexes - /** base url for a repository compare */ - compareBaseUrl?: string /** * the character that should be used to indicate an account. * @@ -224,112 +208,83 @@ function resolveGitLinkText(href: string, label: string, repoInfo: MarkdownRepoI return } - const pathSegments = parseURL(href).pathname.split('/').filter(Boolean) - const lastSegment = pathSegments.at(-1) - if (!lastSegment) { + // `forges` reads the thread, commit or comparison a URL names, including forms + // a forge has moved away from. Accounts stay unresolved: forges has no syntax. + const { web, repo } = repoInfo.forge + const parsed = web.parseUrl(href) + if (!parsed) { return } - switch (true) { - case href.startsWith(repoInfo.commitBaseUrl): { - return lastSegment.slice(0, 7) // only show the first 7 letters/numbers of a commit - } - case !!repoInfo.issueChar && - !!repoInfo.issueBaseUrl && - href.startsWith(repoInfo.issueBaseUrl): { - return `${repoInfo.issueChar}${lastSegment}` - } - case !!repoInfo.prChar && !!repoInfo.prBaseUrl && href.startsWith(repoInfo.prBaseUrl): { - return `${repoInfo.prChar}${lastSegment}` - } - case !!repoInfo.compareBaseUrl && href.startsWith(repoInfo.compareBaseUrl): { - return lastSegment - } - // for account we don't resolve, this is something the git providers also don't do + if (parsed.compare) { + return `${parsed.compare.base}...${parsed.compare.head}` + } + const ref = parsed.thread ?? parsed.commit + if (ref) { + return web.referenceFor(ref, { from: repo }) } } -const issuePrRegexes = { - '#': /\B#\d+\b/g, - '!': /\B!\d+\b/g, -} as const - const accountRegexes = { '@': /\B@(?![\d.]+\b)(?![\w.-]*\/)[\w\-.]+\b/g, '~': /\B~(?![\d.]+\b)(?![\w.-]*\/)[\w\-.]+\b/g, } as const -const commitRegex = /(?${label}` +} + function createResolveGitTextToLinks(mdInfo: MarkdownRepoInfo): IOptions['textFilter'] { return (text, tag) => { if (tagsToIgnore.has(tag)) return text - // issues - text = text - // commits come first to prevent matching issue/pr that has been formatted - .replace(commitRegex, match => { - if (excludeWordsFromCommitMatch.has(match.toLowerCase())) { - return match - } - - return `${match.slice(0, 7)}` - }) - if (mdInfo.accountChar !== false) { - // account - text = text.replace(accountRegexes[mdInfo.accountChar ?? '@'], match => { - const acc = mdInfo.keepAccountChar - ? match - : match.replace((mdInfo.accountChar as string) ?? '@', '') - return `${match}` - }) + const links: GitTextLink[] = [] + const { web, repo } = mdInfo.forge + for (const { start, end, ref } of web.parseReferences(text, { from: repo })) { + const href = 'sha' in ref ? web.urlFor({ commit: ref }) : web.urlFor({ thread: ref }) + const label = web.referenceFor(ref, { from: repo }) + if (href && label) { + links.push({ start, end, html: toAnchor(href, label) }) + } } - if (mdInfo.issueChar && mdInfo.issueBaseUrl) { - text = text.replace(mdInfo.issueRegex ?? issuePrRegexes[mdInfo.issueChar], match => { - const id = match.replace(mdInfo.issueChar!, '').toUpperCase() - return `${match}` - }) + if (mdInfo.accountChar !== false) { + const char = mdInfo.accountChar ?? '@' + for (const match of text.matchAll(accountRegexes[char])) { + const account = mdInfo.keepAccountChar ? match[0] : match[0].replace(char, '') + links.push({ + start: match.index, + end: match.index + match[0].length, + html: toAnchor(joinURL(mdInfo.forge.web.webOrigin, account), match[0]), + }) + } } - // pr/mr - if (mdInfo.issueChar != mdInfo.prChar && mdInfo.prChar && mdInfo.prBaseUrl) { - text = text.replace(issuePrRegexes[mdInfo.prChar], match => { - const id = match.replace(mdInfo.prChar!, '') - return `${match}` - }) + if (!links.length) return text + links.sort((a, b) => a.start - b.start) + + // A reference and an account mention can share text, so a link that starts + // inside the previous one is dropped. + let html = '' + let cursor = 0 + for (const link of links) { + if (link.start < cursor) continue + html += text.slice(cursor, link.start) + link.html + cursor = link.end } + html += text.slice(cursor) - return sanitize(text, { + return sanitize(html, { allowedAttributes: ALLOWED_ATTR, allowedSchemes: ['http', 'https', 'mailto'], }) } } - -// source https://raw.githubusercontent.com/potch/sowpods/refs/heads/master/SOWPODS.txt and filtered with /^[a-f]{6,40}$/i -const excludeWordsFromCommitMatch = new Set([ - 'accede', - 'acceded', - 'baccae', - 'baffed', - 'beaded', - 'bedded', - 'beebee', - 'beefed', - 'cabbed', - 'dabbed', - 'dadded', - 'daffed', - 'deaded', - 'decade', - 'decaff', - 'deeded', - 'deface', - 'defaced', - 'efface', - 'effaced', - 'facade', - 'faffed', -]) diff --git a/server/utils/changelog/mdRepoInfo.ts b/server/utils/changelog/mdRepoInfo.ts index d806e384d4..dcb45a7419 100644 --- a/server/utils/changelog/mdRepoInfo.ts +++ b/server/utils/changelog/mdRepoInfo.ts @@ -1,162 +1,38 @@ +import type { UrlTarget } from 'forges' +import type { ProviderId, RepoRef } from '~~/shared/utils/git-providers' +import { forgeWeb, toForgeRepoRef } from '~~/shared/utils/forges' import type { MarkdownRepoInfo } from './markdown' -export function createGithubRepoInfo(owner: string, repo: string, path?: string): MarkdownRepoInfo { - const hostBaseUrl = 'https://github.com' +type AccountSyntax = Pick - return { - hostBaseUrl, - blobBaseUrl: `${hostBaseUrl}/${owner}/${repo}/blob/HEAD`, - rawBaseUrl: `https://raw.githubusercontent.com/${owner}/${repo}/HEAD`, - path, - commitBaseUrl: `${hostBaseUrl}/${owner}/${repo}/commit`, - issueChar: '#', - issueBaseUrl: `${hostBaseUrl}/${owner}/${repo}/issues`, - prChar: '#', - prBaseUrl: `${hostBaseUrl}/${owner}/${repo}/pull`, - compareBaseUrl: `${hostBaseUrl}/${owner}/${repo}/compare`, - } -} - -export function createForgejoRepoInfo( - host: string, - owner: string, - repo: string, - path?: string, -): MarkdownRepoInfo { - const hostBaseUrl = `https://${host}` - - return { - hostBaseUrl, - blobBaseUrl: `${hostBaseUrl}/${owner}/${repo}/src/branch/HEAD`, - rawBaseUrl: `${hostBaseUrl}/${owner}/${repo}/raw/branch/HEAD`, - path, - commitBaseUrl: `${hostBaseUrl}/${owner}/${repo}/commit`, - issueChar: '#', - issueBaseUrl: `${hostBaseUrl}/${owner}/${repo}/issues`, - prChar: '#', - prBaseUrl: `${hostBaseUrl}/${owner}/${repo}/pulls`, - compareBaseUrl: `${hostBaseUrl}/${owner}/${repo}/compare`, - } -} - -export function createGitLabRepoInfo( - host: string, - owner: string, - repo: string, - path?: string, -): MarkdownRepoInfo { - const hostBaseUrl = `https://${host}` - - return { - hostBaseUrl, - blobBaseUrl: `${hostBaseUrl}/${owner}/${repo}/-/blob/HEAD`, - rawBaseUrl: `${hostBaseUrl}/${owner}/${repo}/-/raw/HEAD`, - path, - commitBaseUrl: `${hostBaseUrl}/${owner}/${repo}/-/commit`, - issueChar: '#', - // it seems that issues are work items in gitlab - issueBaseUrl: `${hostBaseUrl}/${owner}/${repo}/-/work_items`, - prChar: '!', - prBaseUrl: `${hostBaseUrl}/${owner}/${repo}/-/merge_requests`, - compareBaseUrl: `${hostBaseUrl}/${owner}/${repo}/-/compare`, - } -} - -export function createTangledRepoInfo( - owner: string, - repo: string, - path?: string, -): MarkdownRepoInfo { - const hostBaseUrl = 'https://tangled.org' - - return { - hostBaseUrl, - blobBaseUrl: `${hostBaseUrl}/${owner}/${repo}/blob/HEAD`, - rawBaseUrl: `${hostBaseUrl}/${owner}/${repo}/raw/HEAD/`, - path, - commitBaseUrl: `${hostBaseUrl}/${owner}/${repo}/commit`, - issueChar: '#', - issueBaseUrl: `${hostBaseUrl}/${owner}/${repo}/issues`, - prChar: '#', - prBaseUrl: `${hostBaseUrl}/${owner}/${repo}/pulls`, - compareBaseUrl: `${hostBaseUrl}/${owner}/${repo}/compare`, - } +/** + * How a forge writes an account mention, which `forges` has no syntax for, so it + * stays here. + */ +const ACCOUNT_SYNTAX: Partial> = { + // Bitbucket has no account page to link to. + bitbucket: { accountChar: false }, + // Sourcehut writes accounts as `~user`, and the tilde is part of the URL. + sourcehut: { accountChar: '~', keepAccountChar: true }, } -// similar to forgejo, but they're seperate projects so keeping this also seperate -export function createGiteaRepoInfo( - host: string, - owner: string, - repo: string, - path?: string, -): MarkdownRepoInfo { - const hostBaseUrl = `https://${host}` - return { - hostBaseUrl, - blobBaseUrl: `${hostBaseUrl}/${owner}/${repo}/src/branch/HEAD`, - rawBaseUrl: `${hostBaseUrl}/${owner}/${repo}/raw/branch/HEAD`, - path, - commitBaseUrl: `${hostBaseUrl}/${owner}/${repo}/commit`, - issueChar: '#', - issueBaseUrl: `${hostBaseUrl}/${owner}/${repo}/issues`, - prChar: '#', - prBaseUrl: `${hostBaseUrl}/${owner}/${repo}/pulls`, - compareBaseUrl: `${hostBaseUrl}/${owner}/${repo}/compare`, - } -} +/** + * The markdown renderer's view of a repository, built from `forges/web`. + * `null` when the ref has no web entry (radicle). + */ +export function createRepoInfo(ref: RepoRef, path?: string): MarkdownRepoInfo | null { + const web = forgeWeb(ref) + if (!web) return null -export function createBitbucketRepoInfo( - owner: string, - repo: string, - path?: string, -): MarkdownRepoInfo { - const hostBaseUrl = 'https://bitbucket.org' - return { - hostBaseUrl, - blobBaseUrl: `${hostBaseUrl}/${owner}/${repo}/src/HEAD`, - rawBaseUrl: `${hostBaseUrl}/${owner}/${repo}/raw/HEAD`, - path, - commitBaseUrl: `${hostBaseUrl}/${owner}/${repo}/commits`, - prChar: '#', - prBaseUrl: `${hostBaseUrl}/${owner}/${repo}/pull-requests`, - accountChar: false, - // bitbucket uses jira instead of normal issues which can't be resolved, further the compare page also seems to be different - } -} - -export function createSourcehutRepoInfo( - owner: string, - repo: string, - path?: string, -): MarkdownRepoInfo { - const hostBaseUrl = 'https://git.sr.ht' - return { - hostBaseUrl, - blobBaseUrl: `${hostBaseUrl}/${owner}/${repo}/tree/HEAD/item`, - rawBaseUrl: `${hostBaseUrl}/${owner}/${repo}/blob/HEAD`, - path, - commitBaseUrl: `${hostBaseUrl}/${owner}/${repo}/commit`, - issueChar: '#', - issueBaseUrl: `https://todo.sr.ht/${owner}/${repo}`, - accountChar: '~', - keepAccountChar: true, - // source hut doesn't have/support pr & compare - } -} + const repo = toForgeRepoRef(ref) + // A base is the URL of an empty path, without the trailing slash it ends in. + const base = (target: UrlTarget) => web.urlFor(target)?.replace(/\/$/, '') -export function createGiteeRepoInfo(owner: string, repo: string, path?: string): MarkdownRepoInfo { - const hostBaseUrl = `https://gitee.com` return { - hostBaseUrl, - blobBaseUrl: `${hostBaseUrl}/${owner}/${repo}/blob/HEAD`, - rawBaseUrl: `${hostBaseUrl}/${owner}/${repo}/raw/HEAD`, + forge: { web, repo }, + blobBaseUrl: base({ file: { repo, path: '' } }) ?? '', + rawBaseUrl: base({ raw: { repo, path: '' } }) ?? '', path, - commitBaseUrl: `${hostBaseUrl}/${owner}/${repo}/commit`, - issueChar: '#', - issueBaseUrl: `${hostBaseUrl}/${owner}/${repo}/issues`, - issueRegex: /\B#[\dA-Z]+\b/gi, - prChar: '!', - prBaseUrl: `${hostBaseUrl}/${owner}/${repo}/pulls`, - compareBaseUrl: `${hostBaseUrl}/${owner}/${repo}/compare`, + ...ACCOUNT_SYNTAX[ref.provider], } } diff --git a/server/utils/changelog/validateHost.ts b/server/utils/changelog/validateHost.ts index 997efad7fe..d04a5432f1 100644 --- a/server/utils/changelog/validateHost.ts +++ b/server/utils/changelog/validateHost.ts @@ -1,12 +1,11 @@ -import { NEED_HOST, type ProviderId } from '~~/shared/utils/git-providers' +import { providerNeedsHost, validateHost, type ProviderId } from '~~/shared/utils/git-providers' import * as v from 'valibot' import { ERROR_UNKNOWN_GIT_HOST } from '~~/shared/utils/constants' -import { validateHost } from '~~/shared/utils/git-providers' export function validateHostWithValibot(provider: ProviderId) { return v.optional( v.custom(host => { - if (!NEED_HOST.includes(provider)) return true + if (!providerNeedsHost(provider)) return true if (typeof host !== 'string') return false return validateHost(provider, host) }, ERROR_UNKNOWN_GIT_HOST), diff --git a/server/utils/github.ts b/server/utils/github.ts deleted file mode 100644 index 3ab9e927da..0000000000 --- a/server/utils/github.ts +++ /dev/null @@ -1,58 +0,0 @@ -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) { - // oxlint-disable-next-line eslint/no-underscore-dangle - 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/server/utils/guarded-fetch.ts b/server/utils/guarded-fetch.ts new file mode 100644 index 0000000000..e196dd9a99 --- /dev/null +++ b/server/utils/guarded-fetch.ts @@ -0,0 +1,14 @@ +import type { FetchLike } from 'forges' +import { resolveAndValidateHost } from '#server/utils/image-proxy' + +/** + * A `fetch` for URLs whose host comes from user input. `forges` dereferences a + * Tangled handle as a host, so this refuses private addresses the way the image + * proxy does instead of letting the request reach an internal service. + */ +export const guardedFetch: FetchLike = async (input, init) => { + if (!(await resolveAndValidateHost(input))) { + throw new Error(`Refusing to fetch ${input}`) + } + return fetch(input, init) +} diff --git a/server/utils/readme-loaders.ts b/server/utils/readme-loaders.ts index 41d8d0df65..0bb87b6fa2 100644 --- a/server/utils/readme-loaders.ts +++ b/server/utils/readme-loaders.ts @@ -79,6 +79,19 @@ export async function fetchReadmeFromJsdelivr( return null } +/** + * The commit a version was built from (`gitHead`), or `undefined`: npm omits it + * for many versions, and callers then link to the default branch. + */ +export function publishedRevision(packageData: Packument, version?: string): string | undefined { + const requested = version ?? packageData['dist-tags']?.latest + const resolved = + requested && packageData.versions[requested] + ? requested + : packageData['dist-tags']?.[requested ?? ''] + return resolved ? packageData.versions[resolved]?.gitHead : undefined +} + export const resolvePackageReadmeSource = defineCachedFunction( async (packagePath: string) => { const pkgParamSegments = packagePath.split('/') @@ -142,6 +155,7 @@ export const resolvePackageReadmeSource = defineCachedFunction( version, markdown: undefined, repoInfo: undefined, + revision: undefined, } } @@ -152,6 +166,7 @@ export const resolvePackageReadmeSource = defineCachedFunction( version, markdown: readmeContent, repoInfo, + revision: publishedRevision(packageData, version), } }, { diff --git a/server/utils/readme.ts b/server/utils/readme.ts index 38be0f9e41..791fff7bc0 100644 --- a/server/utils/readme.ts +++ b/server/utils/readme.ts @@ -15,10 +15,12 @@ import { sanitizeRawHTML, decodeHashFragment, } from './mdKit' +import type { UrlTarget } from 'forges' import matter from 'gray-matter' import { marked } from 'marked' import { hasProtocol } from 'ufo' -import { convertBlobOrFileToRawUrl, type RepositoryInfo } from '#shared/utils/git-providers' +import { convertBlobOrFileToRawUrl, forgeWeb, toForgeRepoRef } from '#shared/utils/forges' +import type { RepositoryInfo } from '#shared/utils/git-providers' import { decodeHtmlEntities, slugify } from '#shared/utils/html' import { convertToEmoji } from '#shared/utils/emoji' import { toProxiedImageUrl } from '#server/utils/image-proxy' @@ -184,12 +186,15 @@ function toUserContentHash(value: string): string { } /** - * Resolve a relative URL to an absolute URL. - * If repository info is available, resolve to provider's raw file URLs. - * For markdown files (.md), use blob URLs so they render properly. - * Otherwise, fall back to jsdelivr CDN (except for .md files which are left unchanged). + * Resolves a relative URL through the forge, so links match the revision the + * README was published from. Without repository info, jsdelivr is the fallback. */ -function resolveUrl(url: string, packageName: string, repoInfo?: RepositoryInfo): string { +function resolveUrl( + url: string, + packageName: string, + repoInfo?: RepositoryInfo, + revision?: string, +): string { if (!url || url.startsWith('$')) return url if (url.startsWith('#')) { // Prefix anchor links to match heading IDs (avoids collision with page IDs) @@ -209,13 +214,15 @@ function resolveUrl(url: string, packageName: string, repoInfo?: RepositoryInfo) // Check if this is a markdown file link const isMarkdownFile = isMarkdownFileUrl(url) + // An absolute path names a file in the repository, which may not be in the npm + // tarball. Local routes arrive `$`-prefixed, so they return above. if (url.startsWith('/') && !url.startsWith('//')) { - if (!repoInfo?.rawBaseUrl) { + if (!repoInfo) { return url } - - const baseUrl = isMarkdownFile ? repoInfo.blobBaseUrl : repoInfo.rawBaseUrl - return `${baseUrl}${url}` + // Root-relative, so the package's directory does not apply. + const rootInfo = { ...repoInfo, directory: undefined } + return forgeFileUrl(rootInfo, url.slice(1), isMarkdownFile, revision) ?? url } if (hasProtocol(url, { acceptRelative: true })) { try { @@ -239,35 +246,11 @@ function resolveUrl(url: string, packageName: string, repoInfo?: RepositoryInfo) // for non-HTTP protocols (javascript:, data:, etc.), don't return, treat as relative } - // Use provider's URL base when repository info is available - // This handles assets that exist in the repo but not in the npm tarball - if (repoInfo?.rawBaseUrl) { - // Normalize the relative path (remove leading ./) - let relativePath = url.replace(/^\.\//, '') - - // If package is in a subdirectory, resolve relative paths from there - // e.g., for packages/ai with ./assets/hero.gif → packages/ai/assets/hero.gif - // but for ../../.github/assets/banner.jpg → resolve relative to subdirectory - if (repoInfo.directory) { - // Split directory into parts for relative path resolution - const dirParts = repoInfo.directory.split('/').filter(Boolean) - - // Handle ../ navigation - while (relativePath.startsWith('../')) { - relativePath = relativePath.slice(3) - dirParts.pop() - } - - // Reconstruct the path - if (dirParts.length > 0) { - relativePath = `${dirParts.join('/')}/${relativePath}` - } + if (repoInfo) { + const resolved = forgeFileUrl(repoInfo, url, isMarkdownFile, revision) + if (resolved) { + return resolved } - - // For markdown files, use blob URL so they render on the provider's site - // For other files, use raw URL for direct access - const baseUrl = isMarkdownFile ? repoInfo.blobBaseUrl : repoInfo.rawBaseUrl - return `${baseUrl}/${relativePath}` } // For markdown files without repo info, leave unchanged (like npm does) @@ -280,6 +263,44 @@ function resolveUrl(url: string, packageName: string, repoInfo?: RepositoryInfo) return `https://cdn.jsdelivr.net/npm/${packageName}/${url.replace(/^\.\//, '')}` } +/** + * The forge's URL for a file, or `undefined` where the forge has none. A markdown + * file links to its page, anything else to bytes; query and fragment are kept. + */ +function forgeFileUrl( + repoInfo: RepositoryInfo, + url: string, + asPage: boolean, + revision?: string, +): string | undefined { + // `match[0]` is the whole match, so the groups start at 1. + const match = /^([^?#]*)([?#].*)?$/.exec(url) + const rawPath = match?.[1] ?? url + const suffix = match?.[2] ?? '' + let path = rawPath.replace(/^\.\//, '') + + // Resolve from the package's subdirectory: for packages/ai, ./assets/a.gif is + // packages/ai/assets/a.gif, and ../../.github/x.jpg climbs back out of it. + if (repoInfo.directory) { + const parts = repoInfo.directory.split('/').filter(Boolean) + while (path.startsWith('../')) { + path = path.slice(3) + parts.pop() + } + if (parts.length > 0) { + path = `${parts.join('/')}/${path}` + } + } + + const repo = toForgeRepoRef(repoInfo) + // Without a known revision, the forge's default branch is what the package shipped from. + const target: UrlTarget = asPage + ? { file: { repo, path, at: revision } } + : { raw: { repo, path, at: revision } } + const resolved = forgeWeb(repoInfo)?.urlFor(target) + return resolved ? `${resolved}${suffix}` : undefined +} + // Convert blob/src URLs to raw URLs for images across all providers // e.g. https://github.com/nuxt/nuxt/blob/main/.github/assets/banner.svg // → https://github.com/nuxt/nuxt/raw/main/.github/assets/banner.svg @@ -288,16 +309,19 @@ function resolveUrl(url: string, packageName: string, repoInfo?: RepositoryInfo) // third-party servers from collecting visitor IP addresses and User-Agent data. // Proxy URLs are HMAC-signed to prevent open proxy abuse. // See: https://github.com/npmx-dev/npmx.dev/issues/1138 -function resolveImageUrl(url: string, packageName: string, repoInfo?: RepositoryInfo): string { +function resolveImageUrl( + url: string, + packageName: string, + repoInfo?: RepositoryInfo, + revision?: string, +): string { // Skip already-proxied URLs (from a previous resolveImageUrl call in the // marked renderer — sanitizeHtml transformTags may call this again) if (url.startsWith('/api/registry/image-proxy')) { return url } - const resolved = resolveUrl(url, packageName, repoInfo) - const rawUrl = repoInfo?.provider - ? convertBlobOrFileToRawUrl(resolved, repoInfo.provider) - : resolved + const resolved = resolveUrl(url, packageName, repoInfo, revision) + const rawUrl = repoInfo?.provider ? convertBlobOrFileToRawUrl(resolved, repoInfo) : resolved const { imageProxySecret } = useRuntimeConfig() return toProxiedImageUrl(rawUrl, imageProxySecret) } @@ -323,6 +347,7 @@ export async function renderReadmeHtml( content: string, packageName: string, repoInfo?: RepositoryInfo, + revision?: string, ): Promise { if (!content) return { html: '', playgroundLinks: [], toc: [] } @@ -353,7 +378,7 @@ export async function renderReadmeHtml( renderer.code = await createCodeHighlighter() function processImageUrl(href: string) { - return resolveImageUrl(href, packageName, repoInfo) + return resolveImageUrl(href, packageName, repoInfo, revision) } renderer.image = createImage(processImageUrl) @@ -362,7 +387,7 @@ export async function renderReadmeHtml( // Used by both the markdown renderer.link and the HTML interceptor so that // all link processing happens in a single pass during marked rendering. const processLink: ProcessLinkFn = (href: string, label: string) => { - const resolvedHref = resolveUrl(href, packageName, repoInfo) + const resolvedHref = resolveUrl(href, packageName, repoInfo, revision) // Collect playground links const provider = matchPlaygroundProvider(resolvedHref) diff --git a/shared/schemas/changelog/release.ts b/shared/schemas/changelog/release.ts index f6915855ca..96c78ff907 100644 --- a/shared/schemas/changelog/release.ts +++ b/shared/schemas/changelog/release.ts @@ -13,60 +13,3 @@ export const GithubReleaseSchama = v.object({ export const GithubReleaseCollectionSchama = v.object({ releases: v.array(GithubReleaseSchama), }) - -// keeping this here in case it's needed -// export type GithubRelease = v.InferOutput -// export type GithubReleaseCollection = v.InferOutput - -export const ForgejoReleaseSchama = v.object({ - id: v.number(), - tag_name: v.string(), - name: v.string(), - body: v.string(), - html_url: v.pipe(v.string(), v.url()), - draft: v.boolean(), - prerelease: v.boolean(), - published_at: v.pipe(v.string(), v.isoTimestamp()), -}) - -export const ForgejoReleaseCollectionSchema = v.array(ForgejoReleaseSchama) - -export const GitlabReleaseSchame = v.object({ - tag_name: v.string(), - name: v.string(), - description: v.string(), - released_at: v.pipe(v.string(), v.isoTimestamp()), - upcoming_release: v.boolean(), - commit: v.object({ - short_id: v.string(), - }), - _links: v.object({ - self: v.pipe(v.string(), v.url()), - }), -}) - -export const GitlabReleaseCollectionSchema = v.array(GitlabReleaseSchame) - -export const GiteaReleaseSchema = v.object({ - id: v.number(), - tag_name: v.string(), - name: v.string(), - body: v.string(), - html_url: v.pipe(v.string(), v.url()), - draft: v.boolean(), - prerelease: v.boolean(), - published_at: v.pipe(v.string(), v.isoTimestamp()), -}) - -export const GiteaReleaseCollectionSchema = v.array(GiteaReleaseSchema) - -export const GiteeReleaseSchema = v.object({ - id: v.number(), - tag_name: v.string(), - name: v.string(), - body: v.string(), - prerelease: v.boolean(), - created_at: v.pipe(v.string(), v.isoTimestamp()), -}) - -export const GiteeReleaseCollectionSchema = v.array(GiteeReleaseSchema) diff --git a/shared/utils/forges.ts b/shared/utils/forges.ts new file mode 100644 index 0000000000..59a1d187b1 --- /dev/null +++ b/shared/utils/forges.ts @@ -0,0 +1,118 @@ +import type { + FetchLike, + ForgeProvider, + RepoRef as ForgeRepoRef, + TokenAuth, + UrlTarget, +} from 'forges' +import type { ForgeWeb } from 'forges/web' +import { + instanceBaseUrl, + providerWeb, + type ProviderId, + type RepoRef, +} from '#shared/utils/git-providers' + +/** + * Maps npmx's providers onto `forges` clients. Codeberg is a Forgejo instance, + * and not every forge has a client npmx can use. + */ + +/** The options npmx passes to a `forges` factory. */ +interface ForgeBase { + /** A `fetch` to route through, for example npmx's stale-while-revalidate cache. */ + fetch?: FetchLike + timeout?: number + /** The API base URL. `forges` derives the website, its raw host and the instance from it. */ + baseUrl?: string + /** Credentials. npmx only has a GitHub token today. */ + auth?: TokenAuth +} + +/** + * The `forges` API client per provider. Imported on first use: together they are + * ~140 KB gzipped, and the browser only needs the forge on screen. + */ +const PROVIDER_FACTORIES: Partial< + Record Promise> +> = { + github: async base => (await import('forges/github')).github(base), + gitlab: async base => (await import('forges/gitlab')).gitlab(base), + bitbucket: async base => (await import('forges/bitbucket')).bitbucket(base), + // Codeberg is a Forgejo instance. + codeberg: async base => (await import('forges/forgejo')).forgejo(base), + forgejo: async base => (await import('forges/forgejo')).forgejo(base), + gitea: async base => (await import('forges/gitea')).gitea(base), + gitee: async base => (await import('forges/gitee')).gitee(base), + pushin: async base => (await import('forges/pushin')).pushin(base), + // Every sr.ht read needs a token, so there is no client without one. + sourcehut: async base => + base.auth + ? (await import('forges/sourcehut')).sourcehut({ ...base, auth: base.auth }) + : undefined, + // Tangled takes an app password or an OAuth client, not a token. + tangled: async ({ fetch, timeout, baseUrl }) => + (await import('forges/tangled')).tangled({ fetch, timeout, baseUrl }), +} + +export function toForgeRepoRef(ref: RepoRef): ForgeRepoRef { + const web = forgeWeb(ref) + return { + forge: web?.forge ?? ref.provider, + instance: web?.instance ?? ref.host ?? '', + owner: ref.owner, + name: ref.repo, + } +} + +/** Options for {@link createForgeProvider}. */ +export interface ForgeBridgeOptions { + /** A `fetch` to route through, for example npmx's stale-while-revalidate cache. */ + fetch?: FetchLike + /** Credentials for the forge. npmx only has a GitHub token today. */ + token?: string + /** Request timeout in milliseconds. Defaults to 15000. */ + timeout?: number +} + +/** + * Builds a `forges` provider for an npmx ref, or `undefined` when there is none: + * radicle has no entry, and Sourcehut needs a token npmx does not have. + */ +export async function createForgeProvider( + ref: RepoRef, + options: ForgeBridgeOptions = {}, +): Promise { + const factory = PROVIDER_FACTORIES[ref.provider] + if (!factory) { + return undefined + } + return factory({ + fetch: options.fetch, + timeout: options.timeout ?? 15_000, + baseUrl: instanceBaseUrl(ref), + ...(options.token ? { auth: { type: 'token' as const, token: options.token } } : {}), + }) +} + +/** + * Web links for an npmx ref, or `undefined` for radicle. No API client or + * credentials, which is why this is separate from {@link createForgeProvider}. + */ +export function forgeWeb(ref: RepoRef): ForgeWeb | undefined { + return providerWeb(ref.provider)?.({ baseUrl: instanceBaseUrl(ref) }) +} + +/** Points a blob/file URL at the forge's raw bytes, so a README image renders. */ +export function convertBlobOrFileToRawUrl(url: string, ref: RepoRef): string { + const web = forgeWeb(ref) + const parsed = web?.parseUrl(url) + const file = parsed?.file + if (!web || !parsed || !file) { + return url + } + const target: UrlTarget = { + raw: { repo: parsed.repo, path: file.path, at: file.at }, + } + return web.urlFor(target) ?? url +} diff --git a/shared/utils/git-providers.ts b/shared/utils/git-providers.ts index bb016f6ee3..a27333b845 100644 --- a/shared/utils/git-providers.ts +++ b/shared/utils/git-providers.ts @@ -1,3 +1,15 @@ +import type { ForgeWeb, WebOptions } from 'forges/web' +import { + bitbucket, + forgejo, + gitea, + gitee, + github, + gitlab, + pushin, + sourcehut, + tangled, +} from 'forges/web' import { withoutTrailingSlash } from 'ufo' export type ProviderId = @@ -21,10 +33,6 @@ export interface RepoRef { } export interface RepositoryInfo extends RepoRef { - /** Raw file URL base (e.g., https://raw.githubusercontent.com/owner/repo/HEAD) */ - rawBaseUrl: string - /** Blob/rendered file URL base (e.g., https://github.com/owner/repo/blob/HEAD) */ - blobBaseUrl: string /** Subdirectory within repo where package lives (e.g., packages/ai) */ directory?: string } @@ -53,257 +61,68 @@ const GITEA_HOSTS = ['gitea.com'] interface ProviderConfig { id: ProviderId - /** Check if hostname matches this provider */ - matchHost(host: string): boolean - /** Parse URL path into owner/repo, returns null if invalid */ - parsePath(parts: string[]): { owner: string; repo: string } | null - /** Get raw file URL base for resolving relative paths */ - getRawBaseUrl(ref: RepoRef, branch?: string): string - /** Get blob/rendered URL base for markdown files */ - getBlobBaseUrl(ref: RepoRef, branch?: string): string - /** Convert file URLs to blob URLs (for images) */ - fileToRaw?(url: string): string - /** Convert blob URLs to raw URLs (for images) */ - blobToRaw?(url: string): string + /** Hosts the provider is recognised on; the first is its public instance. */ + hosts: readonly string[] + /** The forge's web entry, which reads a repository path with the forge's own rules. */ + web?: (options: WebOptions) => ForgeWeb + /** The provider has self-hosted instances, so the ref's host names the instance. */ + selfHostable?: boolean + /** The instance base URL, where the forge's default is not the one npmx serves. */ + baseUrl?: (ref: RepoRef) => string | undefined + /** The host an npm shorthand expands to, where it is not the first host. */ + shorthandHost?: string } -const providers: ProviderConfig[] = [ - { - id: 'github', - matchHost: host => host === 'github.com' || host === 'www.github.com', - parsePath: parts => { - if (parts.length < 2) return null - const owner = decodeURIComponent(parts[0] ?? '').trim() - const repo = decodeURIComponent(parts[1] ?? '') - .trim() - .replace(/\.git$/i, '') - if (!owner || !repo) return null - return { owner, repo } - }, - getRawBaseUrl: (ref, branch = 'HEAD') => - `https://raw.githubusercontent.com/${ref.owner}/${ref.repo}/${branch}`, - getBlobBaseUrl: (ref, branch = 'HEAD') => - `https://github.com/${ref.owner}/${ref.repo}/blob/${branch}`, - fileToRaw: url => url.replace('/tree/', '/raw/'), - blobToRaw: url => url.replace('/blob/', '/raw/'), - }, - { - id: 'gitlab', - matchHost: host => GITLAB_HOSTS.some(h => host === h || host === `www.${h}`), - parsePath: parts => { - if (parts.length < 2) return null - // GitLab supports nested groups - const repo = decodeURIComponent(parts[parts.length - 1] ?? '') - .trim() - .replace(/\.git$/i, '') - const owner = parts - .slice(0, -1) - .map(p => decodeURIComponent(p).trim()) - .join('/') - if (!owner || !repo) return null - return { owner, repo } - }, - getRawBaseUrl: (ref, branch = 'HEAD') => { - const host = ref.host ?? 'gitlab.com' - return `https://${host}/${ref.owner}/${ref.repo}/-/raw/${branch}` - }, - getBlobBaseUrl: (ref, branch = 'HEAD') => { - const host = ref.host ?? 'gitlab.com' - return `https://${host}/${ref.owner}/${ref.repo}/-/blob/${branch}` - }, - blobToRaw: url => url.replace('/-/blob/', '/-/raw/'), - }, - { - id: 'bitbucket', - matchHost: host => host === 'bitbucket.org' || host === 'www.bitbucket.org', - parsePath: parts => { - if (parts.length < 2) return null - const owner = decodeURIComponent(parts[0] ?? '').trim() - const repo = decodeURIComponent(parts[1] ?? '') - .trim() - .replace(/\.git$/i, '') - if (!owner || !repo) return null - return { owner, repo } - }, - getRawBaseUrl: (ref, branch = 'HEAD') => - `https://bitbucket.org/${ref.owner}/${ref.repo}/raw/${branch}`, - getBlobBaseUrl: (ref, branch = 'HEAD') => - `https://bitbucket.org/${ref.owner}/${ref.repo}/src/${branch}`, - blobToRaw: url => url.replace('/src/', '/raw/'), - }, - { - id: 'codeberg', - matchHost: host => host === 'codeberg.org' || host === 'www.codeberg.org', - parsePath: parts => { - if (parts.length < 2) return null - const owner = decodeURIComponent(parts[0] ?? '').trim() - const repo = decodeURIComponent(parts[1] ?? '') - .trim() - .replace(/\.git$/i, '') - if (!owner || !repo) return null - return { owner, repo } - }, - getRawBaseUrl: (ref, branch = 'HEAD') => - `https://codeberg.org/${ref.owner}/${ref.repo}/raw/branch/${branch === 'HEAD' ? 'main' : branch}`, - getBlobBaseUrl: (ref, branch = 'HEAD') => - `https://codeberg.org/${ref.owner}/${ref.repo}/src/branch/${branch === 'HEAD' ? 'main' : branch}`, - blobToRaw: url => url.replace('/src/', '/raw/'), - }, - { - id: 'gitee', - matchHost: host => host === 'gitee.com' || host === 'www.gitee.com', - parsePath: parts => { - if (parts.length < 2) return null - const owner = decodeURIComponent(parts[0] ?? '').trim() - const repo = decodeURIComponent(parts[1] ?? '') - .trim() - .replace(/\.git$/i, '') - if (!owner || !repo) return null - return { owner, repo } - }, - getRawBaseUrl: (ref, branch = 'master') => - `https://gitee.com/${ref.owner}/${ref.repo}/raw/${branch}`, - getBlobBaseUrl: (ref, branch = 'master') => - `https://gitee.com/${ref.owner}/${ref.repo}/blob/${branch}`, - blobToRaw: url => url.replace('/blob/', '/raw/'), - }, - { - id: 'sourcehut', - matchHost: host => host === 'sr.ht' || host === 'git.sr.ht', - parsePath: parts => { - if (parts.length < 2) return null - // Sourcehut uses ~username/repo format - const owner = decodeURIComponent(parts[0] ?? '').trim() - const repo = decodeURIComponent(parts[1] ?? '') - .trim() - .replace(/\.git$/i, '') - if (!owner || !repo) return null - return { owner, repo } - }, - getRawBaseUrl: (ref, branch = 'HEAD') => - `https://git.sr.ht/${ref.owner}/${ref.repo}/blob/${branch}`, - getBlobBaseUrl: (ref, branch = 'HEAD') => - `https://git.sr.ht/${ref.owner}/${ref.repo}/tree/${branch}/item`, - }, - { - id: 'tangled', - matchHost: host => - host === 'tangled.sh' || - host === 'www.tangled.sh' || - host === 'tangled.org' || - host === 'www.tangled.org', - parsePath: parts => { - if (parts.length < 2) return null - // Tangled uses owner/repo format (owner is a domain-like identifier, e.g., nonbinary.computer) - const owner = decodeURIComponent(parts[0] ?? '').trim() - const repo = decodeURIComponent(parts[1] ?? '') - .trim() - .replace(/\.git$/i, '') - if (!owner || !repo) return null - return { owner, repo } - }, - getRawBaseUrl: (ref, branch = 'main') => - `https://tangled.sh/${ref.owner}/${ref.repo}/raw/branch/${branch}`, - getBlobBaseUrl: (ref, branch = 'main') => - `https://tangled.sh/${ref.owner}/${ref.repo}/src/branch/${branch}`, - blobToRaw: url => url.replace('/blob/', '/raw/branch/'), - }, - { - id: 'radicle', - matchHost: host => - host === 'radicle.at' || host === 'app.radicle.at' || host === 'seed.radicle.at', - parsePath: parts => { - // Radicle URLs: app.radicle.at/nodes/seed.radicle.at/rad:z3nP4yT1PE3m1PxLEzr173sZtJVnT - // We extract the rad:... identifier as the "repo" with no owner - const path = parts.join('/') - const radMatch = path.match(/rad:[a-zA-Z0-9]+/) - if (!radMatch?.[0]) return null - // Use empty owner, store full rad: ID as repo - return { owner: '', repo: radMatch[0] } - }, - getRawBaseUrl: (ref, branch = 'HEAD') => - `https://seed.radicle.at/api/v1/projects/${ref.repo}/blob/${branch}`, - getBlobBaseUrl: (ref, branch = 'HEAD') => - `https://app.radicle.at/nodes/seed.radicle.at/${ref.repo}/tree/${branch}`, - }, - { - id: 'pushin', - // Pushin serves the web UI and API on pushin.eu, but clone URLs use git.pushin.eu - matchHost: host => host === 'pushin.eu' || host === 'www.pushin.eu' || host === 'git.pushin.eu', - parsePath: parts => { - if (parts.length < 2) return null - const owner = decodeURIComponent(parts[0] ?? '').trim() - const repo = decodeURIComponent(parts[1] ?? '') - .trim() - .replace(/\.git$/i, '') - if (!owner || !repo) return null - return { owner, repo } - }, - // Pushin doesn't serve raw files yet, so the blob URL is the closest we have. - // Relative README images resolve to HTML pages and won't render until then. - // TODO: switch to the raw file URL once Pushin supports serving raw files - getRawBaseUrl: (ref, branch = 'HEAD') => - `https://pushin.eu/${ref.owner}/${ref.repo}/blob/${branch}`, - getBlobBaseUrl: (ref, branch = 'HEAD') => - `https://pushin.eu/${ref.owner}/${ref.repo}/blob/${branch}`, - }, +/** The ref's own instance base URL, for a provider that can be self-hosted. */ +const instanceUrl = (ref: RepoRef) => (ref.host ? `https://${ref.host}` : undefined) + +const PROVIDERS: readonly ProviderConfig[] = [ + { id: 'github', hosts: ['github.com'], web: github }, + { id: 'gitlab', hosts: GITLAB_HOSTS, web: gitlab, selfHostable: true, baseUrl: instanceUrl }, + { id: 'bitbucket', hosts: ['bitbucket.org'], web: bitbucket }, + // Codeberg is a Forgejo instance. + { id: 'codeberg', hosts: ['codeberg.org'], web: forgejo, baseUrl: () => 'https://codeberg.org' }, + { id: 'gitee', hosts: ['gitee.com'], web: gitee }, + { id: 'sourcehut', hosts: ['git.sr.ht'], web: sourcehut }, + { id: 'tangled', hosts: ['tangled.org', 'tangled.sh'], web: tangled }, + // Radicle has no `forges/web` entry: refs keep the `rad:` id as the repo. + { id: 'radicle', hosts: ['radicle.at', 'app.radicle.at', 'seed.radicle.at'], selfHostable: true }, + // Pushin serves the web UI and API on pushin.eu, but clone URLs use git.pushin.eu. + { id: 'pushin', hosts: ['pushin.eu', 'git.pushin.eu'], web: pushin }, { id: 'forgejo', - matchHost: host => FORGEJO_HOSTS.includes(host), - parsePath: parts => { - if (parts.length < 2) return null - const owner = decodeURIComponent(parts[0] ?? '').trim() - const repo = decodeURIComponent(parts[1] ?? '') - .trim() - .replace(/\.git$/i, '') - if (!owner || !repo) return null - return { owner, repo } - }, - getRawBaseUrl: (ref, branch = 'HEAD') => { - const host = ref.host ?? 'codeberg.org' - return `https://${host}/${ref.owner}/${ref.repo}/raw/branch/${branch === 'HEAD' ? 'main' : branch}` - }, - getBlobBaseUrl: (ref, branch = 'HEAD') => { - const host = ref.host ?? 'codeberg.org' - return `https://${host}/${ref.owner}/${ref.repo}/src/branch/${branch === 'HEAD' ? 'main' : branch}` - }, - blobToRaw: url => url.replace('/src/', '/raw/'), - }, - { - id: 'gitea', - matchHost: host => GITEA_HOSTS.includes(host), - parsePath: parts => { - if (parts.length < 2) return null - const owner = decodeURIComponent(parts[0] ?? '').trim() - const repo = decodeURIComponent(parts[1] ?? '') - .trim() - .replace(/\.git$/i, '') - if (!owner || !repo) return null - return { owner, repo } - }, - getRawBaseUrl: (ref, branch = 'HEAD') => { - const host = ref.host ?? 'gitea.io' - return `https://${host}/${ref.owner}/${ref.repo}/raw/branch/${branch === 'HEAD' ? 'main' : branch}` - }, - getBlobBaseUrl: (ref, branch = 'HEAD') => { - const host = ref.host ?? 'gitea.io' - return `https://${host}/${ref.owner}/${ref.repo}/src/branch/${branch === 'HEAD' ? 'main' : branch}` - }, - blobToRaw: url => url.replace('/src/', '/raw/'), + hosts: FORGEJO_HOSTS, + web: forgejo, + selfHostable: true, + baseUrl: instanceUrl, + shorthandHost: 'code.forgejo.org', }, + { id: 'gitea', hosts: GITEA_HOSTS, web: gitea, selfHostable: true, baseUrl: instanceUrl }, ] -const SHORTHAND_PROVIDERS = { - github: 'github.com', - gitlab: 'gitlab.com', - bitbucket: 'bitbucket.org', - codeberg: 'codeberg.org', - gitee: 'gitee.com', - sourcehut: 'git.sr.ht', - gitea: 'gitea.com', - tangled: 'tangled.org', - forgejo: 'code.forgejo.org', -} satisfies Partial> +function configFor(provider: ProviderId) { + return PROVIDERS.find(providerConfig => providerConfig.id === provider) +} + +/** Whether a host is one the provider is recognised on, ignoring the `www.` prefix. */ +function matchesHost(config: ProviderConfig, host: string) { + return config.hosts.some(known => host === known || host === `www.${known}`) +} + +/** The instance base URL for a ref, or `undefined` for the forge's own default. */ +export function instanceBaseUrl(ref: RepoRef): string | undefined { + return configFor(ref.provider)?.baseUrl?.(ref) +} + +/** The `forges/web` entry for a provider, or `undefined` where it has none (radicle). */ +export function providerWeb(provider: ProviderId): ((options: WebOptions) => ForgeWeb) | undefined { + return configFor(provider)?.web +} + +/** Whether a ref must name its instance, because the provider is self-hostable. */ +export function providerNeedsHost(provider: ProviderId): boolean { + return configFor(provider)?.selfHostable === true +} /** * Normalize various git URL formats to a standard HTTPS URL. @@ -314,13 +133,11 @@ export function normalizeGitUrl(input: string): string | null { if (!url) return null // Expand shorthand provider prefixes (e.g. "github:owner/repo" -> "https://github.com/owner/repo") - for (const [provider, host] of Object.entries(SHORTHAND_PROVIDERS)) { - const prefix = `${provider}:` - if (url.startsWith(prefix)) { - const replacement = `https://${host}/` - url = url.replace(prefix, replacement) - break - } + for (const provider of PROVIDERS) { + const prefix = `${provider.id}:` + if (!url.startsWith(prefix)) continue + url = `https://${provider.shorthandHost ?? provider.hosts[0]}/${url.slice(prefix.length)}` + break } url = url @@ -357,34 +174,42 @@ export function normalizeGitUrl(input: string): string | null { return url.includes('://') ? url : `https://${url}` } -export const NEED_HOST: ProviderId[] = ['gitlab', 'gitea', 'forgejo', 'radicle'] +/** Reads `owner/repo` with the forge's own URL rules, so nested groups and `~user` parse. */ +function parseRepoPath(config: ProviderConfig, host: string, url: URL) { + const repo = config.web?.({ baseUrl: `https://${host}` }).parseUrl(url)?.repo + return repo ? { owner: repo.owner, repo: repo.name } : null +} + +/** Radicle has no web entry; the `rad:` id is the repo, with no owner. */ +function parseRadiclePath(url: URL) { + const rad = /rad:[a-zA-Z0-9]+/.exec(url.pathname) + return rad ? { owner: '', repo: rad[0] } : null +} export function parseRepoUrl(input: string): RepoRef | null { const normalized = normalizeGitUrl(input) if (!normalized) return null + let url: URL try { - const url = new URL(normalized) - const host = url.hostname.toLowerCase() - const parts = url.pathname.split('/').filter(Boolean) - - for (const provider of providers) { - if (!provider.matchHost(host)) continue - const parsed = provider.parsePath(parts) - if (parsed) { - const needsHost = NEED_HOST.includes(provider.id) - return { - provider: provider.id, - owner: parsed.owner, - repo: parsed.repo, - host: needsHost ? host : undefined, - } - } - } - return null + url = new URL(normalized) } catch { return null } + + const host = url.hostname.toLowerCase() + const config = PROVIDERS.find(providerConfig => matchesHost(providerConfig, host)) + if (!config) return null + + const repo = config.web ? parseRepoPath(config, host, url) : parseRadiclePath(url) + if (!repo) return null + + return { + provider: config.id, + owner: repo.owner, + repo: repo.repo, + host: config.selfHostable ? host : undefined, + } } /** @@ -411,29 +236,12 @@ export function parseRepositoryInfo( const ref = parseRepoUrl(url) if (!ref) return undefined - const provider = providers.find(p => p.id === ref.provider) - if (!provider) return undefined - return { ...ref, - rawBaseUrl: provider.getRawBaseUrl(ref), - blobBaseUrl: provider.getBlobBaseUrl(ref), directory: directory ? withoutTrailingSlash(directory) : undefined, } } -export function convertBlobOrFileToRawUrl(url: string, providerId: ProviderId): string { - const provider = providers.find(p => p.id === providerId) - let rawUrl = url - if (provider?.fileToRaw) { - rawUrl = provider.fileToRaw(url) - } - if (provider?.blobToRaw) { - rawUrl = provider.blobToRaw(rawUrl) - } - return rawUrl -} - /** * API origins used by each provider for client-side repo metadata fetches. * Self-hosted providers are excluded because their origins can be anything. @@ -462,6 +270,6 @@ export const ALL_KNOWN_GIT_API_ORIGINS: readonly string[] = [ * validates whether the given host is part of the known hosts of a provider */ export function validateHost(provider: ProviderId, host: string) { - const providerConfig = providers.find(config => config.id == provider) - return !!providerConfig?.matchHost(host) + const config = configFor(provider) + return !!config && matchesHost(config, host) } diff --git a/shared/utils/repository-meta.ts b/shared/utils/repository-meta.ts index 1046136fd4..177eab8bf1 100644 --- a/shared/utils/repository-meta.ts +++ b/shared/utils/repository-meta.ts @@ -1,6 +1,8 @@ +import type { FetchLike } from 'forges' import type { CachedFetchFunction } from '#shared/utils/fetch-cache-config' import type { ProviderId, RepoRef } from '#shared/utils/git-providers' import { GIT_PROVIDER_API_ORIGINS } from '#shared/utils/git-providers' +import { createForgeProvider, forgeWeb, toForgeRepoRef } from '#shared/utils/forges' // TTL for git repo metadata (10 minutes - repo stats don't change frequently) const REPO_META_TTL = 60 * 10 @@ -9,93 +11,34 @@ const UNGH_REPO_META_TTL = 60 * 60 * 3 // 3 hours (ungh caches 6 hours server-si type RepoMetaLinks = { repo: string - stars: string - forks: string - watchers?: string + forks?: string } export type RepoMeta = { provider: ProviderId url: string - stars: number - forks: number - watchers?: number - description?: string | null - defaultBranch?: string + /** Absent when the forge did not report a count, which is not the same as zero. */ + stars?: number + forks?: number links: RepoMetaLinks } +/** What `/api/atproto/tangled-stats` returns. */ +export type TangledStats = { + stars?: number + forks?: number +} + type UnghRepoResponse = { repo: { - description?: string | null stars?: number forks?: number - watchers?: number - defaultBranch?: string } | null } -/** GitLab API response for project details */ -type GitLabProjectResponse = { - id: number - description?: string | null - default_branch?: string - star_count?: number - forks_count?: number -} - -/** Gitea/Forgejo API response for repository details */ -type GiteaRepoResponse = { - id: number - description?: string - default_branch?: string - stars_count?: number - forks_count?: number - watchers_count?: number -} - -/** Bitbucket API response for repository details */ -type BitbucketRepoResponse = { - name: string - full_name: string - description?: string - mainbranch?: { name: string } - // Bitbucket doesn't expose star/fork counts in public API -} - -/** Gitee API response for repository details */ -type GiteeRepoResponse = { - id: number - name: string - full_name: string - description?: string - default_branch?: string - stargazers_count?: number - forks_count?: number - watchers_count?: number -} - -/** Radicle API response for project details */ +/** Radicle API response for project details. */ type RadicleProjectResponse = { - id: string - name: string - description?: string - defaultBranch?: string - head?: string seeding?: number - delegates?: Array<{ id: string; alias?: string }> - patches?: { open: number; draft: number; archived: number; merged: number } - issues?: { open: number; closed: number } -} - -/** Pushin API response for repository details (GitHub-compatible) */ -type PushinRepoResponse = { - id: number - description?: string | null - default_branch?: string - stargazers_count?: number - forks_count?: number - watchers_count?: number } type ProviderAdapter = { @@ -108,19 +51,24 @@ type ProviderAdapter = { ): Promise } +function linksFor(ref: RepoRef): RepoMetaLinks { + const web = forgeWeb(ref) + const repo = toForgeRepoRef(ref) + // Fall back to the repository page where the forge has no dedicated one. + const page = web?.urlFor({ repo }) ?? '' + return { + repo: page, + forks: web?.urlFor({ repo, section: 'forks' }) ?? page, + } +} + const githubAdapter: ProviderAdapter = { - links(ref) { - const base = `https://github.com/${ref.owner}/${ref.repo}` - return { - repo: base, - stars: `${base}/stargazers`, - forks: `${base}/forks`, - watchers: `${base}/watchers`, - } - }, + links: linksFor, async fetchMeta(cachedFetch, ref, links, options = {}) { // Using UNGH to avoid API limitations of the GitHub API + // TODO(serhalp): refactor to use `forges` once it can read GitHub through a + // proxy; ungh is here only to stay under GitHub's anonymous rate limit. let res: UnghRepoResponse | null = null try { const { data } = await cachedFetch( @@ -139,230 +87,84 @@ const githubAdapter: ProviderAdapter = { return { provider: 'github', url: links.repo, - stars: repo.stars ?? 0, - forks: repo.forks ?? 0, - watchers: repo.watchers ?? 0, - description: repo.description ?? null, - defaultBranch: repo.defaultBranch, + stars: repo.stars, + forks: repo.forks, links, } }, } -const gitlabAdapter: ProviderAdapter = { - links(ref) { - const baseHost = ref.host ?? 'gitlab.com' - const base = `https://${baseHost}/${ref.owner}/${ref.repo}` - return { - repo: base, - stars: `${base}/-/starrers`, - forks: `${base}/-/forks`, - } - }, - - async fetchMeta(cachedFetch, ref, links, options = {}) { - const baseHost = ref.host ?? 'gitlab.com' - const projectPath = encodeURIComponent(`${ref.owner}/${ref.repo}`) - let res: GitLabProjectResponse | null = null - try { - const { data } = await cachedFetch( - `https://${baseHost}/api/v4/projects/${projectPath}`, - { ...options, headers: { 'User-Agent': 'npmx', ...options.headers } }, - REPO_META_TTL, - ) - res = data - } catch { - return null - } - - if (!res) return null - - return { - provider: 'gitlab', - url: links.repo, - stars: res.star_count ?? 0, - forks: res.forks_count ?? 0, - description: res.description ?? null, - defaultBranch: res.default_branch, - links, - } - }, +/** + * Metadata from `forges`. GitHub (ungh), Sourcehut (token), Tangled (server) and + * Radicle have their own adapters. + */ +function forgeAdapter(): ProviderAdapter { + return { + links: linksFor, + async fetchMeta(cachedFetch, ref, links, options = {}) { + try { + const provider = await createForgeProvider(ref, { + fetch: cachedFetchAsFetchLike(cachedFetch, REPO_META_TTL), + }) + if (!provider) return null + + const repo = await provider.repos.get(toForgeRepoRef(ref), { + signal: options?.signal ?? undefined, + }) + return { + provider: ref.provider, + url: links.repo, + stars: repo.stars, + forks: repo.forks, + links, + } + } catch { + return null + } + }, + } } -const bitbucketAdapter: ProviderAdapter = { - links(ref) { - const base = `https://bitbucket.org/${ref.owner}/${ref.repo}` - return { - repo: base, - stars: base, // Bitbucket doesn't have public stars - forks: `${base}/forks`, - } - }, - - async fetchMeta(cachedFetch, ref, links, options = {}) { - let res: BitbucketRepoResponse | null = null +/** + * Presents npmx's `cachedFetch` as the `Response`-shaped fetch `forges` expects. + * It loses `Link`/`ETag`, so only unpaginated reads (`repos.get()`) are sound. + */ +function cachedFetchAsFetchLike(cachedFetch: CachedFetchFunction, ttl: number): FetchLike { + return async (input, init = {}) => { try { - const { data } = await cachedFetch( - `${GIT_PROVIDER_API_ORIGINS.bitbucket}/2.0/repositories/${ref.owner}/${ref.repo}`, - { ...options, headers: { 'User-Agent': 'npmx', ...options.headers } }, - REPO_META_TTL, - ) - res = data - } catch { - return null - } - - if (!res) return null - - // Bitbucket doesn't expose star/fork counts in their public API - return { - provider: 'bitbucket', - url: links.repo, - stars: 0, - forks: 0, - description: res.description ?? null, - defaultBranch: res.mainbranch?.name, - links, + const requestOptions = { + method: init.method, + headers: init.headers, + body: init.body as BodyInit | undefined, + signal: init.signal ?? undefined, + } as Parameters[1] + const { data } = await cachedFetch(input, requestOptions, ttl) + return jsonResponse(data, 200) + } catch (error) { + const status = + (error as { response?: { status?: number } })?.response?.status ?? + (error as { statusCode?: number })?.statusCode ?? + 502 + return jsonResponse((error as { data?: unknown })?.data, status) } - }, + } } -const codebergAdapter: ProviderAdapter = { - links(ref) { - const base = `https://codeberg.org/${ref.owner}/${ref.repo}` - return { - repo: base, - stars: base, // Codeberg doesn't have a separate stargazers page - forks: `${base}/forks`, - watchers: base, - } - }, - - async fetchMeta(cachedFetch, ref, links, options = {}) { - let res: GiteaRepoResponse | null = null - try { - const { data } = await cachedFetch( - `${GIT_PROVIDER_API_ORIGINS.codeberg}/api/v1/repos/${ref.owner}/${ref.repo}`, - { ...options, headers: { 'User-Agent': 'npmx', ...options.headers } }, - REPO_META_TTL, - ) - res = data - } catch { - return null - } - - if (!res) return null - - return { - provider: 'codeberg', - url: links.repo, - stars: res.stars_count ?? 0, - forks: res.forks_count ?? 0, - watchers: res.watchers_count ?? 0, - description: res.description ?? null, - defaultBranch: res.default_branch, - links, - } - }, -} - -const giteeAdapter: ProviderAdapter = { - links(ref) { - const base = `https://gitee.com/${ref.owner}/${ref.repo}` - return { - repo: base, - stars: `${base}/stargazers`, - forks: `${base}/members`, - watchers: `${base}/watchers`, - } - }, - - async fetchMeta(cachedFetch, ref, links, options = {}) { - let res: GiteeRepoResponse | null = null - try { - const { data } = await cachedFetch( - `${GIT_PROVIDER_API_ORIGINS.gitee}/api/v5/repos/${ref.owner}/${ref.repo}`, - { ...options, headers: { 'User-Agent': 'npmx', ...options.headers } }, - REPO_META_TTL, - ) - res = data - } catch { - return null - } - - if (!res) return null - - return { - provider: 'gitee', - url: links.repo, - stars: res.stargazers_count ?? 0, - forks: res.forks_count ?? 0, - watchers: res.watchers_count ?? 0, - description: res.description ?? null, - defaultBranch: res.default_branch, - links, - } - }, +function jsonResponse(data: unknown, status: number): Response { + return new Response(typeof data === 'string' ? data : JSON.stringify(data ?? null), { + status, + headers: { 'content-type': 'application/json' }, + }) } /** - * Adapter for exact allowlisted Gitea instances. + * Sourcehut's links come from `forges`; its metadata needs a token npmx does not + * have, and it reports no stars or forks, so a request would buy nothing. */ -const giteaAdapter: ProviderAdapter = { - links(ref) { - const base = `https://${ref.host}/${ref.owner}/${ref.repo}` - return { - repo: base, - stars: base, - forks: `${base}/forks`, - watchers: base, - } - }, - - async fetchMeta(cachedFetch, ref, links, options = {}) { - if (!ref.host) return null - - let res: GiteaRepoResponse | null = null - try { - const { data } = await cachedFetch( - `https://${ref.host}/api/v1/repos/${ref.owner}/${ref.repo}`, - { ...options, headers: { 'User-Agent': 'npmx', ...options.headers } }, - REPO_META_TTL, - ) - res = data - } catch { - return null - } - - if (!res) return null - - return { - provider: 'gitea', - url: links.repo, - stars: res.stars_count ?? 0, - forks: res.forks_count ?? 0, - watchers: res.watchers_count ?? 0, - description: res.description ?? null, - defaultBranch: res.default_branch, - links, - } - }, -} - const sourcehutAdapter: ProviderAdapter = { - links(ref) { - // Sourcehut uses ~username/repo format. - const base = `https://git.sr.ht/${ref.owner}/${ref.repo}` - return { - repo: base, - stars: base, // Sourcehut doesn't have stars - forks: base, - } - }, + links: linksFor, async fetchMeta(_cachedFetch, _ref, links) { - // Sourcehut doesn't have a public API for repo stats - // Just return basic info without fetching return { provider: 'sourcehut', url: links.repo, @@ -373,40 +175,23 @@ const sourcehutAdapter: ProviderAdapter = { }, } +/** + * Tangled is read on the server: it reaches hosts the browser's CSP cannot list + * (see `/api/atproto/tangled-stats`). + */ const tangledAdapter: ProviderAdapter = { - links(ref) { - // Tangled uses owner/repo format, where owner is a domain-like identifier. - const base = `https://tangled.org/${ref.owner}/${ref.repo}` - return { - repo: base, - stars: base, // Tangled shows stars on the repo page - forks: `${base}/fork`, - } - }, + links: linksFor, async fetchMeta(cachedFetch, ref, links, options = {}) { try { - const { data } = await cachedFetch<{ stars: number; forks: number }>( + const { data } = await cachedFetch( `/api/atproto/tangled-stats/${ref.owner}/${ref.repo}`, options, REPO_META_TTL, ) - - return { - provider: 'tangled', - url: links.repo, - stars: data.stars, - forks: data.forks, - links, - } + return { provider: 'tangled', url: links.repo, ...data, links } } catch { - return { - provider: 'tangled', - url: links.repo, - stars: 0, - forks: 0, - links, - } + return null } }, } @@ -417,12 +202,12 @@ const radicleAdapter: ProviderAdapter = { const base = `https://app.radicle.at/nodes/seed.radicle.at/${ref.repo}` return { repo: base, - stars: base, // Radicle doesn't have stars, shows seeding count forks: base, } }, async fetchMeta(cachedFetch, ref, links, options = {}) { + // TODO(serhalp): refactor to use `forges` once it has a Radicle provider. let res: RadicleProjectResponse | null = null try { const { data } = await cachedFetch( @@ -443,95 +228,29 @@ const radicleAdapter: ProviderAdapter = { // Use seeding count as a proxy for "stars" (number of nodes hosting this repo) stars: res.seeding ?? 0, forks: 0, // Radicle doesn't have forks in the traditional sense - description: res.description ?? null, - defaultBranch: res.defaultBranch, links, } }, } -const pushinAdapter: ProviderAdapter = { - links(ref) { - const base = `https://pushin.eu/${ref.owner}/${ref.repo}` - return { - repo: base, - stars: `${base}/stargazers`, - forks: base, // Pushin doesn't have a forks page - watchers: base, // Pushin doesn't have a watchers page - } - }, +const pushinAdapter = forgeAdapter() - async fetchMeta(cachedFetch, ref, links, options = {}) { - let res: PushinRepoResponse | null = null - try { - const { data } = await cachedFetch( - `${GIT_PROVIDER_API_ORIGINS.pushin}/api/v1/repos/${ref.owner}/${ref.repo}`, - { ...options, headers: { 'User-Agent': 'npmx', ...options.headers } }, - REPO_META_TTL, - ) - res = data - } catch { - return null - } +// Providers that fetch their metadata through `forges`. +const gitlabAdapter = forgeAdapter() - if (!res) return null +const bitbucketAdapter = forgeAdapter() - return { - provider: 'pushin', - url: links.repo, - stars: res.stargazers_count ?? 0, - forks: res.forks_count ?? 0, - watchers: res.watchers_count ?? 0, - description: res.description ?? null, - defaultBranch: res.default_branch, - links, - } - }, -} +const codebergAdapter = forgeAdapter() + +const giteeAdapter = forgeAdapter() /** - * Adapter for exact allowlisted Forgejo instances. + * Adapter for exact allowlisted Gitea instances. */ -const forgejoAdapter: ProviderAdapter = { - links(ref) { - const base = `https://${ref.host}/${ref.owner}/${ref.repo}` - return { - repo: base, - stars: base, - forks: `${base}/forks`, - watchers: base, - } - }, - - async fetchMeta(cachedFetch, ref, links, options = {}) { - if (!ref.host) return null - - let res: GiteaRepoResponse | null = null - try { - const { data } = await cachedFetch( - `https://${ref.host}/api/v1/repos/${ref.owner}/${ref.repo}`, - { ...options, headers: { 'User-Agent': 'npmx', ...options.headers } }, - REPO_META_TTL, - ) - res = data - } catch { - return null - } - - if (!res) return null +const giteaAdapter = forgeAdapter() - return { - provider: 'forgejo', - url: links.repo, - stars: res.stars_count ?? 0, - forks: res.forks_count ?? 0, - watchers: res.watchers_count ?? 0, - description: res.description ?? null, - defaultBranch: res.default_branch, - links, - } - }, -} +/** Adapter for exact allowlisted Forgejo instances. */ +const forgejoAdapter = forgeAdapter() const providers = { github: githubAdapter, diff --git a/test/nuxt/composables/use-repo-meta.spec.ts b/test/nuxt/composables/use-repo-meta.spec.ts index 70f006e714..958debd4f9 100644 --- a/test/nuxt/composables/use-repo-meta.spec.ts +++ b/test/nuxt/composables/use-repo-meta.spec.ts @@ -152,16 +152,6 @@ describe('useRepoMeta - URL parsing via repoRef', () => { repo: 'sourcehut', }) }) - - it('should parse Sourcehut URL with sr.ht', () => { - const result = parseRepoUrl('https://sr.ht/~user/repo') - - expect(result).toEqual({ - provider: 'sourcehut', - owner: '~user', - repo: 'repo', - }) - }) }) describe('Tangled URLs', () => { @@ -205,11 +195,11 @@ describe('useRepoMeta - URL parsing via repoRef', () => { describe('Gitea URLs', () => { it('should parse exact allowlisted Gitea hosts', () => { - const result = parseRepoUrl('https://gitea.com/org/project') + const result = parseRepoUrl('https://gitea.com/acme/project') expect(result).toEqual({ provider: 'gitea', - owner: 'org', + owner: 'acme', repo: 'project', host: 'gitea.com', }) diff --git a/test/unit/server/api/atproto/tangled-stats.spec.ts b/test/unit/server/api/atproto/tangled-stats.spec.ts new file mode 100644 index 0000000000..a7fead4ad9 --- /dev/null +++ b/test/unit/server/api/atproto/tangled-stats.spec.ts @@ -0,0 +1,62 @@ +import { afterAll, beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import type { H3Event } from 'h3' + +const createForgeProviderMock = vi.hoisted(() => vi.fn()) +const getRouterParamMock = vi.hoisted(() => vi.fn()) + +vi.mock('h3', () => ({ + createError: vi.fn((options: unknown) => options), + getRouterParam: getRouterParamMock, +})) + +vi.mock('#shared/utils/forges', () => ({ + createForgeProvider: createForgeProviderMock, + toForgeRepoRef: (ref: unknown) => ref, +})) + +vi.stubGlobal('defineCachedEventHandler', (fn: Function) => fn) +// The route uses Nuxt's auto-imported h3 helpers, which unit tests do not get. +vi.stubGlobal('getRouterParam', getRouterParamMock) +vi.stubGlobal( + 'createError', + vi.fn((options: unknown) => options), +) + +const handler = (await import('#server/api/atproto/tangled-stats/[owner]/[...repo].get')).default +const fakeEvent = {} as H3Event + +afterAll(() => { + vi.unstubAllGlobals() +}) + +describe('tangled stats API', () => { + beforeEach(() => { + vi.clearAllMocks() + getRouterParamMock.mockImplementation((_event: unknown, name: string) => + name === 'owner' ? 'evil.test' : 'repo', + ) + }) + + it('refuses a private host before fetching', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + createForgeProviderMock.mockImplementation( + async (_ref: unknown, options: { fetch: (url: string) => Promise }) => ({ + repos: { + get: () => options.fetch('http://127.0.0.1:8080/xrpc/org.tangled.repo.get'), + }, + }), + ) + + await expect(handler(fakeEvent)).rejects.toThrow(/Refusing to fetch/) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('returns the counts the forge reported, without inventing zeros', async () => { + createForgeProviderMock.mockResolvedValue({ + repos: { get: async () => ({ stars: 3 }) }, + }) + + await expect(handler(fakeEvent)).resolves.toEqual({ stars: 3, forks: undefined }) + }) +}) diff --git a/test/unit/server/utils/changelog/markdown.spec.ts b/test/unit/server/utils/changelog/markdown.spec.ts index 66ec5cb0c5..e314c06cbe 100644 --- a/test/unit/server/utils/changelog/markdown.spec.ts +++ b/test/unit/server/utils/changelog/markdown.spec.ts @@ -1,18 +1,21 @@ +import type { ProviderId } from '~~/shared/utils/git-providers' import type { MarkdownRepoInfo } from '~~/server/utils/changelog/markdown' import { describe, expect, it, vi, beforeAll, test } from 'vite-plus/test' -import { - createGithubRepoInfo, - createGitLabRepoInfo, - createGiteeRepoInfo, - createBitbucketRepoInfo, - createForgejoRepoInfo, - createGiteaRepoInfo, - createSourcehutRepoInfo, - createTangledRepoInfo, -} from '~~/server/utils/changelog/mdRepoInfo' +import { createRepoInfo } from '~~/server/utils/changelog/mdRepoInfo' const TEST_OWNER = 'test-owner' const TEST_REPO = 'test-repo' + +function repoInfo( + provider: ProviderId, + owner: string, + repo: string, + path?: string, +): MarkdownRepoInfo { + const info = createRepoInfo({ provider, owner, repo }, path) + if (!info) throw new Error(`no repo info for ${provider}`) + return info +} // testing changelog specific needs, others things are tested at ../readme.spec.ts // some tests test with all git providers, other only the logic of the changelog markdown parser @@ -35,11 +38,11 @@ beforeAll(() => { const { changelogRenderer } = await import('#server/utils/changelog/markdown') function changelogMdinfo(): MarkdownRepoInfo { - return createGithubRepoInfo(TEST_OWNER, TEST_REPO) + return repoInfo('github', TEST_OWNER, TEST_REPO) } function changelogMdInfoWithPath() { - return createGithubRepoInfo(TEST_OWNER, TEST_REPO, 'packages/test/changelog.md') + return repoInfo('github', TEST_OWNER, TEST_REPO, 'packages/test/changelog.md') } describe('URL Resolution', () => { @@ -570,7 +573,7 @@ describe('Turn plaintext #isssue/#pr, !pr, @account & commmit into links', () => }) test('gitlab', async () => { - const info = createGitLabRepoInfo('gitlab.com', TEST_OWNER, TEST_REPO) + const info = repoInfo('gitlab', TEST_OWNER, TEST_REPO) const renderer = await changelogRenderer(info) // text from date-fns v4.3.0 const markdown = `- Fixed pt locale first day of week to be Sunday. See #4195 by @ImRodry. @@ -586,7 +589,7 @@ describe('Turn plaintext #isssue/#pr, !pr, @account & commmit into links', () => }) test('codeberg/forgejo', async () => { - const info = createForgejoRepoInfo('codeberg.org', TEST_OWNER, TEST_REPO) + const info = repoInfo('codeberg', TEST_OWNER, TEST_REPO) const renderer = await changelogRenderer(info) // text from date-fns v4.3.0 const markdown = `- Fixed pt locale first day of week to be Sunday. See #4195 by @ImRodry. @@ -602,7 +605,7 @@ describe('Turn plaintext #isssue/#pr, !pr, @account & commmit into links', () => }) test('tangled', async () => { - const info = createTangledRepoInfo(TEST_OWNER, TEST_REPO) + const info = repoInfo('tangled', TEST_OWNER, TEST_REPO) const renderer = await changelogRenderer(info) // text from date-fns v4.3.0 const markdown = `- Fixed pt locale first day of week to be Sunday. See #4195 by @ImRodry. @@ -618,7 +621,7 @@ describe('Turn plaintext #isssue/#pr, !pr, @account & commmit into links', () => }) test('gitea', async () => { - const info = createGiteaRepoInfo('gitea.com', TEST_OWNER, TEST_REPO) + const info = repoInfo('gitea', TEST_OWNER, TEST_REPO) const renderer = await changelogRenderer(info) // text from date-fns v4.3.0 const markdown = `- Fixed pt locale first day of week to be Sunday. See #4195 by @ImRodry. @@ -635,7 +638,7 @@ describe('Turn plaintext #isssue/#pr, !pr, @account & commmit into links', () => test('bitbucket', async () => { // for bitbucket the support is only pull requests. Jira & accounts are external and not offline resolveable - const info = createBitbucketRepoInfo(TEST_OWNER, TEST_REPO) + const info = repoInfo('bitbucket', TEST_OWNER, TEST_REPO) const renderer = await changelogRenderer(info) // text from date-fns v4.3.0 const markdown = `- Fixed pt locale first day of week to be Sunday. See #4195 by @ImRodry. @@ -651,7 +654,7 @@ describe('Turn plaintext #isssue/#pr, !pr, @account & commmit into links', () => }) test('sourcehut', async () => { - const info = createSourcehutRepoInfo(`~${TEST_OWNER}`, TEST_REPO) + const info = repoInfo('sourcehut', `~${TEST_OWNER}`, TEST_REPO) const renderer = await changelogRenderer(info) // text from date-fns v4.3.0 const markdown = `- Fixed pt locale first day of week to be Sunday. See #4195 by ~ImRodry. @@ -667,7 +670,7 @@ describe('Turn plaintext #isssue/#pr, !pr, @account & commmit into links', () => }) test('gitee', async () => { - const info = createGiteeRepoInfo('test-owner', 'test-repo') + const info = repoInfo('gitee', 'test-owner', 'test-repo') const renderer = await changelogRenderer(info) // text from date-fns v4.3.0 const markdown = `- Fixed pt locale first day of week to be Sunday. See #IKF9K6 by @ImRodry @@ -792,7 +795,7 @@ describe('Turn plaintext #isssue/#pr, !pr, @account & commmit into links', () => }) test('gitlab', async () => { - const info = createGitLabRepoInfo('gitlab.com', TEST_OWNER, TEST_REPO) + const info = repoInfo('gitlab', TEST_OWNER, TEST_REPO) const renderer = await changelogRenderer(info) // from tiptap 3.27.1, 3.27.0, 3.26.0 & npmx 0.14.0 & 0.14.1 const markdown = `- a16901d: Fix ordered list parsing so under-indented continuation lines preserve their first character @@ -807,14 +810,14 @@ describe('Turn plaintext #isssue/#pr, !pr, @account & commmit into links', () =>
  • a16901d: Fix ordered list parsing so under-indented continuation lines preserve their first character
  • Updated dependencies [6270b99]
  • 7fb19eb: Only add hash attributes to nodes, not to marks.
  • -
  • Release v0.14.0 36128a5
  • -
  • Empty (4cab893)
  • +
  • Release v0.14.0 36128a54
  • +
  • Empty (4cab893c)
  • `) }) test('codeberg/forgejo', async () => { - const info = createForgejoRepoInfo('codeberg.org', TEST_OWNER, TEST_REPO) + const info = repoInfo('codeberg', TEST_OWNER, TEST_REPO) const renderer = await changelogRenderer(info) // from tiptap 3.27.1, 3.27.0, 3.26.0 & npmx 0.14.0 & 0.14.1 const markdown = `- a16901d: Fix ordered list parsing so under-indented continuation lines preserve their first character @@ -836,7 +839,7 @@ describe('Turn plaintext #isssue/#pr, !pr, @account & commmit into links', () => }) test('tangled', async () => { - const info = createTangledRepoInfo(TEST_OWNER, TEST_REPO) + const info = repoInfo('tangled', TEST_OWNER, TEST_REPO) const renderer = await changelogRenderer(info) // from tiptap 3.27.1, 3.27.0, 3.26.0 & npmx 0.14.0 & 0.14.1 const markdown = `- a16901d: Fix ordered list parsing so under-indented continuation lines preserve their first character @@ -858,7 +861,7 @@ describe('Turn plaintext #isssue/#pr, !pr, @account & commmit into links', () => }) test('gitea', async () => { - const info = createGiteaRepoInfo('gitea.com', TEST_OWNER, TEST_REPO) + const info = repoInfo('gitea', TEST_OWNER, TEST_REPO) const renderer = await changelogRenderer(info) // from tiptap 3.27.1, 3.27.0, 3.26.0 & npmx 0.14.0 & 0.14.1 const markdown = `- a16901d: Fix ordered list parsing so under-indented continuation lines preserve their first character @@ -880,7 +883,7 @@ describe('Turn plaintext #isssue/#pr, !pr, @account & commmit into links', () => }) test('bitbucket', async () => { - const info = createBitbucketRepoInfo(TEST_OWNER, TEST_REPO) + const info = repoInfo('bitbucket', TEST_OWNER, TEST_REPO) const renderer = await changelogRenderer(info) // from tiptap 3.27.1, 3.27.0, 3.26.0 & npmx 0.14.0 & 0.14.1 const markdown = `- a16901d: Fix ordered list parsing so under-indented continuation lines preserve their first character @@ -895,14 +898,14 @@ describe('Turn plaintext #isssue/#pr, !pr, @account & commmit into links', () =>
  • a16901d: Fix ordered list parsing so under-indented continuation lines preserve their first character
  • Updated dependencies [6270b99]
  • 7fb19eb: Only add hash attributes to nodes, not to marks.
  • -
  • Release v0.14.0 36128a5
  • -
  • Empty (4cab893)
  • +
  • Release v0.14.0 36128a54
  • +
  • Empty (4cab893c)
  • `) }) test('sourcehut', async () => { - const info = createSourcehutRepoInfo(`~${TEST_OWNER}`, TEST_REPO) + const info = repoInfo('sourcehut', `~${TEST_OWNER}`, TEST_REPO) const renderer = await changelogRenderer(info) // from tiptap 3.27.1, 3.27.0, 3.26.0 & npmx 0.14.0 & 0.14.1 const markdown = `- a16901d: Fix ordered list parsing so under-indented continuation lines preserve their first character @@ -924,7 +927,7 @@ describe('Turn plaintext #isssue/#pr, !pr, @account & commmit into links', () => }) test('gitee', async () => { - const info = createGiteeRepoInfo(TEST_OWNER, TEST_REPO) + const info = repoInfo('gitee', TEST_OWNER, TEST_REPO) const renderer = await changelogRenderer(info) // from tiptap 3.27.1, 3.27.0, 3.26.0 & npmx 0.14.0 & 0.14.1 const markdown = `- a16901d: Fix ordered list parsing so under-indented continuation lines preserve their first character @@ -1003,7 +1006,7 @@ describe('format unformatted/auto links to git', () => { // links to account won't be formatted, this is something also git providers don't do describe('should turn issue, pr, commit & compare links to formatted links', () => { test('github', async () => { - const info = createGithubRepoInfo('vueuse', 'vueuse') + const info = repoInfo('github', 'vueuse', 'vueuse') const renderer = await changelogRenderer(info) // from vueuse 14.3.0 (last 2 links changed from `issues` -> `pull`) const markdown = `- Expose pointer event onLongPress - by mrcwbr in https://github.com/vueuse/vueuse/issues/5295 https://github.com/vueuse/vueuse/commit/b1688bd2 @@ -1025,11 +1028,12 @@ https://github.com/vueuse/vueuse/compare/v14.2.1...v14.3.0` }) test('gitlab', async () => { - const info = createGitLabRepoInfo('gitlab.com', 'vueuse', 'vueuse') + const info = repoInfo('gitlab', 'vueuse', 'vueuse') const renderer = await changelogRenderer(info) - // from vueuse 14.3.0 (last 2 links changed from `issues` -> `pull`) - const markdown = `- Expose pointer event onLongPress - by mrcwbr in https://gitlab.com/vueuse/vueuse/-/work_items/5295 https://gitlab.com/vueuse/vueuse/-/commit/b1688bd2 -- createInjectionState: Non-undefined return when default specified - by Laupetin in https://gitlab.com/vueuse/vueuse/-/work_items/5306 https://gitlab.com/vueuse/vueuse/-/commit/b0c51c27 + // from vueuse 14.3.0 (last 2 links changed from `issues` -> `pull`). GitLab + // writes issue links as `/-/work_items` now; `/-/issues/` still reads back. + const markdown = `- Expose pointer event onLongPress - by mrcwbr in https://gitlab.com/vueuse/vueuse/-/issues/5295 https://gitlab.com/vueuse/vueuse/-/commit/b1688bd2 +- createInjectionState: Non-undefined return when default specified - by Laupetin in https://gitlab.com/vueuse/vueuse/-/issues/5306 https://gitlab.com/vueuse/vueuse/-/commit/b0c51c27 - createReusableTemplate: Add support for specifying component names - by wbolster in https://gitlab.com/vueuse/vueuse/-/merge_requests/5300 https://gitlab.com/vueuse/vueuse/-/commit/ea29d5cb - nuxt: Add composable variants to auto imports - by OrbisK in https://gitlab.com/vueuse/vueuse/-/merge_requests/5285 https://gitlab.com/vueuse/vueuse/-/commit/ac2ef95d @@ -1037,17 +1041,17 @@ https://gitlab.com/vueuse/vueuse/-/compare/v14.2.1...v14.3.0` const result = renderer(markdown) expect(result.html).toBe(`
      -
    • Expose pointer event onLongPress - by mrcwbr in #5295 b1688bd
    • -
    • createInjectionState: Non-undefined return when default specified - by Laupetin in #5306 b0c51c2
    • -
    • createReusableTemplate: Add support for specifying component names - by wbolster in !5300 ea29d5c
    • -
    • nuxt: Add composable variants to auto imports - by OrbisK in !5285 ac2ef95
    • +
    • Expose pointer event onLongPress - by mrcwbr in #5295 b1688bd2
    • +
    • createInjectionState: Non-undefined return when default specified - by Laupetin in #5306 b0c51c27
    • +
    • createReusableTemplate: Add support for specifying component names - by wbolster in !5300 ea29d5cb
    • +
    • nuxt: Add composable variants to auto imports - by OrbisK in !5285 ac2ef95d

    v14.2.1...v14.3.0

    `) }) test('codeberg/forgejo', async () => { - const info = createForgejoRepoInfo('codeberg.org', 'vueuse', 'vueuse') + const info = repoInfo('codeberg', 'vueuse', 'vueuse') const renderer = await changelogRenderer(info) // from vueuse 14.3.0 (last 2 links changed from `issues` -> `pull`) const markdown = `- Expose pointer event onLongPress - by mrcwbr in https://codeberg.org/vueuse/vueuse/issues/5295 https://codeberg.org/vueuse/vueuse/commit/b1688bd2 @@ -1069,7 +1073,7 @@ https://codeberg.org/vueuse/vueuse/compare/v14.2.1...v14.3.0` }) test('tangled', async () => { - const info = createTangledRepoInfo('vueuse', 'vueuse') + const info = repoInfo('tangled', 'vueuse', 'vueuse') const renderer = await changelogRenderer(info) // from vueuse 14.3.0 (last 2 links changed from `issues` -> `pull`) const markdown = `- Expose pointer event onLongPress - by mrcwbr in https://tangled.org/vueuse/vueuse/issues/5295 https://tangled.org/vueuse/vueuse/commit/b1688bd2 @@ -1091,7 +1095,7 @@ https://tangled.org/vueuse/vueuse/compare/v14.2.1...v14.3.0` }) test('gitea', async () => { - const info = createGiteaRepoInfo('gitea.com', 'vueuse', 'vueuse') + const info = repoInfo('gitea', 'vueuse', 'vueuse') const renderer = await changelogRenderer(info) // from vueuse 14.3.0 (last 2 links changed from `issues` -> `pull`) const markdown = `- Expose pointer event onLongPress - by mrcwbr in https://gitea.com/vueuse/vueuse/issues/5295 https://gitea.com/vueuse/vueuse/commit/b1688bd2 @@ -1113,7 +1117,7 @@ https://gitea.com/vueuse/vueuse/compare/v14.2.1...v14.3.0` }) test('bitbucket', async () => { - const info = createBitbucketRepoInfo('vueuse', 'vueuse') + const info = repoInfo('bitbucket', 'vueuse', 'vueuse') const renderer = await changelogRenderer(info) // from vueuse 14.3.0 (last 2 links changed from `issues` -> `pull`) const markdown = `- Expose pointer event onLongPress - by mrcwbr in https://bitbucket.org/vueuse/vueuse/pull-requests/5295 https://bitbucket.org/vueuse/vueuse/commits/b1688bd2 @@ -1123,16 +1127,16 @@ https://gitea.com/vueuse/vueuse/compare/v14.2.1...v14.3.0` // compare is not supported const result = renderer(markdown) expect(result.html).toBe(`
      -
    • Expose pointer event onLongPress - by mrcwbr in #5295 b1688bd
    • -
    • createInjectionState: Non-undefined return when default specified - by Laupetin in #5306 b0c51c2
    • -
    • createReusableTemplate: Add support for specifying component names - by wbolster in #5300 ea29d5c
    • -
    • nuxt: Add composable variants to auto imports - by OrbisK in #5285 ac2ef95
    • +
    • Expose pointer event onLongPress - by mrcwbr in #5295 b1688bd2
    • +
    • createInjectionState: Non-undefined return when default specified - by Laupetin in #5306 b0c51c27
    • +
    • createReusableTemplate: Add support for specifying component names - by wbolster in #5300 ea29d5cb
    • +
    • nuxt: Add composable variants to auto imports - by OrbisK in #5285 ac2ef95d
    `) }) test('sourcehut', async () => { - const info = createSourcehutRepoInfo('~vueuse', 'vueuse') + const info = repoInfo('sourcehut', '~vueuse', 'vueuse') const renderer = await changelogRenderer(info) // from vueuse 14.3.0 (last 2 links changed from `issues` -> `pull`) const markdown = `- Expose pointer event onLongPress - by mrcwbr in https://todo.sr.ht/~vueuse/vueuse/5295 https://git.sr.ht/~vueuse/vueuse/commit/b1688bd2 @@ -1151,7 +1155,7 @@ https://gitea.com/vueuse/vueuse/compare/v14.2.1...v14.3.0` }) test('gitee', async () => { - const info = createGiteeRepoInfo('vueuse', 'vueuse') + const info = repoInfo('gitee', 'vueuse', 'vueuse') const renderer = await changelogRenderer(info) // from vueuse 14.3.0 (last 2 links changed from `issues` -> `pull`) const markdown = `- Expose pointer event onLongPress - by mrcwbr in https://gitee.com/vueuse/vueuse/issues/ImRodry https://gitee.com/vueuse/vueuse/commit/b1688bd2 @@ -1174,7 +1178,7 @@ https://gitee.com/vueuse/vueuse/compare/v14.2.1...v14.3.0` }) it('should ignore formatted links', async () => { - const info = createGithubRepoInfo('vueuse', 'vueuse') + const info = repoInfo('github', 'vueuse', 'vueuse') const renderer = await changelogRenderer(info) const markdown = `- Expose pointer event onLongPress - by mrcwbr in https://github.com/vueuse/vueuse/issues/5295 https://github.com/vueuse/vueuse/commit/b1688bd2 diff --git a/test/unit/server/utils/guarded-fetch.spec.ts b/test/unit/server/utils/guarded-fetch.spec.ts new file mode 100644 index 0000000000..c0667707d6 --- /dev/null +++ b/test/unit/server/utils/guarded-fetch.spec.ts @@ -0,0 +1,32 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { guardedFetch } from '#server/utils/guarded-fetch' + +const fetchMock = vi.fn() + +describe('guardedFetch', () => { + beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('refuses private addresses without fetching', async () => { + await expect(guardedFetch('http://127.0.0.1/admin')).rejects.toThrow(/Refusing to fetch/) + await expect(guardedFetch('http://10.0.0.5/xrpc')).rejects.toThrow(/Refusing to fetch/) + await expect(guardedFetch('http://[::1]/xrpc')).rejects.toThrow(/Refusing to fetch/) + + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('fetches a public address and passes the request through', async () => { + const response = new Response('{}', { status: 200 }) + fetchMock.mockResolvedValue(response) + const init = { method: 'GET' } + + await expect(guardedFetch('http://93.184.215.14/xrpc', init)).resolves.toBe(response) + expect(fetchMock).toHaveBeenCalledWith('http://93.184.215.14/xrpc', init) + }) +}) diff --git a/test/unit/server/utils/readme-loaders.spec.ts b/test/unit/server/utils/readme-loaders.spec.ts index e2ac8f26a0..fd84f797fa 100644 --- a/test/unit/server/utils/readme-loaders.spec.ts +++ b/test/unit/server/utils/readme-loaders.spec.ts @@ -182,8 +182,6 @@ describe('resolvePackageReadmeSource', () => { provider: 'github', owner: 'u', repo: 'r', - rawBaseUrl: 'https://raw.githubusercontent.com/u/r/HEAD', - blobBaseUrl: 'https://github.com/u/r/blob/HEAD', }) const result = await resolvePackageReadmeSource('some-pkg') @@ -411,8 +409,6 @@ describe('resolvePackageReadmeSource', () => { provider: 'github' as const, owner: 'a', repo: 'b', - rawBaseUrl: 'https://raw.githubusercontent.com/a/b/HEAD', - blobBaseUrl: 'https://github.com/a/b/blob/HEAD', } parseRepositoryInfoMock.mockReturnValue(repoInfo) diff --git a/test/unit/server/utils/readme.spec.ts b/test/unit/server/utils/readme.spec.ts index cfb0161b53..6126f2f4fa 100644 --- a/test/unit/server/utils/readme.spec.ts +++ b/test/unit/server/utils/readme.spec.ts @@ -27,8 +27,6 @@ function createRepoInfo(overrides?: Partial): RepositoryInfo { provider: 'github', owner: 'test-owner', repo: 'test-repo', - rawBaseUrl: 'https://raw.githubusercontent.com/test-owner/test-repo/HEAD', - blobBaseUrl: 'https://github.com/test-owner/test-repo/blob/HEAD', ...overrides, } } @@ -278,8 +276,6 @@ describe('Markdown File URL Resolution', () => { const repoInfo = createRepoInfo({ owner: 'withastro', repo: 'astro', - rawBaseUrl: 'https://raw.githubusercontent.com/withastro/astro/HEAD', - blobBaseUrl: 'https://github.com/withastro/astro/blob/HEAD', directory: 'packages/astro', }) const markdown = `[contributing guide](/CONTRIBUTING.md)` @@ -431,8 +427,8 @@ describe('Markdown File URL Resolution', () => { const repoInfo = createRepoInfo({ provider: 'gitlab', host: 'gitlab.com', - rawBaseUrl: 'https://gitlab.com/owner/repo/-/raw/HEAD', - blobBaseUrl: 'https://gitlab.com/owner/repo/-/blob/HEAD', + owner: 'owner', + repo: 'repo', }) const markdown = `[Docs](./docs/guide.md)` const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo) @@ -445,8 +441,8 @@ describe('Markdown File URL Resolution', () => { it('uses correct blob URL format for Bitbucket', async () => { const repoInfo = createRepoInfo({ provider: 'bitbucket', - rawBaseUrl: 'https://bitbucket.org/owner/repo/raw/HEAD', - blobBaseUrl: 'https://bitbucket.org/owner/repo/src/HEAD', + owner: 'owner', + repo: 'repo', }) const markdown = `[Readme](./other/README.md)` const result = await renderReadmeHtml(markdown, 'test-pkg', repoInfo) diff --git a/test/unit/shared/utils/git-providers.spec.ts b/test/unit/shared/utils/git-providers.spec.ts index 233378d057..9785483e4f 100644 --- a/test/unit/shared/utils/git-providers.spec.ts +++ b/test/unit/shared/utils/git-providers.spec.ts @@ -1,9 +1,5 @@ import { describe, expect, it } from 'vite-plus/test' -import { - normalizeGitUrl, - parseRepositoryInfo, - type RepositoryInfo, -} from '#shared/utils/git-providers' +import { normalizeGitUrl, parseRepositoryInfo } from '#shared/utils/git-providers' describe('normalizeGitUrl', () => { it('should return null for empty input', () => { @@ -124,8 +120,6 @@ describe('normalizeGitUrl', () => { expect.soft(info?.provider).toBe('github') expect.soft(info?.owner).toBe('wevm') expect.soft(info?.repo).toBe('ox') - expect.soft(info?.rawBaseUrl).toBe('https://raw.githubusercontent.com/wevm/ox/HEAD') - expect.soft(info?.blobBaseUrl).toBe('https://github.com/wevm/ox/blob/HEAD') }) }) @@ -143,7 +137,6 @@ describe('parseRepositoryInfo', () => { provider: 'github', owner: 'vercel', repo: 'ai', - rawBaseUrl: 'https://raw.githubusercontent.com/vercel/ai/HEAD', directory: undefined, }) }) @@ -158,7 +151,6 @@ describe('parseRepositoryInfo', () => { provider: 'github', owner: 'withastro', repo: 'astro', - rawBaseUrl: 'https://raw.githubusercontent.com/withastro/astro/HEAD', directory: 'packages/astro', }) }) @@ -169,8 +161,6 @@ describe('parseRepositoryInfo', () => { provider: 'github', owner: 'nuxt', repo: 'nuxt', - rawBaseUrl: 'https://raw.githubusercontent.com/nuxt/nuxt/HEAD', - blobBaseUrl: 'https://github.com/nuxt/nuxt/blob/HEAD', }) }) @@ -183,8 +173,6 @@ describe('parseRepositoryInfo', () => { provider: 'github', owner: 'org', repo: 'repo', - rawBaseUrl: 'https://raw.githubusercontent.com/org/repo/HEAD', - blobBaseUrl: 'https://github.com/org/repo/blob/HEAD', }) }) @@ -196,7 +184,6 @@ describe('parseRepositoryInfo', () => { provider: 'github', owner: 'nuxt', repo: 'nuxt', - rawBaseUrl: 'https://raw.githubusercontent.com/nuxt/nuxt/HEAD', }) }) @@ -206,7 +193,6 @@ describe('parseRepositoryInfo', () => { provider: 'github', owner: 'owner', repo: 'repo', - rawBaseUrl: 'https://raw.githubusercontent.com/owner/repo/HEAD', }) }) @@ -234,7 +220,6 @@ describe('parseRepositoryInfo', () => { owner: 'owner', repo: 'repo', host: 'gitlab.com', - rawBaseUrl: 'https://gitlab.com/owner/repo/-/raw/HEAD', }) }) @@ -322,7 +307,6 @@ describe('parseRepositoryInfo', () => { provider: 'tangled', owner: 'nonbinary.computer', repo: 'weaver', - rawBaseUrl: 'https://tangled.sh/nonbinary.computer/weaver/raw/branch/main', }) }) @@ -334,7 +318,6 @@ describe('parseRepositoryInfo', () => { provider: 'tangled', owner: 'pds.ls', repo: 'pdsls', - rawBaseUrl: 'https://tangled.sh/pds.ls/pdsls/raw/branch/main', }) }) @@ -399,8 +382,6 @@ describe('parseRepositoryInfo', () => { provider: 'pushin', owner: 'tobbe', repo: 'npmx-pushin-test', - rawBaseUrl: 'https://pushin.eu/tobbe/npmx-pushin-test/blob/HEAD', - blobBaseUrl: 'https://pushin.eu/tobbe/npmx-pushin-test/blob/HEAD', }) expect(result?.host).toBeUndefined() }) @@ -413,8 +394,6 @@ describe('parseRepositoryInfo', () => { provider: 'pushin', owner: 'tobbe', repo: 'npmx-pushin-test', - rawBaseUrl: 'https://pushin.eu/tobbe/npmx-pushin-test/blob/HEAD', - blobBaseUrl: 'https://pushin.eu/tobbe/npmx-pushin-test/blob/HEAD', }) }) @@ -426,8 +405,6 @@ describe('parseRepositoryInfo', () => { provider: 'pushin', owner: 'tobbe', repo: 'npmx-pushin-test', - rawBaseUrl: 'https://pushin.eu/tobbe/npmx-pushin-test/blob/HEAD', - blobBaseUrl: 'https://pushin.eu/tobbe/npmx-pushin-test/blob/HEAD', }) expect(result?.host).toBeUndefined() }) @@ -466,132 +443,7 @@ describe('parseRepositoryInfo', () => { owner: 'owner', repo: 'repo', host: 'gitea.com', - rawBaseUrl: 'https://gitea.com/owner/repo/raw/branch/main', }) }) }) - - describe('blobBaseUrl generation', () => { - it('generates correct blobBaseUrl for GitHub', () => { - const result = parseRepositoryInfo({ - url: 'https://github.com/vercel/ai.git', - }) - expect(result).toMatchObject({ - rawBaseUrl: 'https://raw.githubusercontent.com/vercel/ai/HEAD', - blobBaseUrl: 'https://github.com/vercel/ai/blob/HEAD', - }) - }) - - it('generates correct blobBaseUrl for GitLab', () => { - const result = parseRepositoryInfo({ - url: 'https://gitlab.com/owner/repo.git', - }) - expect(result).toMatchObject({ - rawBaseUrl: 'https://gitlab.com/owner/repo/-/raw/HEAD', - blobBaseUrl: 'https://gitlab.com/owner/repo/-/blob/HEAD', - }) - }) - - it('generates correct blobBaseUrl for self-hosted GitLab', () => { - const result = parseRepositoryInfo({ - url: 'https://gitlab.gnome.org/ewlsh/packages.gi.ts.git', - }) - expect(result).toMatchObject({ - rawBaseUrl: 'https://gitlab.gnome.org/ewlsh/packages.gi.ts/-/raw/HEAD', - blobBaseUrl: 'https://gitlab.gnome.org/ewlsh/packages.gi.ts/-/blob/HEAD', - }) - }) - - it('generates correct blobBaseUrl for Bitbucket', () => { - const result = parseRepositoryInfo({ - url: 'https://bitbucket.org/atlassian/atlassian-frontend-mirror.git', - }) - expect(result).toMatchObject({ - rawBaseUrl: 'https://bitbucket.org/atlassian/atlassian-frontend-mirror/raw/HEAD', - blobBaseUrl: 'https://bitbucket.org/atlassian/atlassian-frontend-mirror/src/HEAD', - }) - }) - - it('generates correct blobBaseUrl for Codeberg', () => { - const result = parseRepositoryInfo({ - url: 'https://codeberg.org/jgarber/CashCash', - }) - expect(result).toMatchObject({ - rawBaseUrl: 'https://codeberg.org/jgarber/CashCash/raw/branch/main', - blobBaseUrl: 'https://codeberg.org/jgarber/CashCash/src/branch/main', - }) - }) - - it('generates correct blobBaseUrl for Gitee', () => { - const result = parseRepositoryInfo({ - url: 'https://gitee.com/oschina/mcp-gitee.git', - }) - expect(result).toMatchObject({ - rawBaseUrl: 'https://gitee.com/oschina/mcp-gitee/raw/master', - blobBaseUrl: 'https://gitee.com/oschina/mcp-gitee/blob/master', - }) - }) - - it('generates correct blobBaseUrl for Sourcehut', () => { - const result = parseRepositoryInfo({ - url: 'https://git.sr.ht/~ayoayco/astro-resume.git', - }) - expect(result).toMatchObject({ - rawBaseUrl: 'https://git.sr.ht/~ayoayco/astro-resume/blob/HEAD', - blobBaseUrl: 'https://git.sr.ht/~ayoayco/astro-resume/tree/HEAD/item', - }) - }) - - it('generates correct blobBaseUrl for Tangled', () => { - const result = parseRepositoryInfo({ - url: 'https://tangled.sh/pds.ls/pdsls', - }) - expect(result).toMatchObject({ - rawBaseUrl: 'https://tangled.sh/pds.ls/pdsls/raw/branch/main', - blobBaseUrl: 'https://tangled.sh/pds.ls/pdsls/src/branch/main', - }) - }) - - it('generates correct blobBaseUrl for Radicle', () => { - const result = parseRepositoryInfo({ - url: 'https://app.radicle.at/nodes/seed.radicle.at/rad:z3nP4yT1PE3m1PxLEzr173sZtJVnT', - }) - expect(result).toMatchObject({ - rawBaseUrl: - 'https://seed.radicle.at/api/v1/projects/rad:z3nP4yT1PE3m1PxLEzr173sZtJVnT/blob/HEAD', - blobBaseUrl: - 'https://app.radicle.at/nodes/seed.radicle.at/rad:z3nP4yT1PE3m1PxLEzr173sZtJVnT/tree/HEAD', - }) - }) - - it('generates correct blobBaseUrl for Forgejo', () => { - const result = parseRepositoryInfo({ - url: 'https://next.forgejo.org/forgejo/forgejo', - }) - expect(result).toMatchObject({ - rawBaseUrl: 'https://next.forgejo.org/forgejo/forgejo/raw/branch/main', - blobBaseUrl: 'https://next.forgejo.org/forgejo/forgejo/src/branch/main', - }) - }) - - it('generates correct blobBaseUrl for Pushin', () => { - const result = parseRepositoryInfo({ - url: 'https://pushin.eu/tobbe/npmx-pushin-test', - }) - expect(result).toMatchObject({ - rawBaseUrl: 'https://pushin.eu/tobbe/npmx-pushin-test/blob/HEAD', - blobBaseUrl: 'https://pushin.eu/tobbe/npmx-pushin-test/blob/HEAD', - }) - }) - }) -}) - -describe('RepositoryInfo type', () => { - it('includes blobBaseUrl in RepositoryInfo', () => { - const result = parseRepositoryInfo({ - url: 'https://github.com/test/repo', - }) as RepositoryInfo - expect(result).toHaveProperty('blobBaseUrl') - expect(typeof result.blobBaseUrl).toBe('string') - }) }) diff --git a/test/unit/shared/utils/repository-meta.spec.ts b/test/unit/shared/utils/repository-meta.spec.ts index b89cbd8d56..f3b65835fe 100644 --- a/test/unit/shared/utils/repository-meta.spec.ts +++ b/test/unit/shared/utils/repository-meta.spec.ts @@ -11,19 +11,26 @@ function createCachedFetch(response: () => unknown) { })) as unknown as CachedFetchFunction & ReturnType } +/** A GitHub-shaped repository, as Pushin returns it. */ +function pushinRepo(overrides: Record = {}) { + return { + id: 1110, + name: 'npmx-pushin-test', + full_name: 'tobbe/npmx-pushin-test', + html_url: 'https://pushin.eu/tobbe/npmx-pushin-test', + owner: { login: 'tobbe' }, + stargazers_count: 1, + forks_count: 2, + ...overrides, + } +} + describe('getRepoMeta', () => { describe('Pushin', () => { const ref: RepoRef = { provider: 'pushin', owner: 'tobbe', repo: 'npmx-pushin-test' } - it('fetches repository details from the Pushin API', async () => { - const cachedFetch = createCachedFetch(() => ({ - id: 1110, - description: 'Package to test the npmx pushin integration', - default_branch: 'main', - stargazers_count: 1, - forks_count: 2, - watchers_count: 3, - })) + it('fetches repository details through forges', async () => { + const cachedFetch = createCachedFetch(() => pushinRepo()) const result = await getRepoMeta(cachedFetch, ref) @@ -37,24 +44,21 @@ describe('getRepoMeta', () => { url: 'https://pushin.eu/tobbe/npmx-pushin-test', stars: 1, forks: 2, - watchers: 3, - description: 'Package to test the npmx pushin integration', - defaultBranch: 'main', links: { repo: 'https://pushin.eu/tobbe/npmx-pushin-test', - stars: 'https://pushin.eu/tobbe/npmx-pushin-test/stargazers', forks: 'https://pushin.eu/tobbe/npmx-pushin-test', - watchers: 'https://pushin.eu/tobbe/npmx-pushin-test', }, }) }) - it('defaults missing counts to zero', async () => { - const cachedFetch = createCachedFetch(() => ({ id: 1110 })) + it('leaves missing counts undefined', async () => { + const cachedFetch = createCachedFetch(() => + pushinRepo({ stargazers_count: undefined, forks_count: undefined }), + ) const result = await getRepoMeta(cachedFetch, ref) - expect(result).toMatchObject({ stars: 0, forks: 0, watchers: 0, description: null }) + expect(result).toMatchObject({ stars: undefined, forks: undefined }) }) it('returns null when the request fails', async () => {