Public: anyone can read this; the crew can change it.
The terminal's waves screen cuts a wave with `c` and a confirm: a draft pull listing every pull merged since the last wave, as the waves page does #7340
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7324-the-terminal-s-waves-screen-cuts-a-wave → main 2 commits fork
5 files
changed +171
−7
README.md +2
| @@ -408,3 +408,5 @@ This page is the front door. Everything else is a file of its own under | ||
| 408 | 408 | - [](docs/SPIKE-CLOUDFLARE.md), [](docs/AUDIT-MANY-HANDS.md), [](docs/BACKLOG.md) and [](docs/research/) — a spike, an audit, a backlog and research notes, kept as written. |
| 409 | 409 | |
| 410 | 410 | `/changelog` lists the last 30 releases of `FORGE_REPO` (public only) with their pulls, for anyone; the terminal's palette has `Changelog` for the same list (`docs/features/changelog.md`). |
| 411 | ||
| 412 | A repository admin cuts a wave from two doors, both through `cutWaveFor` (`src/actions/cut_wave.ts`): Settings → *Cut a wave*, and `c` on the terminal's waves screen, which says how many pulls would go in, asks `cut wave N? y/n` (`y` and `n` choose outright) and shows the draft pull's number (Enter opens it). Each opens a draft pull listing the pulls merged since the last wave; nothing merged, an earlier wave that does not parse, or a viewer who is not an admin is refused in the action's words, writing nothing. |
docs/STATUS.md +4 −1
| @@ -19,11 +19,14 @@ | ||
| 19 | 19 | "Reconnecting" while the connection is down. |
| 20 | 20 | |
| 21 | 21 | - **Cut a wave** — Settings → *Cut a wave* (repository admins, same-origin |
| 22 | POST; web-only, the terminal's settings hand over to the web) opens a pull | |
| 22 | POST) opens a pull | |
| 23 | 23 | adding `waves/<next>.md`, listing the pulls merged since the last wave and |
| 24 | 24 | not already in an earlier wave. The headline and highlights are `TODO` |
| 25 | 25 | placeholders; no model is called. It refuses, writing nothing, when an |
| 26 | 26 | earlier wave does not parse (naming the file) or nothing new has merged. |
| 27 | The terminal's waves screen does the same with `c`: it says how many pulls | |
| 28 | would go in, asks `cut wave N? y/n`, and on `y` shows the draft pull's | |
| 29 | number (Enter opens it), through the same action and refusals. | |
| 27 | 30 | `/:owner/:name/waves` lists the waves on the default branch, newest version |
| 28 | 31 | first, each line opening `/:owner/:name/waves/<version>` (headline, date, |
| 29 | 32 | highlights with why, pulls as links; linked from that settings card); in the |
src/actions/cut_wave.ts +14 −4
| @@ -12,7 +12,7 @@ import type { User } from "../core/users.ts"; | ||
| 12 | 12 | import { listTree, readerFor } from "../git/read.ts"; |
| 13 | 13 | import { repoDir } from "../git/repos.ts"; |
| 14 | 14 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 15 | import { cutWave, type WaveFile } from "../story/cut.ts"; | |
| 15 | import { cutWave, type Cut, type WaveFile } from "../story/cut.ts"; | |
| 16 | 16 | import { editFiles } from "./contents.ts"; |
| 17 | 17 | |
| 18 | 18 | export interface CutDone { |
| @@ -35,12 +35,12 @@ async function readWaves(app: App, repo: Repo): Promise<WaveFile[]> { | ||
| 35 | 35 | return files; |
| 36 | 36 | } |
| 37 | 37 | |
| 38 | /** The reason is a plain sentence, ready to show. */ | |
| 39 | export async function cutWaveFor( | |
| 38 | /** What a cut would write, or the reason it cannot; nothing is written. */ | |
| 39 | export async function planCutFor( | |
| 40 | 40 | app: App, |
| 41 | 41 | actor: User | undefined, |
| 42 | 42 | repo: Repo, |
| 43 | ): Promise<Result<CutDone, string>> { | |
| 43 | ): Promise<Result<Cut, string>> { | |
| 44 | 44 | if (!actor || actor.kind === "agent" || !canAdmin(app.db, repo, actor)) |
| 45 | 45 | return err("Only a repository admin can cut a wave."); |
| 46 | 46 | const waves = await readWaves(app, repo); |
| @@ -58,6 +58,16 @@ export async function cutWaveFor( | ||
| 58 | 58 | today: new Date().toISOString().slice(0, 10), |
| 59 | 59 | link: (n) => `${repoUrl}/pulls/${n}`, |
| 60 | 60 | }); |
| 61 | return plan.ok ? ok(plan.value) : err(plan.error); | |
| 62 | } | |
| 63 | ||
| 64 | /** The reason is a plain sentence, ready to show. */ | |
| 65 | export async function cutWaveFor( | |
| 66 | app: App, | |
| 67 | actor: User | undefined, | |
| 68 | repo: Repo, | |
| 69 | ): Promise<Result<CutDone, string>> { | |
| 70 | const plan = await planCutFor(app, actor, repo); | |
| 61 | 71 | if (!plan.ok) return err(plan.error); |
| 62 | 72 | const { version, path, text, pulls } = plan.value; |
| 63 | 73 | const edited = await editFiles(app, actor, repo, { |
src/tui/screens/waves.ts +68 −2
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | * opens that wave as plain text. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { cutWaveFor, planCutFor } from "../../actions/cut_wave.ts"; | |
| 8 | 9 | import { canRead } from "../../core/repos.ts"; |
| 9 | 10 | import { VERSION } from "../../story/wave.ts"; |
| 10 | 11 | import { carriedPulls, pullPath, type CarriedPull } from "../../story/wave_pulls.ts"; |
| @@ -18,7 +19,8 @@ import { truncate } from "../term/width.ts"; | ||
| 18 | 19 | import { fill, len, split } from "../ui/layout.ts"; |
| 19 | 20 | import { emptyState } from "../ui/widgets.ts"; |
| 20 | 21 | import type { Session } from "../session.ts"; |
| 21 | import { ListState, Scroll, drawList, drawLines, hitList, plural, runAsync } from "./common.ts"; | |
| 22 | import { ListState, Scroll, confirmScreen, drawList, drawLines, hitList, plural, runAsync } from "./common.ts"; | |
| 23 | import { pullScreen } from "./pull.ts"; | |
| 22 | 24 | import { localRepo, sourceName, whereRows, whereStrip } from "./where.ts"; |
| 23 | 25 | |
| 24 | 26 | /** One line per wave, as the screen draws it. */ |
| @@ -114,11 +116,55 @@ export function wavesScreen(f: Session, ctx: Ctx, src: RepoSource): Screen { | ||
| 114 | 116 | ctx.invalidate(); |
| 115 | 117 | }; |
| 116 | 118 | |
| 117 | return { | |
| 119 | const cut = (): void => { | |
| 120 | if (repo === undefined) return; | |
| 121 | runAsync(ctx, async () => { | |
| 122 | const plan = await planCutFor(f.app, f.user, repo); | |
| 123 | if (!plan.ok) return void ctx.toast(plan.error, "danger"); | |
| 124 | const { version, pulls } = plan.value; | |
| 125 | const choose = (choice: number): void => { | |
| 126 | if (choice !== 0) return; | |
| 127 | runAsync(ctx, async () => { | |
| 128 | const done = await cutWaveFor(f.app, f.user, repo); | |
| 129 | if (!done.ok) return void ctx.toast(done.error, "danger"); | |
| 130 | await reload(); | |
| 131 | const number = done.value.number; | |
| 132 | ctx.push( | |
| 133 | confirmScreen(ctx, self, { | |
| 134 | title: "Wave drafted", | |
| 135 | body: [`Draft pull #${number} holds wave ${done.value.version}.`], | |
| 136 | actions: [{ label: "Open" }, { label: "Close" }], | |
| 137 | onChoose: (c) => { | |
| 138 | if (c === 0) ctx.push(pullScreen(f, ctx, src, number)); | |
| 139 | }, | |
| 140 | }), | |
| 141 | ); | |
| 142 | }); | |
| 143 | }; | |
| 144 | ctx.push( | |
| 145 | yesNo( | |
| 146 | ctx, | |
| 147 | confirmScreen(ctx, self, { | |
| 148 | title: "Cut wave", | |
| 149 | body: [ | |
| 150 | `${plural(pulls, "pull")} since the last wave would go into it.`, | |
| 151 | `cut wave ${version}? y/n`, | |
| 152 | ], | |
| 153 | actions: [{ label: "Cut" }, { label: "Cancel" }], | |
| 154 | onChoose: choose, | |
| 155 | }), | |
| 156 | choose, | |
| 157 | ), | |
| 158 | ); | |
| 159 | }); | |
| 160 | }; | |
| 161 | ||
| 162 | const self: Screen = { | |
| 118 | 163 | title: "Waves", |
| 119 | 164 | keys: [ |
| 120 | 165 | binding(["j", "k"], "move"), |
| 121 | 166 | binding(["enter"], "open"), |
| 167 | binding(["c"], "cut a wave"), | |
| 122 | 168 | binding(["r"], "refresh"), |
| 123 | 169 | ], |
| 124 | 170 | load: reload, |
| @@ -165,7 +211,27 @@ export function wavesScreen(f: Session, ctx: Ctx, src: RepoSource): Screen { | ||
| 165 | 211 | runAsync(ctx, reload); |
| 166 | 212 | return true; |
| 167 | 213 | } |
| 214 | if (e.key === "c") { | |
| 215 | cut(); | |
| 216 | return true; | |
| 217 | } | |
| 168 | 218 | return false; |
| 169 | 219 | }, |
| 170 | 220 | }; |
| 221 | return self; | |
| 222 | } | |
| 223 | ||
| 224 | /** `y` cuts and `n` cancels, whatever the selection: each chooses outright rather than pressing Enter. */ | |
| 225 | function yesNo(ctx: Ctx, dialog: Screen, choose: (choice: number) => void): Screen { | |
| 226 | return { | |
| 227 | ...dialog, | |
| 228 | key(e) { | |
| 229 | if (e.key === "y" || e.key === "n") { | |
| 230 | ctx.pop(); | |
| 231 | choose(e.key === "y" ? 0 : -1); | |
| 232 | return true; | |
| 233 | } | |
| 234 | return dialog.key(e); | |
| 235 | }, | |
| 236 | }; | |
| 171 | 237 | } |
test/tui-waves.test.ts +83
| @@ -4,6 +4,11 @@ import { tmpdir } from "node:os"; | ||
| 4 | 4 | import { join } from "node:path"; |
| 5 | 5 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 6 | 6 | import { signUp } from "../src/actions/users.ts"; |
| 7 | import { listOpenPulls } from "../src/core/pulls.ts"; | |
| 8 | import { createSession } from "../src/core/sessions.ts"; | |
| 9 | import { refTip } from "../src/git/read.ts"; | |
| 10 | import { repoDir } from "../src/git/repos.ts"; | |
| 11 | import { serve } from "../src/http/server.ts"; | |
| 7 | 12 | import { createApp, type App as Forge } from "../src/app.ts"; |
| 8 | 13 | import type { Repo } from "../src/core/repos.ts"; |
| 9 | 14 | import type { User } from "../src/core/users.ts"; |
| @@ -201,6 +206,84 @@ describe("waves in the terminal", () => { | ||
| 201 | 206 | expect(lines.join("\n")).not.toMatch(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/); |
| 202 | 207 | }); |
| 203 | 208 | |
| 209 | /** A fresh public repository with one merged pull and no waves. */ | |
| 210 | const cuttable = async (name: string): Promise<Repo> => { | |
| 211 | const r = await createRepo(forge, alice, { name, visibility: "public" }); | |
| 212 | if (!r.ok) throw new Error("repo failed"); | |
| 213 | await commitToBranch(forge, alice, r.value, { message: "init", files: [{ path: "README.md", content: "hi\n" }] }); | |
| 214 | await mergedPull(forge, alice, r.value, "Cut this work"); | |
| 215 | return r.value; | |
| 216 | }; | |
| 217 | const pullCount = (repo: Repo): number => | |
| 218 | forge.db.query<{ n: number }, [string]>("SELECT count(*) AS n FROM issue WHERE repo_id = ? AND kind = 'pull'").get(repo.id)!.n; | |
| 219 | const hasBranch = async (repo: Repo): Promise<boolean> => | |
| 220 | (await refTip(repoDir(forge.reposRoot, repo.ownerName, repo.name), "refs/heads/wave/0.1.0")) !== undefined; | |
| 221 | ||
| 222 | test("c asks, y cuts a wave as a draft pull the web sees, and Enter opens it", async () => { | |
| 223 | const repo = await cuttable("cutme"); | |
| 224 | const before = pullCount(repo); | |
| 225 | const ask = await open(alice, repo, ["c"]); | |
| 226 | ask.stop(); | |
| 227 | expect(ask.text).toContain("1 pull since the last wave"); | |
| 228 | expect(ask.text).toContain("cut wave 0.1.0? y/n"); | |
| 229 | expect(pullCount(repo)).toBe(before); | |
| 230 | const opened = await open(alice, repo, ["c", "y", "enter"]); | |
| 231 | opened.stop(); | |
| 232 | expect(opened.text).toContain("Wave 0.1.0"); | |
| 233 | expect(pullCount(repo)).toBe(before + 1); | |
| 234 | const draft = listOpenPulls(forge.db, repo.id).find((p) => p.headBranch === "wave/0.1.0"); | |
| 235 | expect(draft).toBeDefined(); | |
| 236 | expect(draft!.state).toBe("open"); | |
| 237 | expect(draft!.body).toContain("A draft of waves/0.1.0.md, listing 1 merged pull."); | |
| 238 | expect(await hasBranch(repo)).toBe(true); | |
| 239 | // The web sees the same pull, and the file it adds lists the merged one. | |
| 240 | const servers = await serve(forge); | |
| 241 | try { | |
| 242 | const cookie = `forge_session=${createSession(forge.db, alice.id).token}`; | |
| 243 | const page = await fetch(`${servers.url}/alice/cutme/pulls/${draft!.number}`, { headers: { cookie } }); | |
| 244 | expect(page.status).toBe(200); | |
| 245 | const html = await page.text(); | |
| 246 | expect(html).toContain("Wave 0.1.0"); | |
| 247 | } finally { | |
| 248 | servers.stop(); | |
| 249 | } | |
| 250 | const file = await Bun.$`git -C ${repoDir(forge.reposRoot, repo.ownerName, repo.name)} show wave/0.1.0:waves/0.1.0.md`.text(); | |
| 251 | expect(file).toContain("Cut this work"); | |
| 252 | }); | |
| 253 | ||
| 254 | test("n and Esc write nothing, and y after moving to Cancel still cuts", async () => { | |
| 255 | const repo = await cuttable("cutno"); | |
| 256 | const before = pullCount(repo); | |
| 257 | for (const keys of [["c", "n"], ["c", "esc"], ["c", "tab", "n"]]) { | |
| 258 | const h = await open(alice, repo, keys); | |
| 259 | h.stop(); | |
| 260 | expect(pullCount(repo)).toBe(before); | |
| 261 | expect(await hasBranch(repo)).toBe(false); | |
| 262 | } | |
| 263 | const h = await open(alice, repo, ["c", "tab", "y"]); | |
| 264 | h.stop(); | |
| 265 | expect(pullCount(repo)).toBe(before + 1); | |
| 266 | expect(await hasBranch(repo)).toBe(true); | |
| 267 | }); | |
| 268 | ||
| 269 | test("nothing to cut, an unparsable wave, and a non-admin are refused with nothing written", async () => { | |
| 270 | const counts = [pub, mix].map(pullCount); | |
| 271 | const none = await open(alice, mix, ["c"]); | |
| 272 | none.stop(); | |
| 273 | expect(none.text).toContain("Nothing has been merged since the last wave"); | |
| 274 | const broken = await open(alice, pub, ["c"]); | |
| 275 | broken.stop(); | |
| 276 | expect(broken.text).toContain("broken.md"); | |
| 277 | expect(broken.text).not.toContain("cut wave"); | |
| 278 | const viewer = await open(bob, mix, ["c", "y"]); | |
| 279 | viewer.stop(); | |
| 280 | expect(viewer.text).toContain("Only a repository admin can cut a wave."); | |
| 281 | expect(viewer.text).not.toContain("cut wave"); | |
| 282 | expect([pub, mix].map(pullCount)).toEqual(counts); | |
| 283 | expect(await hasBranch(mix)).toBe(false); | |
| 284 | expect(await hasBranch(pub)).toBe(false); | |
| 285 | }); | |
| 286 | ||
| 204 | 287 | test("a live browse says waves are only read on this forge", async () => { |
| 205 | 288 | const h = await open(alice, pub, [], true); |
| 206 | 289 | h.stop(); |
new above
offline