Public: anyone can read this; the crew can change it.
The Later gate's proposal reads grouped by theme in one screen at phone width #8864
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8857-the-later-gate-s-proposal-reads-grouped → main 3 commits fork 2 threads · 2 unresolved
4 files
changed +85
−19
src/core/gate_triage.ts +31 −6
| @@ -11,9 +11,9 @@ | ||
| 11 | 11 | * A found-done issue closes first. Only done and merge start ticked (a |
| 12 | 12 | * merge loses no words); the rest are the person's to tick, and one that |
| 13 | 13 | * open work waits for is flagged, for closing it parks that work. A closed |
| 14 | * one stays findable and one tap revives it. The lot is grouped by theme, | |
| 15 | * the word its titles share most. Nothing here changes an issue | |
| 16 | * (`actions/gate_review.ts` applies the approval). | |
| 14 | * one stays findable and one tap revives it. The lot is grouped by theme: | |
| 15 | * the pillar an issue serves, else the word its titles share most. Nothing | |
| 16 | * here changes an issue (`actions/gate_review.ts` applies the approval). | |
| 17 | 17 | */ |
| 18 | 18 | |
| 19 | 19 | export interface Held { |
| @@ -47,9 +47,24 @@ export interface Triaged { | ||
| 47 | 47 | |
| 48 | 48 | export interface Theme { |
| 49 | 49 | readonly name: string; |
| 50 | /** The group is a pillar's, not a shared word. */ | |
| 51 | readonly pillar?: true; | |
| 50 | 52 | readonly items: readonly Triaged[]; |
| 51 | 53 | } |
| 52 | 54 | |
| 55 | /** A group's issues counted by verdict: the one line a group reads as. */ | |
| 56 | export function tally(items: readonly Triaged[]): { close: number; keep: number; merge: number } { | |
| 57 | const n = { close: 0, keep: 0, merge: 0 }; | |
| 58 | for (const i of items) n[i.verdict.kind]++; | |
| 59 | return n; | |
| 60 | } | |
| 61 | ||
| 62 | /** The tally as one line, `3 close · 1 keep`: a verdict with none is left out. */ | |
| 63 | export function tallyLine(items: readonly Triaged[]): string { | |
| 64 | const n = tally(items); | |
| 65 | return [`${n.close} close`, `${n.keep} keep`, `${n.merge} merge`].filter((p) => !p.startsWith("0 ")).join(" · "); | |
| 66 | } | |
| 67 | ||
| 53 | 68 | const STOP = new Set( |
| 54 | 69 | "a an and are as at be by for from has have how in is it its of on or that the this to was we with when what who which not no can do does your you our new add make use used using into out up per via issue issues".split( |
| 55 | 70 | " ", |
| @@ -133,7 +148,9 @@ export function triage( | ||
| 133 | 148 | serves: ReadonlyMap<number, string> = new Map(), |
| 134 | 149 | ): Theme[] { |
| 135 | 150 | const folds = duplicates(held.filter((h) => !done.has(h.number))); |
| 136 | const sets = new Map(held.map((h) => [h.number, new Set(words(h.title))])); | |
| 151 | // Issues that serve a pillar group under it; the rest by shared title words. | |
| 152 | const loose = held.filter((h) => serves.get(h.number) === undefined); | |
| 153 | const sets = new Map(loose.map((h) => [h.number, new Set(words(h.title))])); | |
| 137 | 154 | // A theme is a word many titles share: each issue goes under the commonest |
| 138 | 155 | // of its words that is common enough, ties to the shorter, then alphabetical. |
| 139 | 156 | const freq = new Map<string, number>(); |
| @@ -145,6 +162,7 @@ export function triage( | ||
| 145 | 162 | .map(([w]) => w); |
| 146 | 163 | const groups = new Map<string, Triaged[]>(chosen.map((w) => [w, []])); |
| 147 | 164 | const other: Triaged[] = []; |
| 165 | const byPillar = new Map<string, Triaged[]>(); | |
| 148 | 166 | for (const h of held) { |
| 149 | 167 | const joined = anchorOf(h, anchors); |
| 150 | 168 | const pillar = serves.get(h.number); |
| @@ -158,12 +176,19 @@ export function triage( | ||
| 158 | 176 | ? { kind: "merge", why: `says the same as #${folds.get(h.number)}: merge into it, words appended`, reason: "merged", into: folds.get(h.number)! } |
| 159 | 177 | : { kind: "close", why: "not chosen: an idea that matters comes back", reason: "dropped" }; |
| 160 | 178 | const row = { number: h.number, title: h.title, waiting: h.waiting, verdict }; |
| 179 | if (pillar !== undefined) { | |
| 180 | byPillar.set(pillar, [...(byPillar.get(pillar) ?? []), row]); | |
| 181 | continue; | |
| 182 | } | |
| 161 | 183 | const theme = chosen.find((w) => sets.get(h.number)!.has(w)); |
| 162 | 184 | if (theme === undefined) other.push(row); |
| 163 | 185 | else groups.get(theme)!.push(row); |
| 164 | 186 | } |
| 165 | const out = [...groups] | |
| 187 | const pillars: Theme[] = [...byPillar] | |
| 188 | .sort((a, b) => a[0].localeCompare(b[0])) | |
| 189 | .map(([name, items]) => ({ name, pillar: true as const, items })); | |
| 190 | const out = [...pillars, ...[...groups] | |
| 166 | 191 | .filter(([, items]) => items.length > 0) |
| 167 | .map(([name, items]) => ({ name, items })); | |
| 192 | .map(([name, items]) => ({ name, items }))]; | |
| 168 | 193 | return other.length === 0 ? out : [...out, { name: OTHER, items: other }]; |
| 169 | 194 | } |
src/tui/screens/held.ts +2 −1
| @@ -16,6 +16,7 @@ import { applyGateReview, reviewGate, type GateReview } from "../../actions/gate | ||
| 16 | 16 | import { THREAD_SAYS } from "../../actions/thread_words.ts"; |
| 17 | 17 | import { reviewedClosed } from "../../core/gate_hold.ts"; |
| 18 | 18 | import { getIssue } from "../../core/issues.ts"; |
| 19 | import { tallyLine } from "../../core/gate_triage.ts"; | |
| 19 | 20 | import { pillarsOf } from "../../core/pillars.ts"; |
| 20 | 21 | import type { Repo } from "../../core/repos.ts"; |
| 21 | 22 | import type { Ctx, Screen } from "../app/app.ts"; |
| @@ -51,7 +52,7 @@ export function readHeld(f: Session, repo: Repo): HeldRead | undefined { | ||
| 51 | 52 | t.items.map((i) => ({ |
| 52 | 53 | number: i.number, |
| 53 | 54 | title: i.title, |
| 54 | theme: t.name, | |
| 55 | theme: `${t.name} · ${tallyLine(t.items)}`, | |
| 55 | 56 | why: i.verdict.why, |
| 56 | 57 | waiting: i.waiting, |
| 57 | 58 | closed: false, |
src/web/pages/gate.ts +13 −11
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | |
| 7 | 7 | import { applyGateReview, reviewGate } from "../../actions/gate_review.ts"; |
| 8 | 8 | import { liftHold } from "../../actions/gate.ts"; |
| 9 | import { startsTicked, type Triaged } from "../../core/gate_triage.ts"; | |
| 9 | import { startsTicked, tallyLine, type Theme, type Triaged } from "../../core/gate_triage.ts"; | |
| 10 | 10 | import { pillarsOf } from "../../core/pillars.ts"; |
| 11 | 11 | import { THREAD_SAYS } from "../../actions/thread_words.ts"; |
| 12 | 12 | import { isCrew } from "../../core/agents.ts"; |
| @@ -99,6 +99,14 @@ function reviewRow(base: string, mayLift: boolean, item: Triaged): Html { | ||
| 99 | 99 | </li>`; |
| 100 | 100 | } |
| 101 | 101 | |
| 102 | /** A group is one line: its name and its counts by verdict, opening to its issues. */ | |
| 103 | function themeLine(base: string, mayLift: boolean, t: Theme): Html { | |
No test renders this line. Add one that fetches the held page and asserts the summary text with counts, omitted zeros, and the rows inside the disclosure. Then attach the 390px look showing the page fits one screen. |
||
| 104 | return disclosure( | |
| 105 | html`${t.name} <span class="muted small">${tallyLine(t.items)}</span>`, | |
| 106 | html`<ul class="stack stack-tight">${t.items.map((i) => reviewRow(base, mayLift, i))}</ul>`, | |
| 107 | ); | |
| 108 | } | |
| 109 | ||
| 102 | 110 | // GET /:owner/:name/held — what the gate holds, grouped by theme, with one pass proposed |
| 103 | 111 | export function heldPage(ctx: Ctx): Response { |
| 104 | 112 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| @@ -131,10 +139,9 @@ export function heldPage(ctx: Ctx): Response { | ||
| 131 | 139 | </p> |
| 132 | 140 | </div> |
| 133 | 141 | <p class="muted small">${counts.close} to close · ${counts.keep} to keep · ${counts.merge} to merge</p> |
| 134 | <p class="small"> | |
| 135 | Close is the default. A keep joins a bet or a pillar's work; a merge | |
| 136 | folds a duplicate into the older issue, words appended. Nothing changes | |
| 137 | until you approve. | |
| 142 | <p class="muted small"> | |
| 143 | Close is the default; a keep joins a bet or a pillar's work; a merge | |
| 144 | folds a duplicate in. Nothing changes until you approve. | |
| 138 | 145 | </p> |
| 139 | 146 | ${mayApply && review.changes.length > 0 |
| 140 | 147 | ? html`<form id="apply" method="post" action="${base}/held/apply" class="bar"> |
| @@ -143,12 +150,7 @@ export function heldPage(ctx: Ctx): Response { | ||
| 143 | 150 | </form>` |
| 144 | 151 | : null} |
| 145 | 152 | <div class="stack mt"> |
| 146 | ${review.themes.map((t) => | |
| 147 | disclosure( | |
| 148 | html`${t.name} <span class="muted small">${t.items.length}</span>`, | |
| 149 | html`<ul class="stack stack-tight">${t.items.map((i) => reviewRow(base, mayLift, i))}</ul>`, | |
| 150 | ), | |
| 151 | )} | |
| 153 | ${review.themes.map((t) => themeLine(base, mayLift, t))} | |
| 152 | 154 | ${closed.length === 0 |
| 153 | 155 | ? null |
| 154 | 156 | : disclosure( |
test/later-gate.test.ts +39 −1
| @@ -15,7 +15,7 @@ import { addComment, openIssue, setIssueLabels, setIssueState } from "../src/act | ||
| 15 | 15 | import { createLabel, createRepo } from "../src/actions/repos.ts"; |
| 16 | 16 | import { closedByReview, gateHold, gateHolding, reviewedClosed } from "../src/core/gate_hold.ts"; |
| 17 | 17 | import { heldRows } from "../src/tui/screens/held.ts"; |
| 18 | import { startsTicked, triage } from "../src/core/gate_triage.ts"; | |
| 18 | import { startsTicked, tally, triage } from "../src/core/gate_triage.ts"; | |
| 19 | 19 | import { pillarsOf } from "../src/core/pillars.ts"; |
| 20 | 20 | import { createToken } from "../src/core/tokens.ts"; |
| 21 | 21 | import { getIssueById, labelsFor, type Issue } from "../src/core/issues.ts"; |
| @@ -168,6 +168,24 @@ describe("the Later gate in plain sight", () => { | ||
| 168 | 168 | expect(triage([]).length).toBe(0); |
| 169 | 169 | }); |
| 170 | 170 | |
| 171 | test("a pillar's issues group under it first, each group tallied by verdict", () => { | |
| 172 | const themes = triage( | |
| 173 | [ | |
| 174 | { number: 1, title: "Search the archive", waiting: 0 }, | |
| 175 | { number: 2, title: "Search archive export", waiting: 0 }, | |
| 176 | { number: 3, title: "Search archive filters", waiting: 0 }, | |
| 177 | { number: 4, title: "Search archive sorting", waiting: 0 }, | |
| 178 | ], | |
| 179 | new Set(), | |
| 180 | [], | |
| 181 | new Map([[2, "P0 Trust"]]), | |
| 182 | ); | |
| 183 | expect(themes[0]).toMatchObject({ name: "P0 Trust", pillar: true }); | |
| 184 | expect(tally(themes[0]!.items)).toEqual({ close: 0, keep: 1, merge: 0 }); | |
| 185 | expect(themes[1]!.name).toBe("search"); | |
| 186 | expect(tally(themes[1]!.items)).toEqual({ close: 3, keep: 0, merge: 0 }); | |
| 187 | }); | |
| 188 | ||
| 171 | 189 | test("triage proposes merge for a duplicate pair and keep for what a bet names", () => { |
| 172 | 190 | const themes = triage( |
| 173 | 191 | [ |
| @@ -211,6 +229,26 @@ describe("the Later gate in plain sight", () => { | ||
| 211 | 229 | expect(app.db.query("SELECT 1 FROM comment WHERE issue_id = ?1 AND body LIKE ?2").get(b.value.id, `Gate review: merged into #${a.value.number}%`)).not.toBeNull(); |
| 212 | 230 | }); |
| 213 | 231 | |
| 232 | test("the held page reads each group as one tallied line, its issues inside the disclosure", async () => { | |
| 233 | const made = ["Zanzibar ledger reconcile", "Zanzibar harbour tolls", "Zanzibar customs sweep"].map(held); | |
| 234 | const page = await get("/alice/engine/held"); | |
| 235 | const group = page.match(/<details[^>]*>\s*<summary[^>]*>\s*zanzibar[\s\S]*?<\/details>/)?.[0]; | |
| 236 | expect(group).toBeDefined(); | |
| 237 | const summary = group!.match(/<summary[^>]*>([\s\S]*?)<\/summary>/)![1]!; | |
| 238 | expect(summary).toContain("3 close"); | |
| 239 | expect(summary).not.toMatch(/keep|merge|\b0 /); | |
| 240 | const inside = group!.slice(group!.indexOf("</summary>")); | |
| 241 | for (const t of ["Zanzibar ledger reconcile", "Zanzibar harbour tolls", "Zanzibar customs sweep"]) | |
| 242 | expect(inside).toContain(t); | |
| 243 | // The terminal reads the same tally. | |
| 244 | const lines = heldRows( | |
| 245 | (await import("../src/tui/screens/held.ts")).readHeld({ app, db: app.db } as never, fresh())!.lines, | |
| 246 | new Set(), | |
| 247 | ); | |
| 248 | expect(lines.some((l) => JSON.stringify(l.meta).includes("zanzibar · 3 close"))).toBe(true); | |
| 249 | for (const i of made) setIssueState(app, alice, fresh(), current(i), "closed"); | |
| 250 | }); | |
| 251 | ||
| 214 | 252 | test("a merge is skipped when its survivor closes in the same apply", () => { |
| 215 | 253 | const a = openIssue(app, alice, fresh(), { title: "Reticulate the spline index", body: "Survivor words." }); |
| 216 | 254 | const b = openIssue(app, alice, fresh(), { title: "Reticulate spline index", body: "Duplicate words." }); |
new above
offline
Wrap this comment line to match the surrounding width.