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

A variant group is one `choose` item in the feed that opens its deck #1858

Merged opened this

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

Merged

scout/forge:scout/1805-a-variant-group-is-one-choose-item-in-th → main 2 commits fork 4 threads · 4 unresolved

  • Merged. scout/1805-a-variant-group-is-one-choose-item-in-th was merged into main as 3bb4211.

Review

Sign in to review this change.
7 files changed +241 −3
README.md +7
@@ -917,3 +917,10 @@ terminal. Each person has one vote per pull, kept as a `lean` row in the
917917 decision ledger. A dislike closes the pull and leaves its one-line reason as a
918918 comment the builder reads; the last open sibling shows Ship. Readers see the
919919 cards and no buttons.
920
921 While two or more siblings are open and none has shipped, a writer's feed holds
922 one **Choose** item for the group ("choose among N open variants of …") that
923 links to the deck, whatever the number of siblings; it is never muted away.
924 Once a sibling ships or all but one close, the item leaves the feed. The
925 terminal has no feed; the item is in `GET /api/v1/feed` and the MCP feed tool
926 as kind `choose`.
scripts/gate.ts +1
@@ -183,6 +183,7 @@ export const GATE: readonly string[] = [
183183 "test/plan-route.test.ts",
184184 "test/planner-declined.test.ts",
185185 "test/feed.test.ts",
186 "test/feed-choose.test.ts",
186187 "test/feed-page.test.ts",
187188 "test/offline.test.ts",
188189 "test/share.test.ts",
src/feed/choose.ts added +72
@@ -0,0 +1,72 @@
1 /**
2 * A variant group as one feed item: the tell's siblings are one choice, not
3 * one post each. While two or more are open and none has shipped it asks the
4 * viewer to choose, linking to the deck; once one ships, or all but one
5 * close, the item goes: a decided group says nothing. One grouped query, whatever the
6 * number of siblings. Only a repository the viewer may write is read — the
7 * deck's votes are theirs to cast — and a sealed pull is left out.
8 */
9
10 import type { Database } from "bun:sqlite";
11 import type { Repo } from "../core/repos.ts";
12 import type { Candidate } from "./items.ts";
13
14 /** How many groups, newest first, one read looks at. */
15 export const GROUP_WINDOW = 50;
16 /** Above the news, below a failure and an ask. */
17 export const CHOOSE_URGENCY = 3;
18
19 const GROUP = /^[A-Za-z0-9_-]{1,64}$/;
20
21 interface Row {
22 repo_id: string;
23 grp: string;
24 open: number;
25 at: string;
26 title: string;
27 }
28
29 export function chooseCandidates(
30 db: Database,
31 repos: readonly Repo[],
32 ): Candidate[] {
33 if (repos.length === 0) return [];
34 const byId = new Map(repos.map((r) => [r.id, r]));
35 const marks = repos.map((_, i) => `?${i + 1}`).join(",");
36 const rows = db
37 .query<Row, (string | number)[]>(
38 // The bare `title` is the row of MIN(number): the group's first pull.
39 `SELECT i.repo_id, p.variant_group AS grp,
40 SUM(i.state = 'open') AS open,
41 MAX(i.updated_at) AS at,
42 i.title, MIN(i.number)
43 FROM pull p JOIN issue i ON i.id = p.issue_id
44 WHERE i.repo_id IN (${marks}) AND p.variant_group IS NOT NULL
45 AND i.sealed = 0
46 GROUP BY i.repo_id, p.variant_group
47 HAVING SUM(p.merged_at IS NOT NULL) = 0 AND SUM(i.state = 'open') >= 2
48 ORDER BY at DESC LIMIT ${GROUP_WINDOW}`,
49 )
50 .all(...repos.map((r) => r.id));
51 const out: Candidate[] = [];
52 for (const row of rows) {

id is choose:<group> only; the group is unique per repo, not globally. Include repo.id so a dismissal or the rank cannot cross repos.

53 const repo = byId.get(row.repo_id);
54 if (!repo || !GROUP.test(row.grp)) continue;
55 const title = row.title.length > 60 ? `${row.title.slice(0, 59)}…` : row.title;
56 const what = `choose among ${row.open} open variants of “${title}”`;
57 out.push({
outdated

Decided groups are emitted with urgency 0 and never expire. Skip them (continue when !asks) or bound them by age.

58 id: `choose:${row.grp}`,
59 kind: "choose",
60 repoId: repo.id,
61 app: `${repo.ownerName}/${repo.name}`,
62 actorId: null,
63 actor: null,
64 at: row.at,
65 link: `/${repo.ownerName}/${repo.name}/variants/${row.grp}`,
66 what,
67 urgency: CHOOSE_URGENCY,
68 said: what,
69 });
70 }
71 return out;
72 }
src/feed/feed.ts +4 −1
@@ -54,7 +54,10 @@ export function readFeed(
5454 c.id.startsWith("ask:")
5555 ? true
5656 : !dismissed.has(c.id) &&
57 (c.kind === "ask" || c.kind === "milestone" || !muted.has(c.repoId)),
57 (c.kind === "ask" ||
58 c.kind === "milestone" ||
59 c.kind === "choose" ||
60 !muted.has(c.repoId)),
5861 );
5962 let ranked = rank(candidates, affinityFor(db, viewer, now), now);
6063 const cursor = options.cursor;
src/feed/items.ts +5 −1
@@ -25,6 +25,7 @@ import {
2525 } from "../core/repos.ts";
2626 import { getUserById, type User } from "../core/users.ts";
2727 import { watchedBy } from "../core/watches.ts";
28 import { chooseCandidates } from "./choose.ts";
2829 import { askLinesFor } from "../web/studio/asks.ts";
2930
3031 export type FeedKind =
@@ -39,7 +40,9 @@ export type FeedKind =
3940 | "tell"
4041 | "reply"
4142 | "look"
42 | "milestone";
43 | "milestone"
44 // A variant group: one choice among open siblings, or that it was made.
45 | "choose";

Stale: the item is gone once the group is decided, not reworded as made. Say: a variant group with two or more open siblings and none shipped.

4346
4447 /** What a candidate is, before it is ranked and worded. */
4548 export interface Candidate {
@@ -256,6 +259,7 @@ export function candidatesFor(
256259 }
257260 out.push(...watchedCandidates(db, viewer, now));
258261 out.push(...frozenCandidates(db, viewer, now));
262 out.push(...chooseCandidates(db, writable));
259263 for (const d of decided) {
260264 const repo = repos.get(d.repoId);
261265 if (!repo) continue;
src/web/studio/feed.ts +2 −1
@@ -37,6 +37,7 @@ const KIND_LABEL: Record<FeedItem["kind"], string> = {
3737 map: "Map",
3838 cost: "Cost",
3939 milestone: "Watching",
40 choose: "Choose",
4041 tell: "Told",
4142 reply: "Reply",
4243 look: "Look",
@@ -88,7 +89,7 @@ export function feedCard(
8889 </form>
8990 <span class="fcard-acts">
9091 <button type="button" class="btn btn-sm btn-ghost" data-feed="dismissed" title="Dismiss (x)">Dismiss</button>
91 ${item.kind === "ask" || item.kind === "milestone" ? null : html`<button type="button" class="btn btn-sm btn-ghost" data-feed="muted" title="Mute ${item.app}">Mute app</button>`}
92 ${item.kind === "ask" || item.kind === "milestone" || item.kind === "choose" ? null : html`<button type="button" class="btn btn-sm btn-ghost" data-feed="muted" title="Mute ${item.app}">Mute app</button>`}
9293 </span>
9394 </details>
9495 </article>`;
test/feed-choose.test.ts added +150
@@ -0,0 +1,150 @@
1 /** A variant group is one `choose` item in the feed, and it stops asking once decided. */
2 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3 import type { Database } from "bun:sqlite";
4 import { mkdtemp, rm } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { createApp, type App } from "../src/app.ts";
8 import { createRepo } from "../src/actions/repos.ts";
9 import { muteRepo } from "../src/core/interactions.ts";
10 import { createToken } from "../src/core/tokens.ts";
11 import { serve, type Servers } from "../src/http/server.ts";
12 import { createIssue } from "../src/core/issues.ts";
13 import type { Repo } from "../src/core/repos.ts";
14 import { createUser, type User } from "../src/core/users.ts";
15 import { readFeed } from "../src/feed/feed.ts";
16 import { chooseCandidates } from "../src/feed/choose.ts";
17
18 describe("the feed's choose item", () => {
19 let dataDir: string;
20 let app: App;
21 let servers: Servers;
22 let alice: User;
23 let bob: User;
24 let repo: Repo;
25
26 beforeAll(async () => {
27 dataDir = await mkdtemp(join(tmpdir(), "forge-choose-"));
28 app = createApp({ dataDir, port: 0 });
29 servers = await serve(app);
30 const mk = async (username: string, first = false) => {
31 const r = await createUser(app.db, { trusted: true,
32 username,
33 allowReserved: true,
34 email: `${username}@f.dev`,
35 password: "a-good-passphrase",
36 adminIfFirst: first,
37 });
38 if (!r.ok) throw new Error(r.error);
39 return r.value;
40 };
41 await mk("root", true);
42 alice = await mk("alice");
43 bob = await mk("bob");
44 const r = await createRepo(app, alice, { name: "garden", visibility: "public" });
45 if (!r.ok) throw new Error(`repo: ${JSON.stringify(r)}`);
46 repo = r.value;
47 });
48 afterAll(async () => {
49 servers.stop();
50 app.close();
51 await rm(dataDir, { recursive: true, force: true });
52 });
53
54 /** Sibling pulls in one group, written straight to the tables. */
55 function group(name: string, n: number): string[] {
56 const ids: string[] = [];
57 for (let i = 0; i < n; i++) {
58 const issue = createIssue(app.db, {
59 repoId: repo.id,
60 authorId: alice.id,
61 title: `${name} <b>${i}</b>`,
62 kind: "pull",
63 });
64 if (!issue.ok) throw new Error(issue.error);
65 app.db
66 .query(
67 `INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, variant_group)
68 VALUES (?1, 'main', ?2, 'a', 'b', ?3)`,
69 )
70 .run(issue.value.id, `${name}-${i}`, name);
71 ids.push(issue.value.id);
72 }
73 return ids;
74 }
75 const chooses = (viewer: User, name: string) =>
76 readFeed(app.db, viewer, { now: Date.now() }).items.filter(
77 (i) => i.id === `choose:${name}`,
78 );
79
80 test("two sibling pulls are one choose item that asks and links to the deck", () => {
81 group("pair", 2);
82 const items = chooses(alice, "pair");
83 expect(items).toHaveLength(1);
84 expect(items[0]!.kind).toBe("choose");
85 expect(items[0]!.what).toBe("choose among 2 open variants of “pair <b>0</b>”");
86 expect(items[0]!.link).toBe("/alice/garden/variants/pair");
87 expect(items[0]!.what).toContain("<b>0</b>");
88 // Nothing else in the feed repeats the group per pull.
89 expect(readFeed(app.db, alice).items.filter((i) => i.kind === "choose")).toHaveLength(1);
90 });
91
92 test("one who cannot write the repository is not asked", () => {
93 expect(chooses(bob, "pair")).toHaveLength(0);
94 });
95
96 test("a shipped sibling decides the group: the item is gone", () => {
97 const ids = group("shipped", 3);
98 expect(chooses(alice, "shipped")).toHaveLength(1);
99 app.db.query("UPDATE pull SET merged_at = '2026-10-01T00:00:00Z' WHERE issue_id = ?1").run(ids[0]!);
100 app.db.query("UPDATE issue SET state = 'closed' WHERE id IN (?1, ?2)").run(ids[1]!, ids[2]!);
101 expect(chooses(alice, "shipped")).toHaveLength(0);
102 });
103
104 test("a group whose siblings all closed asks nothing", () => {
105 const ids = group("closed", 2);
106 app.db.query("UPDATE issue SET state = 'closed' WHERE id IN (?1, ?2)").run(ids[0]!, ids[1]!);
107 expect(chooses(alice, "closed")).toHaveLength(0);
108 });
109
110 test("a group down to one open sibling has nothing left to choose", () => {
111 const ids = group("last", 2);
112 app.db.query("UPDATE issue SET state = 'closed' WHERE id = ?1").run(ids[0]!);
113 expect(chooses(alice, "last")).toHaveLength(0);
114 });
115
116 test("muting the app does not mute an open choice", () => {
117 muteRepo(app.db, alice.id, repo.id);
118 expect(chooses(alice, "pair")).toHaveLength(1);
119 });
outdated

Dead code: void name and the unused parameter; drop both.

120
121 test("the choose item reaches the API feed", async () => {
122 const token = createToken(app.db, alice.id, "t", "rw").token;
123 const res = await fetch(`${servers.url}/api/v1/feed`, {
124 headers: { authorization: `Bearer ${token}` },
125 });
126 const body = (await res.json()) as { items: { id: string; kind: string; what: string }[] };
127 const item = body.items.find((i) => i.id === "choose:pair");
128 expect(item?.kind).toBe("choose");
129 expect(item?.what).toBe("choose among 2 open variants of “pair <b>0</b>”");
130 });
131
132 test("the item's query count does not grow with the siblings", () => {
133 const count = (): number => {
134 let n = 0;
135 const spy = new Proxy(app.db, {
136 get(t, k) {
137 const v: unknown = Reflect.get(t, k, t);
138 if (k === "query")
139 return (...a: Parameters<Database["query"]>) => (n++, t.query(...a));
140 return typeof v === "function" ? v.bind(t) : v;
141 },
142 }) as Database;
143 expect(chooseCandidates(spy, [repo]).length).toBeGreaterThan(0);
144 return n;
145 };
146 const before = count();
147 group("wide", 12);
148 expect(count()).toBe(before);
149 });
150 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs