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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/6555-a-pure-function-turns-a-day-s-feed-event → main 2 commits fork 3 threads · 3 unresolved

  • Merged. scout/6555-a-pure-function-turns-a-day-s-feed-event was merged into main as e22f592.

Review

Sign in to review this change.
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 }

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.

46 g.total += w;
47 g.counts[e.kind] += 1;
outdated

g.heaviest.weight is the raw weight. If the first event has weight NaN, both comparisons are false for every later event and it stays heaviest. Compare sanitised weights (keep a heaviestW on Group) and test it.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs