clone

a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases

main
forge / lib / recent.ts
recent.ts 6.8K
1// What happened lately on the forge's public repos, for the index's recent2// list and its profiles: landed reviews, pushes to a default branch,3// releases and site deploys, newest first.4//5// Pushes, releases and site deploys come from the events log6// (.forge/events.jsonl): post-receive writes a line for each push with the7// refs it moved, and the default branch's new tip as head. Landed reviews8// come from the review logs, which last: a review whose branch was deleted9// with its tip in the default branch, at the time it was deleted. A push10// whose head is a review's tip is that landing, and shows as landed. The11// events log is cut to its last 1000 lines and left out of backups, so12// after a restore only landed reviews show until the next pushes.13//14// Private repos are never read: the list is made from the public repos the15// build was given, and their events, which carry no "private" mark.16import fs from 'node:fs';17import path from 'node:path';18import { isAncestor, tryRun } from './git.ts';19import { readReviewLogs } from './reviews.ts';20import type { Config, RepoSummary } from './types.ts';2122export type RecentKind = 'landed' | 'pushed' | 'release' | 'site';2324export interface RecentItem {25 kind: RecentKind;26 // ISO time, for <time datetime>.27 at: string;28 repo: { slug: string; name: string; owner: string };29 href: string;30 // The review's #id or the tag, before the text (a release's is its tag only).31 ref: string;32 text: string;33}3435// When each owner last did something public: their newest event, landed36// review or commit. ISO, '' for never.37export type Active = Record<string, string>;3839interface Candidate {40 kind: RecentKind;41 t: number;42 repo: RepoSummary;43 // pushed: the new head; landed: the review's tip; release, site: the name.44 key: string;45 // landed: the review.46 id?: number;47 title?: string;48 base?: string | null;49}5051interface Event {52 t?: number;53 repo?: string;54 type?: string;55 refs?: string;56 head?: string;57 private?: boolean;58}5960function events(state: string): Event[] {61 let text = '';62 try {63 text = fs.readFileSync(path.join(state, 'events.jsonl'), 'utf8');64 } catch {65 return [];66 }67 const out: Event[] = [];68 for (const line of text.split('\n')) {69 if (!line.includes('"type":"push"')) continue;70 try {71 const e = JSON.parse(line);72 if (e && e.type === 'push' && !e.private && typeof e.repo === 'string' && typeof e.t === 'number') out.push(e);73 } catch {74 // A line cut short by a crash.75 }76 }77 return out;78}7980const iso = (ms: number): string => new Date(ms).toISOString().replace(/\.\d+Z$/, 'Z');8182export function recent(cfg: Config, repos: RepoSummary[], n = 8): { items: RecentItem[]; active: Active } {83 const bySlug = new Map(repos.map((r) => [r.slug, r]));84 const latest: Record<string, number> = {};85 const seen = (owner: string, t: number): void => {86 if (t > (latest[owner] ?? 0)) latest[owner] = t;87 };88 for (const r of repos) if (r.lastCommit) seen(r.owner, Date.parse(r.lastCommit));8990 const list: Candidate[] = [];91 for (const e of events(cfg.state)) {92 const repo = bySlug.get(e.repo!);93 if (!repo) continue;94 seen(repo.owner, e.t!);95 const named = new Set<string>();96 for (const ref of (e.refs ?? '').split(/\s+/).filter(Boolean)) {97 if (ref === `refs/heads/${repo.defaultBranch}`) {98 if (e.head && /^[0-9a-f]{40,64}$/.test(e.head)) list.push({ kind: 'pushed', t: e.t!, repo, key: e.head });99 } else {100 const m = /^refs\/(tags|releases|sites)\/(.+)$/.exec(ref);101 if (!m) continue;102 const kind = m[1] === 'sites' ? 'site' : 'release';103 // A tag and its release in one push are one release.104 if (named.has(kind + ' ' + m[2])) continue;105 named.add(kind + ' ' + m[2]);106 list.push({ kind, t: e.t!, repo, key: m[2] });107 }108 }109 }110111 // Every review's revisions, by repo: a push of one to the default branch landed it.112 const revs = new Map<string, Set<string>>();113 for (const repo of repos) {114 const shas = new Set<string>();115 for (const r of readReviewLogs(repo.dir, false)) {116 for (const v of r.revs) shas.add(v.sha);117 const tip = r.revs.at(-1);118 if (!r.deleted || !tip) continue;119 seen(repo.owner, r.deleted.ts * 1000);120 list.push({ kind: 'landed', t: r.deleted.ts * 1000, repo, key: tip.sha, id: r.id, title: r.opts.title, base: tip.base });121 }122 revs.set(repo.slug, shas);123 }124125 // The refs each repo has now: a deleted tag or site isn't news.126 const refs = new Map<string, Set<string>>();127 const refsOf = (repo: RepoSummary): Set<string> => {128 let s = refs.get(repo.slug);129 if (!s) {130 s = new Set((tryRun(repo.dir, ['for-each-ref', '--format=%(refname)', 'refs/tags', 'refs/releases', 'refs/sites']) ?? '').split('\n').filter(Boolean));131 refs.set(repo.slug, s);132 }133 return s;134 };135 const subject = (repo: RepoSummary, rev: string): string | null => tryRun(repo.dir, ['log', '-1', '--format=%s', rev, '--'])?.trim() ?? null;136137 // Newest first; checked one by one (git), until there are n.138 list.sort((a, b) => b.t - a.t || Number(b.kind === 'landed') - Number(a.kind === 'landed'));139 const items: RecentItem[] = [];140 const done = new Set<string>();141 for (const c of list) {142 if (items.length >= n) break;143 const { repo } = c;144 const once = `${c.kind} ${repo.slug} ${c.key}`;145 if (done.has(once)) continue;146 let href = '', ref = '', text = '';147 if (c.kind === 'pushed') {148 if (revs.get(repo.slug)?.has(c.key)) continue;149 const s = subject(repo, c.key);150 if (s == null) continue;151 href = repo.urls.commit(c.key).href;152 text = s;153 } else if (c.kind === 'landed') {154 if (!isAncestor(repo.dir, c.key, `refs/heads/${repo.defaultBranch}`)) continue;155 // Untitled, a review is named for its first commit (as lib/repo.ts names it).156 const first = c.title ? null : tryRun(repo.dir, ['log', '--reverse', '--format=%s', c.base ? `${c.base}..${c.key}` : c.key, '--'])?.split('\n')[0];157 href = repo.urls.review(c.id!).href;158 ref = `#${c.id}`;159 text = c.title || first || '';160 } else if (c.kind === 'release') {161 const have = refsOf(repo);162 if (!have.has(`refs/tags/${c.key}`) && !have.has(`refs/releases/${c.key}`)) continue;163 href = `${repo.urls.releases.href}#${encodeURIComponent(c.key)}`;164 ref = c.key;165 } else {166 if (!refsOf(repo).has(`refs/sites/${c.key}`)) continue;167 const sites = repo.sites ?? [];168 href = sites.find((s) => s.name === c.key)?.url ?? repo.urls.site(c.key);169 text = sites.length > 1 ? `deployed its site ${c.key}` : 'deployed its site';170 }171 done.add(once);172 items.push({ kind: c.kind, at: iso(c.t), repo: { slug: repo.slug, name: repo.name, owner: repo.owner }, href, ref, text });173 }174 return { items, active: Object.fromEntries(Object.entries(latest).map(([o, t]) => [o, iso(t)])) };175}