Public: anyone can read this; the crew can change it.
The Later gate is in plain sight: what it holds, who set it and when, and one action to lift any of it #8711
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8700-the-later-gate-is-in-plain-sight-what-it → main 6 commits fork
25 files
changed +1577
−3
docs/features/later-gate.md added +12
| @@ -0,0 +1,12 @@ | ||
| 1 | # The Later gate in plain sight (#8700) | |
| 2 | ||
| 3 | The Later gate (`Later gate: …`, read by `core/pillars.ts`) holds work back from the crew. A person is told so where they look: | |
| 4 | ||
| 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 | - **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 | - **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. | |
| 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. | |
| 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. |
src/actions/gate.ts added +79
| @@ -0,0 +1,79 @@ | ||
| 1 | /** | |
| 2 | * The Later gate, in plain sight (#8700): lifting what it holds, and keeping | |
| 3 | * the `Later` label and the gate one thing. The gate is an edge (the issue | |
| 4 | * waits for the gate issue) and the label is that edge made visible | |
| 5 | * (`actions/pillars.ts` writes the label when the edge changes); this is the | |
| 6 | * other direction, so a person who sets or removes the label by hand changes | |
| 7 | * the edge too, and lifting by either road reads the same. | |
| 8 | */ | |
| 9 | ||
| 10 | import type { App } from "../app.ts"; | |
| 11 | import { closedByReview, gateHolding, gateOf } from "../core/gate_hold.ts"; | |
| 12 | import type { Issue, Label } from "../core/issues.ts"; | |
| 13 | import { isStructural, LATER, pillarsOf } from "../core/pillars.ts"; | |
| 14 | import type { Repo } from "../core/repos.ts"; | |
| 15 | import type { User } from "../core/users.ts"; | |
| 16 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 17 | import { isMirror } from "../origins/write-through.ts"; | |
| 18 | import type { DependencyError } from "../core/dependencies.ts"; | |
| 19 | import type { AccessError } from "./access.ts"; | |
| 20 | import { linkIssue, unlinkIssue } from "./dependencies.ts"; | |
| 21 | import { setIssueState } from "./issues.ts"; | |
| 22 | ||
| 23 | /** Whether a label set carries the `Later` label. */ | |
| 24 | export const hasLater = (labels: readonly Pick<Label, "name">[]): boolean => | |
| 25 | labels.some((l) => l.name.toLowerCase() === LATER.toLowerCase()); | |
| 26 | ||
| 27 | /** | |
| 28 | * Let the issue go from behind the gate: it waits for the gate no more, and | |
| 29 | * its `Later` label goes with it. One the gate review closed is reopened as | |
| 30 | * part of it: an idea that matters comes back with one tap. The hold is | |
| 31 | * checked first, and a reopen that fails puts the hold back, so the answer | |
| 32 | * is the whole outcome. One action, the same for a person and an agent; | |
| 33 | * whoever may link may lift. | |
| 34 | */ | |
| 35 | export function liftHold( | |
| 36 | app: App, | |
| 37 | actor: User | undefined, | |
| 38 | repo: Repo, | |
| 39 | issue: Issue, | |
| 40 | ): Result<Issue, AccessError | "not_held"> { | |
| 41 | const revive = closedByReview(app.db, issue); | |
| 42 | const gate = revive ? gateOf(app.db, repo, issue) : gateHolding(app.db, repo, issue); | |
| 43 | if (gate === undefined) return err("not_held"); | |
| 44 | const lifted = unlinkIssue(app, actor, repo, issue, gate); | |
| 45 | if (!lifted.ok) return lifted; | |
| 46 | if (!revive) return ok(issue); | |
| 47 | const reopened = setIssueState(app, actor, repo, issue, "open"); | |
| 48 | if (!reopened.ok) { | |
| 49 | linkIssue(app, actor, repo, issue, gate); | |
| 50 | return err(reopened.error === "unauthorized" || reopened.error === "not_found" ? reopened.error : "forbidden"); | |
| 51 | } | |
| 52 | return ok(reopened.value); | |
| 53 | } | |
| 54 | ||
| 55 | /** | |
| 56 | * Labels were just replaced by hand. Giving an issue `Later` puts it behind | |
| 57 | * the gate; taking `Later` off lifts it. Nothing happens where the | |
| 58 | * repository has no gate, for the issue is the plan's own furniture, or for | |
| 59 | * a mirror (its labels are its origin's). A link or unlink that fails is | |
| 60 | * returned, so the caller never leaves the label and the gate apart. | |
| 61 | */ | |
| 62 | export function followLabel( | |
| 63 | app: App, | |
| 64 | actor: User, | |
| 65 | repo: Repo, | |
| 66 | issue: Issue, | |
| 67 | before: readonly Pick<Label, "name">[], | |
| 68 | after: readonly Pick<Label, "name">[], | |
| 69 | ): Result<void, AccessError | DependencyError> { | |
| 70 | const was = hasLater(before); | |
| 71 | const is = hasLater(after); | |
| 72 | if (was === is || issue.kind !== "issue" || isMirror(app, repo)) return ok(undefined); | |
| 73 | const set = pillarsOf(app.db, repo); | |
| 74 | if (set.gate === undefined || isStructural(set, issue.id)) return ok(undefined); | |
| 75 | const holding = gateHolding(app.db, repo, issue) !== undefined; | |
| 76 | if (is && !holding) return linkIssue(app, actor, repo, issue, set.gate); | |
| 77 | if (!is && holding) return unlinkIssue(app, actor, repo, issue, set.gate); | |
| 78 | return ok(undefined); | |
| 79 | } |
src/actions/gate_review.ts added +118
| @@ -0,0 +1,118 @@ | ||
| 1 | /** | |
| 2 | * The one-time pass over what the Later gate holds (#8700): the candidates | |
| 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 | |
| 6 | * `issue.gate_reviewed` event only this path writes, which is how it is found | |
| 7 | * again and revived (`liftHold`). The approval carries a digest of the | |
| 8 | * candidates the person read; if the gate holds other issues now, the apply | |
| 9 | * refuses. A person approves — an agent may read the proposal and never | |
| 10 | * apply it. | |
| 11 | */ | |
| 12 | ||
| 13 | import { createHash } from "node:crypto"; | |
| 14 | import type { App } from "../app.ts"; | |
| 15 | import { alreadyDone } from "../core/done_on_main.ts"; | |
| 16 | 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"; | |
| 20 | import { canWrite, type Repo } from "../core/repos.ts"; | |
| 21 | import type { User } from "../core/users.ts"; | |
| 22 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 23 | import { addComment, setIssueState } from "./issues.ts"; | |
| 24 | ||
| 25 | export interface GateReview { | |
| 26 | readonly themes: readonly Theme[]; | |
| 27 | /** Every held issue as a candidate to close. */ | |
| 28 | readonly changes: readonly Triaged[]; | |
| 29 | /** The numbers that start ticked: what the crew found done on main. */ | |
| 30 | readonly ticked: readonly number[]; | |
| 31 | /** Names the candidates shown; the apply must carry it back. */ | |
| 32 | readonly digest: string; | |
| 33 | readonly held: number; | |
| 34 | } | |
| 35 | ||
| 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(","); | |
| 39 | return createHash("sha256").update(sorted).digest("hex").slice(0, 16); | |
| 40 | } | |
| 41 | ||
| 42 | /** How many open issues wait for each of these, in one read. */ | |
| 43 | function waitingOn(app: App, ids: readonly string[]): Map<string, number> { | |
| 44 | const out = new Map<string, number>(); | |
| 45 | if (ids.length === 0) return out; | |
| 46 | const rows = app.db | |
| 47 | .query<{ blocked_by_id: string; n: number }, string[]>( | |
| 48 | `SELECT d.blocked_by_id, COUNT(*) AS n FROM issue_dependency d | |
| 49 | JOIN issue ON issue.id = d.issue_id | |
| 50 | WHERE issue.state = 'open' AND d.blocked_by_id IN (${ids.map((_, i) => `?${i + 1}`).join(",")}) | |
| 51 | GROUP BY d.blocked_by_id`, | |
| 52 | ) | |
| 53 | .all(...ids); | |
| 54 | for (const r of rows) out.set(r.blocked_by_id, r.n); | |
| 55 | return out; | |
| 56 | } | |
| 57 | ||
| 58 | /** What the gate holds, triaged by title: one read. */ | |
| 59 | export function reviewGate(app: App, repo: Repo): GateReview | undefined { | |
| 60 | const set = pillarsOf(app.db, repo); | |
| 61 | if (set.gate === undefined) return undefined; | |
| 62 | const held = heldIssues(app.db, repo, set); | |
| 63 | const ids = held.map((i) => i.id); | |
| 64 | const done = alreadyDone(app.db, ids); | |
| 65 | const waiting = waitingOn(app, ids); | |
| 66 | const themes = triage( | |
| 67 | held.map((i) => ({ number: i.number, title: i.title, waiting: waiting.get(i.id) ?? 0 })), | |
| 68 | new Set(held.filter((i) => done.has(i.id)).map((i) => i.number)), | |
| 69 | ); | |
| 70 | const changes = themes.flatMap((t) => t.items); | |
| 71 | return { | |
| 72 | themes, | |
| 73 | changes, | |
| 74 | ticked: changes.filter(startsTicked).map((c) => c.number), | |
| 75 | digest: reviewDigest(changes.map((c) => c.number)), | |
| 76 | held: held.length, | |
| 77 | }; | |
| 78 | } | |
| 79 | ||
| 80 | export type ApplyError = "not_found" | "forbidden" | "stale"; | |
| 81 | ||
| 82 | /** | |
| 83 | * Apply the approval the person gave: `approved` are the issue numbers they | |
| 84 | * ticked and `digest` names the candidates they read. If the gate holds other | |
| 85 | * 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. | |
| 87 | */ | |
| 88 | export function applyGateReview( | |
| 89 | app: App, | |
| 90 | actor: User, | |
| 91 | repo: Repo, | |
| 92 | approved: readonly number[], | |
| 93 | digest: string | undefined, | |
| 94 | ): Result<number, ApplyError> { | |
| 95 | if (actor.kind === "agent" || !canWrite(app.db, repo, actor)) return err("forbidden"); | |
| 96 | const review = reviewGate(app, repo); | |
| 97 | if (review === undefined) return err("not_found"); | |
| 98 | if (digest !== review.digest) return err("stale"); | |
| 99 | const proposed = new Map(review.changes.map((c) => [c.number, c.verdict])); | |
| 100 | if (!approved.every((n) => proposed.has(n))) return err("stale"); | |
| 101 | let closed = 0; | |
| 102 | for (const number of new Set(approved)) { | |
| 103 | const issue = getIssue(app.db, repo.id, number); | |
| 104 | const verdict = proposed.get(number)!; | |
| 105 | if (issue === undefined || issue.state !== "open") continue; | |
| 106 | if (!setIssueState(app, actor, repo, issue, "closed", { reason: verdict.reason }).ok) continue; | |
| 107 | // The marker follows the close, never the other way: a failed close leaves none. | |
| 108 | app.bus.append({ | |
| 109 | kind: "issue.gate_reviewed", | |
| 110 | repoId: repo.id, | |
| 111 | actorId: actor.id, | |
| 112 | payload: { number: issue.number }, | |
| 113 | }); | |
| 114 | addComment(app, actor, repo, issue, `${REVIEW_NOTE}closed: ${verdict.why}. Revive it from the held page to bring it back.`); | |
| 115 | closed++; | |
| 116 | } | |
| 117 | return ok(closed); | |
| 118 | } |
src/actions/issues.ts +9
| @@ -68,6 +68,7 @@ import { | ||
| 68 | 68 | } from "../origins/import.ts"; |
| 69 | 69 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 70 | 70 | import { clearFirst } from "../core/first.ts"; |
| 71 | import { followLabel } from "./gate.ts"; | |
| 71 | 72 | import { inheritPillar } from "./pillars.ts"; |
| 72 | 73 | import { |
| 73 | 74 | requireParticipant, |
| @@ -705,6 +706,7 @@ export function setIssueLabels( | ||
| 705 | 706 | if (!label) return err("unknown_label"); |
| 706 | 707 | if (!resolved.some((l) => l.id === label.id)) resolved.push(label); |
| 707 | 708 | } |
| 709 | const before = labelsFor(app.db, issue.id); | |
| 708 | 710 | writeLabels( |
| 709 | 711 | app.db, |
| 710 | 712 | issue.id, |
| @@ -716,6 +718,13 @@ export function setIssueLabels( | ||
| 716 | 718 | actorId: writer.value.id, |
| 717 | 719 | payload: { number: issue.number, labels: resolved.map((l) => l.name) }, |
| 718 | 720 | }); |
| 721 | // `Later` is the Later gate made visible: changing the label changes the gate. | |
| 722 | const followed = followLabel(app, writer.value, repo, issue, before, resolved); | |
| 723 | if (!followed.ok) { | |
| 724 | // The label and the gate move together or not at all. | |
| 725 | writeLabels(app.db, issue.id, before.map((l) => l.id)); | |
| 726 | return err("forbidden"); | |
| 727 | } | |
| 719 | 728 | return ok(labelsFor(app.db, issue.id)); |
| 720 | 729 | } |
| 721 | 730 |
src/core/event_audience.ts +1
| @@ -38,6 +38,7 @@ const READER_KINDS: ReadonlySet<string> = new Set([ | ||
| 38 | 38 | "issue.labeled", |
| 39 | 39 | "issue.first", |
| 40 | 40 | "issue.closed", |
| 41 | "issue.gate_reviewed", | |
| 41 | 42 | "issue.commented", |
| 42 | 43 | "usage.delta", |
| 43 | 44 | "issue.raised", |
src/core/gate_hold.ts added +159
| @@ -0,0 +1,159 @@ | ||
| 1 | /** | |
| 2 | * What the Later gate holds (#8700): the open issues that wait behind it, who | |
| 3 | * set the gate and when, read from the same edges `core/pillars.ts` reads. | |
| 4 | * Nothing is stored. The gate is the plan's own issue, so "who set it" is its | |
| 5 | * author and "when" is the day it was opened. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { Database } from "bun:sqlite"; | |
| 9 | import { getIssue, type Issue } from "./issues.ts"; | |
| 10 | import { isStructural, pillarsOf, type Pillars } from "./pillars.ts"; | |
| 11 | import type { Repo } from "./repos.ts"; | |
| 12 | ||
| 13 | export interface GateHold { | |
| 14 | readonly gate: Issue; | |
| 15 | /** Open issues waiting behind the gate, not counting the plan's furniture. */ | |
| 16 | readonly held: number; | |
| 17 | /** Who opened the gate. */ | |
| 18 | readonly by: string; | |
| 19 | /** When it was opened, an ISO instant. */ | |
| 20 | readonly since: string; | |
| 21 | } | |
| 22 | ||
| 23 | /** The open issues waiting behind the gate, newest first, the plan's furniture left out. */ | |
| 24 | export function heldIssues( | |
| 25 | db: Database, | |
| 26 | repo: Repo, | |
| 27 | set: Pillars, | |
| 28 | limit = 500, | |
| 29 | ): Issue[] { | |
| 30 | if (set.gate === undefined) return []; | |
| 31 | const rows = db | |
| 32 | .query<{ number: number }, [string, string, number]>( | |
| 33 | `SELECT issue.number FROM issue_dependency d | |
| 34 | JOIN issue ON issue.id = d.issue_id | |
| 35 | WHERE d.blocked_by_id = ?1 AND issue.repo_id = ?2 | |
| 36 | AND issue.kind = 'issue' AND issue.state = 'open' AND issue.sealed = 0 | |
| 37 | ORDER BY issue.number DESC LIMIT ?3`, | |
| 38 | ) | |
| 39 | .all(set.gate.id, repo.id, limit + 8); | |
| 40 | const out: Issue[] = []; | |
| 41 | for (const row of rows) { | |
| 42 | const issue = getIssue(db, repo.id, row.number); | |
| 43 | if (issue !== undefined && !isStructural(set, issue.id)) out.push(issue); | |
| 44 | if (out.length === limit) break; | |
| 45 | } | |
| 46 | return out; | |
| 47 | } | |
| 48 | ||
| 49 | /** How many open issues wait behind the gate: one read. */ | |
| 50 | export function countHeld(db: Database, repo: Repo, set: Pillars): number { | |
| 51 | if (set.gate === undefined) return 0; | |
| 52 | const structural = [ | |
| 53 | ...set.pillars.map((p) => p.umbrella.id), | |
| 54 | ...(set.later === undefined ? [] : [set.later.id]), | |
| 55 | ]; | |
| 56 | const row = db | |
| 57 | .query<{ n: number }, string[]>( | |
| 58 | `SELECT COUNT(*) AS n FROM issue_dependency d | |
| 59 | JOIN issue ON issue.id = d.issue_id | |
| 60 | WHERE d.blocked_by_id = ?1 AND issue.repo_id = ?2 | |
| 61 | AND issue.kind = 'issue' AND issue.state = 'open' AND issue.sealed = 0 | |
| 62 | ${structural.length === 0 ? "" : `AND issue.id NOT IN (${structural.map((_, i) => `?${i + 3}`).join(",")})`}`, | |
| 63 | ) | |
| 64 | .get(set.gate.id, repo.id, ...structural); | |
| 65 | return row?.n ?? 0; | |
| 66 | } | |
| 67 | ||
| 68 | /** The repository's gate hold, or nothing where there is no gate or it holds nothing. */ | |
| 69 | export function gateHold(db: Database, repo: Repo): GateHold | undefined { | |
| 70 | const set = pillarsOf(db, repo); | |
| 71 | if (set.gate === undefined) return undefined; | |
| 72 | const held = countHeld(db, repo, set); | |
| 73 | if (held === 0) return undefined; | |
| 74 | return { | |
| 75 | gate: set.gate, | |
| 76 | held, | |
| 77 | by: set.gate.authorName, | |
| 78 | since: set.gate.createdAt, | |
| 79 | }; | |
| 80 | } | |
| 81 | ||
| 82 | /** The gate this issue waits behind, open or closed, if it does. */ | |
| 83 | export function gateOf( | |
| 84 | db: Database, | |
| 85 | repo: Repo, | |
| 86 | issue: Issue, | |
| 87 | ): Issue | undefined { | |
| 88 | if (issue.kind !== "issue") return undefined; | |
| 89 | const set = pillarsOf(db, repo); | |
| 90 | if (set.gate === undefined || isStructural(set, issue.id)) return undefined; | |
| 91 | const row = db | |
| 92 | .query<{ one: number }, [string, string]>( | |
| 93 | "SELECT 1 AS one FROM issue_dependency WHERE issue_id = ?1 AND blocked_by_id = ?2", | |
| 94 | ) | |
| 95 | .get(issue.id, set.gate.id); | |
| 96 | return row === null ? undefined : set.gate; | |
| 97 | } | |
| 98 | ||
| 99 | /** The gate this open issue waits behind, if it does. */ | |
| 100 | export function gateHolding( | |
| 101 | db: Database, | |
| 102 | repo: Repo, | |
| 103 | issue: Issue, | |
| 104 | ): Issue | undefined { | |
| 105 | return issue.state === "open" ? gateOf(db, repo, issue) : undefined; | |
| 106 | } | |
| 107 | ||
| 108 | /** The note a gate review leaves on an issue it closed (`actions/gate_review.ts`). */ | |
| 109 | export const REVIEW_NOTE = "Gate review: "; | |
| 110 | ||
| 111 | /** | |
| 112 | * The event only the review's apply path writes, right after the close. An | |
| 113 | * issue is "closed by the review" while it is the latest of this, a close and | |
| 114 | * a reopen on that issue: a comment cannot forge it, and a reopen followed by | |
| 115 | * a manual close does not read as the review's. | |
| 116 | */ | |
| 117 | export const GATE_REVIEWED = "issue.gate_reviewed"; // also named in `actions/gate_review.ts` and `core/event_audience.ts` | |
| 118 | ||
| 119 | /** The newest event of a kind on `issue` (a row of `issue`), by rowid; each reads the event index by equality. */ | |
| 120 | const newest = (kind: string): string => | |
| 121 | `COALESCE((SELECT MAX(e.rowid) FROM event e | |
| 122 | WHERE e.repo_id = issue.repo_id AND e.kind = '${kind}' | |
| 123 | AND json_extract(e.payload, '$.number') = issue.number), 0)`; | |
| 124 | ||
| 125 | /** The SQL that says whether the review closed `issue`: its event is newer than the issue's last close and reopen. */ | |
| 126 | const BY_REVIEW = `(${newest(GATE_REVIEWED)} > ${newest("issue.closed")} AND ${newest(GATE_REVIEWED)} > ${newest("issue.reopened")})`; | |
| 127 | ||
| 128 | /** Whether the gate review closed this issue, so it can be found and revived. */ | |
| 129 | export function closedByReview(db: Database, issue: Issue): boolean { | |
| 130 | if (issue.state !== "closed") return false; | |
| 131 | return ( | |
| 132 | db | |
| 133 | .query<{ one: number }, [string]>( | |
| 134 | `SELECT 1 AS one FROM issue WHERE id = ?1 AND ${BY_REVIEW}`, | |
| 135 | ) | |
| 136 | .get(issue.id) !== null | |
| 137 | ); | |
| 138 | } | |
| 139 | ||
| 140 | /** The issues the gate review closed that are still behind the gate, newest first. */ | |
| 141 | export function reviewedClosed( | |
| 142 | db: Database, | |
| 143 | repo: Repo, | |
| 144 | set: Pillars, | |
| 145 | limit = 300, | |
| 146 | ): Issue[] { | |
| 147 | if (set.gate === undefined) return []; | |
| 148 | const rows = db | |
| 149 | .query<{ number: number }, [string, string, number]>( | |
| 150 | `SELECT issue.number FROM issue_dependency d | |
| 151 | JOIN issue ON issue.id = d.issue_id | |
| 152 | WHERE d.blocked_by_id = ?1 AND issue.repo_id = ?2 | |
| 153 | AND issue.kind = 'issue' AND issue.state = 'closed' AND issue.sealed = 0 | |
| 154 | AND ${BY_REVIEW} | |
| 155 | ORDER BY issue.number DESC LIMIT ?3`, | |
| 156 | ) | |
| 157 | .all(set.gate.id, repo.id, limit); | |
| 158 | return rows.flatMap((r) => getIssue(db, repo.id, r.number) ?? []); | |
| 159 | } |
src/core/gate_triage.ts added +89
| @@ -0,0 +1,89 @@ | ||
| 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). | |
| 10 | */ | |
| 11 | ||
| 12 | export interface Held { | |
| 13 | readonly number: number; | |
| 14 | readonly title: string; | |
| 15 | /** Open issues that wait for this one: closing it parks them. */ | |
| 16 | readonly waiting: number; | |
| 17 | } | |
| 18 | ||
| 19 | export interface Verdict { | |
| 20 | readonly kind: "close"; | |
| 21 | readonly why: string; | |
| 22 | readonly reason: "done" | "dropped"; | |
| 23 | } | |
| 24 | ||
| 25 | export interface Triaged { | |
| 26 | readonly number: number; | |
| 27 | readonly title: string; | |
| 28 | readonly waiting: number; | |
| 29 | readonly verdict: Verdict; | |
| 30 | } | |
| 31 | ||
| 32 | export interface Theme { | |
| 33 | readonly name: string; | |
| 34 | readonly items: readonly Triaged[]; | |
| 35 | } | |
| 36 | ||
| 37 | const STOP = new Set( | |
| 38 | "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( | |
| 39 | " ", | |
| 40 | ), | |
| 41 | ); | |
| 42 | ||
| 43 | /** Lowercase words of three letters or more, the stop words left out. */ | |
| 44 | export function words(text: string): string[] { | |
| 45 | return (text.toLowerCase().match(/[a-z][a-z0-9]{2,}/g) ?? []).filter( | |
| 46 | (w) => !STOP.has(w), | |
| 47 | ); | |
| 48 | } | |
| 49 | ||
| 50 | /** Whether a candidate starts ticked: only one the crew found done on main. */ | |
| 51 | export const startsTicked = (t: Triaged): boolean => t.verdict.reason === "done"; | |
| 52 | ||
| 53 | /** A theme needs this many issues; a word on fewer is not a theme. */ | |
| 54 | export const THEME_MIN = 3; | |
| 55 | /** Themes shown; the rest go under `OTHER`. */ | |
| 56 | export const THEMES_MAX = 8; | |
| 57 | export const OTHER = "Other"; | |
| 58 | ||
| 59 | /** The candidate close for each held issue, under the theme its title belongs to. */ | |
| 60 | export function triage( | |
| 61 | held: readonly Held[], | |
| 62 | done: ReadonlySet<number> = new Set(), | |
| 63 | ): Theme[] { | |
| 64 | const sets = new Map(held.map((h) => [h.number, new Set(words(h.title))])); | |
| 65 | // A theme is a word many titles share: each issue goes under the commonest | |
| 66 | // of its words that is common enough, ties to the shorter, then alphabetical. | |
| 67 | const freq = new Map<string, number>(); | |
| 68 | for (const s of sets.values()) for (const w of s) freq.set(w, (freq.get(w) ?? 0) + 1); | |
| 69 | const chosen = [...freq] | |
| 70 | .filter(([, n]) => n >= THEME_MIN) | |
| 71 | .sort((a, b) => b[1] - a[1] || a[0].length - b[0].length || a[0].localeCompare(b[0])) | |
| 72 | .slice(0, THEMES_MAX) | |
| 73 | .map(([w]) => w); | |
| 74 | const groups = new Map<string, Triaged[]>(chosen.map((w) => [w, []])); | |
| 75 | const other: Triaged[] = []; | |
| 76 | for (const h of held) { | |
| 77 | const verdict: Verdict = done.has(h.number) | |
| 78 | ? { 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" }; | |
| 80 | const row = { number: h.number, title: h.title, waiting: h.waiting, verdict }; | |
| 81 | const theme = chosen.find((w) => sets.get(h.number)!.has(w)); | |
| 82 | if (theme === undefined) other.push(row); | |
| 83 | else groups.get(theme)!.push(row); | |
| 84 | } | |
| 85 | const out = [...groups] | |
| 86 | .filter(([, items]) => items.length > 0) | |
| 87 | .map(([name, items]) => ({ name, items })); | |
| 88 | return other.length === 0 ? out : [...out, { name: OTHER, items: other }]; | |
| 89 | } |
src/crew/conversation_confirm.ts +6
| @@ -57,6 +57,12 @@ export const ASKING: Readonly<Record<string, Asking>> = { | ||
| 57 | 57 | model_grant: { does: "give an account a monthly model grant", undo: () => "`model_grant` with `usdPerMonth: 0` takes it back" }, |
| 58 | 58 | model_ceiling: { does: "cap an account's model spend", undo: () => "`model_ceiling` with both `null` clears it" }, |
| 59 | 59 | limits: { does: "change one of the forge's limits", undo: () => "`limits` with `value: \"\"` restores the default" }, |
| 60 | // Closing the Later gate's unchosen ideas in one go; reading its proposal asks nothing. | |
| 61 | held: { | |
| 62 | does: "close the issues the Later gate review proposed to close", | |
| 63 | asks: (a) => a["apply"] !== undefined, | |
| 64 | undo: () => "`lift` on each issue revives it", | |
| 65 | }, | |
| 60 | 66 | // Secrets and credentials. |
| 61 | 67 | secrets: { does: "set or remove a sealed secret", asks: not("action", "list") }, |
| 62 | 68 | app_env: { does: "set or unset an app's variable", asks: not("action", "list") }, |
src/http/mcp/tools/held.ts added +50
| @@ -0,0 +1,50 @@ | ||
| 1 | import { applyGateReview, reviewGate } from "../../../actions/gate_review.ts"; | |
| 2 | import { THREAD_SAYS } from "../../../actions/thread_words.ts"; | |
| 3 | import { good, bad, type Tool, APP_ARG, planRepo, isAnswer } from "../shared.ts"; | |
| 4 | ||
| 5 | export const tool: Tool = { | |
| 6 | name: "held", | |
| 7 | doc: "`{ app, apply? }`: what the Later gate holds, grouped by theme, with each as a candidate to close, from titles alone; `apply: [numbers], digest` (a person's token; the numbers to close, and the `digest` the read gave) does it, refused if what the gate holds has moved; only `done` ones are in `approve`; `lift` revives a closed one", | |
| 8 | description: "What the Later gate holds, and one proposed pass over it.", | |
| 9 | inputSchema: { | |
| 10 | type: "object", | |
| 11 | properties: { | |
| 12 | app: APP_ARG, | |
| 13 | apply: { type: "array", items: { type: "integer", minimum: 1 }, maxItems: 1000 }, | |
| 14 | digest: { type: "string", maxLength: 64 }, | |
| 15 | }, | |
| 16 | required: ["app"], | |
| 17 | }, | |
| 18 | writes: true, | |
| 19 | run(ctx, user, args) { | |
| 20 | const repo = planRepo(ctx, user, args); | |
| 21 | if (isAnswer(repo)) return repo; | |
| 22 | const apply = args["apply"]; | |
| 23 | if (apply !== undefined) { | |
| 24 | if (!Array.isArray(apply) || !apply.every((n) => Number.isInteger(n) && n > 0)) | |
| 25 | return bad("invalid_apply", "apply is the list of issue numbers the proposal names"); | |
| 26 | const done = applyGateReview(ctx.app, user, repo, apply as number[], typeof args["digest"] === "string" ? args["digest"] : undefined); | |
| 27 | if (!done.ok) | |
| 28 | return bad( | |
| 29 | done.error, | |
| 30 | done.error === "stale" | |
| 31 | ? "what the gate holds changed since you read it; read it again" | |
| 32 | : done.error === "forbidden" | |
| 33 | ? THREAD_SAYS.planForbidden | |
| 34 | : "there is no Later gate here", | |
| 35 | ); | |
| 36 | return good({ says: `Closed ${done.value}.` }); | |
| 37 | } | |
| 38 | const review = reviewGate(ctx.app, repo); | |
| 39 | if (review === undefined) return bad("not_found", "there is no Later gate here"); | |
| 40 | return good({ | |
| 41 | says: `${review.held} held; ${review.ticked.length} found done on main.`, | |
| 42 | themes: review.themes.map((t) => ({ | |
| 43 | theme: t.name, | |
| 44 | issues: t.items.map((i) => ({ number: i.number, title: i.title, waiting: i.waiting, ...i.verdict })), | |
| 45 | })), | |
| 46 | approve: review.ticked, | |
| 47 | digest: review.digest, | |
| 48 | }); | |
| 49 | }, | |
| 50 | }; |
src/http/mcp/tools/index.ts +2
| @@ -65,6 +65,7 @@ export { tool as forges } from "./forges.ts"; | ||
| 65 | 65 | export { tool as forks } from "./forks.ts"; |
| 66 | 66 | export { tool as front_page } from "./front_page.ts"; |
| 67 | 67 | export { tool as health } from "./health.ts"; |
| 68 | export { tool as held } from "./held.ts"; | |
| 68 | 69 | export { tool as here } from "./here.ts"; |
| 69 | 70 | export { tool as hold } from "./hold.ts"; |
| 70 | 71 | export { tool as hooks } from "./hooks.ts"; |
| @@ -74,6 +75,7 @@ export { tool as issue_teams } from "./issue_teams.ts"; | ||
| 74 | 75 | export { tool as issues } from "./issues.ts"; |
| 75 | 76 | export { tool as labels } from "./labels.ts"; |
| 76 | 77 | export { tool as land_past_gate } from "./land_past_gate.ts"; |
| 78 | export { tool as lift } from "./lift.ts"; | |
| 77 | 79 | export { tool as limb } from "./limb.ts"; |
| 78 | 80 | export { tool as limits } from "./limits.ts"; |
| 79 | 81 | export { tool as make_it_yours } from "./make_it_yours.ts"; |
src/http/mcp/tools/lift.ts added +31
| @@ -0,0 +1,31 @@ | ||
| 1 | import { liftHold } from "../../../actions/gate.ts"; | |
| 2 | import { THREAD_SAYS } from "../../../actions/thread_words.ts"; | |
| 3 | import { good, bad, type Tool, APP_ARG, planRepo, issueArg, isAnswer } from "../shared.ts"; | |
| 4 | ||
| 5 | export const tool: Tool = { | |
| 6 | name: "lift", | |
| 7 | doc: "`{ app, issue }`: let an issue out from behind the Later gate, as the page's Lift does; its `Later` label goes with it, and the answer carries the issue's URL", | |
| 8 | description: "Lift an issue out from behind the Later gate.", | |
| 9 | inputSchema: { | |
| 10 | type: "object", | |
| 11 | properties: { app: APP_ARG, issue: { type: "integer", minimum: 1 } }, | |
| 12 | required: ["app", "issue"], | |
| 13 | }, | |
| 14 | writes: true, | |
| 15 | run(ctx, user, args) { | |
| 16 | const repo = planRepo(ctx, user, args); | |
| 17 | if (isAnswer(repo)) return repo; | |
| 18 | const issue = issueArg(ctx, repo, args, "issue"); | |
| 19 | if ("ok" in issue) return issue; | |
| 20 | const lifted = liftHold(ctx.app, user, repo, issue); | |
| 21 | if (!lifted.ok) | |
| 22 | return bad( | |
| 23 | lifted.error, | |
| 24 | lifted.error === "not_held" ? `#${issue.number} is not behind the Later gate.` : THREAD_SAYS.planForbidden, | |
| 25 | ); | |
| 26 | return good({ | |
| 27 | says: `#${issue.number} is lifted from behind the Later gate.`, | |
| 28 | url: `${ctx.url.origin}/${repo.ownerName}/${repo.name}/issues/${issue.number}`, | |
| 29 | }); | |
| 30 | }, | |
| 31 | }; |
src/http/server.ts +4
| @@ -266,6 +266,7 @@ import { appNowFragment } from "../web/studio/now.ts"; | ||
| 266 | 266 | import { appStateFragment } from "../web/studio/previews.ts"; |
| 267 | 267 | import { changesList, tellsList } from "../web/studio/lists.ts"; |
| 268 | 268 | import { dependencySubmit } from "../web/pages/thread.ts"; |
| 269 | import { heldApply, heldPage, liftHoldSubmit } from "../web/pages/gate.ts"; | |
| 269 | 270 | import { |
| 270 | 271 | assigneeSubmit, |
| 271 | 272 | labelsSubmit, |
| @@ -1020,6 +1021,9 @@ export function buildRouter(): Router<Handler> { | ||
| 1020 | 1021 | "/:owner/:name/issues/:number/dependencies", |
| 1021 | 1022 | dependencySubmit, |
| 1022 | 1023 | ); |
| 1024 | router.add("POST", "/:owner/:name/issues/:number/lift", liftHoldSubmit); | |
| 1025 | router.add("GET", "/:owner/:name/held", heldPage); | |
| 1026 | router.add("POST", "/:owner/:name/held/apply", heldApply); | |
| 1023 | 1027 | router.add("GET", "/:owner/:name/map", mapPage); |
| 1024 | 1028 | router.add("POST", "/:owner/:name/issues/:number/first", firstSubmit); |
| 1025 | 1029 | router.add("GET", "/:owner/:name/milestones", milestonesPage); |
src/tui/forge.ts +7
| @@ -73,6 +73,7 @@ import { keeperScreen } from "./screens/keeper.ts"; | ||
| 73 | 73 | import { releasesScreen } from "./screens/releases.ts"; |
| 74 | 74 | import { mayRead } from "../release/gate.ts"; |
| 75 | 75 | import { fileHoldsPrompt } from "./screens/fileholds.ts"; |
| 76 | import { heldScreen } from "./screens/held.ts"; | |
| 76 | 77 | import { stalledScreen } from "./screens/stalled.ts"; |
| 77 | 78 | import { issuesScreen, newIssueScreen } from "./screens/issues.ts"; |
| 78 | 79 | import { limbsScreen } from "./screens/limbs.ts"; |
| @@ -109,6 +110,12 @@ const REPO_VERBS: readonly { | ||
| 109 | 110 | title: "Issues in a repository", |
| 110 | 111 | open: (f, ctx, repo) => issuesScreen(f, ctx, local(f, repo), "issue"), |
| 111 | 112 | }, |
| 113 | { | |
| 114 | // What the Later gate holds, as /held shows it: reading is the repository's own. | |
| 115 | id: "held", | |
| 116 | title: "Held by the Later gate", | |
| 117 | open: (f, ctx, repo) => heldScreen(f, ctx, repo), | |
| 118 | }, | |
| 112 | 119 | { |
| 113 | 120 | id: "pulls", |
| 114 | 121 | title: "Pull requests in a repository", |
src/tui/screens/held.ts added +161
| @@ -0,0 +1,161 @@ | ||
| 1 | /** | |
| 2 | * What the Later gate holds, from the terminal (#8700): the held page's twin. | |
| 3 | * The same reads (`reviewGate`, `reviewedClosed`) and the same actions | |
| 4 | * (`liftHold`, `applyGateReview`) the page and the MCP tools use, so who may | |
| 5 | * see and who may change is theirs: a repository the viewer cannot read is | |
| 6 | * not found, and only a writer who is not an agent applies. The list is live; | |
| 7 | * a change to what the gate holds resets the ticks, so what is approved is | |
| 8 | * always what is shown. | |
| 9 | * | |
| 10 | * space tick or untick a candidate A close the ticked | |
| 11 | * l lift (or revive, on a closed) enter open the issue | |
| 12 | */ | |
| 13 | ||
| 14 | import { liftHold } from "../../actions/gate.ts"; | |
| 15 | import { applyGateReview, reviewGate, type GateReview } from "../../actions/gate_review.ts"; | |
| 16 | import { THREAD_SAYS } from "../../actions/thread_words.ts"; | |
| 17 | import { reviewedClosed } from "../../core/gate_hold.ts"; | |
| 18 | import { getIssue } from "../../core/issues.ts"; | |
| 19 | import { pillarsOf } from "../../core/pillars.ts"; | |
| 20 | import type { Repo } from "../../core/repos.ts"; | |
| 21 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 22 | import { binding } from "../app/keys.ts"; | |
| 23 | import type { Session } from "../session.ts"; | |
| 24 | import { ListState, drawList, hitList, live, metaOf, runAsync, type ListRow } from "./common.ts"; | |
| 25 | import { issueScreen } from "./issue.ts"; | |
| 26 | import { local } from "./where.ts"; | |
| 27 | import { emptyState } from "../ui/widgets.ts"; | |
| 28 | ||
| 29 | /** One line of the screen: a candidate, or an issue the review closed. */ | |
| 30 | export interface HeldLine { | |
| 31 | readonly number: number; | |
| 32 | readonly title: string; | |
| 33 | readonly theme: string; | |
| 34 | readonly why: string; | |
| 35 | readonly waiting: number; | |
| 36 | readonly closed: boolean; | |
| 37 | } | |
| 38 | ||
| 39 | export interface HeldRead { | |
| 40 | readonly review: GateReview; | |
| 41 | readonly lines: readonly HeldLine[]; | |
| 42 | } | |
| 43 | ||
| 44 | /** The gate's candidates and what it closed, or nothing where the repository has no gate. */ | |
| 45 | export function readHeld(f: Session, repo: Repo): HeldRead | undefined { | |
| 46 | const review = reviewGate(f.app, repo); | |
| 47 | if (review === undefined) return undefined; | |
| 48 | const lines: HeldLine[] = review.themes.flatMap((t) => | |
| 49 | t.items.map((i) => ({ | |
| 50 | number: i.number, | |
| 51 | title: i.title, | |
| 52 | theme: t.name, | |
| 53 | why: i.verdict.why, | |
| 54 | waiting: i.waiting, | |
| 55 | closed: false, | |
| 56 | })), | |
| 57 | ); | |
| 58 | 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 }); | |
| 60 | return { review, lines }; | |
| 61 | } | |
| 62 | ||
| 63 | /** One row per line; a candidate shows its tick, a flagged one says closing parks work. */ | |
| 64 | export function heldRows(lines: readonly HeldLine[], ticked: ReadonlySet<number>): ListRow[] { | |
| 65 | return lines.map((l) => ({ | |
| 66 | title: `${l.closed ? " " : ticked.has(l.number) ? "[x]" : "[ ]"} #${l.number} ${l.title}`, | |
| 67 | meta: metaOf( | |
| 68 | l.theme, | |
| 69 | l.why, | |
| 70 | l.waiting > 0 ? `${l.waiting} open waits for it: closing parks it` : "", | |
| 71 | ), | |
| 72 | })); | |
| 73 | } | |
| 74 | ||
| 75 | export function heldScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | |
| 76 | const state = new ListState(); | |
| 77 | let read: HeldRead | undefined; | |
| 78 | let ticked = new Set<number>(); | |
| 79 | const lines = (): readonly HeldLine[] => read?.lines ?? []; | |
| 80 | const reload = (): void => { | |
| 81 | const next = readHeld(f, repo); | |
| 82 | if (next?.review.digest !== read?.review.digest) ticked = new Set(next?.review.ticked ?? []); | |
| 83 | read = next; | |
| 84 | state.clamp(lines().length); | |
| 85 | ctx.invalidate(); | |
| 86 | }; | |
| 87 | const lift = (index: number): void => { | |
| 88 | const line = lines()[index]; | |
| 89 | if (line === undefined) return; | |
| 90 | const issue = getIssue(f.db, repo.id, line.number); | |
| 91 | if (issue === undefined) return; | |
| 92 | const done = liftHold(f.app, f.user, repo, issue); | |
| 93 | if (done.ok) ctx.toast(`#${line.number} is lifted from behind the Later gate.`, "success"); | |
| 94 | else ctx.toast(done.error === "not_held" ? `#${line.number} is not behind the Later gate.` : THREAD_SAYS.planForbidden, "danger"); | |
| 95 | reload(); | |
| 96 | }; | |
| 97 | const apply = (): void => { | |
| 98 | if (read === undefined) return; | |
| 99 | if (f.user === undefined) return void ctx.toast("Sign in first.", "danger"); | |
| 100 | const user = f.user; | |
| 101 | const digest = read.review.digest; | |
| 102 | const numbers = [...ticked]; | |
| 103 | runAsync(ctx, async () => { | |
| 104 | const done = applyGateReview(f.app, user, repo, numbers, digest); | |
| 105 | if (done.ok) ctx.toast(`Closed ${done.value}.`, "success"); | |
| 106 | else | |
| 107 | ctx.toast( | |
| 108 | done.error === "stale" | |
| 109 | ? "What the gate holds changed since you read it. Nothing was closed." | |
| 110 | : done.error === "forbidden" | |
| 111 | ? THREAD_SAYS.planForbidden | |
| 112 | : "There is no Later gate here.", | |
| 113 | "danger", | |
| 114 | ); | |
| 115 | reload(); | |
| 116 | }); | |
| 117 | }; | |
| 118 | const openAt = (index: number): void => { | |
| 119 | const line = lines()[index]; | |
| 120 | if (line !== undefined) ctx.push(issueScreen(f, ctx, local(f, repo), line.number)); | |
| 121 | }; | |
| 122 | const toggle = (index: number): void => { | |
| 123 | const line = lines()[index]; | |
| 124 | if (line === undefined || line.closed) return; | |
| 125 | if (!ticked.delete(line.number)) ticked.add(line.number); | |
| 126 | ctx.invalidate(); | |
| 127 | }; | |
| 128 | return { | |
| 129 | title: `Held · ${repo.ownerName}/${repo.name}`, | |
| 130 | keys: [ | |
| 131 | binding(["j", "k"], "move"), | |
| 132 | binding(["space"], "tick"), | |
| 133 | binding(["A"], "close the ticked"), | |
| 134 | binding(["l"], "lift / revive"), | |
| 135 | binding(["enter"], "open"), | |
| 136 | binding(["esc", "q"], "close"), | |
| 137 | ], | |
| 138 | load(signal) { | |
| 139 | live(f, signal, { repoId: repo.id }, () => runAsync(ctx, async () => reload())); | |
| 140 | reload(); | |
| 141 | return Promise.resolve(); | |
| 142 | }, | |
| 143 | render(area, s) { | |
| 144 | if (read === undefined) { | |
| 145 | emptyState(s, area, ctx.theme.c, { title: "No Later gate", body: "This repository holds nothing back." }); | |
| 146 | return; | |
| 147 | } | |
| 148 | drawList(s, area, ctx.theme.c, state, heldRows(lines(), ticked)); | |
| 149 | hitList(ctx, area, state, lines().length, { open: openAt }); | |
| 150 | }, | |
| 151 | key(e) { | |
| 152 | if (e.key === "esc" || e.key === "q") ctx.pop(); | |
| 153 | else if (e.key === " " || e.key === "space") toggle(state.index); | |
| 154 | else if (e.key === "A") apply(); | |
| 155 | else if (e.key === "l") lift(state.index); | |
| 156 | else if (e.key === "enter") openAt(state.index); | |
| 157 | else state.key(e, lines().length); | |
| 158 | return true; | |
| 159 | }, | |
| 160 | }; | |
| 161 | } |
src/tui/screens/issues.ts +6 −1
| @@ -44,6 +44,7 @@ import { | ||
| 44 | 44 | } from "./common.ts"; |
| 45 | 45 | import { issueScreen } from "./issue.ts"; |
| 46 | 46 | import { newPullScreen } from "./newpull.ts"; |
| 47 | import { heldScreen } from "./held.ts"; | |
| 47 | 48 | import { planScreen } from "./plan.ts"; |
| 48 | 49 | import { pullScreen } from "./pull.ts"; |
| 49 | 50 | import { verdictsForPulls, type Verdict } from "./verdicts.ts"; |
| @@ -272,7 +273,7 @@ export function issuesScreen( | ||
| 272 | 273 | ...(stops() > 0 ? [binding(["P"], "pillar")] : []), |
| 273 | 274 | binding(["a"], mine ? "all authors" : "only mine"), |
| 274 | 275 | ...(mayWrite() ? [binding(["c"], `new ${noun}`)] : []), |
| 275 | ...(kind === "issue" && repo !== undefined ? [binding(["m"], "plan")] : []), | |
| 276 | ...(kind === "issue" && repo !== undefined ? [binding(["m"], "plan"), binding(["h"], "held")] : []), | |
| 276 | 277 | binding(["/"], "search"), |
| 277 | 278 | binding(["r"], "refresh"), |
| 278 | 279 | ]; |
| @@ -426,6 +427,10 @@ export function issuesScreen( | ||
| 426 | 427 | ctx.push(planScreen(f, ctx, repo)); |
| 427 | 428 | return true; |
| 428 | 429 | } |
| 430 | if (e.key === "h" && kind === "issue" && repo !== undefined) { | |
| 431 | ctx.push(heldScreen(f, ctx, repo)); | |
| 432 | return true; | |
| 433 | } | |
| 429 | 434 | if (e.key === "enter") { |
| 430 | 435 | openAt(state.index); |
| 431 | 436 | return true; |
src/web/pages/gate.ts added +204
| @@ -0,0 +1,204 @@ | ||
| 1 | /** | |
| 2 | * The Later gate in plain sight (#8700): the banner at the top of a held | |
| 3 | * issue, with Lift, and the route behind it. Reading is `core/gate_hold.ts`, | |
| 4 | * acting is `actions/gate.ts`. | |
| 5 | */ | |
| 6 | ||
| 7 | import { applyGateReview, reviewGate } from "../../actions/gate_review.ts"; | |
| 8 | import { liftHold } from "../../actions/gate.ts"; | |
| 9 | import { startsTicked, type Triaged } from "../../core/gate_triage.ts"; | |
| 10 | import { pillarsOf } from "../../core/pillars.ts"; | |
| 11 | import { THREAD_SAYS } from "../../actions/thread_words.ts"; | |
| 12 | import { isCrew } from "../../core/agents.ts"; | |
| 13 | import { closedByReview, gateHolding, reviewedClosed } from "../../core/gate_hold.ts"; | |
| 14 | import type { Issue } from "../../core/issues.ts"; | |
| 15 | import { canWrite, type Repo } from "../../core/repos.ts"; | |
| 16 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; | |
| 17 | import type { Ctx } from "../../http/context.ts"; | |
| 18 | import { crumbsFor, loadRepo, notFoundPage, repoBase, repoHeader, repoPalette, signInFirst } from "../chrome.ts"; | |
| 19 | import { readForm } from "../form.ts"; | |
| 20 | import { html, type Html } from "../html.ts"; | |
| 21 | import { flashTo, page } from "../layout.ts"; | |
| 22 | import { btn, disclosure, time } from "../ui.ts"; | |
| 23 | import { threadContext } from "./thread.ts"; | |
| 24 | ||
| 25 | /** What the banner says about the hold, and the one button that ends it. */ | |
| 26 | export function heldBanner(ctx: Ctx, repo: Repo, issue: Issue): Html | null { | |
| 27 | const user = ctx.auth.user; | |
| 28 | const base0 = repoBase(repo); | |
| 29 | if (closedByReview(ctx.app.db, issue)) | |
| 30 | return html`<div class="bar" id="held" role="note"> | |
| 31 | <span>Closed by the <a href="${base0}/held">Later gate review</a>: not chosen. An idea that matters comes back.</span> | |
| 32 | ${user !== undefined && canWrite(ctx.app.db, repo, user) | |
| 33 | ? html`<form method="post" action="${base0}/issues/${issue.number}/lift" class="inline"> | |
| 34 | ${btn("Revive", { type: "submit", variant: "primary", size: "sm" })} | |
| 35 | </form>` | |
| 36 | : null} | |
| 37 | </div>`; | |
| 38 | const gate = gateHolding(ctx.app.db, repo, issue); | |
| 39 | if (gate === undefined) return null; | |
| 40 | const mayLift = | |
| 41 | user !== undefined && | |
| 42 | (canWrite(ctx.app.db, repo, user) || isCrew(ctx.app.db, repo.id, user.id)); | |
| 43 | const base = repoBase(repo); | |
| 44 | return html`<div class="bar" id="held" role="note"> | |
| 45 | <span | |
| 46 | >Held by the <a href="${base}/held">Later gate</a>, set | |
| 47 | by ${gate.authorName} ${time(gate.createdAt)}. The crew picks nothing | |
| 48 | here until it is lifted.</span | |
| 49 | > | |
| 50 | ${mayLift | |
| 51 | ? html`<form method="post" action="${base}/issues/${issue.number}/lift" class="inline"> | |
| 52 | ${btn("Lift", { type: "submit", variant: "primary", size: "sm" })} | |
| 53 | </form>` | |
| 54 | : null} | |
| 55 | </div>`; | |
| 56 | } | |
| 57 | ||
| 58 | // POST /:owner/:name/issues/:number/lift | |
| 59 | export async function liftHoldSubmit(ctx: Ctx): Promise<Response> { | |
| 60 | const found = threadContext(ctx, "issue"); | |
| 61 | if (found instanceof Response) return found; | |
| 62 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 63 | const form = await readForm(ctx, "rw"); | |
| 64 | if (form instanceof Response) return form; | |
| 65 | const repo = loadRepo(ctx) ?? found.repo; | |
| 66 | const dest = `${repoBase(repo)}/issues/${found.issue.number}`; | |
| 67 | const lifted = liftHold(ctx.app, ctx.auth.user, repo, found.issue); | |
| 68 | if (!lifted.ok) | |
| 69 | return flashTo(ctx, dest, { | |
| 70 | tone: "danger", | |
| 71 | message: | |
| 72 | lifted.error === "not_held" | |
| 73 | ? `#${found.issue.number} is not behind the Later gate.` | |
| 74 | : THREAD_SAYS.planForbidden, | |
| 75 | }); | |
| 76 | return flashTo(ctx, dest, { | |
| 77 | message: `#${found.issue.number} is lifted from behind the Later gate.`, | |
| 78 | }); | |
| 79 | } | |
| 80 | ||
| 81 | // ---- the list, and the one pass over it ---- | |
| 82 | ||
| 83 | /** One row of the review: ticked to close, and Lift to let it go instead. */ | |
| 84 | function reviewRow(base: string, mayLift: boolean, item: Triaged): Html { | |
| 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}" /> | |
| 87 | <a href="${base}/issues/${item.number}">${item.title}</a> | |
| 88 | <span class="muted small">#${item.number} · ${item.verdict.why}</span> | |
| 89 | ${item.waiting > 0 | |
| 90 | ? html`<span class="small" role="note">${item.waiting} open ${item.waiting === 1 ? "issue waits" : "issues wait"} for it: closing parks ${item.waiting === 1 ? "it" : "them"}</span>` | |
| 91 | : null} | |
| 92 | ${mayLift | |
| 93 | ? html`<form method="post" action="${base}/issues/${item.number}/lift" class="inline"> | |
| 94 | ${btn("Lift", { type: "submit", size: "sm", variant: "ghost" })} | |
| 95 | </form>` | |
| 96 | : null} | |
| 97 | </li>`; | |
| 98 | } | |
| 99 | ||
| 100 | // GET /:owner/:name/held — what the gate holds, grouped by theme, with one pass proposed | |
| 101 | export function heldPage(ctx: Ctx): Response { | |
| 102 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 103 | const repo = loadRepo(ctx); | |
| 104 | const review = repo === undefined ? undefined : reviewGate(ctx.app, repo); | |
| 105 | if (repo === undefined || review === undefined) { | |
| 106 | notePhase(ctx.request, "render", 0); | |
| 107 | return notFoundPage(ctx); | |
| 108 | } | |
| 109 | const rendering = performance.now(); | |
| 110 | const set = pillarsOf(ctx.app.db, repo); | |
| 111 | const gate = set.gate!; | |
| 112 | const base = repoBase(repo); | |
| 113 | const user = ctx.auth.user; | |
| 114 | const mayLift = | |
| 115 | user !== undefined && | |
| 116 | (canWrite(ctx.app.db, repo, user) || isCrew(ctx.app.db, repo.id, user.id)); | |
| 117 | const mayApply = user !== undefined && user.kind !== "agent" && canWrite(ctx.app.db, repo, user); | |
| 118 | const closed = reviewedClosed(ctx.app.db, repo, set); | |
| 119 | const body = html`${repoHeader(ctx, repo, "issues", { compact: true })} | |
| 120 | <div class="page-head mt"> | |
| 121 | <h1>Held by the Later gate</h1> | |
| 122 | <p class="muted"> | |
| 123 | ${review.held} open ${review.held === 1 ? "issue waits" : "issues wait"} | |
| 124 | behind <a href="${base}/issues/${gate.number}">the gate</a>, set by | |
| 125 | ${gate.authorName} ${time(gate.createdAt)}. The crew picks none of | |
| 126 | them until it is lifted. | |
| 127 | </p> | |
| 128 | </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. | |
| 136 | </p> | |
| 137 | ${mayApply && review.changes.length > 0 | |
| 138 | ? html`<form id="apply" method="post" action="${base}/held/apply" class="bar"> | |
| 139 | <input type="hidden" name="digest" value="${review.digest}" /> | |
| 140 | ${btn("Close the ticked", { type: "submit", variant: "primary" })} | |
| 141 | </form>` | |
| 142 | : null} | |
| 143 | <div class="stack mt"> | |
| 144 | ${review.themes.map((t) => | |
| 145 | disclosure( | |
| 146 | html`${t.name} <span class="muted small">${t.items.length}</span>`, | |
| 147 | html`<ul class="stack stack-tight">${t.items.map((i) => reviewRow(base, mayLift, i))}</ul>`, | |
| 148 | ), | |
| 149 | )} | |
| 150 | ${closed.length === 0 | |
| 151 | ? null | |
| 152 | : disclosure( | |
| 153 | html`Closed by the review <span class="muted small">${closed.length}</span>`, | |
| 154 | html`<ul class="stack stack-tight"> | |
| 155 | ${closed.map( | |
| 156 | (i) => html`<li class="bar"> | |
| 157 | <a href="${base}/issues/${i.number}">${i.title}</a> | |
| 158 | <span class="muted small">#${i.number}</span> | |
| 159 | ${mayLift | |
| 160 | ? html`<form method="post" action="${base}/issues/${i.number}/lift" class="inline"> | |
| 161 | ${btn("Revive", { type: "submit", size: "sm", variant: "ghost" })} | |
| 162 | </form>` | |
| 163 | : null} | |
| 164 | </li>`, | |
| 165 | )} | |
| 166 | </ul>`, | |
| 167 | )} | |
| 168 | </div>`; | |
| 169 | const response = page({ | |
| 170 | ctx, | |
| 171 | title: `Held · ${repo.name}`, | |
| 172 | crumbs: crumbsFor(repo), | |
| 173 | palette: repoPalette(ctx, repo), | |
| 174 | body, | |
| 175 | }); | |
| 176 | notePhase(ctx.request, "render", performance.now() - rendering); | |
| 177 | return response; | |
| 178 | } | |
| 179 | ||
| 180 | // POST /:owner/:name/held/apply { approved } — the proposal the person read, done | |
| 181 | export async function heldApply(ctx: Ctx): Promise<Response> { | |
| 182 | const repo = loadRepo(ctx); | |
| 183 | if (!repo) return notFoundPage(ctx); | |
| 184 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 185 | const form = await readForm(ctx, "rw"); | |
| 186 | if (form instanceof Response) return form; | |
| 187 | const dest = `${repoBase(repo)}/held`; | |
| 188 | const approved = [...form.getAll("n")] | |
| 189 | .map((n) => Number(n)) | |
| 190 | .filter((n) => Number.isInteger(n) && n > 0) | |
| 191 | .slice(0, 1000); | |
| 192 | const done = applyGateReview(ctx.app, ctx.auth.user, repo, approved, form.get("digest")?.toString()); | |
| 193 | if (!done.ok) | |
| 194 | return flashTo(ctx, dest, { | |
| 195 | tone: "danger", | |
| 196 | message: | |
| 197 | done.error === "stale" | |
| 198 | ? "What the gate holds changed since you read it. Nothing was closed; read it again." | |
| 199 | : done.error === "forbidden" | |
| 200 | ? THREAD_SAYS.planForbidden | |
| 201 | : "There is no Later gate here.", | |
| 202 | }); | |
| 203 | return flashTo(ctx, dest, { message: `Closed ${done.value}.` }); | |
| 204 | } |
src/web/studio/held.ts added +51
| @@ -0,0 +1,51 @@ | ||
| 1 | /** | |
| 2 | * Home says what is being held back for a person (#8700): one line per | |
| 3 | * repository whose Later gate holds open issues — how many, who set the | |
| 4 | * gate, when — and a link that opens the list. | |
| 5 | */ | |
| 6 | ||
| 7 | import { gateHold } from "../../core/gate_hold.ts"; | |
| 8 | import { reposWithUmbrellas } from "../../core/pillars.ts"; | |
| 9 | import { canWrite, listReposOwnedBy, type Repo } from "../../core/repos.ts"; | |
| 10 | import type { User } from "../../core/users.ts"; | |
| 11 | import type { Ctx } from "../../http/context.ts"; | |
| 12 | import { repoBase } from "../chrome.ts"; | |
| 13 | import { html, type Html } from "../html.ts"; | |
| 14 | import { time } from "../ui.ts"; | |
| 15 | import { writableAppsOf } from "./asks.ts"; | |
| 16 | ||
| 17 | /** The repositories whose gate holds work back from this person, with the hold. */ | |
| 18 | function holds(ctx: Ctx, user: User) { | |
| 19 | const db = ctx.app.db; | |
| 20 | const own = listReposOwnedBy(db, user.id); | |
| 21 | const byId = new Map<string, Repo>(own.map((r) => [r.id, r])); | |
| 22 | for (const r of writableAppsOf(ctx, user).repos) byId.set(r.id, r); | |
| 23 | const withUmbrellas = reposWithUmbrellas(db, [...byId.keys()]); | |
| 24 | const out = []; | |
| 25 | for (const id of withUmbrellas) { | |
| 26 | const repo = byId.get(id)!; | |
| 27 | if (!canWrite(db, repo, user)) continue; | |
| 28 | const hold = gateHold(db, repo); | |
| 29 | if (hold !== undefined) out.push({ repo, hold }); | |
| 30 | } | |
| 31 | return out; | |
| 32 | } | |
| 33 | ||
| 34 | /** One line for each gate that holds work back; nothing where none does. */ | |
| 35 | export function heldLine(ctx: Ctx, user: User): Html | null { | |
| 36 | const found = holds(ctx, user); | |
| 37 | if (found.length === 0) return null; | |
| 38 | return html`<section class="stack stack-tight" id="held-line" aria-label="Held back"> | |
| 39 | ${found.map( | |
| 40 | ({ repo, hold }) => | |
| 41 | html`<p class="small"> | |
| 42 | <a href="${repoBase(repo)}/held" | |
| 43 | >${hold.held} ${hold.held === 1 ? "issue is" : "issues are"} held | |
| 44 | behind the Later gate</a | |
| 45 | > | |
| 46 | in ${repo.name}, set by ${hold.by} ${time(hold.since)}. Nothing there | |
| 47 | starts until you lift it. | |
| 48 | </p>`, | |
| 49 | )} | |
| 50 | </section>`; | |
| 51 | } |
src/web/studio/home.ts +3 −1
| @@ -52,6 +52,7 @@ import { writableAppsOf } from "./asks.ts"; | ||
| 52 | 52 | import { needsYouSection } from "./asks_page.ts"; |
| 53 | 53 | import { flowCards } from "./flow_card.ts"; |
| 54 | 54 | import { composer, feedJs, feedNote, feedRegion, registerWorkerJs } from "./feed.ts"; |
| 55 | import { heldLine } from "./held.ts"; | |
| 55 | 56 | import { appChips, movingRegion, tellBox } from "./living.ts"; |
| 56 | 57 | import { alarmLines, cachedAlarms } from "./alarm_lines.ts"; |
| 57 | 58 | import { kpiRow, waitingLine } from "./kpi_row.ts"; |
| @@ -299,7 +300,7 @@ export async function studioHome( | ||
| 299 | 300 | ) { |
| 300 | 301 | const apps = await clientApps(ctx, user); |
| 301 | 302 | const lean = html`<h1 class="sr-only">Home</h1> |
| 302 | ${firstSteps} ${address} ${crewKeyNote(ctx)} ${apps} | |
| 303 | ${firstSteps} ${address} ${crewKeyNote(ctx)} ${heldLine(ctx, user)} ${apps} | |
| 303 | 304 | <div class="stack"> |
| 304 | 305 | ${composer(ctx, user)} |
| 305 | 306 | <p class="small muted"> |
| @@ -327,6 +328,7 @@ export async function studioHome( | ||
| 327 | 328 | const body = html`<h1 class="sr-only">Home</h1> |
| 328 | 329 | ${tellBox(ctx, user)} |
| 329 | 330 | ${ask} ${address} |
| 331 | ${timePhase(ctx.request, "held", () => heldLine(ctx, user))} | |
| 330 | 332 | <section class="stack" aria-label="The forge">${kpis}</section> |
| 331 | 333 | ${timePhase(ctx.request, "moving", () => movingRegion(ctx.app.db, user))} |
| 332 | 334 | ${appChips(ctx, user)} |
src/web/studio/tell.ts +2
| @@ -19,6 +19,7 @@ import { getAppByRepo } from "../../core/apps.ts"; | ||
| 19 | 19 | import { LATER_TITLED } from "../../core/pillars.ts"; |
| 20 | 20 | import { deferredOf, linkedIssueIds } from "../../core/dependencies.ts"; |
| 21 | 21 | import { hasPicture } from "../../core/note_pictures.ts"; |
| 22 | import { heldBanner } from "../pages/gate.ts"; | |
| 22 | 23 | import { |
| 23 | 24 | getIssuesByNumbers, |
| 24 | 25 | labelsFor, |
| @@ -564,6 +565,7 @@ async function studioTell( | ||
| 564 | 565 | ownTitle: true, |
| 565 | 566 | })} |
| 566 | 567 | ${head} |
| 568 | ${heldBanner(ctx, repo, issue)} | |
| 567 | 569 | <div class="split"> |
| 568 | 570 | <div> |
| 569 | 571 | ${talk.node} ${doneLine} |
test/conversation-confirm.test.ts +1 −1
| @@ -123,7 +123,7 @@ describe("the one list of what asks first", () => { | ||
| 123 | 123 | "access", "account_verify", "app_members", "app_notes", "asks_act", "asks_decide", "asks_proposal", "asks_undecide", |
| 124 | 124 | "assign", "attach_look", "board_pins", "board_write", "branches", "capacity", "cloud_accounts", "close", "confirm_wait", |
| 125 | 125 | "crew", "decisions_by", "detach", "edit", "email_verify", "feed_chips", "feed_dismiss", "flag_set", "flag_winner", |
| 126 | "follow_mode", "front_page", "hold", "issue_teams", "labels", "limb", "make_it_yours", "map", "merge", "new_app", | |
| 126 | "follow_mode", "front_page", "hold", "issue_teams", "labels", "lift", "limb", "make_it_yours", "map", "merge", "new_app", | |
| 127 | 127 | "notify", "org_create", "origin_apps", "pins", "plan", "profile", "propose_to_parent_forge", "repo_labels", "reports", |
| 128 | 128 | "restore_drill", "review", "say", "signup", "snapshot_now", "spend", "steer", "sync", "take_back", "take_updates", |
| 129 | 129 | "teams", "tell", "threads", "trail", "upstream", "variant", "wait_for", "watch", "whoami_can", "write_file", |
test/hot-reads-indexed.test.ts +12
| @@ -5,6 +5,7 @@ import { tmpdir } from "node:os"; | ||
| 5 | 5 | import { join } from "node:path"; |
| 6 | 6 | import { createApp, type App } from "../src/app.ts"; |
| 7 | 7 | import { listEvents } from "../src/core/events.ts"; |
| 8 | import { closedByReview, reviewedClosed } from "../src/core/gate_hold.ts"; | |
| 8 | 9 | import { askToLiveByPillar } from "../src/core/pillars.ts"; |
| 9 | 10 | |
| 10 | 11 | let root = ""; |
| @@ -63,3 +64,14 @@ test("the window of merged pulls walks the merged ones, not the repository's iss | ||
| 63 | 64 | expect(lines.join("\n")).toContain("pull_merged"); |
| 64 | 65 | expect(lines.join("\n")).not.toContain("issue_repo_updated"); |
| 65 | 66 | }); |
| 67 | ||
| 68 | test("whether the Later gate review closed an issue reads the event index, not the table", () => { | |
| 69 | const one = plans(() => void closedByReview(app.db, { id: "i", state: "closed" } as never)); | |
| 70 | expect(one.join("\n")).toContain("SEARCH e USING"); | |
| 71 | noScan(one.filter((l) => !/^SCAN issue|SEARCH issue/.test(l))); | |
| 72 | const many = plans( | |
| 73 | () => void reviewedClosed(app.db, { id: "r" } as never, { gate: { id: "g" } } as never), | |
| 74 | ); | |
| 75 | expect(many.join("\n")).toContain("SEARCH e USING"); | |
| 76 | noScan(many); | |
| 77 | }); |
test/later-gate.test.ts added +394
| @@ -0,0 +1,394 @@ | ||
| 1 | /** | |
| 2 | * The Later gate in plain sight (#8700): home says what it holds, who set it | |
| 3 | * and when; a held issue says so at its top with Lift; the `Later` label and | |
| 4 | * the gate are one thing; and one pass over the lot proposes what to close. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { linkIssue, unlinkIssue } from "../src/actions/dependencies.ts"; | |
| 12 | import { applyGateReview, reviewGate } from "../src/actions/gate_review.ts"; | |
| 13 | import { liftHold } from "../src/actions/gate.ts"; | |
| 14 | import { addComment, openIssue, setIssueLabels, setIssueState } from "../src/actions/issues.ts"; | |
| 15 | import { createLabel, createRepo } from "../src/actions/repos.ts"; | |
| 16 | import { closedByReview, gateHold, gateHolding, reviewedClosed } from "../src/core/gate_hold.ts"; | |
| 17 | import { startsTicked, triage } from "../src/core/gate_triage.ts"; | |
| 18 | import { pillarsOf } from "../src/core/pillars.ts"; | |
| 19 | import { createToken } from "../src/core/tokens.ts"; | |
| 20 | import { getIssueById, labelsFor, type Issue } from "../src/core/issues.ts"; | |
| 21 | import { getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 22 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 23 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 24 | ||
| 25 | describe("the Later gate in plain sight", () => { | |
| 26 | let dataDir: string; | |
| 27 | let app: App; | |
| 28 | let servers: Servers; | |
| 29 | let alice: User; | |
| 30 | let repo: Repo; | |
| 31 | let cookie = ""; | |
| 32 | let gate: Issue; | |
| 33 | ||
| 34 | const fresh = () => getRepoById(app.db, repo.id)!; | |
| 35 | const current = (i: Issue) => getIssueById(app.db, i.id)!; | |
| 36 | const open = (title: string): Issue => { | |
| 37 | const made = openIssue(app, alice, fresh(), { title }); | |
| 38 | if (!made.ok) throw new Error(String(made.error)); | |
| 39 | return made.value; | |
| 40 | }; | |
| 41 | const names = (i: Issue) => labelsFor(app.db, i.id).map((l) => l.name); | |
| 42 | const holds = (i: Issue) => gateHolding(app.db, fresh(), current(i)) !== undefined; | |
| 43 | const labels = (i: Issue, to: string[]) => { | |
| 44 | const set = setIssueLabels(app, alice, fresh(), current(i), to); | |
| 45 | if (!set.ok) throw new Error(String(set.error)); | |
| 46 | }; | |
| 47 | const get = async (path: string) => | |
| 48 | (await fetch(`${servers.url}${path}`, { headers: { cookie } })).text(); | |
| 49 | const post = (path: string, body: Record<string, string> = {}) => | |
| 50 | fetch(`${servers.url}${path}`, { | |
| 51 | method: "POST", | |
| 52 | headers: { cookie, origin: servers.url }, | |
| 53 | body: new URLSearchParams(body), | |
| 54 | redirect: "manual", | |
| 55 | }); | |
| 56 | ||
| 57 | beforeAll(async () => { | |
| 58 | dataDir = await mkdtemp(join(tmpdir(), "forge-later-gate-")); | |
| 59 | app = createApp({ dataDir, port: 0 }); | |
| 60 | servers = await serve(app); | |
| 61 | const made = await createUser(app.db, { | |
| 62 | origin: "seed", | |
| 63 | trusted: true, | |
| 64 | username: "alice", | |
| 65 | email: "[email protected]", | |
| 66 | password: "a-good-passphrase", | |
| 67 | adminIfFirst: true, | |
| 68 | }); | |
| 69 | if (!made.ok) throw new Error(made.error); | |
| 70 | alice = getUserByName(app.db, "alice")!; | |
| 71 | const created = await createRepo(app, alice, { name: "engine", visibility: "public" }); | |
| 72 | if (!created.ok) throw new Error(String(created.error)); | |
| 73 | repo = created.value; | |
| 74 | const signin = await fetch(`${servers.url}/signin`, { | |
| 75 | method: "POST", | |
| 76 | headers: { origin: servers.url }, | |
| 77 | body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), | |
| 78 | redirect: "manual", | |
| 79 | }); | |
| 80 | cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 81 | open("P0 Trust: be trusted"); | |
| 82 | gate = open("Later gate: nothing behind this starts until the owner opens it"); | |
| 83 | }); | |
| 84 | afterAll(async () => { | |
| 85 | await servers.stop(); | |
| 86 | await rm(dataDir, { recursive: true, force: true }); | |
| 87 | }); | |
| 88 | ||
| 89 | test("a gate that holds nothing says nothing on home", async () => { | |
| 90 | expect(gateHold(app.db, fresh())).toBeUndefined(); | |
| 91 | expect(await get("/")).not.toContain("held-line"); | |
| 92 | }); | |
| 93 | ||
| 94 | test("a new gate shows up on home: how many, who set it", async () => { | |
| 95 | const a = open("Later thing one"); | |
| 96 | const b = open("Later thing two"); | |
| 97 | expect(linkIssue(app, alice, fresh(), current(a), gate).ok).toBe(true); | |
| 98 | expect(linkIssue(app, alice, fresh(), current(b), gate).ok).toBe(true); | |
| 99 | const hold = gateHold(app.db, fresh())!; | |
| 100 | expect(hold.held).toBe(2); | |
| 101 | expect(hold.by).toBe("alice"); | |
| 102 | const home = await get("/"); | |
| 103 | expect(home).toContain('id="held-line"'); | |
| 104 | expect(home).toContain("2 issues are held"); | |
| 105 | expect(home).toContain("set by alice"); | |
| 106 | expect(home).toContain("/alice/engine/held"); | |
| 107 | }); | |
| 108 | ||
| 109 | test("a held issue says so at its top with Lift, and Lift lifts it", async () => { | |
| 110 | const i = open("Held by the gate"); | |
| 111 | linkIssue(app, alice, fresh(), current(i), gate); | |
| 112 | expect(names(i)).toContain("Later"); | |
| 113 | const page = await get(`/alice/engine/issues/${i.number}`); | |
| 114 | expect(page).toContain('id="held"'); | |
| 115 | expect(page).toContain(`/alice/engine/issues/${i.number}/lift`); | |
| 116 | const res = await post(`/alice/engine/issues/${i.number}/lift`); | |
| 117 | expect(res.status).toBe(303); | |
| 118 | expect(holds(i)).toBe(false); | |
| 119 | expect(names(i)).not.toContain("Later"); | |
| 120 | expect(await get(`/alice/engine/issues/${i.number}`)).not.toContain('id="held"'); | |
| 121 | }); | |
| 122 | ||
| 123 | test("lifting what is not held is refused", () => { | |
| 124 | const i = open("Free"); | |
| 125 | const lifted = liftHold(app, alice, fresh(), current(i)); | |
| 126 | expect(lifted.ok).toBe(false); | |
| 127 | }); | |
| 128 | ||
| 129 | test("removing the Later label lifts the hold, and adding it holds", () => { | |
| 130 | const i = open("Label and gate are one"); | |
| 131 | linkIssue(app, alice, fresh(), current(i), gate); | |
| 132 | expect(holds(i)).toBe(true); | |
| 133 | labels(i, []); | |
| 134 | expect(holds(i)).toBe(false); | |
| 135 | expect(names(i)).not.toContain("Later"); | |
| 136 | labels(i, ["Later"]); | |
| 137 | expect(holds(i)).toBe(true); | |
| 138 | expect(names(i)).toContain("Later"); | |
| 139 | }); | |
| 140 | ||
| 141 | test("an unrelated label change leaves the hold alone", () => { | |
| 142 | const i = open("Other labels"); | |
| 143 | linkIssue(app, alice, fresh(), current(i), gate); | |
| 144 | const made = createLabel(app, alice, fresh(), "ui", "336699"); | |
| 145 | if (!made.ok) throw new Error(String(made.error)); | |
| 146 | labels(i, ["Later", "ui"]); | |
| 147 | expect(holds(i)).toBe(true); | |
| 148 | }); | |
| 149 | ||
| 150 | test("triage makes every held issue a candidate; only one found done starts ticked", () => { | |
| 151 | const themes = triage( | |
| 152 | [ | |
| 153 | { number: 1, title: "Search the archive", waiting: 0 }, | |
| 154 | { number: 2, title: "Search archive export", waiting: 2 }, | |
| 155 | { number: 3, title: "Search archive filters", waiting: 0 }, | |
| 156 | { number: 4, title: "Paint the footer blue", waiting: 0 }, | |
| 157 | ], | |
| 158 | new Set([4]), | |
| 159 | ); | |
| 160 | const rows = themes.flatMap((t) => t.items); | |
| 161 | expect(rows.length).toBe(4); | |
| 162 | expect(rows.find((r) => r.number === 1)!.verdict).toMatchObject({ kind: "close", reason: "dropped" }); | |
| 163 | expect(rows.find((r) => r.number === 4)!.verdict).toMatchObject({ kind: "close", reason: "done" }); | |
| 164 | expect(rows.filter(startsTicked).map((r) => r.number)).toEqual([4]); | |
| 165 | expect(rows.find((r) => r.number === 2)!.waiting).toBe(2); | |
| 166 | expect(themes[0]!.items.length).toBe(3); | |
| 167 | expect(triage([]).length).toBe(0); | |
| 168 | }); | |
| 169 | ||
| 170 | test("one approval closes what is ticked, it stays findable, and one tap revives it", async () => { | |
| 171 | const a = open("Idea nobody chose"); | |
| 172 | const b = open("Idea to keep behind the gate"); | |
| 173 | for (const i of [a, b]) linkIssue(app, alice, fresh(), current(i), gate); | |
| 174 | const review = reviewGate(app, fresh())!; | |
| 175 | expect(review.changes.map((c) => c.number)).toContain(a.number); | |
| 176 | expect(await get("/alice/engine/held")).toContain("Close the ticked"); | |
| 177 | // A proposal that is not the one shown is refused whole. | |
| 178 | expect(applyGateReview(app, alice, fresh(), [999999], review.digest)).toEqual({ ok: false, error: "stale" }); | |
| 179 | expect(current(a).state).toBe("open"); | |
| 180 | const res = await post("/alice/engine/held/apply", { n: String(a.number), digest: review.digest }); | |
| 181 | expect(res.status).toBe(303); | |
| 182 | expect(current(a).state).toBe("closed"); | |
| 183 | expect(current(b).state).toBe("open"); | |
| 184 | expect(holds(b)).toBe(true); | |
| 185 | // Findable: on the held page and with a Revive at the issue's top. | |
| 186 | expect(await get("/alice/engine/held")).toContain(`/alice/engine/issues/${a.number}/lift`); | |
| 187 | const closedPage = await get(`/alice/engine/issues/${a.number}`); | |
| 188 | expect(closedPage).toContain("Revive"); | |
| 189 | const back = await post(`/alice/engine/issues/${a.number}/lift`); | |
| 190 | expect(back.status).toBe(303); | |
| 191 | expect(current(a).state).toBe("open"); | |
| 192 | expect(holds(a)).toBe(false); | |
| 193 | expect(names(a)).not.toContain("Later"); | |
| 194 | }); | |
| 195 | ||
| 196 | test("an agent reads the proposal and never applies it", async () => { | |
| 197 | const bot = await createUser(app.db, { | |
| 198 | origin: "seed", | |
| 199 | trusted: true, | |
| 200 | username: "botty", | |
| 201 | email: "[email protected]", | |
| 202 | password: "a-good-passphrase", | |
| 203 | }); | |
| 204 | expect(bot.ok).toBe(true); | |
| 205 | const agent = { ...getUserByName(app.db, "botty")!, kind: "agent" as const }; | |
| 206 | expect(applyGateReview(app, agent, fresh(), [], reviewGate(app, fresh())!.digest)).toEqual({ ok: false, error: "forbidden" }); | |
| 207 | }); | |
| 208 | ||
| 209 | // ---- the review's safety, its marker, and who may do what ---- | |
| 210 | ||
| 211 | const held = (title: string): Issue => { | |
| 212 | const i = open(title); | |
| 213 | expect(linkIssue(app, alice, fresh(), current(i), gate).ok).toBe(true); | |
| 214 | return i; | |
| 215 | }; | |
| 216 | const proposal = () => reviewGate(app, fresh())!; | |
| 217 | const reviewedBy = (i: Issue) => closedByReview(app.db, current(i)); | |
| 218 | ||
| 219 | test("the page ticks nothing unless the crew found it done, and flags what open work waits for", async () => { | |
| 220 | const blocker = held("Blocker the review might close"); | |
| 221 | const waiter = open("Waits for the blocker"); | |
| 222 | expect(linkIssue(app, alice, fresh(), current(waiter), current(blocker)).ok).toBe(true); | |
| 223 | expect(proposal().changes.find((c) => c.number === blocker.number)!.waiting).toBe(1); | |
| 224 | expect(proposal().ticked).not.toContain(blocker.number); | |
| 225 | const page = await get("/alice/engine/held"); | |
| 226 | expect(page).toContain("closing parks it"); | |
| 227 | expect(page).not.toMatch(/<input[^>]*name="n"[^>]*\bchecked\b/); | |
| 228 | }); | |
| 229 | ||
| 230 | test("an apply that carries no digest, or a stale one, closes nothing", async () => { | |
| 231 | const a = held("Digest candidate"); | |
| 232 | const read = proposal(); | |
| 233 | expect(applyGateReview(app, alice, fresh(), [a.number], undefined)).toEqual({ ok: false, error: "stale" }); | |
| 234 | // An issue newly held since the page was read changes what was shown. | |
| 235 | const late = held("Held after the page was read"); | |
| 236 | expect(applyGateReview(app, alice, fresh(), [a.number], read.digest)).toEqual({ ok: false, error: "stale" }); | |
| 237 | const res = await post("/alice/engine/held/apply", { n: String(a.number), digest: read.digest }); | |
| 238 | expect(res.status).toBe(303); | |
| 239 | expect(current(a).state).toBe("open"); | |
| 240 | expect(current(late).state).toBe("open"); | |
| 241 | expect(applyGateReview(app, alice, fresh(), [a.number], proposal().digest)).toEqual({ ok: true, value: 1 }); | |
| 242 | }); | |
| 243 | ||
| 244 | test("a comment cannot forge the review's marker; a close after a reopen is not the review's", () => { | |
| 245 | const a = held("Forged marker candidate"); | |
| 246 | const forged = held("Closed by a person, with a lookalike note"); | |
| 247 | expect(addComment(app, alice, fresh(), current(forged), "Gate review: closed: not chosen.").ok).toBe(true); | |
| 248 | expect(setIssueState(app, alice, fresh(), current(forged), "closed").ok).toBe(true); | |
| 249 | expect(reviewedBy(forged)).toBe(false); | |
| 250 | expect(reviewedClosed(app.db, fresh(), pillarsOf(app.db, fresh())).map((i) => i.number)).not.toContain(forged.number); | |
| 251 | expect(applyGateReview(app, alice, fresh(), [a.number], proposal().digest).ok).toBe(true); | |
| 252 | expect(reviewedBy(a)).toBe(true); | |
| 253 | // Reopened, then closed by hand: no longer the review's. | |
| 254 | expect(setIssueState(app, alice, fresh(), current(a), "open").ok).toBe(true); | |
| 255 | expect(reviewedBy(a)).toBe(false); | |
| 256 | expect(setIssueState(app, alice, fresh(), current(a), "closed").ok).toBe(true); | |
| 257 | expect(reviewedBy(a)).toBe(false); | |
| 258 | }); | |
| 259 | ||
| 260 | test("a close that does not happen leaves no marker and no note", () => { | |
| 261 | const a = held("Closed elsewhere first"); | |
| 262 | const read = proposal(); | |
| 263 | expect(setIssueState(app, alice, fresh(), current(a), "closed").ok).toBe(true); | |
| 264 | // Stale now: the issue left the candidates, and nothing is written. | |
| 265 | expect(applyGateReview(app, alice, fresh(), [a.number], read.digest).ok).toBe(false); | |
| 266 | expect(reviewedBy(a)).toBe(false); | |
| 267 | expect(app.db.query("SELECT 1 FROM comment WHERE issue_id = ?1 AND body LIKE 'Gate review:%'").get(a.id)).toBeNull(); | |
| 268 | }); | |
| 269 | ||
| 270 | test("lifting what is not held changes nothing, even on a closed issue", () => { | |
| 271 | const a = held("Closed by hand but still behind the gate"); | |
| 272 | expect(setIssueState(app, alice, fresh(), current(a), "closed").ok).toBe(true); | |
| 273 | expect(liftHold(app, alice, fresh(), current(a))).toEqual({ ok: false, error: "not_held" }); | |
| 274 | expect(current(a).state).toBe("closed"); | |
| 275 | expect(holds(a)).toBe(false); | |
| 276 | // Reviewed-closed, then let out of the gate by another road: nothing to lift, and it stays closed. | |
| 277 | const b = held("Reviewed then unlinked"); | |
| 278 | expect(applyGateReview(app, alice, fresh(), [b.number], proposal().digest).ok).toBe(true); | |
| 279 | expect(unlinkIssue(app, alice, fresh(), current(b), gate).ok).toBe(true); | |
| 280 | expect(liftHold(app, alice, fresh(), current(b))).toEqual({ ok: false, error: "not_held" }); | |
| 281 | expect(current(b).state).toBe("closed"); | |
| 282 | }); | |
| 283 | ||
| 284 | describe("who may lift, revive and apply", () => { | |
| 285 | let bobCookie = ""; | |
| 286 | let bobToken = ""; | |
| 287 | let aliceToken = ""; | |
| 288 | const as = (c: string, path: string, body: Record<string, string> = {}) => | |
| 289 | fetch(`${servers.url}${path}`, { | |
| 290 | method: "POST", | |
| 291 | headers: { ...(c === "" ? {} : { cookie: c }), origin: servers.url }, | |
| 292 | body: new URLSearchParams(body), | |
| 293 | redirect: "manual", | |
| 294 | }); | |
| 295 | const mcp = async (token: string, name: string, args: unknown) => { | |
| 296 | const res = await fetch(`${servers.url}/mcp`, { | |
| 297 | method: "POST", | |
| 298 | headers: { | |
| 299 | "content-type": "application/json", | |
| 300 | accept: "application/json, text/event-stream", | |
| 301 | authorization: `Bearer ${token}`, | |
| 302 | }, | |
| 303 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }), | |
| 304 | }); | |
| 305 | const body = (await res.json()) as any; | |
| 306 | const text: string = body.result.content[0].text; | |
| 307 | return body.result.isError === true ? { error: text } : { value: JSON.parse(text) }; | |
| 308 | }; | |
| 309 | ||
| 310 | beforeAll(async () => { | |
| 311 | const made = await createUser(app.db, { | |
| 312 | origin: "seed", | |
| 313 | trusted: true, | |
| 314 | username: "bob", | |
| 315 | email: "[email protected]", | |
| 316 | password: "a-good-passphrase", | |
| 317 | }); | |
| 318 | if (!made.ok) throw new Error(made.error); | |
| 319 | const bob = getUserByName(app.db, "bob")!; | |
| 320 | const signin = await fetch(`${servers.url}/signin`, { | |
| 321 | method: "POST", | |
| 322 | headers: { origin: servers.url }, | |
| 323 | body: new URLSearchParams({ username: "bob", password: "a-good-passphrase" }), | |
| 324 | redirect: "manual", | |
| 325 | }); | |
| 326 | bobCookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 327 | bobToken = createToken(app.db, bob.id, "rw", "rw").token; | |
| 328 | aliceToken = createToken(app.db, alice.id, "rw", "rw").token; | |
| 329 | }); | |
| 330 | ||
| 331 | test("a guest and a reader cannot lift or apply on the page; the work stays held", async () => { | |
| 332 | const a = held("Reader cannot lift this"); | |
| 333 | const read = proposal(); | |
| 334 | for (const c of ["", bobCookie]) { | |
| 335 | await as(c, `/alice/engine/issues/${a.number}/lift`); | |
| 336 | expect(holds(a)).toBe(true); | |
| 337 | await as(c, "/alice/engine/held/apply", { n: String(a.number), digest: read.digest }); | |
| 338 | expect(current(a).state).toBe("open"); | |
| 339 | } | |
| 340 | expect(applyGateReview(app, getUserByName(app.db, "bob")!, fresh(), [a.number], read.digest)).toEqual({ | |
| 341 | ok: false, | |
| 342 | error: "forbidden", | |
| 343 | }); | |
| 344 | }); | |
| 345 | ||
| 346 | test("a reader cannot revive on the page, and sees no Revive form", async () => { | |
| 347 | const a = held("Reader cannot revive this"); | |
| 348 | expect(applyGateReview(app, alice, fresh(), [a.number], proposal().digest).ok).toBe(true); | |
| 349 | await as("", `/alice/engine/issues/${a.number}/lift`); | |
| 350 | await as(bobCookie, `/alice/engine/issues/${a.number}/lift`); | |
| 351 | expect(current(a).state).toBe("closed"); | |
| 352 | const page = await (await fetch(`${servers.url}/alice/engine/issues/${a.number}`, { headers: { cookie: bobCookie } })).text(); | |
| 353 | expect(page).not.toContain(`/issues/${a.number}/lift`); | |
| 354 | }); | |
| 355 | ||
| 356 | test("the MCP lift and held tools hold the same line", async () => { | |
| 357 | const a = held("Lifted over MCP"); | |
| 358 | const read = await mcp(aliceToken, "held", { app: "alice/engine" }); | |
| 359 | expect(read.value.digest).toBe(proposal().digest); | |
| 360 | expect(read.value.themes.length).toBeGreaterThan(0); | |
| 361 | expect((await mcp(bobToken, "lift", { app: "alice/engine", issue: a.number })).error).toBeDefined(); | |
| 362 | expect(holds(a)).toBe(true); | |
| 363 | expect((await mcp(bobToken, "held", { app: "alice/engine", apply: [a.number], digest: read.value.digest })).error).toContain("forbidden"); | |
| 364 | expect(current(a).state).toBe("open"); | |
| 365 | // A writer's apply needs the digest it read. | |
| 366 | expect((await mcp(aliceToken, "held", { app: "alice/engine", apply: [a.number] })).error).toBeDefined(); | |
| 367 | expect(current(a).state).toBe("open"); | |
| 368 | const lifted = await mcp(aliceToken, "lift", { app: "alice/engine", issue: a.number }); | |
| 369 | expect(lifted.value.says).toContain("lifted"); | |
| 370 | expect(holds(a)).toBe(false); | |
| 371 | expect((await mcp(aliceToken, "lift", { app: "alice/engine", issue: a.number })).error).toContain("not_held"); | |
| 372 | }); | |
| 373 | ||
| 374 | test("a private repository's held page is not found by a guest or a stranger", async () => { | |
| 375 | const made = await createRepo(app, alice, { name: "vault", visibility: "private" }); | |
| 376 | if (!made.ok) throw new Error(String(made.error)); | |
| 377 | const priv = made.value; | |
| 378 | const mk = (title: string) => { | |
| 379 | const o = openIssue(app, alice, priv, { title }); | |
| 380 | if (!o.ok) throw new Error(String(o.error)); | |
| 381 | return o.value; | |
| 382 | }; | |
| 383 | mk("P0 Trust: be trusted"); | |
| 384 | const g = mk("Later gate: nothing behind this starts until the owner opens it"); | |
| 385 | const h = mk("Secret held idea"); | |
| 386 | expect(linkIssue(app, alice, priv, h, g).ok).toBe(true); | |
| 387 | expect((await fetch(`${servers.url}/alice/vault/held`)).status).toBe(404); | |
| 388 | const stranger = await fetch(`${servers.url}/alice/vault/held`, { headers: { cookie: bobCookie } }); | |
| 389 | expect(stranger.status).toBe(404); | |
| 390 | expect(await stranger.text()).not.toContain("Secret held idea"); | |
| 391 | expect(await get("/alice/vault/held")).toContain("Secret held idea"); | |
| 392 | }); | |
| 393 | }); | |
| 394 | }); |
test/mcp.test.ts +2
| @@ -329,6 +329,8 @@ describe("the forge over MCP", () => { | ||
| 329 | 329 | "land_past_gate", |
| 330 | 330 | "repo_labels", |
| 331 | 331 | "wait_for", |
| 332 | "lift", | |
| 333 | "held", | |
| 332 | 334 | "confirm_wait", |
| 333 | 335 | "attach_look", |
| 334 | 336 | "hold", |
test/palette-roster.test.ts +4
| @@ -50,6 +50,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 50 | 50 | ["profile", "Open a profile", "username or org"], |
| 51 | 51 | ["repo", "Open a repository", "owner/name"], |
| 52 | 52 | ["issues", "Issues in a repository", "owner/name"], |
| 53 | ["held", "Held by the Later gate", "owner/name"], | |
| 53 | 54 | ["pulls", "Pull requests in a repository", "owner/name"], |
| 54 | 55 | ["milestones", "Milestones in a repository", "owner/name"], |
| 55 | 56 | ["repo-usage", "Usage of a repository", "owner/name"], |
| @@ -132,6 +133,7 @@ test("a site-admin agent is offered the roster without the person-only entries", | ||
| 132 | 133 | ["profile", "Open a profile", "username or org"], |
| 133 | 134 | ["repo", "Open a repository", "owner/name"], |
| 134 | 135 | ["issues", "Issues in a repository", "owner/name"], |
| 136 | ["held", "Held by the Later gate", "owner/name"], | |
| 135 | 137 | ["pulls", "Pull requests in a repository", "owner/name"], |
| 136 | 138 | ["milestones", "Milestones in a repository", "owner/name"], |
| 137 | 139 | ["repo-usage", "Usage of a repository", "owner/name"], |
| @@ -188,6 +190,7 @@ test("a member person is offered the signed-in roster without the admin entries" | ||
| 188 | 190 | ["profile", "Open a profile", "username or org"], |
| 189 | 191 | ["repo", "Open a repository", "owner/name"], |
| 190 | 192 | ["issues", "Issues in a repository", "owner/name"], |
| 193 | ["held", "Held by the Later gate", "owner/name"], | |
| 191 | 194 | ["pulls", "Pull requests in a repository", "owner/name"], |
| 192 | 195 | ["milestones", "Milestones in a repository", "owner/name"], |
| 193 | 196 | ["repo-usage", "Usage of a repository", "owner/name"], |
| @@ -238,6 +241,7 @@ test("signed out, the palette offers sign-in and the public entries", () => { | ||
| 238 | 241 | ["profile", "Open a profile", "username or org"], |
| 239 | 242 | ["repo", "Open a repository", "owner/name"], |
| 240 | 243 | ["issues", "Issues in a repository", "owner/name"], |
| 244 | ["held", "Held by the Later gate", "owner/name"], | |
| 241 | 245 | ["pulls", "Pull requests in a repository", "owner/name"], |
| 242 | 246 | ["milestones", "Milestones in a repository", "owner/name"], |
| 243 | 247 | ["repo-usage", "Usage of a repository", "owner/name"], |
test/tui-held.test.ts added +170
| @@ -0,0 +1,170 @@ | ||
| 1 | /** | |
| 2 | * What the Later gate holds, in the terminal (#8700): `h` on a repository's | |
| 3 | * issues opens the held screen, the page's twin. A writer ticks and closes, | |
| 4 | * lifts and revives through the same actions; a reader is refused in the same | |
| 5 | * words and changes nothing; a private repository is not found; and the web | |
| 6 | * hears every write as the events its viewers already follow. | |
| 7 | */ | |
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { linkIssue } from "../src/actions/dependencies.ts"; | |
| 13 | import { openIssue } from "../src/actions/issues.ts"; | |
| 14 | import { createRepo } from "../src/actions/repos.ts"; | |
| 15 | import { THREAD_SAYS } from "../src/actions/thread_words.ts"; | |
| 16 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 17 | import { gateHolding } from "../src/core/gate_hold.ts"; | |
| 18 | import { getIssueById, type Issue } from "../src/core/issues.ts"; | |
| 19 | import { getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 20 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 21 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 22 | import type { TuiIdentity } from "../src/tui/session.ts"; | |
| 23 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 24 | import { quietLogs } from "./helpers/globals.ts"; | |
| 25 | import { settleApp } from "./helpers/tui.ts"; | |
| 26 | ||
| 27 | quietLogs(); | |
| 28 | ||
| 29 | describe("the Later gate, in the terminal", () => { | |
| 30 | let dataDir: string; | |
| 31 | let forge: Forge; | |
| 32 | let repo: Repo; | |
| 33 | let idea: Issue; | |
| 34 | let other: Issue; | |
| 35 | let gate: Issue; | |
| 36 | ||
| 37 | const fresh = () => getRepoById(forge.db, repo.id)!; | |
| 38 | const current = (i: Issue) => getIssueById(forge.db, i.id)!; | |
| 39 | const held = (i: Issue) => gateHolding(forge.db, fresh(), current(i)) !== undefined; | |
| 40 | ||
| 41 | function boot(user: User | undefined) { | |
| 42 | const terminal = headlessTerminal(140, 32); | |
| 43 | const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user }; | |
| 44 | const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false }); | |
| 45 | app.start(); | |
| 46 | const press = async (...keys: string[]) => { | |
| 47 | for (const key of keys) { | |
| 48 | const ctrl = key.startsWith("ctrl+"); | |
| 49 | app.dispatch({ type: "key", key: ctrl ? key.slice(5) : key, ctrl, alt: false, shift: false }); | |
| 50 | } | |
| 51 | await settleApp(app); | |
| 52 | }; | |
| 53 | const typeText = async (text: string) => { | |
| 54 | for (const ch of text) app.dispatch({ type: "key", key: ch, ctrl: false, alt: false, shift: false }); | |
| 55 | await settleApp(app); | |
| 56 | }; | |
| 57 | const toHeld = async (slug: string) => { | |
| 58 | await settleApp(app); | |
| 59 | await press("ctrl+k"); | |
| 60 | await typeText("Held by the Later gate"); | |
| 61 | await press("enter"); | |
| 62 | await typeText(slug); | |
| 63 | await press("enter"); | |
| 64 | }; | |
| 65 | return { text: () => app.screen.toText(), press, typeText, toHeld, stop: () => app.stop() }; | |
| 66 | } | |
| 67 | ||
| 68 | beforeAll(async () => { | |
| 69 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-held-")); | |
| 70 | forge = createApp({ dataDir, port: 0 }); | |
| 71 | for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) { | |
| 72 | const made = await createUser(forge.db, { | |
| 73 | origin: "seed", | |
| 74 | trusted: true, | |
| 75 | username, | |
| 76 | email: `${username}@forge.local`, | |
| 77 | password: "a-good-passphrase", | |
| 78 | adminIfFirst, | |
| 79 | }); | |
| 80 | if (!made.ok) throw new Error(made.error); | |
| 81 | } | |
| 82 | const alice = getUserByName(forge.db, "alice")!; | |
| 83 | const made = await createRepo(forge, alice, { name: "plan", visibility: "public" }); | |
| 84 | if (!made.ok) throw new Error("repo"); | |
| 85 | repo = made.value; | |
| 86 | const open = (r: Repo, title: string): Issue => { | |
| 87 | const o = openIssue(forge, alice, r, { title }); | |
| 88 | if (!o.ok) throw new Error(String(o.error)); | |
| 89 | return o.value; | |
| 90 | }; | |
| 91 | open(fresh(), "P0 Trust: be trusted"); | |
| 92 | gate = open(fresh(), "Later gate: nothing behind this starts until the owner opens it"); | |
| 93 | idea = open(fresh(), "Unchosen idea about colours"); | |
| 94 | other = open(fresh(), "Another idea about fonts"); | |
| 95 | for (const i of [idea, other]) linkIssue(forge, alice, fresh(), current(i), gate); | |
| 96 | const vault = await createRepo(forge, alice, { name: "vault", visibility: "private" }); | |
| 97 | if (!vault.ok) throw new Error("vault"); | |
| 98 | open(vault.value, "P0 Trust: be trusted"); | |
| 99 | const g = open(vault.value, "Later gate: nothing behind this starts until the owner opens it"); | |
| 100 | linkIssue(forge, alice, vault.value, open(vault.value, "Secret held idea"), g); | |
| 101 | }, 60_000); | |
| 102 | ||
| 103 | afterAll(async () => { | |
| 104 | forge.close(); | |
| 105 | await rm(dataDir, { recursive: true, force: true }); | |
| 106 | }); | |
| 107 | ||
| 108 | test("a reader sees the candidates unticked, and is refused to lift or close", async () => { | |
| 109 | const h = boot(getUserByName(forge.db, "bob")!); | |
| 110 | await h.toHeld("alice/plan"); | |
| 111 | expect(h.text()).toContain("Unchosen idea about colours"); | |
| 112 | expect(h.text()).toContain("[ ] #"); | |
| 113 | expect(h.text()).not.toContain("[x]"); | |
| 114 | await h.press("l"); | |
| 115 | expect(h.text()).toContain(THREAD_SAYS.planForbidden); | |
| 116 | await h.press("space", "A"); | |
| 117 | expect(held(idea) && held(other)).toBe(true); | |
| 118 | expect(current(idea).state).toBe("open"); | |
| 119 | h.stop(); | |
| 120 | }); | |
| 121 | ||
| 122 | test("a guest sees a public gate and changes nothing", async () => { | |
| 123 | const h = boot(undefined); | |
| 124 | await h.toHeld("alice/plan"); | |
| 125 | expect(h.text()).toContain("Unchosen idea about colours"); | |
| 126 | await h.press("l"); | |
| 127 | expect(held(idea) && held(other)).toBe(true); | |
| 128 | await h.press("space", "A"); | |
| 129 | expect(current(idea).state).toBe("open"); | |
| 130 | h.stop(); | |
| 131 | }); | |
| 132 | ||
| 133 | test("a private repository is not found by a reader or a guest", async () => { | |
| 134 | for (const user of [getUserByName(forge.db, "bob"), undefined]) { | |
| 135 | const h = boot(user); | |
| 136 | await h.toHeld("alice/vault"); | |
| 137 | expect(h.text()).toContain("not found"); | |
| 138 | expect(h.text()).not.toContain("Secret held idea"); | |
| 139 | h.stop(); | |
| 140 | } | |
| 141 | }); | |
| 142 | ||
| 143 | test("a writer ticks and closes, lifts and revives, and the web hears each write", async () => { | |
| 144 | const heard: string[] = []; | |
| 145 | const off = forge.bus.subscribe((e) => { | |
| 146 | if (e.repoId === repo.id) heard.push(`${e.kind}:${e.payload["number"]}`); | |
| 147 | }); | |
| 148 | const h = boot(getUserByName(forge.db, "alice")!); | |
| 149 | await h.toHeld("alice/plan"); | |
| 150 | // Nothing is ticked until the writer ticks: the first line is the newest. | |
| 151 | await h.press("space"); | |
| 152 | expect(h.text()).toContain("[x]"); | |
| 153 | await h.press("A"); | |
| 154 | expect(current(other).state).toBe("closed"); | |
| 155 | expect(current(idea).state).toBe("open"); | |
| 156 | expect(heard).toContain(`issue.closed:${other.number}`); | |
| 157 | expect(h.text()).toContain("Closed by the review"); | |
| 158 | // The closed one is listed after the candidates; Revive is `l` on it. | |
| 159 | await h.press("j", "l"); | |
| 160 | expect(current(other).state).toBe("open"); | |
| 161 | expect(held(other)).toBe(false); | |
| 162 | expect(heard).toContain(`issue.reopened:${other.number}`); | |
| 163 | expect(heard).toContain(`issue.unblocked:${other.number}`); | |
| 164 | // And lifting an open candidate lets it out. | |
| 165 | await h.press("k", "l"); | |
| 166 | expect(held(idea)).toBe(false); | |
| 167 | off(); | |
| 168 | h.stop(); | |
| 169 | }); | |
| 170 | }); |
new above
offline