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

Merged opened this

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

Merged

scout/forge:scout/5808-a-pull-s-opened-reviewed-and-merged-post → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/5808-a-pull-s-opened-reviewed-and-merged-post was merged into main as cea02b1.

Review

Sign in to review this change.
2 files changed +125 −1
src/feed/items.ts +57 −1
@@ -27,6 +27,7 @@ import {
2727 import { getUserById, type User } from "../core/users.ts";
2828 import { watchedBy } from "../core/watches.ts";
2929 import { chooseCandidates } from "./choose.ts";
30 import { ago } from "./rank.ts";
3031 import { askLinesFor } from "../web/studio/asks.ts";
3132
3233 export type FeedKind =
@@ -255,11 +256,19 @@ export function candidatesFor(
255256 };
256257
257258 const out: Candidate[] = [];
259 const folded = foldPulls(events);
258260 for (const e of events) {
259261 const repo = repos.get(e.repoId!);
260262 if (!repo) continue;
263 const fold = folded.get(e.id);
264 if (fold === "skip") continue;
261265 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 });
263272 }
264273 for (const a of asks) {
265274 const repo = repos.get(a.repoId);
@@ -307,6 +316,53 @@ export function candidatesFor(
307316 return awake(db, out, repos);
308317 }
309318
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(", ");

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.

364 }
365
310366 /**
311367 * Archived things are hidden, not gone (#1838): whatever a sleeping
312368 * 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs