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

Merged opened this

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

Merged

scout/forge:scout/7324-the-terminal-s-waves-screen-cuts-a-wave → main 2 commits fork

  • Merged. scout/7324-the-terminal-s-waves-screen-cuts-a-wave was merged into main as 40fb216.

Review

Sign in to review this change.
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
408408 - [`docs/SPIKE-CLOUDFLARE.md`](docs/SPIKE-CLOUDFLARE.md), [`docs/AUDIT-MANY-HANDS.md`](docs/AUDIT-MANY-HANDS.md), [`docs/BACKLOG.md`](docs/BACKLOG.md) and [`docs/research/`](docs/research/) — a spike, an audit, a backlog and research notes, kept as written.
409409
410410 `/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 @@
1919 "Reconnecting" while the connection is down.
2020
2121 - **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
2323 adding `waves/<next>.md`, listing the pulls merged since the last wave and
2424 not already in an earlier wave. The headline and highlights are `TODO`
2525 placeholders; no model is called. It refuses, writing nothing, when an
2626 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.
2730 `/:owner/:name/waves` lists the waves on the default branch, newest version
2831 first, each line opening `/:owner/:name/waves/<version>` (headline, date,
2932 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";
1212 import { listTree, readerFor } from "../git/read.ts";
1313 import { repoDir } from "../git/repos.ts";
1414 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";
1616 import { editFiles } from "./contents.ts";
1717
1818 export interface CutDone {
@@ -35,12 +35,12 @@ async function readWaves(app: App, repo: Repo): Promise<WaveFile[]> {
3535 return files;
3636 }
3737
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(
4040 app: App,
4141 actor: User | undefined,
4242 repo: Repo,
43 ): Promise<Result<CutDone, string>> {
43 ): Promise<Result<Cut, string>> {
4444 if (!actor || actor.kind === "agent" || !canAdmin(app.db, repo, actor))
4545 return err("Only a repository admin can cut a wave.");
4646 const waves = await readWaves(app, repo);
@@ -58,6 +58,16 @@ export async function cutWaveFor(
5858 today: new Date().toISOString().slice(0, 10),
5959 link: (n) => `${repoUrl}/pulls/${n}`,
6060 });
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);
6171 if (!plan.ok) return err(plan.error);
6272 const { version, path, text, pulls } = plan.value;
6373 const edited = await editFiles(app, actor, repo, {
src/tui/screens/waves.ts +68 −2
@@ -5,6 +5,7 @@
55 * opens that wave as plain text.
66 */
77
8 import { cutWaveFor, planCutFor } from "../../actions/cut_wave.ts";
89 import { canRead } from "../../core/repos.ts";
910 import { VERSION } from "../../story/wave.ts";
1011 import { carriedPulls, pullPath, type CarriedPull } from "../../story/wave_pulls.ts";
@@ -18,7 +19,8 @@ import { truncate } from "../term/width.ts";
1819 import { fill, len, split } from "../ui/layout.ts";
1920 import { emptyState } from "../ui/widgets.ts";
2021 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";
2224 import { localRepo, sourceName, whereRows, whereStrip } from "./where.ts";
2325
2426 /** One line per wave, as the screen draws it. */
@@ -114,11 +116,55 @@ export function wavesScreen(f: Session, ctx: Ctx, src: RepoSource): Screen {
114116 ctx.invalidate();
115117 };
116118
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 = {
118163 title: "Waves",
119164 keys: [
120165 binding(["j", "k"], "move"),
121166 binding(["enter"], "open"),
167 binding(["c"], "cut a wave"),
122168 binding(["r"], "refresh"),
123169 ],
124170 load: reload,
@@ -165,7 +211,27 @@ export function wavesScreen(f: Session, ctx: Ctx, src: RepoSource): Screen {
165211 runAsync(ctx, reload);
166212 return true;
167213 }
214 if (e.key === "c") {
215 cut();
216 return true;
217 }
168218 return false;
169219 },
170220 };
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 };
171237 }
test/tui-waves.test.ts +83
@@ -4,6 +4,11 @@ import { tmpdir } from "node:os";
44 import { join } from "node:path";
55 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
66 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";
712 import { createApp, type App as Forge } from "../src/app.ts";
813 import type { Repo } from "../src/core/repos.ts";
914 import type { User } from "../src/core/users.ts";
@@ -201,6 +206,84 @@ describe("waves in the terminal", () => {
201206 expect(lines.join("\n")).not.toMatch(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/);
202207 });
203208
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
204287 test("a live browse says waves are only read on this forge", async () => {
205288 const h = await open(alice, pub, [], true);
206289 h.stop();

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs