mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-23 14:16:00 +00:00
Compare commits
1 Commits
glary/mar-
...
codex/webs
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f6e0f8b74 |
28
.github/workflows/release-website.yaml
vendored
28
.github/workflows/release-website.yaml
vendored
@@ -1,6 +1,6 @@
|
||||
# Description: Manual workflow to refresh the apps/website Ashby roles snapshot
|
||||
# and open a PR. Merging the PR triggers the existing Vercel website production
|
||||
# deploy via ci-vercel-website-preview.yaml.
|
||||
# Description: Manual workflow to refresh apps/website data snapshots and open
|
||||
# a PR. Merging the PR triggers the existing Vercel website production deploy
|
||||
# via ci-vercel-website-preview.yaml.
|
||||
name: 'Release: Website'
|
||||
|
||||
on:
|
||||
@@ -11,7 +11,7 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
refresh-snapshot:
|
||||
refresh-snapshots:
|
||||
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -31,15 +31,20 @@ jobs:
|
||||
api_key: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
|
||||
job_board_name: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
|
||||
|
||||
- name: Refresh GitHub stars snapshot
|
||||
run: pnpm --filter @comfyorg/website github-stars:refresh-snapshot
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
token: ${{ secrets.PR_GH_TOKEN }}
|
||||
commit-message: 'chore(website): refresh Ashby roles snapshot'
|
||||
title: 'chore(website): refresh Ashby roles snapshot'
|
||||
commit-message: 'chore(website): refresh data snapshots'
|
||||
title: 'chore(website): refresh data snapshots'
|
||||
body: |
|
||||
Automated refresh of `apps/website/src/data/ashby-roles.snapshot.json`
|
||||
from the Ashby job board API.
|
||||
Automated refresh of website build-data snapshots:
|
||||
|
||||
- `apps/website/src/data/ashby-roles.snapshot.json`
|
||||
- `apps/website/src/data/github-stars.snapshot.json`
|
||||
|
||||
**Flow:**
|
||||
1. `Release: Website` workflow ran (manual trigger).
|
||||
@@ -47,12 +52,11 @@ jobs:
|
||||
3. `CI: Vercel Website Preview` deploys a preview for review.
|
||||
4. Merging to `main` triggers the production Vercel deploy.
|
||||
|
||||
The snapshot fallback in `apps/website/src/utils/ashby.ts` remains
|
||||
intact: builds without `WEBSITE_ASHBY_API_KEY` continue to use the
|
||||
committed snapshot.
|
||||
The snapshot fallback paths remain intact: builds use committed
|
||||
snapshots whenever a live build-data source is unavailable.
|
||||
|
||||
Triggered by workflow run `${{ github.run_id }}`.
|
||||
branch: chore/refresh-ashby-snapshot-${{ github.run_id }}
|
||||
branch: chore/refresh-website-data-snapshots-${{ github.run_id }}
|
||||
base: main
|
||||
labels: |
|
||||
Release:Website
|
||||
|
||||
@@ -2,6 +2,20 @@
|
||||
|
||||
Marketing/brand website built with Astro + Vue.
|
||||
|
||||
## Build-time data sources
|
||||
|
||||
External data used during static generation should use the shared build-data
|
||||
source helpers in `src/utils/buildDataSource.ts` and
|
||||
`src/utils/buildDataReporter.ts`. Each source owns its domain parsing, but the
|
||||
shared machinery owns the repeated lifecycle:
|
||||
|
||||
1. Fetch fresh data once per build process.
|
||||
2. Validate and map to a committed snapshot shape.
|
||||
3. Fall back to the committed snapshot on fetch/schema/network failure.
|
||||
4. Emit GitHub Actions annotations and `$GITHUB_STEP_SUMMARY` rows once per
|
||||
build process.
|
||||
5. Refresh snapshots through package scripts that write atomically.
|
||||
|
||||
## Ashby careers integration
|
||||
|
||||
`/careers` and `/zh-CN/careers` are rendered from Ashby's public job board
|
||||
@@ -113,6 +127,19 @@ git commit apps/website/src/data/ashby-roles.snapshot.json
|
||||
The script exits non-zero on any non-fresh outcome so stale/empty
|
||||
snapshots can't be accidentally committed.
|
||||
|
||||
## GitHub stars integration
|
||||
|
||||
The navigation star badge is rendered from the GitHub repository API at build
|
||||
time through the same snapshot-fallback lifecycle.
|
||||
|
||||
- Runtime source: `src/utils/github.ts`
|
||||
- Snapshot: `src/data/github-stars.snapshot.json`
|
||||
- CI reporter: `src/utils/github.ci.ts`
|
||||
- Refresh script: `pnpm --filter @comfyorg/website github-stars:refresh-snapshot`
|
||||
|
||||
`WEBSITE_GITHUB_STARS_OVERRIDE` remains available for visual snapshots and local
|
||||
determinism. It must be a non-negative integer and is build-time only.
|
||||
|
||||
## HubSpot contact form
|
||||
|
||||
The contact page uses HubSpot's hosted form embed for the interest form:
|
||||
@@ -146,3 +173,5 @@ renders the documented embed container.
|
||||
- `pnpm test:unit` — Vitest unit tests
|
||||
- `pnpm test:e2e` — Playwright E2E tests (requires `pnpm build` first)
|
||||
- `pnpm ashby:refresh-snapshot` — refresh the committed careers snapshot
|
||||
- `pnpm github-stars:refresh-snapshot` — refresh the committed GitHub stars
|
||||
snapshot
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"test:e2e:local": "cross-env PLAYWRIGHT_LOCAL=1 playwright test",
|
||||
"test:visual": "playwright test --project visual",
|
||||
"test:visual:update": "playwright test --project visual --update-snapshots",
|
||||
"ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts"
|
||||
"ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts",
|
||||
"github-stars:refresh-snapshot": "tsx ./scripts/refresh-github-stars-snapshot.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/sitemap": "catalog:",
|
||||
|
||||
29
apps/website/scripts/refresh-github-stars-snapshot.ts
Normal file
29
apps/website/scripts/refresh-github-stars-snapshot.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { renameSync, writeFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { fetchGitHubStarsForBuild } from '../src/utils/github'
|
||||
|
||||
const snapshotPath = fileURLToPath(
|
||||
new URL('../src/data/github-stars.snapshot.json', import.meta.url)
|
||||
)
|
||||
const tempPath = `${snapshotPath}.tmp`
|
||||
|
||||
const outcome = await fetchGitHubStarsForBuild()
|
||||
|
||||
if (outcome.status !== 'fresh') {
|
||||
const reason = 'reason' in outcome ? outcome.reason : '(none)'
|
||||
console.error(
|
||||
`Snapshot refresh aborted. Outcome: ${outcome.status}; reason: ${reason}`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
writeFileSync(
|
||||
tempPath,
|
||||
JSON.stringify(outcome.snapshot, null, 2) + '\n',
|
||||
'utf8'
|
||||
)
|
||||
renameSync(tempPath, snapshotPath)
|
||||
process.stdout.write(
|
||||
`Wrote snapshot with ${outcome.snapshot.stargazersCount} star(s) to ${snapshotPath}\n`
|
||||
)
|
||||
5
apps/website/src/data/github-stars.snapshot.json
Normal file
5
apps/website/src/data/github-stars.snapshot.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"fetchedAt": "2026-05-11T21:41:14.168Z",
|
||||
"repository": "Comfy-Org/ComfyUI",
|
||||
"stargazersCount": 112467
|
||||
}
|
||||
23
apps/website/src/data/githubStars.ts
Normal file
23
apps/website/src/data/githubStars.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export interface GitHubStarsSnapshot {
|
||||
fetchedAt: string
|
||||
repository: 'Comfy-Org/ComfyUI'
|
||||
stargazersCount: number
|
||||
}
|
||||
|
||||
export function isGitHubStarsSnapshot(
|
||||
value: unknown
|
||||
): value is GitHubStarsSnapshot {
|
||||
if (value === null || typeof value !== 'object') return false
|
||||
const candidate = value as {
|
||||
fetchedAt?: unknown
|
||||
repository?: unknown
|
||||
stargazersCount?: unknown
|
||||
}
|
||||
return (
|
||||
typeof candidate.fetchedAt === 'string' &&
|
||||
candidate.repository === 'Comfy-Org/ComfyUI' &&
|
||||
typeof candidate.stargazersCount === 'number' &&
|
||||
Number.isSafeInteger(candidate.stargazersCount) &&
|
||||
candidate.stargazersCount >= 0
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,8 @@ 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'
|
||||
import { reportGitHubStarsOutcome } from '../utils/github.ci'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
@@ -30,8 +31,17 @@ 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 githubStars = rawStars ? formatStarCount(rawStars) : ''
|
||||
const githubStarsOutcome = await fetchGitHubStarsForBuild()
|
||||
reportGitHubStarsOutcome(githubStarsOutcome)
|
||||
|
||||
if (githubStarsOutcome.status === 'failed') {
|
||||
throw new Error(
|
||||
`GitHub stars fetch failed and no snapshot is available. Reason: ${githubStarsOutcome.reason}. ` +
|
||||
'Run `pnpm --filter @comfyorg/website github-stars:refresh-snapshot` locally and commit the snapshot.'
|
||||
)
|
||||
}
|
||||
|
||||
const githubStars = formatStarCount(githubStarsOutcome.snapshot.stargazersCount)
|
||||
|
||||
const gtmId = 'GTM-NP9JM6K7'
|
||||
const gtmEnabled = import.meta.env.PROD
|
||||
|
||||
@@ -1,39 +1,31 @@
|
||||
import { appendFileSync } from 'node:fs'
|
||||
|
||||
import type { FetchOutcome } from './ashby'
|
||||
|
||||
let hasReported = false
|
||||
import {
|
||||
createBuildDataReporter,
|
||||
describeSnapshotAge,
|
||||
escapeAnnotation
|
||||
} from './buildDataReporter'
|
||||
|
||||
export function resetAshbyReporterForTests(): void {
|
||||
hasReported = false
|
||||
}
|
||||
const ashbyReporter = createBuildDataReporter({
|
||||
summaryHeading: '## 💼 Careers (Ashby)\n',
|
||||
buildAnnotations,
|
||||
buildSummaryRows
|
||||
})
|
||||
|
||||
export function reportAshbyOutcome(outcome: FetchOutcome): void {
|
||||
if (hasReported) return
|
||||
hasReported = true
|
||||
|
||||
const lines = buildAnnotations(outcome)
|
||||
for (const line of lines) {
|
||||
process.stdout.write(`${line}\n`)
|
||||
}
|
||||
|
||||
const summaryPath = process.env.GITHUB_STEP_SUMMARY
|
||||
if (summaryPath) {
|
||||
try {
|
||||
appendFileSync(summaryPath, buildStepSummary(outcome))
|
||||
} catch {
|
||||
// Writing the summary is best-effort; do not fail the build if the
|
||||
// runner's summary file is unavailable (e.g. local dev).
|
||||
}
|
||||
}
|
||||
}
|
||||
export const resetAshbyReporterForTests = ashbyReporter.resetForTests
|
||||
export const reportAshbyOutcome = ashbyReporter.report
|
||||
|
||||
function buildAnnotations(outcome: FetchOutcome): string[] {
|
||||
if (outcome.status === 'fresh') {
|
||||
if (outcome.droppedCount === 0) return []
|
||||
const roleCount = outcome.droppedCount === 1 ? 'role' : 'roles'
|
||||
const drops = outcome.droppedRoles
|
||||
.map((d) => ` - ${d.title ? `"${d.title}"` : '(untitled)'}: ${d.reason}`)
|
||||
.map(
|
||||
(d) =>
|
||||
` - ${escapeAnnotation(
|
||||
d.title ? `"${d.title}"` : '(untitled)'
|
||||
)}: ${escapeAnnotation(d.reason)}`
|
||||
)
|
||||
.join('%0A')
|
||||
return [
|
||||
`::warning title=Ashby: dropped ${outcome.droppedCount} invalid ${roleCount}::Dropped roles:%0A${drops}%0A%0AAction items:%0A 1. Fix the posting in Ashby admin (e.g. assign a department, fix the URL).%0A 2. If the v1 schema is too strict for a legitimate case, relax the field in apps/website/src/utils/ashby.schema.ts and add a test.%0A 3. These roles will not appear on the careers page until fixed.`
|
||||
@@ -63,12 +55,7 @@ function staleAnnotation(reason: string): string {
|
||||
return `::warning title=Ashby API unavailable::${escaped}. Using last-known-good snapshot.%0A%0AAction items:%0A 1. Check https://status.ashbyhq.com%0A 2. Re-run this workflow once Ashby is healthy.`
|
||||
}
|
||||
|
||||
function escapeAnnotation(value: string): string {
|
||||
return value.replace(/\r?\n/g, '%0A').replace(/\r/g, '%0D')
|
||||
}
|
||||
|
||||
function buildStepSummary(outcome: FetchOutcome): string {
|
||||
const header = '## 💼 Careers (Ashby)\n'
|
||||
function buildSummaryRows(outcome: FetchOutcome): Array<[string, string]> {
|
||||
const rows: Array<[string, string]> = []
|
||||
|
||||
if (outcome.status === 'fresh') {
|
||||
@@ -95,19 +82,5 @@ function buildStepSummary(outcome: FetchOutcome): string {
|
||||
rows.push(['Reason', outcome.reason])
|
||||
}
|
||||
|
||||
const table =
|
||||
'| | |\n|---|---|\n' +
|
||||
rows.map(([k, v]) => `| **${k}** | ${v} |`).join('\n') +
|
||||
'\n'
|
||||
|
||||
return `${header}${table}\n`
|
||||
}
|
||||
|
||||
function describeSnapshotAge(fetchedAt: string): string {
|
||||
const fetched = new Date(fetchedAt).getTime()
|
||||
if (Number.isNaN(fetched)) return 'unknown'
|
||||
const days = Math.floor((Date.now() - fetched) / 86_400_000)
|
||||
if (days <= 0) return 'today'
|
||||
if (days === 1) return '1 day'
|
||||
return `${days} days`
|
||||
return rows
|
||||
}
|
||||
|
||||
@@ -9,25 +9,24 @@ import {
|
||||
AshbyJobBoardResponseSchema,
|
||||
AshbyJobPostingSchema
|
||||
} from './ashby.schema'
|
||||
import type { BuildDataFetchResult, BuildDataOutcome } from './buildDataSource'
|
||||
import { createBuildDataSource } from './buildDataSource'
|
||||
|
||||
const DEFAULT_BASE_URL = 'https://api.ashbyhq.com'
|
||||
const DEFAULT_TIMEOUT_MS = 10_000
|
||||
const RETRY_DELAYS_MS = [1_000, 2_000, 4_000]
|
||||
|
||||
export interface DroppedRole {
|
||||
interface DroppedRole {
|
||||
title: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
export type FetchOutcome =
|
||||
| {
|
||||
status: 'fresh'
|
||||
snapshot: RolesSnapshot
|
||||
droppedCount: number
|
||||
droppedRoles: DroppedRole[]
|
||||
}
|
||||
| { status: 'stale'; snapshot: RolesSnapshot; reason: string }
|
||||
| { status: 'failed'; reason: string }
|
||||
interface FreshRolesData {
|
||||
droppedCount: number
|
||||
droppedRoles: DroppedRole[]
|
||||
}
|
||||
|
||||
export type FetchOutcome = BuildDataOutcome<RolesSnapshot, FreshRolesData>
|
||||
|
||||
interface FetchRolesOptions {
|
||||
apiKey?: string
|
||||
@@ -40,56 +39,50 @@ interface FetchRolesOptions {
|
||||
sleep?: (ms: number) => Promise<void>
|
||||
}
|
||||
|
||||
let inflight: Promise<FetchOutcome> | undefined
|
||||
const ashbySource = createBuildDataSource<
|
||||
FetchRolesOptions,
|
||||
RolesSnapshot,
|
||||
FreshRolesData
|
||||
>({
|
||||
name: 'Ashby roles',
|
||||
fetchFresh: fetchFreshRoles,
|
||||
readSnapshot: (options) => readSnapshot(options.snapshotUrl),
|
||||
getCacheKey: getAshbyCacheKey
|
||||
})
|
||||
|
||||
export function resetAshbyFetcherForTests(): void {
|
||||
inflight = undefined
|
||||
}
|
||||
export const resetAshbyFetcherForTests = ashbySource.resetForTests
|
||||
export const fetchRolesForBuild = ashbySource.fetchForBuild
|
||||
|
||||
export function fetchRolesForBuild(
|
||||
options: FetchRolesOptions = {}
|
||||
): Promise<FetchOutcome> {
|
||||
inflight ??= doFetchRolesForBuild(options)
|
||||
return inflight
|
||||
}
|
||||
|
||||
async function doFetchRolesForBuild(
|
||||
async function fetchFreshRoles(
|
||||
options: FetchRolesOptions
|
||||
): Promise<FetchOutcome> {
|
||||
): Promise<BuildDataFetchResult<RolesSnapshot, FreshRolesData>> {
|
||||
const apiKey = options.apiKey ?? process.env.WEBSITE_ASHBY_API_KEY
|
||||
const boardName =
|
||||
options.boardName ?? process.env.WEBSITE_ASHBY_JOB_BOARD_NAME
|
||||
|
||||
if (!apiKey || !boardName) {
|
||||
return fallback(
|
||||
'missing WEBSITE_ASHBY_API_KEY or WEBSITE_ASHBY_JOB_BOARD_NAME',
|
||||
options.snapshotUrl
|
||||
)
|
||||
return {
|
||||
kind: 'err',
|
||||
reason: 'missing WEBSITE_ASHBY_API_KEY or WEBSITE_ASHBY_JOB_BOARD_NAME'
|
||||
}
|
||||
}
|
||||
|
||||
const result = await tryFetchAndParse(apiKey, boardName, options)
|
||||
if (result.kind === 'ok') {
|
||||
return {
|
||||
status: 'fresh',
|
||||
kind: 'ok',
|
||||
snapshot: {
|
||||
fetchedAt: new Date().toISOString(),
|
||||
departments: result.departments
|
||||
},
|
||||
droppedCount: result.droppedRoles.length,
|
||||
droppedRoles: result.droppedRoles
|
||||
data: {
|
||||
droppedCount: result.droppedRoles.length,
|
||||
droppedRoles: result.droppedRoles
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fallback(result.reason, options.snapshotUrl)
|
||||
}
|
||||
|
||||
async function fallback(
|
||||
reason: string,
|
||||
snapshotUrl: URL | undefined
|
||||
): Promise<FetchOutcome> {
|
||||
const snapshot = await readSnapshot(snapshotUrl)
|
||||
if (snapshot) return { status: 'stale', snapshot, reason }
|
||||
return { status: 'failed', reason }
|
||||
return result
|
||||
}
|
||||
|
||||
interface FetchOk {
|
||||
@@ -297,3 +290,15 @@ function isRolesSnapshot(value: unknown): value is RolesSnapshot {
|
||||
function defaultSleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function getAshbyCacheKey(options: FetchRolesOptions): string {
|
||||
return JSON.stringify({
|
||||
apiKey: options.apiKey ?? process.env.WEBSITE_ASHBY_API_KEY ?? '',
|
||||
boardName:
|
||||
options.boardName ?? process.env.WEBSITE_ASHBY_JOB_BOARD_NAME ?? '',
|
||||
baseUrl: options.baseUrl ?? DEFAULT_BASE_URL,
|
||||
timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
retryDelaysMs: options.retryDelaysMs ?? RETRY_DELAYS_MS,
|
||||
snapshotUrl: options.snapshotUrl?.href ?? ''
|
||||
})
|
||||
}
|
||||
|
||||
78
apps/website/src/utils/buildDataReporter.ts
Normal file
78
apps/website/src/utils/buildDataReporter.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { appendFileSync } from 'node:fs'
|
||||
|
||||
import type { BuildDataOutcome } from './buildDataSource'
|
||||
|
||||
type SummaryRows = Array<[string, string]>
|
||||
|
||||
interface BuildDataReporterConfig<
|
||||
TSnapshot,
|
||||
TFreshData extends object = object
|
||||
> {
|
||||
summaryHeading: string
|
||||
buildAnnotations: (
|
||||
outcome: BuildDataOutcome<TSnapshot, TFreshData>
|
||||
) => string[]
|
||||
buildSummaryRows: (
|
||||
outcome: BuildDataOutcome<TSnapshot, TFreshData>
|
||||
) => SummaryRows
|
||||
}
|
||||
|
||||
export function createBuildDataReporter<
|
||||
TSnapshot,
|
||||
TFreshData extends object = object
|
||||
>(config: BuildDataReporterConfig<TSnapshot, TFreshData>) {
|
||||
let hasReported = false
|
||||
|
||||
function resetForTests(): void {
|
||||
hasReported = false
|
||||
}
|
||||
|
||||
function report(outcome: BuildDataOutcome<TSnapshot, TFreshData>): void {
|
||||
if (hasReported) return
|
||||
hasReported = true
|
||||
|
||||
const lines = config.buildAnnotations(outcome)
|
||||
for (const line of lines) {
|
||||
process.stdout.write(`${line}\n`)
|
||||
}
|
||||
|
||||
const summaryPath = process.env.GITHUB_STEP_SUMMARY
|
||||
if (summaryPath) {
|
||||
try {
|
||||
appendFileSync(
|
||||
summaryPath,
|
||||
buildStepSummary(
|
||||
config.summaryHeading,
|
||||
config.buildSummaryRows(outcome)
|
||||
)
|
||||
)
|
||||
} catch {
|
||||
// Best-effort only; a missing local summary file must not fail a build.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { report, resetForTests }
|
||||
}
|
||||
|
||||
export function escapeAnnotation(value: string): string {
|
||||
return value.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A')
|
||||
}
|
||||
|
||||
export function describeSnapshotAge(fetchedAt: string): string {
|
||||
const fetched = new Date(fetchedAt).getTime()
|
||||
if (Number.isNaN(fetched)) return 'unknown'
|
||||
const days = Math.floor((Date.now() - fetched) / 86_400_000)
|
||||
if (days <= 0) return 'today'
|
||||
if (days === 1) return '1 day'
|
||||
return `${days} days`
|
||||
}
|
||||
|
||||
function buildStepSummary(heading: string, rows: SummaryRows): string {
|
||||
const table =
|
||||
'| | |\n|---|---|\n' +
|
||||
rows.map(([key, value]) => `| **${key}** | ${value} |`).join('\n') +
|
||||
'\n'
|
||||
|
||||
return `${heading}${table}\n`
|
||||
}
|
||||
82
apps/website/src/utils/buildDataSource.test.ts
Normal file
82
apps/website/src/utils/buildDataSource.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createBuildDataSource } from './buildDataSource'
|
||||
|
||||
interface TestOptions {
|
||||
cacheKey?: string
|
||||
snapshot?: string
|
||||
result?: 'fresh' | 'stale' | 'failed'
|
||||
}
|
||||
|
||||
describe('createBuildDataSource', () => {
|
||||
it('returns fresh data without reading the snapshot', async () => {
|
||||
const readSnapshot = vi.fn(async () => 'snapshot')
|
||||
const source = createBuildDataSource<TestOptions, string>({
|
||||
name: 'test',
|
||||
fetchFresh: async () => ({
|
||||
kind: 'ok',
|
||||
snapshot: 'fresh',
|
||||
data: {}
|
||||
}),
|
||||
readSnapshot
|
||||
})
|
||||
|
||||
await expect(source.fetchForBuild()).resolves.toEqual({
|
||||
status: 'fresh',
|
||||
snapshot: 'fresh'
|
||||
})
|
||||
expect(readSnapshot).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to a snapshot when fresh fetch fails', async () => {
|
||||
const source = createBuildDataSource<TestOptions, string>({
|
||||
name: 'test',
|
||||
fetchFresh: async () => ({ kind: 'err', reason: 'HTTP 500' }),
|
||||
readSnapshot: async (options) => options.snapshot ?? null
|
||||
})
|
||||
|
||||
await expect(
|
||||
source.fetchForBuild({ snapshot: 'last-known-good' })
|
||||
).resolves.toEqual({
|
||||
status: 'stale',
|
||||
snapshot: 'last-known-good',
|
||||
reason: 'HTTP 500'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns failed when fresh fetch and snapshot are unavailable', async () => {
|
||||
const source = createBuildDataSource<TestOptions, string>({
|
||||
name: 'test',
|
||||
fetchFresh: async () => ({ kind: 'err', reason: 'HTTP 500' }),
|
||||
readSnapshot: async () => null
|
||||
})
|
||||
|
||||
await expect(source.fetchForBuild()).resolves.toEqual({
|
||||
status: 'failed',
|
||||
reason: 'HTTP 500'
|
||||
})
|
||||
})
|
||||
|
||||
it('memoizes matching cache keys and rejects mismatched cache keys', async () => {
|
||||
const fetchFresh = vi.fn(async () => ({
|
||||
kind: 'ok' as const,
|
||||
snapshot: 'fresh',
|
||||
data: {}
|
||||
}))
|
||||
const source = createBuildDataSource<TestOptions, string>({
|
||||
name: 'test',
|
||||
fetchFresh,
|
||||
readSnapshot: async () => null,
|
||||
getCacheKey: (options) => options.cacheKey ?? 'default'
|
||||
})
|
||||
|
||||
const first = await source.fetchForBuild({ cacheKey: 'a' })
|
||||
const second = await source.fetchForBuild({ cacheKey: 'a' })
|
||||
|
||||
expect(first).toBe(second)
|
||||
expect(fetchFresh).toHaveBeenCalledTimes(1)
|
||||
expect(() => source.fetchForBuild({ cacheKey: 'b' })).toThrow(
|
||||
/called twice with different options/
|
||||
)
|
||||
})
|
||||
})
|
||||
83
apps/website/src/utils/buildDataSource.ts
Normal file
83
apps/website/src/utils/buildDataSource.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
export type BuildDataOutcome<TSnapshot, TFreshData extends object = object> =
|
||||
| ({ status: 'fresh'; snapshot: TSnapshot } & TFreshData)
|
||||
| { status: 'stale'; snapshot: TSnapshot; reason: string }
|
||||
| { status: 'failed'; reason: string }
|
||||
|
||||
export type BuildDataFetchResult<
|
||||
TSnapshot,
|
||||
TFreshData extends object = object
|
||||
> =
|
||||
| { kind: 'ok'; snapshot: TSnapshot; data: TFreshData }
|
||||
| { kind: 'err'; reason: string }
|
||||
|
||||
interface BuildDataSourceConfig<
|
||||
TOptions extends object,
|
||||
TSnapshot,
|
||||
TFreshData extends object
|
||||
> {
|
||||
name: string
|
||||
fetchFresh: (
|
||||
options: TOptions
|
||||
) => Promise<BuildDataFetchResult<TSnapshot, TFreshData>>
|
||||
readSnapshot: (options: TOptions) => Promise<TSnapshot | null>
|
||||
getCacheKey?: (options: TOptions) => string
|
||||
}
|
||||
|
||||
export function createBuildDataSource<
|
||||
TOptions extends object,
|
||||
TSnapshot,
|
||||
TFreshData extends object = object
|
||||
>(config: BuildDataSourceConfig<TOptions, TSnapshot, TFreshData>) {
|
||||
let inflight: Promise<BuildDataOutcome<TSnapshot, TFreshData>> | undefined
|
||||
let inflightCacheKey: string | undefined
|
||||
|
||||
function resetForTests(): void {
|
||||
inflight = undefined
|
||||
inflightCacheKey = undefined
|
||||
}
|
||||
|
||||
function fetchForBuild(
|
||||
options = {} as TOptions
|
||||
): Promise<BuildDataOutcome<TSnapshot, TFreshData>> {
|
||||
const cacheKey = config.getCacheKey?.(options) ?? 'default'
|
||||
if (inflight) {
|
||||
if (inflightCacheKey !== cacheKey) {
|
||||
throw new Error(
|
||||
`${config.name} fetcher called twice with different options; reset between distinct configurations`
|
||||
)
|
||||
}
|
||||
return inflight
|
||||
}
|
||||
|
||||
inflightCacheKey = cacheKey
|
||||
inflight = doFetchForBuild(config, options)
|
||||
return inflight
|
||||
}
|
||||
|
||||
return { fetchForBuild, resetForTests }
|
||||
}
|
||||
|
||||
async function doFetchForBuild<
|
||||
TOptions extends object,
|
||||
TSnapshot,
|
||||
TFreshData extends object
|
||||
>(
|
||||
config: BuildDataSourceConfig<TOptions, TSnapshot, TFreshData>,
|
||||
options: TOptions
|
||||
): Promise<BuildDataOutcome<TSnapshot, TFreshData>> {
|
||||
const result = await config.fetchFresh(options)
|
||||
if (result.kind === 'ok') {
|
||||
return {
|
||||
status: 'fresh',
|
||||
snapshot: result.snapshot,
|
||||
...result.data
|
||||
}
|
||||
}
|
||||
|
||||
const snapshot = await config.readSnapshot(options)
|
||||
if (snapshot) {
|
||||
return { status: 'stale', snapshot, reason: result.reason }
|
||||
}
|
||||
|
||||
return { status: 'failed', reason: result.reason }
|
||||
}
|
||||
83
apps/website/src/utils/github.ci.test.ts
Normal file
83
apps/website/src/utils/github.ci.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { FetchOutcome } from './github'
|
||||
|
||||
import {
|
||||
reportGitHubStarsOutcome,
|
||||
resetGitHubStarsReporterForTests
|
||||
} from './github.ci'
|
||||
|
||||
function snapshot() {
|
||||
return {
|
||||
fetchedAt: new Date().toISOString(),
|
||||
repository: 'Comfy-Org/ComfyUI' as const,
|
||||
stargazersCount: 112464
|
||||
}
|
||||
}
|
||||
|
||||
function freshOutcome(): FetchOutcome {
|
||||
return {
|
||||
status: 'fresh',
|
||||
snapshot: snapshot()
|
||||
}
|
||||
}
|
||||
|
||||
describe('reportGitHubStarsOutcome', () => {
|
||||
let writeSpy: ReturnType<typeof vi.spyOn>
|
||||
let summaryDir: string
|
||||
let summaryPath: string
|
||||
const originalSummary = process.env.GITHUB_STEP_SUMMARY
|
||||
|
||||
beforeEach(() => {
|
||||
resetGitHubStarsReporterForTests()
|
||||
writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
|
||||
summaryDir = mkdtempSync(join(tmpdir(), 'github-stars-summary-'))
|
||||
summaryPath = join(summaryDir, 'summary.md')
|
||||
writeFileSync(summaryPath, '')
|
||||
process.env.GITHUB_STEP_SUMMARY = summaryPath
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
writeSpy.mockRestore()
|
||||
rmSync(summaryDir, { recursive: true, force: true })
|
||||
if (originalSummary === undefined) delete process.env.GITHUB_STEP_SUMMARY
|
||||
else process.env.GITHUB_STEP_SUMMARY = originalSummary
|
||||
})
|
||||
|
||||
it('emits no annotation on a fresh outcome', () => {
|
||||
reportGitHubStarsOutcome(freshOutcome())
|
||||
expect(writeSpy).not.toHaveBeenCalled()
|
||||
expect(readFileSync(summaryPath, 'utf8')).toContain('Fresh')
|
||||
})
|
||||
|
||||
it('emits exactly one stale warning across repeated calls', () => {
|
||||
const outcome: FetchOutcome = {
|
||||
status: 'stale',
|
||||
reason: 'HTTP 403 rate limited',
|
||||
snapshot: snapshot()
|
||||
}
|
||||
|
||||
reportGitHubStarsOutcome(outcome)
|
||||
reportGitHubStarsOutcome(outcome)
|
||||
|
||||
expect(writeSpy).toHaveBeenCalledTimes(1)
|
||||
const annotation = writeSpy.mock.calls[0]![0] as string
|
||||
expect(annotation).toContain('::warning title=GitHub stars unavailable')
|
||||
expect(readFileSync(summaryPath, 'utf8')).toContain('Stale')
|
||||
})
|
||||
|
||||
it('emits an error for failed outcomes', () => {
|
||||
reportGitHubStarsOutcome({
|
||||
status: 'failed',
|
||||
reason: 'HTTP 500 Server Error'
|
||||
})
|
||||
|
||||
const annotation = writeSpy.mock.calls[0]![0] as string
|
||||
expect(annotation).toContain('::error title=GitHub stars fetch failed')
|
||||
expect(readFileSync(summaryPath, 'utf8')).toContain('Failed')
|
||||
})
|
||||
})
|
||||
56
apps/website/src/utils/github.ci.ts
Normal file
56
apps/website/src/utils/github.ci.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { FetchOutcome } from './github'
|
||||
|
||||
import {
|
||||
createBuildDataReporter,
|
||||
describeSnapshotAge,
|
||||
escapeAnnotation
|
||||
} from './buildDataReporter'
|
||||
|
||||
const githubStarsReporter = createBuildDataReporter({
|
||||
summaryHeading: '## GitHub stars\n',
|
||||
buildAnnotations,
|
||||
buildSummaryRows
|
||||
})
|
||||
|
||||
export const resetGitHubStarsReporterForTests =
|
||||
githubStarsReporter.resetForTests
|
||||
export const reportGitHubStarsOutcome = githubStarsReporter.report
|
||||
|
||||
function buildAnnotations(outcome: FetchOutcome): string[] {
|
||||
if (outcome.status === 'fresh') return []
|
||||
|
||||
if (outcome.status === 'stale') {
|
||||
return [
|
||||
`::warning title=GitHub stars unavailable::${escapeAnnotation(
|
||||
outcome.reason
|
||||
)}. Using last-known-good snapshot.%0A%0AAction items:%0A 1. Check GitHub API availability/rate limits.%0A 2. Re-run the build once GitHub is healthy.%0A 3. To refresh the fallback value, run \`pnpm --filter @comfyorg/website github-stars:refresh-snapshot\` and commit apps/website/src/data/github-stars.snapshot.json.`
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
`::error title=GitHub stars fetch failed and no snapshot is available::Cannot render website navigation stars.%0A%0AReason: ${escapeAnnotation(
|
||||
outcome.reason
|
||||
)}%0A%0AAction items:%0A 1. Run \`pnpm --filter @comfyorg/website github-stars:refresh-snapshot\`.%0A 2. Commit apps/website/src/data/github-stars.snapshot.json.%0A 3. Push and re-run CI.`
|
||||
]
|
||||
}
|
||||
|
||||
function buildSummaryRows(outcome: FetchOutcome): Array<[string, string]> {
|
||||
const rows: Array<[string, string]> = []
|
||||
|
||||
if (outcome.status === 'fresh') {
|
||||
rows.push(['Status', 'Fresh (fetched from GitHub)'])
|
||||
rows.push(['Repository', outcome.snapshot.repository])
|
||||
rows.push(['Stars', String(outcome.snapshot.stargazersCount)])
|
||||
} else if (outcome.status === 'stale') {
|
||||
rows.push(['Status', 'Stale (using snapshot - GitHub fetch failed)'])
|
||||
rows.push(['Repository', outcome.snapshot.repository])
|
||||
rows.push(['Stars', String(outcome.snapshot.stargazersCount)])
|
||||
rows.push(['Reason', outcome.reason])
|
||||
rows.push(['Snapshot age', describeSnapshotAge(outcome.snapshot.fetchedAt)])
|
||||
} else {
|
||||
rows.push(['Status', 'Failed (no snapshot available)'])
|
||||
rows.push(['Reason', outcome.reason])
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
@@ -1,10 +1,50 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
import { fetchGitHubStars, formatStarCount } from './github'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { GitHubStarsSnapshot } from '../data/githubStars'
|
||||
|
||||
import {
|
||||
fetchGitHubStars,
|
||||
fetchGitHubStarsForBuild,
|
||||
formatStarCount,
|
||||
resetGitHubStarsFetcherForTests
|
||||
} from './github'
|
||||
|
||||
function response(body: unknown, init: Partial<ResponseInit> = {}): Response {
|
||||
const base: ResponseInit = {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
}
|
||||
return new Response(JSON.stringify(body), { ...base, ...init })
|
||||
}
|
||||
|
||||
function makeSnapshot(stargazersCount = 111_605): GitHubStarsSnapshot {
|
||||
return {
|
||||
fetchedAt: '2026-05-06T00:00:00.000Z',
|
||||
repository: 'Comfy-Org/ComfyUI',
|
||||
stargazersCount
|
||||
}
|
||||
}
|
||||
|
||||
function withSnapshotDir(snapshot: GitHubStarsSnapshot | null): URL {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'github-stars-test-'))
|
||||
const file = join(dir, 'github-stars.snapshot.json')
|
||||
if (snapshot) writeFileSync(file, JSON.stringify(snapshot))
|
||||
return pathToFileURL(file)
|
||||
}
|
||||
|
||||
describe('fetchGitHubStars', () => {
|
||||
const savedOverride = process.env.WEBSITE_GITHUB_STARS_OVERRIDE
|
||||
|
||||
beforeEach(() => {
|
||||
resetGitHubStarsFetcherForTests()
|
||||
delete process.env.WEBSITE_GITHUB_STARS_OVERRIDE
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
if (savedOverride === undefined)
|
||||
@@ -16,16 +56,86 @@ 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'
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
|
||||
await expect(fetchGitHubStars('Comfy-Org', 'ComfyUI')).rejects.toThrow(
|
||||
await expect(fetchGitHubStars()).rejects.toThrow(
|
||||
'WEBSITE_GITHUB_STARS_OVERRIDE must be a non-negative integer'
|
||||
)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns fresh stars when the API succeeds', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>(async () =>
|
||||
response({ stargazers_count: 112464 })
|
||||
)
|
||||
|
||||
const outcome = await fetchGitHubStarsForBuild({ fetchImpl })
|
||||
|
||||
expect(outcome.status).toBe('fresh')
|
||||
if (outcome.status !== 'fresh') return
|
||||
expect(outcome.snapshot.repository).toBe('Comfy-Org/ComfyUI')
|
||||
expect(outcome.snapshot.stargazersCount).toBe(112464)
|
||||
})
|
||||
|
||||
it('memoizes build-time star fetches within a single process', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>(async () =>
|
||||
response({ stargazers_count: 110000 })
|
||||
)
|
||||
|
||||
const [a, b] = await Promise.all([
|
||||
fetchGitHubStarsForBuild({ fetchImpl }),
|
||||
fetchGitHubStarsForBuild({ fetchImpl })
|
||||
])
|
||||
|
||||
expect(a).toBe(b)
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('falls back to the committed snapshot when GitHub fetch fails', async () => {
|
||||
const snapshotUrl = withSnapshotDir(makeSnapshot())
|
||||
const fetchImpl = vi.fn<typeof fetch>(async () =>
|
||||
response({}, { status: 403 })
|
||||
)
|
||||
|
||||
const outcome = await fetchGitHubStarsForBuild({ fetchImpl, snapshotUrl })
|
||||
|
||||
expect(outcome.status).toBe('stale')
|
||||
if (outcome.status !== 'stale') return
|
||||
expect(outcome.reason).toMatch(/^HTTP 403/)
|
||||
expect(outcome.snapshot.stargazersCount).toBe(111605)
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('returns failed when both GitHub fetch and snapshot are unavailable', async () => {
|
||||
const snapshotUrl = withSnapshotDir(null)
|
||||
const fetchImpl = vi.fn<typeof fetch>(async () =>
|
||||
response({}, { status: 403 })
|
||||
)
|
||||
|
||||
const outcome = await fetchGitHubStarsForBuild({ fetchImpl, snapshotUrl })
|
||||
|
||||
expect(outcome.status).toBe('failed')
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('rejects invalid numeric star counts before caching them', async () => {
|
||||
const snapshotUrl = withSnapshotDir(makeSnapshot())
|
||||
const fetchImpl = vi.fn<typeof fetch>(async () =>
|
||||
response({ stargazers_count: -1 })
|
||||
)
|
||||
|
||||
const outcome = await fetchGitHubStarsForBuild({ fetchImpl, snapshotUrl })
|
||||
|
||||
expect(outcome.status).toBe('stale')
|
||||
if (outcome.status !== 'stale') return
|
||||
expect(outcome.reason).toMatch(/^response schema validation failed/)
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,22 +1,129 @@
|
||||
export async function fetchGitHubStars(
|
||||
owner: string,
|
||||
repo: string
|
||||
): Promise<number | null> {
|
||||
const override = readGitHubStarsOverride()
|
||||
if (override !== undefined) return override
|
||||
import { readFile } from 'node:fs/promises'
|
||||
|
||||
import type { GitHubStarsSnapshot } from '../data/githubStars'
|
||||
import type { BuildDataFetchResult, BuildDataOutcome } from './buildDataSource'
|
||||
|
||||
import bundledSnapshot from '../data/github-stars.snapshot.json' with { type: 'json' }
|
||||
import { isGitHubStarsSnapshot } from '../data/githubStars'
|
||||
import { createBuildDataSource } from './buildDataSource'
|
||||
|
||||
const GITHUB_REPOSITORY = 'Comfy-Org/ComfyUI'
|
||||
const GITHUB_REPO_API_URL = `https://api.github.com/repos/${GITHUB_REPOSITORY}`
|
||||
|
||||
export type FetchOutcome = BuildDataOutcome<GitHubStarsSnapshot>
|
||||
|
||||
interface FetchGitHubStarsOptions {
|
||||
fetchImpl?: typeof fetch
|
||||
snapshotUrl?: URL
|
||||
}
|
||||
|
||||
const githubStarsSource = createBuildDataSource<
|
||||
FetchGitHubStarsOptions,
|
||||
GitHubStarsSnapshot
|
||||
>({
|
||||
name: 'GitHub stars',
|
||||
fetchFresh: fetchFreshGitHubStars,
|
||||
readSnapshot: (options) => readSnapshot(options.snapshotUrl),
|
||||
getCacheKey: getGitHubStarsCacheKey
|
||||
})
|
||||
|
||||
export const resetGitHubStarsFetcherForTests = githubStarsSource.resetForTests
|
||||
export const fetchGitHubStarsForBuild = githubStarsSource.fetchForBuild
|
||||
|
||||
export async function fetchGitHubStars(
|
||||
fetchImpl: typeof fetch = fetch
|
||||
): Promise<number | null> {
|
||||
const result = await fetchFreshGitHubStars({ fetchImpl })
|
||||
if (result.kind === 'err') return null
|
||||
return result.snapshot.stargazersCount
|
||||
}
|
||||
|
||||
async function fetchFreshGitHubStars(
|
||||
options: FetchGitHubStarsOptions
|
||||
): Promise<BuildDataFetchResult<GitHubStarsSnapshot>> {
|
||||
const override = readGitHubStarsOverride()
|
||||
if (override !== undefined) {
|
||||
return {
|
||||
kind: 'ok',
|
||||
snapshot: makeSnapshot(override),
|
||||
data: {}
|
||||
}
|
||||
}
|
||||
|
||||
const response = await callOnce(options.fetchImpl ?? fetch)
|
||||
if (response.kind === 'err') return response
|
||||
|
||||
const count = readStargazerCount(response.body)
|
||||
if (count === null) {
|
||||
return {
|
||||
kind: 'err',
|
||||
reason:
|
||||
'response schema validation failed: stargazers_count must be a non-negative safe integer'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'ok',
|
||||
snapshot: makeSnapshot(count),
|
||||
data: {}
|
||||
}
|
||||
}
|
||||
|
||||
type CallResponse =
|
||||
| { kind: 'ok'; body: unknown }
|
||||
| { kind: 'err'; reason: string }
|
||||
|
||||
async function callOnce(fetchImpl: typeof fetch): Promise<CallResponse> {
|
||||
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
|
||||
if (res.ok) return { kind: 'ok', body: await res.json() }
|
||||
return {
|
||||
kind: 'err',
|
||||
reason: `HTTP ${res.status} ${res.statusText || ''}`.trim()
|
||||
}
|
||||
} catch (error) {
|
||||
const reason =
|
||||
error instanceof Error
|
||||
? `network error: ${error.message}`
|
||||
: 'network error'
|
||||
return { kind: 'err', reason }
|
||||
}
|
||||
}
|
||||
|
||||
async function readSnapshot(
|
||||
snapshotUrl: URL | undefined
|
||||
): Promise<GitHubStarsSnapshot | null> {
|
||||
if (!snapshotUrl) {
|
||||
return isGitHubStarsSnapshot(bundledSnapshot) ? bundledSnapshot : null
|
||||
}
|
||||
try {
|
||||
const text = await readFile(snapshotUrl, 'utf8')
|
||||
const parsed: unknown = JSON.parse(text)
|
||||
if (isGitHubStarsSnapshot(parsed)) return parsed
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function makeSnapshot(stargazersCount: number): GitHubStarsSnapshot {
|
||||
return {
|
||||
fetchedAt: new Date().toISOString(),
|
||||
repository: GITHUB_REPOSITORY,
|
||||
stargazersCount
|
||||
}
|
||||
}
|
||||
|
||||
function readStargazerCount(data: unknown): number | null {
|
||||
if (data === null || typeof data !== 'object') return null
|
||||
const count = (data as { stargazers_count?: unknown }).stargazers_count
|
||||
return typeof count === 'number' && Number.isSafeInteger(count) && count >= 0
|
||||
? count
|
||||
: null
|
||||
}
|
||||
|
||||
export function formatStarCount(count: number): string {
|
||||
if (count >= 1_000_000) {
|
||||
const m = count / 1_000_000
|
||||
@@ -42,3 +149,10 @@ function readGitHubStarsOverride(): number | undefined {
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
function getGitHubStarsCacheKey(options: FetchGitHubStarsOptions): string {
|
||||
return JSON.stringify({
|
||||
override: process.env.WEBSITE_GITHUB_STARS_OVERRIDE ?? '',
|
||||
snapshotUrl: options.snapshotUrl?.href ?? ''
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user