Public: anyone can read this; the crew can change it.

Each feed post that links to a closed pull or issue says so: `merged`, `closed` or `parked`, as a badge beside its time #5986

Merged opened this

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

Merged

scout/forge:scout/5981-each-feed-post-that-links-to-a-closed-pu → main 2 commits fork 3 threads · 3 unresolved

  • Merged. scout/5981-each-feed-post-that-links-to-a-closed-pu was merged into main as 0e39ca1.

Review

Sign in to review this change.
6 files changed +183 −3
src/feed/feed.ts +11 −1
@@ -18,6 +18,7 @@ import { candidatesFor, type Candidate } from "./items.ts";
1818 import { writeStamp } from "../core/write_clock.ts";
1919 import { Stamped } from "../kernel/stamped.ts";
2020 import { rank, type FeedItem } from "./rank.ts";
21 import { settledOf } from "./settled.ts";
2122
2223 export const FEED_LIMIT_DEFAULT = 20;
2324 export const FEED_LIMIT_MAX = 50;
@@ -85,6 +86,8 @@ export function readFeed(
8586 chips?: Chips;
8687 /** The home pins asks above the feed: they must not fill its page. */
8788 withoutAsks?: boolean;
89 /** Told how long the settled-state read took, for Server-Timing. */
90 phase?: (name: string, ms: number) => void;
8891 } = {},
8992 ): { items: FeedItem[]; next_cursor: string | null } {
9093 const now = options.now ?? Date.now();
@@ -135,7 +138,14 @@ export function readFeed(
135138 ? ranked.slice(at + 1)
136139 : ranked.filter((i) => i.score < cursor.score);
137140 }
138 const items = ranked.slice(0, limit);
141 const page = ranked.slice(0, limit);
142 const started = performance.now();
143 const settled = settledOf(db, page);
144 options.phase?.("settled", performance.now() - started);
145 const items = page.map((i) => {
146 const state = settled.get(i.id);
147 return state === undefined ? i : { ...i, settled: state };
148 });
139149 const last = items[items.length - 1];
140150 return {
141151 items,
src/feed/rank.ts +3
@@ -8,6 +8,7 @@
88
99 import { decay, type Affinity, type Weighted, type Why } from "./affinity.ts";
1010 import type { Candidate, FeedAsk } from "./items.ts";
11 import type { Settled } from "./settled.ts";
1112
1213 export interface FeedItem {
1314 readonly id: string;
@@ -24,6 +25,8 @@ export interface FeedItem {
2425 readonly score: number;
2526 /** For an ask: the decision, options, default and rank. */
2627 readonly ask?: FeedAsk;
28 /** For a post about a pull or an issue that is over: how it ended. Absent while open. */
29 readonly settled?: Settled;
2730 }
2831
2932 const RECENCY_MAX = 4;
src/feed/settled.ts added +63
@@ -0,0 +1,63 @@
1 /**
2 * What is already settled: a post about a pull or an issue says so once its
3 * subject merged, closed or was parked. One query reads a whole page.
4 */
5
6 import type { Database } from "bun:sqlite";
7
8 export type Settled = "merged" | "closed" | "parked";
9
10 /** The kinds of post whose link is a pull or an issue. */
11 const ABOUT = new Set(["pull", "tell", "reply"]);
12 const SUBJECT = /^\/([^/]+)\/([^/]+)\/(?:pulls|issues)\/(\d{1,9})$/;
13
14 interface Row {
15 owner: string;
16 repo: string;
17 number: number;
18 state: "open" | "closed";
19 parked: number;
20 merged_at: string | null;
21 }
22
23 /** Each post's settled state, by id; an open subject, or another kind of post, has none. */
24 export function settledOf(
25 db: Database,
26 posts: readonly { readonly id: string; readonly kind: string; readonly link: string }[],
27 ): Map<string, Settled> {
28 const out = new Map<string, Settled>();
29 const wanted = new Map<string, string[]>();
30 for (const p of posts) {
31 const m = ABOUT.has(p.kind) ? SUBJECT.exec(p.link) : null;
32 if (m === null) continue;
33 const key = `${m[1]}/${m[2]}/${m[3]}`;
34 wanted.set(key, [...(wanted.get(key) ?? []), p.id]);
35 }
36 if (wanted.size === 0) return out;
37 const keys = [...wanted.keys()].map((k) => k.split("/") as [string, string, string]);
38 const rows = db
39 .query<Row, (string | number)[]>(
40 `SELECT user.username AS owner, repo.name AS repo, issue.number, issue.state,
41 issue.parked, pull.merged_at
42 FROM issue JOIN repo ON repo.id = issue.repo_id JOIN user ON user.id = repo.owner_id
43 LEFT JOIN pull ON pull.issue_id = issue.id
44 WHERE (user.username, repo.name, issue.number) IN (VALUES ${keys
45 .map((_, i) => `(?${3 * i + 1}, ?${3 * i + 2}, ?${3 * i + 3})`)
46 .join(",")})`,
47 )
48 .all(...keys.flatMap(([o, r, n]) => [o, r, Number(n)]));
49 for (const row of rows) {
50 const state: Settled | undefined =
51 row.merged_at !== null
52 ? "merged"
53 : row.state === "closed"
54 ? row.parked === 1
55 ? "parked"
56 : "closed"
57 : undefined;
58 if (state === undefined) continue;
59 for (const id of wanted.get(`${row.owner}/${row.repo}/${row.number}`) ?? [])
60 out.set(id, state);
61 }
62 return out;
63 }
src/http/feed.ts +1
@@ -69,6 +69,7 @@ export function feedRoutes(router: Router<Handler>): void {
6969 return apiError("invalid_cursor", 422, "that cursor was not issued here");
7070 const page = timePhase(ctx.request, "feed", () =>
7171 readFeed(ctx.app.db, viewer, {
72 phase: (name, ms) => notePhase(ctx.request, name, ms),
7273 ...(limit === undefined ? {} : { limit }),
7374 ...(cursor === undefined ? {} : { cursor }),
7475 }),
src/web/studio/feed.ts +13 −2
@@ -20,7 +20,7 @@ import type { FeedItem } from "../../feed/rank.ts";
2020 import { html, type Html } from "../html.ts";
2121 import { fragment } from "../layout.ts";
2222 import { outboxJs } from "../outbox.ts";
23 import { avatar, btn, card, disclosure, empty, nameMark, timeAgo } from "../ui.ts";
23 import { avatar, btn, card, disclosure, empty, nameMark, stateBadge, timeAgo } from "../ui.ts";
2424 import { writableAppsOf } from "./asks.ts";
2525
2626 /** Events that can change what the feed says. */
@@ -55,6 +55,12 @@ const saysMore = (item: FeedItem): boolean =>
5555 !item.reason.startsWith(item.what) &&
5656 !/^because you (own|work on|pinned) /.test(item.reason);
5757
58 /** How a pull or an issue ended, as the quiet word beside a post's time. */
59 function settledBadge(settled: FeedItem["settled"]): Html | null {

High (design system): this duplicates stateBadge() from src/web/ui.ts, which already takes ("merged" | "closed", parked). Use it: return settled === undefined ? null : stateBadge(settled === "merged" ? "merged" : "closed", settled === "parked");. One component, one look for the same state across the forge.

60 if (settled === undefined) return null;
61 return stateBadge(settled === "merged" ? "merged" : "closed", settled === "parked");
62 }
63
5864 /**
5965 * One post: who, where and when; what it says, as a link into the thing;
6066 * why, when that is news; and one quiet control for the rest — say
@@ -70,6 +76,7 @@ export function feedCard(
7076 <strong>${item.actor ?? item.app}</strong
7177 >${nameMark(item.actor === null ? undefined : marks.get(item.actor))}
7278 <span class="muted small">${item.app} · ${timeAgo(item.at)}</span>
79 ${settledBadge(item.settled)}
7380 <span class="spacer"></span>
7481 <span class="badge">${KIND_LABEL[item.kind]}</span>`;
7582 const body = html`<p class="break"><a href="${item.link}" data-feed-open>${item.what}</a></p>
@@ -242,7 +249,11 @@ function chipsBar(user: User, ctx: Ctx, items: readonly FeedItem[]): Html {
242249 function readRegion(user: User, ctx: Ctx) {
243250 // What needs you is pinned above the timeline (`needsYouSection`), so an
244251 // ask is never a second time a post below it; the feed API keeps them.
245 const items = readFeed(ctx.app.db, user, { limit: FEED_FIRST_PAGE, withoutAsks: true }).items;
252 const items = readFeed(ctx.app.db, user, {
253 limit: FEED_FIRST_PAGE,
254 withoutAsks: true,
255 phase: (name, ms) => notePhase(ctx.request, name, ms),
256 }).items;
246257 const thoughts = recentThoughts(ctx.app.db, user.id, THOUGHTS_SHOWN, new Date(Date.now() - THOUGHT_AGE_MS).toISOString());
247258 const placed = placementsOf(ctx.app.db, thoughts.map((t) => t.id));
248259 // Who each poster is, asked once for the page (`core/marks.ts`).
test/feed-settled.test.ts added +92
@@ -0,0 +1,92 @@
1 /** A post about a pull or an issue that is over says how it ended; an open one says nothing. */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { openIssue } from "../src/actions/issues.ts";
8 import { createRepo } from "../src/actions/repos.ts";
9 import { createUser, type User } from "../src/core/users.ts";
10 import { settledOf } from "../src/feed/settled.ts";
11 import { feedCard } from "../src/web/studio/feed.ts";
12 import type { FeedItem } from "../src/feed/rank.ts";
13
14 let dir = "";
15 let app: App;
16 let alice: User;
17 let links: Record<string, string> = {};
18
19 beforeAll(async () => {
20 dir = await mkdtemp(join(tmpdir(), "forge-feed-settled-"));
21 app = createApp({ dataDir: dir, port: 0, forgeRepo: "alice/forge" });
22 const u = await createUser(app.db, {
23 origin: "seed",
24 trusted: true,
25 username: "alice",
26 email: "[email protected]",
27 password: "a-good-passphrase",
28 adminIfFirst: true,
29 });
30 if (!u.ok) throw new Error(u.error);
31 alice = u.value;
32 const made = await createRepo(app, alice, { name: "garden", visibility: "public" });
33 if (!made.ok) throw new Error("repo");
34 const repo = made.value;
35 for (const name of ["merged", "closed", "parked", "open"]) {
36 const o = openIssue(app, alice, repo, { title: name });
37 if (!o.ok) throw new Error(String(o.error));
38 const id = (o.value as { id: string; number: number }).id;
39 const number = (o.value as { number: number }).number;
40 links[name] = `/alice/garden/${name === "merged" || name === "parked" ? "pulls" : "issues"}/${number}`;
41 if (name === "merged" || name === "parked") {
42 app.db.run("UPDATE issue SET kind = 'pull' WHERE id = ?", [id]);
43 app.db.run(
44 "INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at) VALUES (?, 'main', 'x', 'a', 'b', ?)",
45 [id, name === "merged" ? "2026-10-25T00:00:00Z" : null],
46 );
47 }
48 if (name !== "open")
49 app.db.run("UPDATE issue SET state = 'closed', parked = ? WHERE id = ?", [name === "parked" ? 1 : 0, id]);
50 }
outdated

Nit: dead. Line 49 already set state = 'closed' for every name but open.

51 });
52 afterAll(async () => {
53 app.close();
54 await rm(dir, { recursive: true, force: true });
55 });
56
57 const post = (id: string, kind: string, link: string) => ({ id, kind, link });
58
59 test("one post per state: merged, closed, parked, and none while open", () => {
60 const got = settledOf(app.db, [
61 post("a", "pull", links.merged!),
62 post("b", "tell", links.closed!),
63 post("c", "reply", links.parked!),
64 post("d", "tell", links.open!),
65 post("e", "run", links.merged!),
66 ]);
67 expect(got.get("a")).toBe("merged");
68 expect(got.get("b")).toBe("closed");
69 expect(got.get("c")).toBe("parked");
70 expect(got.has("d")).toBe(false);
71 expect(got.has("e")).toBe(false);
72 });
73
74 test("a page of 30 posts gets the right state for every post", () => {
75 const states = [links.merged, links.closed, links.open];
76 const want = ["merged", "closed", undefined] as const;
77 const page = Array.from({ length: 30 }, (_, i) => post(`event:${i}`, "pull", states[i % 3]!));
78 const got = settledOf(app.db, page);
79 page.forEach((p, i) => expect(got.get(p.id)).toBe(want[i % 3] as never));
80 });
81
82 test("the card draws the badge beside the time, and none when open", () => {
outdated

Flake risk: a 3 ms wall-clock ceiling on one sample fails under a loaded check runner. Assert correctness over the 30-post page, or drop the timing assertion. Server-Timing already carries the settled phase.

83 const item = (settled?: FeedItem["settled"]): FeedItem => ({
84 id: "event:1", kind: "pull", app: "alice/garden", actor: "alice", at: "2026-10-25T00:00:00Z",
85 link: "/alice/garden/pulls/1", what: "x", reason: "x", score: 1, ...(settled ? { settled } : {}),
86 });
87 expect(String(feedCard(item("merged")))).toContain('data-pr="merged"');
88 expect(String(feedCard(item("merged")))).toContain("Merged</span");
89 expect(String(feedCard(item("closed")))).toContain("Closed</span");
90 expect(String(feedCard(item("parked")))).toContain("Parked</span");
91 expect(String(feedCard(item()))).not.toMatch(/Merged<|Closed<|Parked</);
92 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs