Portfolio/server/utils/repos.ts
2026-09-08 19:54:06 +02:00

272 lines
9.3 KiB
TypeScript

/**
* The project list is the public repositories of the configured github and
* gitea accounts — not a list anybody maintains by hand.
*
* `site.json` still has a say: an entry there whose `href` or `name` matches a
* repository *enriches* it (bullets, a nicer name, a status, the featured
* flag). Entries that match nothing are kept as `manual` projects, so a thing
* with no repo behind it can still be shown.
*
* Both forges are optional and independent: one being down or rate-limited
* means the other's repos still render, and the failure is reported instead of
* swallowed.
*/
import type { Project, ProjectStatus } from '#shared/site'
import type {
ProjectSourceStatus,
ProjectsPayload,
RepoProject,
} from '#shared/types/projects'
import { cached } from './cache'
import { projectsConfig, type ProjectsConfig } from './config'
import { getSiteData } from './site'
const PROJECTS_KEY = 'projects:list'
/** a repo pushed to within this many days counts as alive */
const ACTIVE_DAYS = 120
/** the subset of the forge payloads we actually use — both speak it */
interface ForgeRepo {
name?: string
full_name?: string
description?: string | null
html_url?: string
homepage?: string | null
website?: string | null
language?: string | null
topics?: string[] | null
fork?: boolean
archived?: boolean
private?: boolean
stargazers_count?: number
stars_count?: number
created_at?: string
pushed_at?: string
updated_at?: string
}
// --- forges ----------------------------------------------------------------
async function fetchGithubRepos(cfg: ProjectsConfig): Promise<ForgeRepo[]> {
const headers: Record<string, string> = {
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'nak0x.dev',
}
if (cfg.githubToken) headers.Authorization = `Bearer ${cfg.githubToken}`
const out: ForgeRepo[] = []
for (let page = 1; page <= 10; page++) {
const batch = await $fetch<ForgeRepo[]>(
`https://api.github.com/users/${encodeURIComponent(cfg.githubUser)}/repos`,
{ headers, query: { per_page: 100, page, type: 'owner', sort: 'pushed' } },
)
if (!Array.isArray(batch) || !batch.length) break
out.push(...batch)
if (batch.length < 100) break
}
return out
}
async function fetchGiteaRepos(cfg: ProjectsConfig): Promise<ForgeRepo[]> {
const base = cfg.giteaUrl.replace(/\/+$/, '')
const headers: Record<string, string> = { Accept: 'application/json' }
if (cfg.giteaToken) headers.Authorization = `token ${cfg.giteaToken}`
const out: ForgeRepo[] = []
for (let page = 1; page <= 10; page++) {
const batch = await $fetch<ForgeRepo[]>(
`${base}/api/v1/users/${encodeURIComponent(cfg.giteaUser)}/repos`,
{ headers, query: { limit: 50, page } },
)
if (!Array.isArray(batch) || !batch.length) break
out.push(...batch)
if (batch.length < 50) break
}
// a token turns this endpoint into "everything i can see", so filter again
return out.filter((r) => !r.private)
}
// --- mapping ---------------------------------------------------------------
const yearOf = (iso: string): string => {
const year = new Date(iso).getFullYear()
return Number.isFinite(year) ? String(year) : ''
}
function deriveStatus(repo: ForgeRepo, pushedAt: string): ProjectStatus {
if (repo.archived) return 'archived'
const age = (Date.now() - Date.parse(pushedAt)) / 86_400_000
// anything quiet for a while is "done" rather than "abandoned"; site.json can
// still call it 'wip' if that is closer to the truth
return Number.isFinite(age) && age <= ACTIVE_DAYS ? 'active' : 'shipped'
}
function toProject(repo: ForgeRepo, source: 'github' | 'gitea'): RepoProject | null {
const name = repo.name?.trim()
const href = repo.html_url?.trim()
if (!name || !href) return null
const pushedAt = repo.pushed_at || repo.updated_at || repo.created_at || ''
const site = (repo.homepage || repo.website || '').trim()
const topics = (repo.topics ?? []).filter((t) => typeof t === 'string' && t.trim())
const language = repo.language?.trim()
return {
name,
tagline: repo.description?.trim() || '',
bullets: [],
// the language first, then whatever topics the repo declares
stack: [...new Set([language, ...topics].filter((s): s is string => !!s))].slice(0, 5),
year: yearOf(repo.created_at || pushedAt),
href,
links: site && site !== href ? [{ label: 'site', href: site }] : [],
featured: false,
status: deriveStatus(repo, pushedAt),
source,
fullName: repo.full_name?.trim() || name,
stars: repo.stargazers_count ?? repo.stars_count ?? 0,
pushedAt,
archived: repo.archived === true,
}
}
// --- site.json overrides ---------------------------------------------------
const normalizeHref = (href: string): string =>
href.trim().toLowerCase().replace(/\.git$/, '').replace(/\/+$/, '')
/** site.json wins wherever it actually says something */
function enrich(base: RepoProject, override: Project): RepoProject {
return {
...base,
name: override.name || base.name,
tagline: override.tagline || base.tagline,
bullets: override.bullets.length ? override.bullets : base.bullets,
stack: override.stack.length ? override.stack : base.stack,
year: override.year || base.year,
links: override.links.length ? override.links : base.links,
featured: override.featured,
hidden: override.hidden,
// `status` is never absent after normalisation, so it always overrides
status: override.status,
}
}
function asManual(project: Project): RepoProject {
return {
...project,
source: 'manual',
fullName: project.name,
stars: 0,
pushedAt: '',
archived: project.status === 'archived',
}
}
// --- assembly --------------------------------------------------------------
const rank: Record<ProjectStatus, number> = { active: 0, wip: 1, shipped: 2, archived: 3 }
function sortProjects(projects: RepoProject[]): RepoProject[] {
return projects.sort(
(a, b) =>
rank[a.status] - rank[b.status] ||
Number(b.featured) - Number(a.featured) ||
(Date.parse(b.pushedAt) || 0) - (Date.parse(a.pushedAt) || 0) ||
b.stars - a.stars ||
a.name.localeCompare(b.name),
)
}
async function readSource(
kind: 'github' | 'gitea',
account: string,
cfg: ProjectsConfig,
load: () => Promise<ForgeRepo[]>,
): Promise<{ status: ProjectSourceStatus; projects: RepoProject[] }> {
if (!account) {
return { status: { kind, account: '', ok: true, count: 0 }, projects: [] }
}
try {
const repos = await load()
const projects = repos
.filter((r) => !r.private)
.filter((r) => cfg.includeForks || !r.fork)
.filter(
(r) =>
!cfg.exclude.includes((r.name ?? '').toLowerCase()) &&
!cfg.exclude.includes((r.full_name ?? '').toLowerCase()),
)
.map((r) => toProject(r, kind))
.filter((p): p is RepoProject => p !== null)
return { status: { kind, account, ok: true, count: projects.length }, projects }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error(`[projects] ${kind} (${account}) failed:`, message)
return {
status: { kind, account, ok: false, count: 0, error: message },
projects: [],
}
}
}
async function loadProjects(): Promise<ProjectsPayload> {
const cfg = projectsConfig()
const [github, gitea, site] = await Promise.all([
readSource('github', cfg.githubUser, cfg, () => fetchGithubRepos(cfg)),
readSource('gitea', cfg.giteaUser, cfg, () => fetchGiteaRepos(cfg)),
getSiteData(),
])
// same repo mirrored on both forges: github is the public-facing one
const byKey = new Map<string, RepoProject>()
for (const project of [...github.projects, ...gitea.projects]) {
const key = project.name.toLowerCase()
if (!byKey.has(key)) byKey.set(key, project)
}
const byHref = new Map<string, Project>()
const byName = new Map<string, Project>()
for (const project of site.projects) {
const href = normalizeHref(project.href)
if (href) byHref.set(href, project)
const name = project.name.trim().toLowerCase()
if (name && !byName.has(name)) byName.set(name, project)
}
// urls first: a url names exactly one repo, where a name can match several
// (`mycelia` and `mycelia-experience` both answer to "mycelia")
const used = new Set<Project>()
const matched = [...byKey.values()].map((repo) => {
const override = byHref.get(normalizeHref(repo.href)) ?? null
if (override) used.add(override)
return { repo, override }
})
const projects = matched.map(({ repo, override }) => {
if (override) return enrich(repo, override)
// fall back to the name, but never let one entry claim a second repo
const byname = byName.get(repo.name.toLowerCase())
if (!byname || used.has(byname)) return repo
used.add(byname)
return enrich(repo, byname)
})
// things with no repository behind them (or whose forge is down) still count
const orphans = site.projects.filter((p) => !used.has(p)).map(asManual)
return {
projects: sortProjects([...projects, ...orphans].filter((p) => !p.hidden)),
sources: [github.status, gitea.status].filter((s) => s.account),
}
}
export async function getProjects(): Promise<ProjectsPayload> {
return cached(PROJECTS_KEY, projectsConfig().ttl, loadProjects)
}