Compare commits

...

2 Commits

Author SHA1 Message Date
Benjamin Lu
ae9209ccfc fix: log website GitHub star failures 2026-05-04 20:10:33 -07:00
Benjamin Lu
72b5f6be68 fix: fetch website GitHub stars once per build 2026-05-04 20:09:21 -07:00
3 changed files with 200 additions and 12 deletions

View File

@@ -5,7 +5,7 @@ import '../styles/global.css'
import type { Locale } from '../i18n/translations'
import SiteFooter from '../components/common/SiteFooter.vue'
import SiteNav from '../components/common/SiteNav.vue'
import { fetchGitHubStars, formatStarCount } from '../utils/github'
import { fetchGitHubStarsForBuild, formatStarCount } from '../utils/github'
interface Props {
title: string
@@ -26,7 +26,7 @@ const canonicalURL = new URL(Astro.url.pathname, siteBase)
const ogImageURL = new URL(ogImage, siteBase)
const rawLocale = Astro.currentLocale ?? 'en'
const locale: Locale = rawLocale === 'zh-CN' ? 'zh-CN' : 'en'
const rawStars = await fetchGitHubStars('Comfy-Org', 'ComfyUI')
const rawStars = await fetchGitHubStarsForBuild()
const githubStars = rawStars ? formatStarCount(rawStars) : ''
const gtmId = 'GTM-NP9JM6K7'

View File

@@ -1,11 +1,17 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { fetchGitHubStars, formatStarCount } from './github'
import {
fetchGitHubStars,
fetchGitHubStarsForBuild,
formatStarCount,
resetGitHubStarsFetcherForTests
} from './github'
describe('fetchGitHubStars', () => {
const savedOverride = process.env.WEBSITE_GITHUB_STARS_OVERRIDE
afterEach(() => {
resetGitHubStarsFetcherForTests()
vi.restoreAllMocks()
if (savedOverride === undefined)
delete process.env.WEBSITE_GITHUB_STARS_OVERRIDE
@@ -16,17 +22,88 @@ describe('fetchGitHubStars', () => {
process.env.WEBSITE_GITHUB_STARS_OVERRIDE = '110000'
const fetchMock = vi.spyOn(globalThis, 'fetch')
await expect(fetchGitHubStars('Comfy-Org', 'ComfyUI')).resolves.toBe(110000)
await expect(fetchGitHubStars()).resolves.toBe(110000)
expect(fetchMock).not.toHaveBeenCalled()
})
it('fails fast when the build-time override is malformed', async () => {
process.env.WEBSITE_GITHUB_STARS_OVERRIDE = '110K'
await expect(fetchGitHubStars('Comfy-Org', 'ComfyUI')).rejects.toThrow(
await expect(fetchGitHubStars()).rejects.toThrow(
'WEBSITE_GITHUB_STARS_OVERRIDE must be a non-negative integer'
)
})
it('memoizes build-time star fetches within a single process', async () => {
const fetchImpl = vi.fn(
async () =>
new Response(JSON.stringify({ stargazers_count: 110000 }), {
status: 200,
headers: { 'content-type': 'application/json' }
})
)
const [a, b] = await Promise.all([
fetchGitHubStarsForBuild(fetchImpl as unknown as typeof fetch),
fetchGitHubStarsForBuild(fetchImpl as unknown as typeof fetch)
])
expect(a).toBe(110000)
expect(b).toBe(110000)
expect(fetchImpl).toHaveBeenCalledTimes(1)
})
it('logs GitHub response details when the API request fails', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const fetchImpl = vi.fn(
async () =>
new Response(JSON.stringify({ message: 'API rate limit exceeded' }), {
status: 403,
statusText: 'Forbidden',
headers: {
'x-github-request-id': 'ABC:123',
'x-ratelimit-limit': '60',
'x-ratelimit-remaining': '0',
'x-ratelimit-reset': '1777937662',
'x-ratelimit-resource': 'core'
}
})
)
await expect(
fetchGitHubStars(fetchImpl as unknown as typeof fetch)
).resolves.toBeNull()
expect(warn).toHaveBeenCalledWith(
expect.stringContaining(
'Failed to fetch GitHub stars for Comfy-Org/ComfyUI: 403 Forbidden: API rate limit exceeded'
)
)
expect(warn).toHaveBeenCalledWith(expect.stringContaining('resource=core'))
expect(warn).toHaveBeenCalledWith(expect.stringContaining('limit=60'))
expect(warn).toHaveBeenCalledWith(expect.stringContaining('remaining=0'))
expect(warn).toHaveBeenCalledWith(
expect.stringContaining(
`reset=${new Date(1777937662 * 1000).toISOString()}`
)
)
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('requestId=ABC:123')
)
})
it('logs thrown request failures', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const fetchImpl = vi.fn(async () => {
throw new Error('network down')
})
await expect(
fetchGitHubStars(fetchImpl as unknown as typeof fetch)
).resolves.toBeNull()
expect(warn).toHaveBeenCalledWith(
'Failed to fetch GitHub stars for Comfy-Org/ComfyUI: network down'
)
})
})
describe('formatStarCount', () => {

View File

@@ -1,18 +1,48 @@
const GITHUB_REPO_API_URL = 'https://api.github.com/repos/Comfy-Org/ComfyUI'
const GITHUB_REPO_LABEL = 'Comfy-Org/ComfyUI'
let inflight: Promise<number | null> | undefined
export function resetGitHubStarsFetcherForTests(): void {
inflight = undefined
}
export function fetchGitHubStarsForBuild(
fetchImpl: typeof fetch = fetch
): Promise<number | null> {
inflight ??= fetchGitHubStars(fetchImpl)
return inflight
}
export async function fetchGitHubStars(
owner: string,
repo: string
fetchImpl: typeof fetch = fetch
): Promise<number | null> {
const override = readGitHubStarsOverride()
if (override !== undefined) return override
try {
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
const res = await fetchImpl(GITHUB_REPO_API_URL, {
headers: { Accept: 'application/vnd.github.v3+json' }
})
if (!res.ok) return null
const data = await res.json()
return data.stargazers_count ?? null
} catch {
if (!res.ok) {
console.warn(await formatGitHubStarsHttpError(res))
return null
}
const data: unknown = await res.json()
const count = readStargazerCount(data)
if (count === null) {
console.warn(
`Failed to fetch GitHub stars for ${GITHUB_REPO_LABEL}: response missing numeric stargazers_count`
)
return null
}
return count
} catch (error) {
console.warn(
`Failed to fetch GitHub stars for ${GITHUB_REPO_LABEL}: ${formatError(error)}`
)
return null
}
}
@@ -42,3 +72,84 @@ function readGitHubStarsOverride(): number | undefined {
return count
}
function readStargazerCount(data: unknown): number | null {
if (!isRecord(data) || typeof data.stargazers_count !== 'number') {
return null
}
return data.stargazers_count
}
async function formatGitHubStarsHttpError(res: Response): Promise<string> {
const bodyMessage = await readGitHubErrorMessage(res)
const statusText = res.statusText ? ` ${res.statusText}` : ''
const message = bodyMessage ? `: ${bodyMessage}` : ''
const headers = formatGitHubResponseHeaders(res.headers)
return `Failed to fetch GitHub stars for ${GITHUB_REPO_LABEL}: ${res.status}${statusText}${message}${headers}`
}
async function readGitHubErrorMessage(
res: Response
): Promise<string | undefined> {
const body = await res.text().catch(() => '')
if (!body) return undefined
const parsedMessage = readGitHubErrorBodyMessage(body)
if (parsedMessage !== undefined) return parsedMessage
return body.slice(0, 200)
}
function readGitHubErrorBodyMessage(body: string): string | undefined {
try {
const parsed: unknown = JSON.parse(body)
if (isRecord(parsed) && typeof parsed.message === 'string') {
return parsed.message
}
} catch {
return undefined
}
return undefined
}
function formatGitHubResponseHeaders(headers: Headers): string {
const parts = [
formatHeader(headers, 'x-ratelimit-resource', 'resource'),
formatHeader(headers, 'x-ratelimit-limit', 'limit'),
formatHeader(headers, 'x-ratelimit-remaining', 'remaining'),
formatResetHeader(headers),
formatHeader(headers, 'x-github-request-id', 'requestId')
].filter((part): part is string => part !== undefined)
return parts.length ? ` (${parts.join(', ')})` : ''
}
function formatHeader(
headers: Headers,
headerName: string,
label: string
): string | undefined {
const value = headers.get(headerName)
return value === null ? undefined : `${label}=${value}`
}
function formatResetHeader(headers: Headers): string | undefined {
const value = headers.get('x-ratelimit-reset')
if (value === null) return undefined
const resetSeconds = Number(value)
if (!Number.isFinite(resetSeconds)) return `reset=${value}`
return `reset=${new Date(resetSeconds * 1000).toISOString()}`
}
function formatError(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}