Public: anyone can read this; the crew can change it.
The Later gate's one-time pass proposes keep and merge as well as close, reading each issue's body #8829
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8813-the-later-gate-s-one-time-pass-proposes → main 3 commits fork 3 threads · 3 unresolved
6 files
changed +270
−54
docs/features/later-gate.md +9 −3
| @@ -5,8 +5,14 @@ The Later gate (`Later gate: …`, read by `core/pillars.ts`) holds work back fr | ||
| 5 | 5 | - **Home** says, per repository you write, `N issues are held behind the Later gate in <repo>, set by <who> <when>` (`web/studio/held.ts`), and the line opens `/:owner/:name/held`. |
| 6 | 6 | - **A held issue** says so at its top, with **Lift** (`web/pages/gate.ts`). Lift is `liftHold` (`actions/gate.ts`): the issue stops waiting for the gate, and its `Later` label goes with it. The MCP `lift` tool is the same action. |
| 7 | 7 | - **The label and the gate are one thing.** Giving an issue `Later` puts it behind the gate; taking `Later` off lifts it (`followLabel`, called from `setIssueLabels`). The other direction, an edge changing the label, was already `actions/pillars.ts`. |
| 8 | - **The pass over the lot** (`/:owner/:name/held`, MCP `held`, the terminal's held screen) reads titles only, with no model and no run (`core/gate_triage.ts`). Unchosen ideas are dropped, not tracked (Shape Up): every held issue is a candidate to close, grouped by the word its titles share most. Only one the crew found done on main starts ticked; **unticked is keep**. An issue that open work waits for says so on its row, because closing it parks that work (`dropped` holds dependents). One button closes the ticked (`applyGateReview`). The form carries a digest of the candidates shown (`reviewDigest`, their sorted numbers); if the gate holds other issues when it is applied, it refuses and closes nothing. An agent may read the proposal and never applies it. | |
| 8 | - **The pass over the lot** (`/:owner/:name/held`, MCP `held`, the terminal's held screen) reads titles and bodies, with no model and no run (`core/gate_triage.ts`). Unchosen ideas are dropped, not tracked (Shape Up): every held issue is a candidate to close, grouped by the word its titles share most, unless it is proposed keep or merge (below). Only done and merge start ticked; **unticked stays behind the gate**. An issue that open work waits for says so on its row, because closing it parks that work (`dropped` holds dependents). One button applies the ticked (`applyGateReview`). The form carries a digest of the candidates shown (`reviewDigest`, their sorted numbers and verdicts); if the gate holds other issues when it is applied, it refuses and closes nothing. An agent may read the proposal and never applies it. | |
| 9 | 9 | - **Closed ones stay findable.** The apply writes the `issue.gate_reviewed` event after each close, and a comment note beside it. The event, not the note, is the marker (`closedByReview`): it is the review's only while it is newer than the issue's last close and reopen, and a comment cannot forge it. Each is listed under "Closed by the review" on the held page and shows **Revive** at its top. Revive (the same `lift` action) lets it out from behind the gate and reopens it as one outcome: the hold is checked first, and a reopen that fails puts the hold back. |
| 10 | - **The terminal** has the same page: `h` on a repository's issues, or "Held by the Later gate" in the palette (`tui/screens/held.ts`). `space` ticks, `A` closes the ticked, `l` lifts or revives. It reads and acts through the same functions, so a reader is refused in the same words and a private repository is not found; the web hears each write as the close, reopen and unblock events it already follows. | |
| 10 | - **The terminal** has the same page: `h` on a repository's issues, or "Held by the Later gate" in the palette (`tui/screens/held.ts`). `space` ticks (a keep row shows `[-]` and has no tick), `A` applies the ticked (close or merge), `l` lifts or revives. It reads and acts through the same functions, so a reader is refused in the same words and a private repository is not found; the web hears each write as the close, reopen and unblock events it already follows. | |
| 11 | 11 | |
| 12 | Not done: snoozing an issue until a date or until new activity; merging duplicates; a per-issue "keep" verdict beyond leaving the box unticked. | |
| 12 | Not done: snoozing an issue until a date or until new activity. | |
| 13 | ||
| 14 | ## The pass proposes keep and merge too (#8813) | |
| 15 | ||
| 16 | `core/gate_triage.ts` reads titles and bodies in one pass of word counting: no model call, no cost. Close is the default. An issue that serves a pillar, or that a pillar's open work or a `Bet:` issue names as `#N` (or names it), is proposed **keep**, with the anchor named. A held issue whose title or body is nearly the same as an older held one is proposed **merge**: on apply, its body is appended to the survivor (never replaced), and the duplicate is closed with a note pointing at the survivor. Done and merge start ticked; keep has nothing to tick. The digest covers each verdict, so the apply refuses if the gate changed. | |
| 17 | ||
| 18 | A merge whose survivor is ticked in the same apply is skipped, so the words never land on an issue that closes in the same breath; the duplicate stays open. A bet is found by its `Bet` title among the first 64 such open issues outside the gate; more than that are not read, which is a heuristic until bets (#8713) are a thing of their own. In the terminal, `A` applies and the toast says "Closed or merged N."; a keep row is `[-]` and `space` does nothing on it. |
src/actions/gate_review.ts +78 −16
| @@ -1,8 +1,10 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The one-time pass over what the Later gate holds (#8700): the candidates |
| 3 | 3 | * (`core/gate_triage.ts`) read for a repository, and applied on one approval. |
| 4 | * Applying closes what the person ticked; what they left unticked stays | |
| 5 | * behind the gate. A closed issue keeps a "Gate review:" note and the | |
| 4 | * Applying closes what the person ticked and merges what they ticked as a | |
| 5 | * duplicate (the duplicate's words are appended to the survivor, never | |
| 6 | * replacing them, and its note points at the survivor); what they left | |
| 7 | * unticked stays behind the gate, as does a proposed keep. A closed issue keeps a "Gate review:" note and the | |
| 6 | 8 | * `issue.gate_reviewed` event only this path writes, which is how it is found |
| 7 | 9 | * again and revived (`liftHold`). The approval carries a digest of the |
| 8 | 10 | * candidates the person read; if the gate holds other issues now, the apply |
| @@ -14,17 +16,17 @@ import { createHash } from "node:crypto"; | ||
| 14 | 16 | import type { App } from "../app.ts"; |
| 15 | 17 | import { alreadyDone } from "../core/done_on_main.ts"; |
| 16 | 18 | import { heldIssues, REVIEW_NOTE } from "../core/gate_hold.ts"; |
| 17 | import { startsTicked, triage, type Theme, type Triaged } from "../core/gate_triage.ts"; | |
| 18 | import { getIssue } from "../core/issues.ts"; | |
| 19 | import { pillarsOf } from "../core/pillars.ts"; | |
| 19 | import { startsTicked, triage, type Anchor, type Theme, type Triaged } from "../core/gate_triage.ts"; | |
| 20 | import { getIssue, getIssuesByIds } from "../core/issues.ts"; | |
| 21 | import { pillarsOf, servedBy } from "../core/pillars.ts"; | |
| 20 | 22 | import { canWrite, type Repo } from "../core/repos.ts"; |
| 21 | 23 | import type { User } from "../core/users.ts"; |
| 22 | 24 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 23 | import { addComment, setIssueState } from "./issues.ts"; | |
| 25 | import { addComment, editIssue, setIssueState } from "./issues.ts"; | |
| 24 | 26 | |
| 25 | 27 | export interface GateReview { |
| 26 | 28 | readonly themes: readonly Theme[]; |
| 27 | /** Every held issue as a candidate to close. */ | |
| 29 | /** Every held issue with what is proposed for it: close, keep or merge. */ | |
| 28 | 30 | readonly changes: readonly Triaged[]; |
| 29 | 31 | /** The numbers that start ticked: what the crew found done on main. */ |
| 30 | 32 | readonly ticked: readonly number[]; |
| @@ -33,12 +35,47 @@ export interface GateReview { | ||
| 33 | 35 | readonly held: number; |
| 34 | 36 | } |
| 35 | 37 | |
| 36 | /** A short name for a set of issue numbers, whatever their order. */ | |
| 37 | export function reviewDigest(numbers: readonly number[]): string { | |
| 38 | const sorted = [...numbers].sort((a, b) => a - b).join(","); | |
| 38 | /** A short name for the candidates and what is proposed for each, whatever their order. */ | |
| 39 | export function reviewDigest(changes: readonly Triaged[]): string { | |
| 40 | const sorted = changes | |
| 41 | .map((c) => `${c.number}:${c.verdict.kind}:${c.verdict.kind === "merge" ? c.verdict.into : ""}`) | |
| 42 | .sort() | |
| 43 | .join(","); | |
| 39 | 44 | return createHash("sha256").update(sorted).digest("hex").slice(0, 16); |
| 40 | 45 | } |
| 41 | 46 | |
| 47 | /** A bet is open work titled `Bet:` outside the gate (#8713). */ | |
| 48 | const BET = /^bet\b/i; | |
| 49 | ||
| 50 | /** | |
| 51 | * The open work held issues can join, and which held issues already serve a | |
| 52 | * pillar: two reads, bodies included for the anchors only. | |
| 53 | */ | |
| 54 | function anchorsFor(app: App, repo: Repo, heldIds: ReadonlySet<string>) { | |
| 55 | const set = pillarsOf(app.db, repo); | |
| 56 | const serves = servedBy(app.db, set); | |
| 57 | const ids = [...serves.entries()] | |
| 58 | .filter(([id, name]) => name !== "Later" && !heldIds.has(id)) | |
| 59 | .map(([id]) => id); | |
| 60 | const found = getIssuesByIds(app.db, ids); | |
| 61 | const anchors: Anchor[] = []; | |
| 62 | for (const [id, name] of serves) { | |
| 63 | const issue = found.get(id); | |
| 64 | if (issue === undefined || issue.state !== "open" || issue.kind !== "issue") continue; | |
| 65 | anchors.push({ number: issue.number, label: name, title: issue.title, body: issue.body }); | |
| 66 | } | |
| 67 | for (const p of set.pillars) anchors.push({ number: p.umbrella.number, label: p.name, title: p.umbrella.title, body: p.umbrella.body }); | |
| 68 | const bets = app.db | |
| 69 | .query<{ id: string }, [string]>( | |
| 70 | `SELECT id FROM issue WHERE repo_id = ?1 AND kind = 'issue' AND state = 'open' AND sealed = 0 AND title LIKE 'bet%' LIMIT 64`, | |
| 71 | ) | |
| 72 | .all(repo.id) | |
| 73 | .filter((r) => !heldIds.has(r.id)); | |
| 74 | for (const issue of getIssuesByIds(app.db, bets.map((r) => r.id)).values()) | |
| 75 | if (BET.test(issue.title)) anchors.push({ number: issue.number, label: "Bet", title: issue.title, body: issue.body }); | |
| 76 | return { anchors, serves }; | |
| 77 | } | |
| 78 | ||
| 42 | 79 | /** How many open issues wait for each of these, in one read. */ |
| 43 | 80 | function waitingOn(app: App, ids: readonly string[]): Map<string, number> { |
| 44 | 81 | const out = new Map<string, number>(); |
| @@ -55,7 +92,7 @@ function waitingOn(app: App, ids: readonly string[]): Map<string, number> { | ||
| 55 | 92 | return out; |
| 56 | 93 | } |
| 57 | 94 | |
| 58 | /** What the gate holds, triaged by title: one read. */ | |
| 95 | /** What the gate holds, triaged by title and body: one pass, no model. */ | |
| 59 | 96 | export function reviewGate(app: App, repo: Repo): GateReview | undefined { |
| 60 | 97 | const set = pillarsOf(app.db, repo); |
| 61 | 98 | if (set.gate === undefined) return undefined; |
| @@ -63,16 +100,19 @@ export function reviewGate(app: App, repo: Repo): GateReview | undefined { | ||
| 63 | 100 | const ids = held.map((i) => i.id); |
| 64 | 101 | const done = alreadyDone(app.db, ids); |
| 65 | 102 | const waiting = waitingOn(app, ids); |
| 103 | const { anchors, serves } = anchorsFor(app, repo, new Set(ids)); | |
| 66 | 104 | const themes = triage( |
| 67 | held.map((i) => ({ number: i.number, title: i.title, waiting: waiting.get(i.id) ?? 0 })), | |
| 105 | held.map((i) => ({ number: i.number, title: i.title, body: i.body, waiting: waiting.get(i.id) ?? 0 })), | |
| 68 | 106 | new Set(held.filter((i) => done.has(i.id)).map((i) => i.number)), |
| 107 | anchors, | |
| 108 | new Map(held.flatMap((i) => (serves.get(i.id) !== undefined && serves.get(i.id) !== "Later" ? [[i.number, serves.get(i.id)!] as const] : []))), | |
| 69 | 109 | ); |
| 70 | 110 | const changes = themes.flatMap((t) => t.items); |
| 71 | 111 | return { |
| 72 | 112 | themes, |
| 73 | 113 | changes, |
| 74 | 114 | ticked: changes.filter(startsTicked).map((c) => c.number), |
| 75 | digest: reviewDigest(changes.map((c) => c.number)), | |
| 115 | digest: reviewDigest(changes), | |
| 76 | 116 | held: held.length, |
| 77 | 117 | }; |
| 78 | 118 | } |
| @@ -83,7 +123,7 @@ export type ApplyError = "not_found" | "forbidden" | "stale"; | ||
| 83 | 123 | * Apply the approval the person gave: `approved` are the issue numbers they |
| 84 | 124 | * ticked and `digest` names the candidates they read. If the gate holds other |
| 85 | 125 | * issues now, or an approved one is not a candidate, the whole is refused, so |
| 86 | * a person never approves what they did not read. Returns how many closed. | |
| 126 | * a person never approves what they did not read. Returns how many were closed or merged. | |
| 87 | 127 | */ |
| 88 | 128 | export function applyGateReview( |
| 89 | 129 | app: App, |
| @@ -99,10 +139,32 @@ export function applyGateReview( | ||
| 99 | 139 | const proposed = new Map(review.changes.map((c) => [c.number, c.verdict])); |
| 100 | 140 | if (!approved.every((n) => proposed.has(n))) return err("stale"); |
| 101 | 141 | let closed = 0; |
| 102 | for (const number of new Set(approved)) { | |
| 142 | const chosen = new Set(approved); | |
| 143 | // Merges first, so a survivor takes its words before anything closes it. | |
| 144 | const order = [...new Set(approved)].sort((a, b) => Number(proposed.get(b)!.kind === "merge") - Number(proposed.get(a)!.kind === "merge")); | |
| 145 | for (const number of order) { | |
| 103 | 146 | const issue = getIssue(app.db, repo.id, number); |
| 104 | 147 | const verdict = proposed.get(number)!; |
| 105 | if (issue === undefined || issue.state !== "open") continue; | |
| 148 | if (issue === undefined || issue.state !== "open" || verdict.kind === "keep") continue; | |
| 149 | if (verdict.kind === "merge") { | |
| 150 | const survivor = getIssue(app.db, repo.id, verdict.into); | |
| 151 | if (survivor === undefined || survivor.state !== "open") continue; | |
| 152 | // A survivor closed in this same apply would take the words with it: the duplicate waits. | |
| 153 | if (chosen.has(survivor.number) && proposed.get(survivor.number)!.kind !== "keep") continue; | |
outdated
Survivor may itself be ticked to close in this batch; its merged words are then lost on a closed issue. Skip/refuse that merge. |
||
| 154 | // Append, never replace; if the words do not fit, nothing is closed. | |
| 155 | const merged = `${survivor.body}\n\n---\nMerged from #${issue.number}: ${issue.title}\n\n${issue.body}`; | |
| 156 | if (!editIssue(app, actor, repo, survivor, { body: merged }).ok) continue; | |
| 157 | if (!setIssueState(app, actor, repo, issue, "closed", { reason: "dropped", system: true }).ok) continue; | |
| 158 | app.bus.append({ | |
| 159 | kind: "issue.gate_reviewed", | |
| 160 | repoId: repo.id, | |
| 161 | actorId: actor.id, | |
| 162 | payload: { number: issue.number }, | |
| 163 | }); | |
| 164 | addComment(app, actor, repo, issue, `${REVIEW_NOTE}merged into #${survivor.number}: its words are appended there. Revive it from the held page to bring it back.`); | |
| 165 | closed++; | |
| 166 | continue; | |
| 167 | } | |
| 106 | 168 | if (!setIssueState(app, actor, repo, issue, "closed", { reason: verdict.reason, system: true }).ok) continue; |
| 107 | 169 | // The marker follows the close, never the other way: a failed close leaves none. |
| 108 | 170 | app.bus.append({ |
src/core/gate_triage.ts +94 −14
| @@ -1,27 +1,43 @@ | ||
| 1 | 1 | /** |
| 2 | * A one-time look at what the Later gate holds (#8700), from titles alone: | |
| 3 | * no model, no run, nothing stored. Unchosen ideas are dropped, not tracked | |
| 4 | * (Shape Up): every held issue is a candidate to close, and only one the | |
| 5 | * crew found done on main starts ticked; the rest are the person's to tick, | |
| 6 | * and one that open work waits for is flagged, for closing it parks that | |
| 7 | * work. Unticked is keep. A closed one stays findable and one tap revives | |
| 8 | * it. The lot is grouped by theme, the word its titles share most. Nothing | |
| 9 | * here changes an issue (`actions/gate_review.ts` applies the approval). | |
| 2 | * A one-time look at what the Later gate holds (#8700, #8813), from titles | |
| 3 | * and bodies: one pass of plain word counting, no model, no run, nothing | |
| 4 | * stored. Unchosen ideas are dropped, not tracked (Shape Up), so close is | |
| 5 | * the default. Two things change that, each named in the verdict: | |
| 6 | * keep the issue joins a bet or a pillar's open work: the work serves a | |
| 7 | * pillar, or names it as `#N` (or it names the work), so it stays | |
| 8 | * behind the gate; | |
| 9 | * merge another held issue says the same thing (the older survives): | |
| 10 | * the duplicate's body is appended to it on apply, never replaced. | |
| 11 | * A found-done issue closes first. Only done and merge start ticked (a | |
| 12 | * merge loses no words); the rest are the person's to tick, and one that | |
| 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). | |
| 10 | 17 | */ |
| 11 | 18 | |
| 12 | 19 | export interface Held { |
| 13 | 20 | readonly number: number; |
| 14 | 21 | readonly title: string; |
| 22 | readonly body?: string; | |
| 15 | 23 | /** Open issues that wait for this one: closing it parks them. */ |
| 16 | 24 | readonly waiting: number; |
| 17 | 25 | } |
| 18 | 26 | |
| 19 | export interface Verdict { | |
| 20 | readonly kind: "close"; | |
| 21 | readonly why: string; | |
| 22 | readonly reason: "done" | "dropped"; | |
| 27 | /** Open work outside the gate that held issues can join: a pillar's, or a bet. */ | |
| 28 | export interface Anchor { | |
| 29 | readonly number: number; | |
| 30 | /** `P0 Trust`, or `Bet`. */ | |
| 31 | readonly label: string; | |
| 32 | readonly title: string; | |
| 33 | readonly body: string; | |
| 23 | 34 | } |
| 24 | 35 | |
| 36 | export type Verdict = | |
| 37 | | { readonly kind: "close"; readonly why: string; readonly reason: "done" | "dropped" } | |
| 38 | | { readonly kind: "keep"; readonly why: string; readonly reason: "kept"; readonly anchor: number } | |
| 39 | | { readonly kind: "merge"; readonly why: string; readonly reason: "merged"; readonly into: number }; | |
| 40 | ||
| 25 | 41 | export interface Triaged { |
| 26 | 42 | readonly number: number; |
| 27 | 43 | readonly title: string; |
| @@ -48,7 +64,60 @@ export function words(text: string): string[] { | ||
| 48 | 64 | } |
| 49 | 65 | |
| 50 | 66 | /** Whether a candidate starts ticked: only one the crew found done on main. */ |
| 51 | export const startsTicked = (t: Triaged): boolean => t.verdict.reason === "done"; | |
| 67 | export const startsTicked = (t: Triaged): boolean => | |
| 68 | t.verdict.reason === "done" || t.verdict.kind === "merge"; | |
| 69 | ||
| 70 | /** Words two issues share, over all they use: 1 is the same set. */ | |
| 71 | function overlap(a: ReadonlySet<string>, b: ReadonlySet<string>): number { | |
| 72 | let shared = 0; | |
| 73 | for (const w of a) if (b.has(w)) shared++; | |
| 74 | const union = a.size + b.size - shared; | |
| 75 | return union === 0 ? 0 : shared / union; | |
| 76 | } | |
| 77 | ||
| 78 | /** Titles this alike, or title and body this alike, are one issue said twice. */ | |
| 79 | export const DUPLICATE_TITLE = 0.7; | |
| 80 | export const DUPLICATE_TEXT = 0.6; | |
| 81 | /** Fewer words than this say too little to call a duplicate. */ | |
| 82 | const DUPLICATE_WORDS = 3; | |
| 83 | ||
| 84 | const mentions = (text: string, n: number): boolean => | |
| 85 | new RegExp(`(?<![\\w#])#${n}(?!\\w)`).test(text); | |
| 86 | ||
| 87 | /** The held issue each duplicate folds into: the oldest of its group, by number. */ | |
| 88 | export function duplicates(held: readonly Held[]): Map<number, number> { | |
| 89 | const title = new Map(held.map((h) => [h.number, new Set(words(h.title))])); | |
| 90 | const text = new Map(held.map((h) => [h.number, new Set(words(`${h.title} ${h.body ?? ""}`))])); | |
| 91 | const parent = new Map(held.map((h) => [h.number, h.number])); | |
| 92 | const root = (n: number): number => (parent.get(n) === n ? n : root(parent.get(n)!)); | |
| 93 | const sorted = [...held].sort((a, b) => a.number - b.number); | |
| 94 | for (let i = 0; i < sorted.length; i++) | |
| 95 | for (let j = i + 1; j < sorted.length; j++) { | |
| 96 | const a = sorted[i]!.number; | |
| 97 | const b = sorted[j]!.number; | |
| 98 | const ta = title.get(a)!; | |
| 99 | const tb = title.get(b)!; | |
| 100 | const xa = text.get(a)!; | |
| 101 | const xb = text.get(b)!; | |
| 102 | const alike = | |
| 103 | (ta.size >= DUPLICATE_WORDS && tb.size >= DUPLICATE_WORDS && overlap(ta, tb) >= DUPLICATE_TITLE) || | |
| 104 | (xa.size >= 2 * DUPLICATE_WORDS && xb.size >= 2 * DUPLICATE_WORDS && overlap(xa, xb) >= DUPLICATE_TEXT); | |
| 105 | if (!alike) continue; | |
| 106 | const ra = root(a); | |
| 107 | const rb = root(b); | |
| 108 | if (ra !== rb) parent.set(Math.max(ra, rb), Math.min(ra, rb)); | |
| 109 | } | |
| 110 | const out = new Map<number, number>(); | |
| 111 | for (const h of held) if (root(h.number) !== h.number) out.set(h.number, root(h.number)); | |
| 112 | return out; | |
| 113 | } | |
| 114 | ||
| 115 | /** The anchor a held issue joins: one that names it, or that it names. */ | |
| 116 | function anchorOf(h: Held, anchors: readonly Anchor[]): Anchor | undefined { | |
| 117 | return anchors.find( | |
| 118 | (a) => mentions(`${a.title}\n${a.body}`, h.number) || mentions(`${h.title}\n${h.body ?? ""}`, a.number), | |
| 119 | ); | |
| 120 | } | |
| 52 | 121 | |
| 53 | 122 | /** A theme needs this many issues; a word on fewer is not a theme. */ |
| 54 | 123 | export const THEME_MIN = 3; |
| @@ -60,7 +129,10 @@ export const OTHER = "Other"; | ||
| 60 | 129 | export function triage( |
| 61 | 130 | held: readonly Held[], |
| 62 | 131 | done: ReadonlySet<number> = new Set(), |
| 132 | anchors: readonly Anchor[] = [], | |
| 133 | serves: ReadonlyMap<number, string> = new Map(), | |
| 63 | 134 | ): Theme[] { |
| 135 | const folds = duplicates(held.filter((h) => !done.has(h.number))); | |
| 64 | 136 | const sets = new Map(held.map((h) => [h.number, new Set(words(h.title))])); |
| 65 | 137 | // A theme is a word many titles share: each issue goes under the commonest |
| 66 | 138 | // of its words that is common enough, ties to the shorter, then alphabetical. |
| @@ -74,9 +146,17 @@ export function triage( | ||
| 74 | 146 | const groups = new Map<string, Triaged[]>(chosen.map((w) => [w, []])); |
| 75 | 147 | const other: Triaged[] = []; |
| 76 | 148 | for (const h of held) { |
| 149 | const joined = anchorOf(h, anchors); | |
| 150 | const pillar = serves.get(h.number); | |
| 77 | 151 | const verdict: Verdict = done.has(h.number) |
| 78 | 152 | ? { kind: "close", why: "the crew found it done on main", reason: "done" } |
| 79 | : { kind: "close", why: "not chosen: an idea that matters comes back", reason: "dropped" }; | |
| 153 | : pillar !== undefined | |
| 154 | ? { kind: "keep", why: `serves ${pillar}: stays behind the gate`, reason: "kept", anchor: 0 } | |
| 155 | : joined !== undefined | |
| 156 | ? { kind: "keep", why: `joins ${joined.label} #${joined.number}: stays behind the gate`, reason: "kept", anchor: joined.number } | |
| 157 | : folds.has(h.number) | |
| 158 | ? { kind: "merge", why: `says the same as #${folds.get(h.number)}: merge into it, words appended`, reason: "merged", into: folds.get(h.number)! } | |
| 159 | : { kind: "close", why: "not chosen: an idea that matters comes back", reason: "dropped" }; | |
| 80 | 160 | const row = { number: h.number, title: h.title, waiting: h.waiting, verdict }; |
| 81 | 161 | const theme = chosen.find((w) => sets.get(h.number)!.has(w)); |
| 82 | 162 | if (theme === undefined) other.push(row); |
src/tui/screens/held.ts +11 −8
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | * a change to what the gate holds resets the ticks, so what is approved is |
| 8 | 8 | * always what is shown. |
| 9 | 9 | * |
| 10 | * space tick or untick a candidate A close the ticked | |
| 10 | * space tick or untick a candidate A apply the ticked (close or merge) | |
| 11 | 11 | * l lift (or revive, on a closed) enter open the issue |
| 12 | 12 | */ |
| 13 | 13 | |
| @@ -34,6 +34,8 @@ export interface HeldLine { | ||
| 34 | 34 | readonly why: string; |
| 35 | 35 | readonly waiting: number; |
| 36 | 36 | readonly closed: boolean; |
| 37 | /** Proposed to stay behind the gate: nothing to tick. */ | |
| 38 | readonly keep: boolean; | |
| 37 | 39 | } |
| 38 | 40 | |
| 39 | 41 | export interface HeldRead { |
| @@ -53,17 +55,18 @@ export function readHeld(f: Session, repo: Repo): HeldRead | undefined { | ||
| 53 | 55 | why: i.verdict.why, |
| 54 | 56 | waiting: i.waiting, |
| 55 | 57 | closed: false, |
| 58 | keep: i.verdict.kind === "keep", | |
| 56 | 59 | })), |
| 57 | 60 | ); |
| 58 | 61 | for (const i of reviewedClosed(f.db, repo, pillarsOf(f.db, repo))) |
| 59 | lines.push({ number: i.number, title: i.title, theme: "Closed by the review", why: "revive with l", waiting: 0, closed: true }); | |
| 62 | lines.push({ number: i.number, title: i.title, theme: "Closed by the review", why: "revive with l", waiting: 0, closed: true, keep: false }); | |
| 60 | 63 | return { review, lines }; |
| 61 | 64 | } |
| 62 | 65 | |
| 63 | 66 | /** One row per line; a candidate shows its tick, a flagged one says closing parks work. */ |
| 64 | 67 | export function heldRows(lines: readonly HeldLine[], ticked: ReadonlySet<number>): ListRow[] { |
| 65 | 68 | return lines.map((l) => ({ |
| 66 | title: `${l.closed ? " " : ticked.has(l.number) ? "[x]" : "[ ]"} #${l.number} ${l.title}`, | |
| 69 | title: `${l.closed ? " " : l.keep ? "[-]" : ticked.has(l.number) ? "[x]" : "[ ]"} #${l.number} ${l.title}`, | |
| 67 | 70 | meta: metaOf( |
| 68 | 71 | l.theme, |
| 69 | 72 | l.why, |
| @@ -99,14 +102,14 @@ export function heldScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 99 | 102 | if (f.user === undefined) return void ctx.toast("Sign in first.", "danger"); |
| 100 | 103 | const user = f.user; |
| 101 | 104 | const digest = read.review.digest; |
| 102 | const numbers = [...ticked]; | |
| 105 | const numbers = [...ticked].filter((n) => !lines().some((l) => l.number === n && l.keep)); | |
| 103 | 106 | runAsync(ctx, async () => { |
| 104 | 107 | const done = applyGateReview(f.app, user, repo, numbers, digest); |
| 105 | if (done.ok) ctx.toast(`Closed ${done.value}.`, "success"); | |
| 108 | if (done.ok) ctx.toast(`Closed or merged ${done.value}.`, "success"); | |
| 106 | 109 | else |
| 107 | 110 | ctx.toast( |
| 108 | 111 | done.error === "stale" |
| 109 | ? "What the gate holds changed since you read it. Nothing was closed." | |
| 112 | ? "What the gate holds changed since you read it. Nothing was changed." | |
| 110 | 113 | : done.error === "forbidden" |
| 111 | 114 | ? THREAD_SAYS.planForbidden |
| 112 | 115 | : "There is no Later gate here.", |
| @@ -121,7 +124,7 @@ export function heldScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 121 | 124 | }; |
| 122 | 125 | const toggle = (index: number): void => { |
| 123 | 126 | const line = lines()[index]; |
| 124 | if (line === undefined || line.closed) return; | |
| 127 | if (line === undefined || line.closed || line.keep) return; | |
| 125 | 128 | if (!ticked.delete(line.number)) ticked.add(line.number); |
| 126 | 129 | ctx.invalidate(); |
| 127 | 130 | }; |
| @@ -130,7 +133,7 @@ export function heldScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 130 | 133 | keys: [ |
| 131 | 134 | binding(["j", "k"], "move"), |
| 132 | 135 | binding(["space"], "tick"), |
| 133 | binding(["A"], "close the ticked"), | |
| 136 | binding(["A"], "apply the ticked"), | |
| 134 | 137 | binding(["l"], "lift / revive"), |
| 135 | 138 | binding(["enter"], "open"), |
| 136 | 139 | binding(["esc", "q"], "close"), |
src/web/pages/gate.ts +14 −12
| @@ -80,10 +80,12 @@ export async function liftHoldSubmit(ctx: Ctx): Promise<Response> { | ||
| 80 | 80 | |
| 81 | 81 | // ---- the list, and the one pass over it ---- |
| 82 | 82 | |
| 83 | /** One row of the review: ticked to close, and Lift to let it go instead. */ | |
| 83 | /** One row of the review: ticked to close or merge, a keep with nothing to tick, and Lift to let it go instead. */ | |
| 84 | 84 | function reviewRow(base: string, mayLift: boolean, item: Triaged): Html { |
| 85 | 85 | return html`<li class="bar"> |
| 86 | <input type="checkbox" form="apply" name="n" value="${item.number}" ${startsTicked(item) ? "checked" : ""} aria-label="Close #${item.number}" /> | |
| 86 | ${item.verdict.kind === "keep" | |
| 87 | ? html`<span class="small">Keep</span>` | |
outdated
aria-label on a span is not announced reliably; drop it, the visible word 'Keep' suffices. |
||
| 88 | : html`<input type="checkbox" form="apply" name="n" value="${item.number}" ${startsTicked(item) ? "checked" : ""} aria-label="${item.verdict.kind === "merge" ? "Merge" : "Close"} #${item.number}" />`} | |
| 87 | 89 | <a href="${base}/issues/${item.number}">${item.title}</a> |
| 88 | 90 | <span class="muted small">#${item.number} · ${item.verdict.why}</span> |
| 89 | 91 | ${item.waiting > 0 |
| @@ -115,6 +117,8 @@ export function heldPage(ctx: Ctx): Response { | ||
| 115 | 117 | user !== undefined && |
| 116 | 118 | (canWrite(ctx.app.db, repo, user) || isCrew(ctx.app.db, repo.id, user.id)); |
| 117 | 119 | const mayApply = user !== undefined && user.kind !== "agent" && canWrite(ctx.app.db, repo, user); |
| 120 | const counts = { close: 0, keep: 0, merge: 0 }; | |
| 121 | for (const c of review.changes) counts[c.verdict.kind]++; | |
| 118 | 122 | const closed = reviewedClosed(ctx.app.db, repo, set); |
| 119 | 123 | const body = html`${repoHeader(ctx, repo, "issues", { compact: true })} |
| 120 | 124 | <div class="page-head mt"> |
| @@ -126,18 +130,16 @@ export function heldPage(ctx: Ctx): Response { | ||
| 126 | 130 | them until it is lifted. |
| 127 | 131 | </p> |
| 128 | 132 | </div> |
| 129 | <p> | |
| 130 | Unchosen ideas are dropped, not tracked. Tick the ones to close; only | |
| 131 | those the crew found done on main start ticked. An unticked issue | |
| 132 | stays behind the gate, and Lift lets one go. An issue that open work | |
| 133 | waits for says so: closing it parks that work. A closed issue stays | |
| 134 | findable here and one tap revives it. Nothing changes until you | |
| 135 | approve. | |
| 133 | <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. | |
| 136 | 138 | </p> |
| 137 | 139 | ${mayApply && review.changes.length > 0 |
| 138 | 140 | ? html`<form id="apply" method="post" action="${base}/held/apply" class="bar"> |
| 139 | 141 | <input type="hidden" name="digest" value="${review.digest}" /> |
| 140 | ${btn("Close the ticked", { type: "submit", variant: "primary" })} | |
| 142 | ${btn("Apply the ticked", { type: "submit", variant: "primary" })} | |
| 141 | 143 | </form>` |
| 142 | 144 | : null} |
| 143 | 145 | <div class="stack mt"> |
| @@ -195,10 +197,10 @@ export async function heldApply(ctx: Ctx): Promise<Response> { | ||
| 195 | 197 | tone: "danger", |
| 196 | 198 | message: |
| 197 | 199 | done.error === "stale" |
| 198 | ? "What the gate holds changed since you read it. Nothing was closed; read it again." | |
| 200 | ? "What the gate holds changed since you read it. Nothing was changed; read it again." | |
| 199 | 201 | : done.error === "forbidden" |
| 200 | 202 | ? THREAD_SAYS.planForbidden |
| 201 | 203 | : "There is no Later gate here.", |
| 202 | 204 | }); |
| 203 | return flashTo(ctx, dest, { message: `Closed ${done.value}.` }); | |
| 205 | return flashTo(ctx, dest, { message: `Closed or merged ${done.value}.` }); | |
| 204 | 206 | } |
test/later-gate.test.ts +64 −1
| @@ -14,6 +14,7 @@ import { liftHold } from "../src/actions/gate.ts"; | ||
| 14 | 14 | import { addComment, openIssue, setIssueLabels, setIssueState } from "../src/actions/issues.ts"; |
| 15 | 15 | import { createLabel, createRepo } from "../src/actions/repos.ts"; |
| 16 | 16 | import { closedByReview, gateHold, gateHolding, reviewedClosed } from "../src/core/gate_hold.ts"; |
| 17 | import { heldRows } from "../src/tui/screens/held.ts"; | |
| 17 | 18 | import { startsTicked, triage } from "../src/core/gate_triage.ts"; |
| 18 | 19 | import { pillarsOf } from "../src/core/pillars.ts"; |
| 19 | 20 | import { createToken } from "../src/core/tokens.ts"; |
| @@ -167,13 +168,75 @@ describe("the Later gate in plain sight", () => { | ||
| 167 | 168 | expect(triage([]).length).toBe(0); |
| 168 | 169 | }); |
| 169 | 170 | |
| 171 | test("triage proposes merge for a duplicate pair and keep for what a bet names", () => { | |
| 172 | const themes = triage( | |
| 173 | [ | |
| 174 | { number: 10, title: "Export the archive as CSV", body: "Let people download rows.", waiting: 0 }, | |
| 175 | { number: 11, title: "Export archive as CSV file", body: "Download rows please.", waiting: 0 }, | |
| 176 | { number: 12, title: "Dark mode toggle", body: "", waiting: 0 }, | |
| 177 | { number: 13, title: "Paint the footer blue", body: "", waiting: 0 }, | |
| 178 | ], | |
| 179 | new Set(), | |
| 180 | [{ number: 90, label: "Bet", title: "Bet: theming", body: "Ships #12 this cycle." }], | |
| 181 | ); | |
| 182 | const v = new Map(themes.flatMap((t) => t.items).map((r) => [r.number, r.verdict])); | |
| 183 | expect(v.get(11)).toMatchObject({ kind: "merge", into: 10 }); | |
| 184 | expect(v.get(10)).toMatchObject({ kind: "close", reason: "dropped" }); | |
| 185 | expect(v.get(12)).toMatchObject({ kind: "keep", anchor: 90 }); | |
| 186 | expect(v.get(12)!.why).toContain("Bet #90"); | |
| 187 | expect(v.get(13)).toMatchObject({ kind: "close", reason: "dropped" }); | |
| 188 | }); | |
| 189 | ||
| 190 | test("a merge appends the duplicate's words, points at the survivor, and a changed gate refuses", () => { | |
| 191 | const a = openIssue(app, alice, fresh(), { title: "Frobnicate the widget pipeline", body: "Original words about frobnicating." }); | |
| 192 | const b = openIssue(app, alice, fresh(), { title: "Frobnicate widget pipeline", body: "Extra words nobody may lose." }); | |
| 193 | if (!a.ok || !b.ok) throw new Error("open"); | |
| 194 | for (const i of [a.value, b.value]) expect(linkIssue(app, alice, fresh(), current(i), gate).ok).toBe(true); | |
| 195 | const read = reviewGate(app, fresh())!; | |
| 196 | expect(read.changes.find((c) => c.number === b.value.number)!.verdict).toMatchObject({ kind: "merge", into: a.value.number }); | |
| 197 | expect(read.ticked).toContain(b.value.number); | |
| 198 | // The gate changes after reading: refused, nothing merged. | |
| 199 | const late = held("Arrived after the read"); | |
| 200 | expect(applyGateReview(app, alice, fresh(), [b.value.number], read.digest)).toEqual({ ok: false, error: "stale" }); | |
| 201 | expect(current(b.value).state).toBe("open"); | |
| 202 | expect(current(late).state).toBe("open"); | |
| 203 | const again = reviewGate(app, fresh())!; | |
| 204 | expect(applyGateReview(app, alice, fresh(), [b.value.number], again.digest)).toEqual({ ok: true, value: 1 }); | |
| 205 | const survivor = current(a.value); | |
| 206 | expect(survivor.state).toBe("open"); | |
| 207 | expect(survivor.body).toContain("Original words about frobnicating."); | |
| 208 | expect(survivor.body).toContain("Extra words nobody may lose."); | |
| 209 | expect(survivor.body).toContain(`Merged from #${b.value.number}`); | |
| 210 | expect(current(b.value).state).toBe("closed"); | |
| 211 | 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 | }); | |
| 213 | ||
| 214 | test("a merge is skipped when its survivor closes in the same apply", () => { | |
| 215 | const a = openIssue(app, alice, fresh(), { title: "Reticulate the spline index", body: "Survivor words." }); | |
| 216 | const b = openIssue(app, alice, fresh(), { title: "Reticulate spline index", body: "Duplicate words." }); | |
| 217 | if (!a.ok || !b.ok) throw new Error("open"); | |
| 218 | for (const i of [a.value, b.value]) expect(linkIssue(app, alice, fresh(), current(i), gate).ok).toBe(true); | |
| 219 | const read = reviewGate(app, fresh())!; | |
| 220 | expect(applyGateReview(app, alice, fresh(), [a.value.number, b.value.number], read.digest)).toEqual({ ok: true, value: 1 }); | |
| 221 | expect(current(a.value).state).toBe("closed"); | |
| 222 | expect(current(a.value).body).not.toContain("Duplicate words."); | |
| 223 | expect(current(b.value).state).toBe("open"); | |
| 224 | }); | |
| 225 | ||
| 226 | test("the terminal draws a keep row with no tick", () => { | |
| 227 | const line = { number: 5, title: "Kept", theme: "t", why: "joins Bet #1", waiting: 0, closed: false, keep: true }; | |
| 228 | const rows = heldRows([line, { ...line, number: 6, keep: false }], new Set([5, 6])); | |
| 229 | expect(rows[0]!.title).toStartWith("[-]"); | |
| 230 | expect(rows[1]!.title).toStartWith("[x]"); | |
| 231 | }); | |
| 232 | ||
| 170 | 233 | test("one approval closes what is ticked, it stays findable, and one tap revives it", async () => { |
| 171 | 234 | const a = open("Idea nobody chose"); |
| 172 | 235 | const b = open("Idea to keep behind the gate"); |
| 173 | 236 | for (const i of [a, b]) linkIssue(app, alice, fresh(), current(i), gate); |
| 174 | 237 | const review = reviewGate(app, fresh())!; |
| 175 | 238 | expect(review.changes.map((c) => c.number)).toContain(a.number); |
| 176 | expect(await get("/alice/engine/held")).toContain("Close the ticked"); | |
| 239 | expect(await get("/alice/engine/held")).toContain("Apply the ticked"); | |
| 177 | 240 | // A proposal that is not the one shown is refused whole. |
| 178 | 241 | expect(applyGateReview(app, alice, fresh(), [999999], review.digest)).toEqual({ ok: false, error: "stale" }); |
| 179 | 242 | expect(current(a).state).toBe("open"); |
new above
offline
byNumber/serves juggling is convoluted; i.number is available directly.