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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1805-a-variant-group-is-one-choose-item-in-th → main 2 commits fork 4 threads · 4 unresolved
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 | ||
| 917 | 917 | decision ledger. A dislike closes the pull and leaves its one-line reason as a |
| 918 | 918 | comment the builder reads; the last open sibling shows Ship. Readers see the |
| 919 | 919 | 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[] = [ | ||
| 183 | 183 | "test/plan-route.test.ts", |
| 184 | 184 | "test/planner-declined.test.ts", |
| 185 | 185 | "test/feed.test.ts", |
| 186 | "test/feed-choose.test.ts", | |
| 186 | 187 | "test/feed-page.test.ts", |
| 187 | 188 | "test/offline.test.ts", |
| 188 | 189 | "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) { | |
| 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( | ||
| 54 | 54 | c.id.startsWith("ask:") |
| 55 | 55 | ? true |
| 56 | 56 | : !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)), | |
| 58 | 61 | ); |
| 59 | 62 | let ranked = rank(candidates, affinityFor(db, viewer, now), now); |
| 60 | 63 | const cursor = options.cursor; |
src/feed/items.ts +5 −1
| @@ -25,6 +25,7 @@ import { | ||
| 25 | 25 | } from "../core/repos.ts"; |
| 26 | 26 | import { getUserById, type User } from "../core/users.ts"; |
| 27 | 27 | import { watchedBy } from "../core/watches.ts"; |
| 28 | import { chooseCandidates } from "./choose.ts"; | |
| 28 | 29 | import { askLinesFor } from "../web/studio/asks.ts"; |
| 29 | 30 | |
| 30 | 31 | export type FeedKind = |
| @@ -39,7 +40,9 @@ export type FeedKind = | ||
| 39 | 40 | | "tell" |
| 40 | 41 | | "reply" |
| 41 | 42 | | "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. |
||
| 43 | 46 | |
| 44 | 47 | /** What a candidate is, before it is ranked and worded. */ |
| 45 | 48 | export interface Candidate { |
| @@ -256,6 +259,7 @@ export function candidatesFor( | ||
| 256 | 259 | } |
| 257 | 260 | out.push(...watchedCandidates(db, viewer, now)); |
| 258 | 261 | out.push(...frozenCandidates(db, viewer, now)); |
| 262 | out.push(...chooseCandidates(db, writable)); | |
| 259 | 263 | for (const d of decided) { |
| 260 | 264 | const repo = repos.get(d.repoId); |
| 261 | 265 | if (!repo) continue; |
src/web/studio/feed.ts +2 −1
| @@ -37,6 +37,7 @@ const KIND_LABEL: Record<FeedItem["kind"], string> = { | ||
| 37 | 37 | map: "Map", |
| 38 | 38 | cost: "Cost", |
| 39 | 39 | milestone: "Watching", |
| 40 | choose: "Choose", | |
| 40 | 41 | tell: "Told", |
| 41 | 42 | reply: "Reply", |
| 42 | 43 | look: "Look", |
| @@ -88,7 +89,7 @@ export function feedCard( | ||
| 88 | 89 | </form> |
| 89 | 90 | <span class="fcard-acts"> |
| 90 | 91 | <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>`} | |
| 92 | 93 | </span> |
| 93 | 94 | </details> |
| 94 | 95 | </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: |
||
| 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 | }); |
new above
offline
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.