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

Merged opened this

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

Merged

mason/forge:mason/8857-the-later-gate-s-proposal-reads-grouped → main 3 commits fork 2 threads · 2 unresolved

  • Merged. mason/8857-the-later-gate-s-proposal-reads-grouped was merged into main as 015259c.

Review

Sign in to review this change.
4 files changed +85 −19
src/core/gate_triage.ts +31 −6
@@ -11,9 +11,9 @@
1111 * A found-done issue closes first. Only done and merge start ticked (a
1212 * merge loses no words); the rest are the person's to tick, and one that
1313 * 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
outdated

Wrap this comment line to match the surrounding width.

16 * here changes an issue (`actions/gate_review.ts` applies the approval).
1717 */
1818
1919 export interface Held {
@@ -47,9 +47,24 @@ export interface Triaged {
4747
4848 export interface Theme {
4949 readonly name: string;
50 /** The group is a pillar's, not a shared word. */
51 readonly pillar?: true;
5052 readonly items: readonly Triaged[];
5153 }
5254
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
5368 const STOP = new Set(
5469 "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(
5570 " ",
@@ -133,7 +148,9 @@ export function triage(
133148 serves: ReadonlyMap<number, string> = new Map(),
134149 ): Theme[] {
135150 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))]));
137154 // A theme is a word many titles share: each issue goes under the commonest
138155 // of its words that is common enough, ties to the shorter, then alphabetical.
139156 const freq = new Map<string, number>();
@@ -145,6 +162,7 @@ export function triage(
145162 .map(([w]) => w);
146163 const groups = new Map<string, Triaged[]>(chosen.map((w) => [w, []]));
147164 const other: Triaged[] = [];
165 const byPillar = new Map<string, Triaged[]>();
148166 for (const h of held) {
149167 const joined = anchorOf(h, anchors);
150168 const pillar = serves.get(h.number);
@@ -158,12 +176,19 @@ export function triage(
158176 ? { kind: "merge", why: `says the same as #${folds.get(h.number)}: merge into it, words appended`, reason: "merged", into: folds.get(h.number)! }
159177 : { kind: "close", why: "not chosen: an idea that matters comes back", reason: "dropped" };
160178 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 }
161183 const theme = chosen.find((w) => sets.get(h.number)!.has(w));
162184 if (theme === undefined) other.push(row);
163185 else groups.get(theme)!.push(row);
164186 }
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]
166191 .filter(([, items]) => items.length > 0)
167 .map(([name, items]) => ({ name, items }));
192 .map(([name, items]) => ({ name, items }))];
168193 return other.length === 0 ? out : [...out, { name: OTHER, items: other }];
169194 }
src/tui/screens/held.ts +2 −1
@@ -16,6 +16,7 @@ import { applyGateReview, reviewGate, type GateReview } from "../../actions/gate
1616 import { THREAD_SAYS } from "../../actions/thread_words.ts";
1717 import { reviewedClosed } from "../../core/gate_hold.ts";
1818 import { getIssue } from "../../core/issues.ts";
19 import { tallyLine } from "../../core/gate_triage.ts";
1920 import { pillarsOf } from "../../core/pillars.ts";
2021 import type { Repo } from "../../core/repos.ts";
2122 import type { Ctx, Screen } from "../app/app.ts";
@@ -51,7 +52,7 @@ export function readHeld(f: Session, repo: Repo): HeldRead | undefined {
5152 t.items.map((i) => ({
5253 number: i.number,
5354 title: i.title,
54 theme: t.name,
55 theme: `${t.name} · ${tallyLine(t.items)}`,
5556 why: i.verdict.why,
5657 waiting: i.waiting,
5758 closed: false,
src/web/pages/gate.ts +13 −11
@@ -6,7 +6,7 @@
66
77 import { applyGateReview, reviewGate } from "../../actions/gate_review.ts";
88 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";
1010 import { pillarsOf } from "../../core/pillars.ts";
1111 import { THREAD_SAYS } from "../../actions/thread_words.ts";
1212 import { isCrew } from "../../core/agents.ts";
@@ -99,6 +99,14 @@ function reviewRow(base: string, mayLift: boolean, item: Triaged): Html {
9999 </li>`;
100100 }
101101
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
102110 // GET /:owner/:name/held — what the gate holds, grouped by theme, with one pass proposed
103111 export function heldPage(ctx: Ctx): Response {
104112 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
@@ -131,10 +139,9 @@ export function heldPage(ctx: Ctx): Response {
131139 </p>
132140 </div>
133141 <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.
138145 </p>
139146 ${mayApply && review.changes.length > 0
140147 ? html`<form id="apply" method="post" action="${base}/held/apply" class="bar">
@@ -143,12 +150,7 @@ export function heldPage(ctx: Ctx): Response {
143150 </form>`
144151 : null}
145152 <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))}
152154 ${closed.length === 0
153155 ? null
154156 : disclosure(
test/later-gate.test.ts +39 −1
@@ -15,7 +15,7 @@ import { addComment, openIssue, setIssueLabels, setIssueState } from "../src/act
1515 import { createLabel, createRepo } from "../src/actions/repos.ts";
1616 import { closedByReview, gateHold, gateHolding, reviewedClosed } from "../src/core/gate_hold.ts";
1717 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";
1919 import { pillarsOf } from "../src/core/pillars.ts";
2020 import { createToken } from "../src/core/tokens.ts";
2121 import { getIssueById, labelsFor, type Issue } from "../src/core/issues.ts";
@@ -168,6 +168,24 @@ describe("the Later gate in plain sight", () => {
168168 expect(triage([]).length).toBe(0);
169169 });
170170
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
171189 test("triage proposes merge for a duplicate pair and keep for what a bet names", () => {
172190 const themes = triage(
173191 [
@@ -211,6 +229,26 @@ describe("the Later gate in plain sight", () => {
211229 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();
212230 });
213231
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
214252 test("a merge is skipped when its survivor closes in the same apply", () => {
215253 const a = openIssue(app, alice, fresh(), { title: "Reticulate the spline index", body: "Survivor words." });
216254 const b = openIssue(app, alice, fresh(), { title: "Reticulate spline index", body: "Duplicate words." });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs