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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5981-each-feed-post-that-links-to-a-closed-pu → main 2 commits fork 3 threads · 3 unresolved
6 files
changed +183
−3
src/feed/feed.ts +11 −1
| @@ -18,6 +18,7 @@ import { candidatesFor, type Candidate } from "./items.ts"; | ||
| 18 | 18 | import { writeStamp } from "../core/write_clock.ts"; |
| 19 | 19 | import { Stamped } from "../kernel/stamped.ts"; |
| 20 | 20 | import { rank, type FeedItem } from "./rank.ts"; |
| 21 | import { settledOf } from "./settled.ts"; | |
| 21 | 22 | |
| 22 | 23 | export const FEED_LIMIT_DEFAULT = 20; |
| 23 | 24 | export const FEED_LIMIT_MAX = 50; |
| @@ -85,6 +86,8 @@ export function readFeed( | ||
| 85 | 86 | chips?: Chips; |
| 86 | 87 | /** The home pins asks above the feed: they must not fill its page. */ |
| 87 | 88 | withoutAsks?: boolean; |
| 89 | /** Told how long the settled-state read took, for Server-Timing. */ | |
| 90 | phase?: (name: string, ms: number) => void; | |
| 88 | 91 | } = {}, |
| 89 | 92 | ): { items: FeedItem[]; next_cursor: string | null } { |
| 90 | 93 | const now = options.now ?? Date.now(); |
| @@ -135,7 +138,14 @@ export function readFeed( | ||
| 135 | 138 | ? ranked.slice(at + 1) |
| 136 | 139 | : ranked.filter((i) => i.score < cursor.score); |
| 137 | 140 | } |
| 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 | }); | |
| 139 | 149 | const last = items[items.length - 1]; |
| 140 | 150 | return { |
| 141 | 151 | items, |
src/feed/rank.ts +3
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | |
| 9 | 9 | import { decay, type Affinity, type Weighted, type Why } from "./affinity.ts"; |
| 10 | 10 | import type { Candidate, FeedAsk } from "./items.ts"; |
| 11 | import type { Settled } from "./settled.ts"; | |
| 11 | 12 | |
| 12 | 13 | export interface FeedItem { |
| 13 | 14 | readonly id: string; |
| @@ -24,6 +25,8 @@ export interface FeedItem { | ||
| 24 | 25 | readonly score: number; |
| 25 | 26 | /** For an ask: the decision, options, default and rank. */ |
| 26 | 27 | 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; | |
| 27 | 30 | } |
| 28 | 31 | |
| 29 | 32 | 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 { | ||
| 69 | 69 | return apiError("invalid_cursor", 422, "that cursor was not issued here"); |
| 70 | 70 | const page = timePhase(ctx.request, "feed", () => |
| 71 | 71 | readFeed(ctx.app.db, viewer, { |
| 72 | phase: (name, ms) => notePhase(ctx.request, name, ms), | |
| 72 | 73 | ...(limit === undefined ? {} : { limit }), |
| 73 | 74 | ...(cursor === undefined ? {} : { cursor }), |
| 74 | 75 | }), |
src/web/studio/feed.ts +13 −2
| @@ -20,7 +20,7 @@ import type { FeedItem } from "../../feed/rank.ts"; | ||
| 20 | 20 | import { html, type Html } from "../html.ts"; |
| 21 | 21 | import { fragment } from "../layout.ts"; |
| 22 | 22 | 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"; | |
| 24 | 24 | import { writableAppsOf } from "./asks.ts"; |
| 25 | 25 | |
| 26 | 26 | /** Events that can change what the feed says. */ |
| @@ -55,6 +55,12 @@ const saysMore = (item: FeedItem): boolean => | ||
| 55 | 55 | !item.reason.startsWith(item.what) && |
| 56 | 56 | !/^because you (own|work on|pinned) /.test(item.reason); |
| 57 | 57 | |
| 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 { | |
| 60 | if (settled === undefined) return null; | |
| 61 | return stateBadge(settled === "merged" ? "merged" : "closed", settled === "parked"); | |
| 62 | } | |
| 63 | ||
| 58 | 64 | /** |
| 59 | 65 | * One post: who, where and when; what it says, as a link into the thing; |
| 60 | 66 | * why, when that is news; and one quiet control for the rest — say |
| @@ -70,6 +76,7 @@ export function feedCard( | ||
| 70 | 76 | <strong>${item.actor ?? item.app}</strong |
| 71 | 77 | >${nameMark(item.actor === null ? undefined : marks.get(item.actor))} |
| 72 | 78 | <span class="muted small">${item.app} · ${timeAgo(item.at)}</span> |
| 79 | ${settledBadge(item.settled)} | |
| 73 | 80 | <span class="spacer"></span> |
| 74 | 81 | <span class="badge">${KIND_LABEL[item.kind]}</span>`; |
| 75 | 82 | 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 { | ||
| 242 | 249 | function readRegion(user: User, ctx: Ctx) { |
| 243 | 250 | // What needs you is pinned above the timeline (`needsYouSection`), so an |
| 244 | 251 | // 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; | |
| 246 | 257 | const thoughts = recentThoughts(ctx.app.db, user.id, THOUGHTS_SHOWN, new Date(Date.now() - THOUGHT_AGE_MS).toISOString()); |
| 247 | 258 | const placed = placementsOf(ctx.app.db, thoughts.map((t) => t.id)); |
| 248 | 259 | // 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 |
||
| 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 |
||
| 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 | }); |
new above
offline
High (design system): this duplicates
stateBadge()fromsrc/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.