Public: anyone can read this; the crew can change it.
A pull's opened, reviewed and merged posts fold into one timeline post that shows its latest state #5814
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5808-a-pull-s-opened-reviewed-and-merged-post → main 1 commit fork 1 thread · 1 unresolved
2 files
changed +125
−1
src/feed/items.ts +57 −1
| @@ -27,6 +27,7 @@ import { | ||
| 27 | 27 | import { getUserById, type User } from "../core/users.ts"; |
| 28 | 28 | import { watchedBy } from "../core/watches.ts"; |
| 29 | 29 | import { chooseCandidates } from "./choose.ts"; |
| 30 | import { ago } from "./rank.ts"; | |
| 30 | 31 | import { askLinesFor } from "../web/studio/asks.ts"; |
| 31 | 32 | |
| 32 | 33 | export type FeedKind = |
| @@ -255,11 +256,19 @@ export function candidatesFor( | ||
| 255 | 256 | }; |
| 256 | 257 | |
| 257 | 258 | const out: Candidate[] = []; |
| 259 | const folded = foldPulls(events); | |
| 258 | 260 | for (const e of events) { |
| 259 | 261 | const repo = repos.get(e.repoId!); |
| 260 | 262 | if (!repo) continue; |
| 263 | const fold = folded.get(e.id); | |
| 264 | if (fold === "skip") continue; | |
| 261 | 265 | const c = fromEvent(db, e, repo, owns(repo), subjects); |
| 262 | if (c) out.push({ ...c, actor: nameOf(e.actorId) }); | |
| 266 | if (c) | |
| 267 | out.push({ | |
| 268 | ...c, | |
| 269 | actor: nameOf(e.actorId), | |
| 270 | ...(fold === undefined ? {} : { what: `${c.what} · ${foldedWords(fold, now)}` }), | |
| 271 | }); | |
| 263 | 272 | } |
| 264 | 273 | for (const a of asks) { |
| 265 | 274 | const repo = repos.get(a.repoId); |
| @@ -307,6 +316,53 @@ export function candidatesFor( | ||
| 307 | 316 | return awake(db, out, repos); |
| 308 | 317 | } |
| 309 | 318 | |
| 319 | /** What a pull's earlier posts add to its latest: when it opened, how many reviews. */ | |
| 320 | interface Fold { | |
| 321 | readonly openedAt: string | undefined; | |
| 322 | readonly reviews: number; | |
| 323 | } | |
| 324 | ||
| 325 | const FOLDS = new Set(["pull.opened", "pull.reviewed", "pull.merged"]); | |
| 326 | ||
| 327 | /** | |
| 328 | * One post per pull (#5808): of the window's opened, reviewed and merged | |
| 329 | * events for the same pull, the newest stands for them and reads by its | |
| 330 | * latest state; the rest are "skip". Only the newest of a folded pull has | |
| 331 | * an entry that is a Fold. A pull with one event in the window is unchanged. | |
| 332 | * A failed run is not a pull event: it keeps its own post. | |
| 333 | */ | |
| 334 | function foldPulls(events: readonly ForgeEvent[]): Map<string, Fold | "skip"> { | |
| 335 | const groups = new Map<string, ForgeEvent[]>(); | |
| 336 | for (const e of events) { | |
| 337 | const n = e.payload["number"]; | |
| 338 | if (!FOLDS.has(e.kind) || typeof n !== "number" || e.repoId === null) continue; | |
| 339 | const key = `${e.repoId}#${n}`; | |
| 340 | const g = groups.get(key); | |
| 341 | if (g) g.push(e); | |
| 342 | else groups.set(key, [e]); | |
| 343 | } | |
| 344 | const out = new Map<string, Fold | "skip">(); | |
| 345 | for (const g of groups.values()) { | |
| 346 | if (g.length < 2) continue; | |
| 347 | const newest = g.reduce((a, b) => | |
| 348 | b.createdAt > a.createdAt || (b.createdAt === a.createdAt && b.id > a.id) ? b : a, | |
| 349 | ); | |
| 350 | for (const e of g) if (e !== newest) out.set(e.id, "skip"); | |
| 351 | out.set(newest.id, { | |
| 352 | openedAt: g.find((e) => e.kind === "pull.opened")?.createdAt, | |
| 353 | reviews: g.filter((e) => e.kind === "pull.reviewed").length, | |
| 354 | }); | |
| 355 | } | |
| 356 | return out; | |
| 357 | } | |
| 358 | ||
| 359 | function foldedWords(f: Fold, now: number): string { | |
| 360 | return [ | |
| 361 | ...(f.openedAt === undefined ? [] : [`opened ${ago(Date.parse(f.openedAt), now)}`]), | |
| 362 | ...(f.reviews === 0 ? [] : [`${f.reviews} review${f.reviews === 1 ? "" : "s"}`]), | |
| 363 | ].join(", "); | |
| 364 | } | |
| 365 | ||
| 310 | 366 | /** |
| 311 | 367 | * Archived things are hidden, not gone (#1838): whatever a sleeping |
| 312 | 368 | * repository did stays out of the feed until it is woken, with no other step. |
test/feed-fold.test.ts added +68
| @@ -0,0 +1,68 @@ | ||
| 1 | /** A pull's opened, reviewed and merged events are one post in the feed (#5808). */ | |
| 2 | import { afterAll, beforeAll, describe, 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 { createRepo } from "../src/actions/repos.ts"; | |
| 8 | import type { Repo } from "../src/core/repos.ts"; | |
| 9 | import { createUser, type User } from "../src/core/users.ts"; | |
| 10 | import { readFeed } from "../src/feed/feed.ts"; | |
| 11 | import { promote } from "./helpers/members.ts"; | |
| 12 | ||
| 13 | describe("the feed folds a pull's events", () => { | |
| 14 | let dataDir: string; | |
| 15 | let app: App; | |
| 16 | let alice: User; | |
| 17 | let repo: Repo; | |
| 18 | const emit = (kind: string, payload: Record<string, unknown>) => | |
| 19 | app.bus.append({ kind, repoId: repo.id, actorId: alice.id, payload }); | |
| 20 | ||
| 21 | beforeAll(async () => { | |
| 22 | dataDir = await mkdtemp(join(tmpdir(), "forge-fold-")); | |
| 23 | app = createApp({ dataDir, port: 0 }); | |
| 24 | const r = await createUser(app.db, { origin: "seed", trusted: true, | |
| 25 | username: "alice", allowReserved: true, email: "[email protected]", | |
| 26 | password: "a-good-passphrase", adminIfFirst: true }); | |
| 27 | if (!r.ok) throw new Error(r.error); | |
| 28 | promote(app.db, r.value); | |
| 29 | alice = r.value; | |
| 30 | const c = await createRepo(app, alice, { name: "garden", visibility: "public" }); | |
| 31 | if (!c.ok) throw new Error("repo"); | |
| 32 | repo = c.value; | |
| 33 | }); | |
| 34 | afterAll(async () => { | |
| 35 | app.close(); | |
| 36 | await rm(dataDir, { recursive: true, force: true }); | |
| 37 | }); | |
| 38 | ||
| 39 | test("30 folded pulls page through once each, and a failed run keeps its post", () => { | |
| 40 | for (let n = 1; n <= 30; n++) { | |
| 41 | emit("pull.opened", { number: n }); | |
| 42 | emit("pull.reviewed", { number: n }); | |
| 43 | emit("pull.reviewed", { number: n }); | |
| 44 | emit("pull.merged", { number: n }); | |
| 45 | } | |
| 46 | emit("run.finished", { kind: "ci", state: "failed", summary: "red", runId: "r1", number: 3 }); | |
| 47 | const seen: string[] = []; | |
| 48 | let cursor: { score: number; id: string } | undefined; | |
| 49 | for (let page = 0; page < 20; page++) { | |
| 50 | const r = readFeed(app.db, alice, { limit: 7, ...(cursor ? { cursor } : {}) }); | |
| 51 | seen.push(...r.items.map((i) => i.link + "|" + i.what)); | |
| 52 | if (r.next_cursor === null) break; | |
| 53 | const [score, id] = [Number(r.next_cursor.split("|")[0]), r.next_cursor.split("|").slice(1).join("|")]; | |
| 54 | cursor = { score, id }; | |
| 55 | } | |
| 56 | const pulls = seen.filter((s) => s.includes("pull request")); | |
| 57 | expect(pulls.length).toBe(30); | |
| 58 | expect(new Set(pulls.map((s) => s.split("|")[0])).size).toBe(30); | |
| 59 | expect(pulls.every((s) => s.includes("merged a pull request") && /opened .+, 2 reviews/.test(s))).toBe(true); | |
| 60 | expect(seen.filter((s) => s.includes("run failed")).length).toBe(1); | |
| 61 | }); | |
| 62 | ||
| 63 | test("a pull with one event reads as before", () => { | |
| 64 | emit("pull.opened", { number: 99 }); | |
| 65 | const item = readFeed(app.db, alice, { limit: 50 }).items.find((i) => i.link.endsWith("/pulls/99")); | |
| 66 | expect(item?.what).toBe("opened a pull request"); | |
| 67 | }); | |
| 68 | }); |
new above
offline
Folded words reuse ago() so they read "3 hours ago". On a narrow screen this makes the line long; consider a compact form here only.