Public: anyone can read this; the crew can change it.
A pure function turns a day's feed events into at most three digest lines, one per repository, biggest change first, each with its evidence link #6564
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6555-a-pure-function-turns-a-day-s-feed-event → main 2 commits fork 3 threads · 3 unresolved
2 files
changed +116
−0
src/feed/digest.ts added +63
| @@ -0,0 +1,63 @@ | ||
| 1 | /** | |
| 2 | * A day of the feed as at most three lines, one per repository, the biggest | |
| 3 | * change first. Plain rows in, plain lines out; the clock is `now`, passed in. | |
| 4 | */ | |
| 5 | ||
| 6 | export const DIGEST_WINDOW_MS = 24 * 3600 * 1000; | |
| 7 | export const DIGEST_LINES = 3; | |
| 8 | ||
| 9 | export type DigestKind = "landed" | "released" | "failed" | "decided"; | |
| 10 | const KINDS: readonly DigestKind[] = ["landed", "released", "failed", "decided"]; | |
| 11 | ||
| 12 | export interface DigestEvent { | |
| 13 | readonly repo: string; | |
| 14 | readonly kind: DigestKind; | |
| 15 | /** Epoch milliseconds. */ | |
| 16 | readonly at: number; | |
| 17 | readonly href: string; | |
| 18 | readonly weight: number; | |
| 19 | } | |
| 20 | ||
| 21 | export interface DigestLine { | |
| 22 | readonly repo: string; | |
| 23 | readonly says: string; | |
| 24 | readonly href: string; | |
| 25 | } | |
| 26 | ||
| 27 | interface Group { | |
| 28 | repo: string; | |
| 29 | total: number; | |
| 30 | heaviest: DigestEvent; | |
| 31 | heaviestW: number; | |
| 32 | counts: Record<DigestKind, number>; | |
| 33 | } | |
| 34 | ||
| 35 | export function digest(events: readonly DigestEvent[], now: number): DigestLine[] { | |
| 36 | const since = now - DIGEST_WINDOW_MS; | |
| 37 | const groups = new Map<string, Group>(); | |
| 38 | for (const e of events) { | |
| 39 | if (!(e.at >= since && e.at <= now)) continue; | |
| 40 | const w = Number.isFinite(e.weight) && e.weight > 0 ? e.weight : 0; | |
| 41 | let g = groups.get(e.repo); | |
| 42 | if (!g) { | |
| 43 | g = { repo: e.repo, total: 0, heaviest: e, heaviestW: w, counts: { landed: 0, released: 0, failed: 0, decided: 0 } }; | |
| 44 | groups.set(e.repo, g); | |
| 45 | } | |
| 46 | g.total += w; | |
| 47 | g.counts[e.kind] += 1; | |
outdated
|
||
| 48 | if (w > g.heaviestW || (w === g.heaviestW && e.at > g.heaviest.at)) { | |
| 49 | g.heaviest = e; | |
| 50 | g.heaviestW = w; | |
| 51 | } | |
| 52 | } | |
| 53 | return [...groups.values()] | |
| 54 | .sort((a, b) => b.total - a.total || a.repo.localeCompare(b.repo)) | |
| 55 | .slice(0, DIGEST_LINES) | |
| 56 | .map((g) => ({ | |
| 57 | repo: g.repo, | |
| 58 | says: `${g.repo}: ${KINDS.filter((k) => g.counts[k] > 0) | |
| 59 | .map((k) => `${g.counts[k]} ${k}`) | |
| 60 | .join(", ")}`, | |
| 61 | href: g.heaviest.href, | |
| 62 | })); | |
| 63 | } |
test/home-digest.test.ts added +53
| @@ -0,0 +1,53 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { digest, type DigestEvent } from "../src/feed/digest.ts"; | |
| 3 | ||
| 4 | const now = Date.parse("2026-10-26T12:00:00Z"); | |
| 5 | const H = 3600 * 1000; | |
| 6 | const ev = (repo: string, kind: DigestEvent["kind"], ago: number, weight: number, href = `/${repo}/${kind}/${ago}`): DigestEvent => ({ | |
| 7 | repo, kind, at: now - ago, href, weight, | |
| 8 | }); | |
| 9 | ||
| 10 | test("nothing in the window gives []", () => { | |
| 11 | expect(digest([], now)).toEqual([]); | |
| 12 | expect(digest([ev("a/x", "landed", 25 * H, 5)], now)).toEqual([]); | |
| 13 | }); | |
| 14 | ||
| 15 | test("three repositories: biggest first, says counts, heaviest link", () => { | |
| 16 | const out = digest( | |
| 17 | [ | |
| 18 | ev("alice/forge", "landed", H, 3, "/l1"), | |
| 19 | ev("alice/forge", "landed", 2 * H, 5, "/l2"), | |
| 20 | ev("alice/forge", "released", 3 * H, 1, "/r1"), | |
| 21 | ev("bob/app", "failed", H, 2, "/f1"), | |
| 22 | ev("carol/lib", "decided", H, 20, "/d1"), | |
| 23 | ], | |
| 24 | now, | |
| 25 | ); | |
| 26 | expect(out.map((l) => l.repo)).toEqual(["carol/lib", "alice/forge", "bob/app"]); | |
| 27 | expect(out[1]).toEqual({ repo: "alice/forge", says: "alice/forge: 2 landed, 1 released", href: "/l2" }); | |
| 28 | expect(out[0]!.href).toBe("/d1"); | |
| 29 | }); | |
| 30 | ||
| 31 | test("capped at three lines", () => { | |
| 32 | const events = ["a", "b", "c", "d", "e"].map((r, i) => ev(`o/${r}`, "landed", H, i + 1)); | |
| 33 | expect(digest(events, now).map((l) => l.repo)).toEqual(["o/e", "o/d", "o/c"]); | |
| 34 | }); | |
| 35 | ||
| 36 | test("window edge: exactly 24 h is in, a millisecond older is out, the future is out", () => { | |
| 37 | expect(digest([ev("o/a", "landed", 24 * H, 1)], now)).toHaveLength(1); | |
| 38 | expect(digest([ev("o/a", "landed", 24 * H + 1, 1)], now)).toEqual([]); | |
| 39 | expect(digest([ev("o/a", "landed", -1, 1)], now)).toEqual([]); | |
| 40 | }); | |
| 41 | ||
Wall-clock budget assertion flakes under load. Remove the timing check; keep the 200-repo cap check. Also add the missing bad-weight test (NaN, -3, Infinity count as 0 and do not choose the link). |
||
| 42 | test("bad weights count as 0 and never choose the link", () => { | |
| 43 | for (const bad of [Number.NaN, -3, Number.POSITIVE_INFINITY]) { | |
| 44 | const out = digest([ev("o/a", "failed", 2 * H, bad, "/bad"), ev("o/a", "landed", H, 1, "/real")], now); | |
| 45 | expect(out).toEqual([{ repo: "o/a", says: "o/a: 1 landed, 1 failed", href: "/real" }]); | |
| 46 | } | |
| 47 | }); | |
| 48 | ||
| 49 | test("200 repositories digest to three lines", () => { | |
| 50 | const events: DigestEvent[] = []; | |
| 51 | for (let r = 0; r < 200; r++) for (let k = 0; k < 5; k++) events.push(ev(`o/r${r}`, "landed", k * H, r + k)); | |
| 52 | expect(digest(events, now)).toHaveLength(3); | |
| 53 | }); |
new above
offline
Nit: a sum of finite weights can overflow to Infinity. Both repositories would then tie, and the alphabetical order would decide. This is harmless today; clamp it if weights can ever be huge.