Public: anyone can read this; the crew can change it.

The map page has a First button on each ready issue, and the ranked one shows first #7835

Merged opened this

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

Merged

torque/forge:torque/7808-the-map-page-has-a-first-button-on-each → main 3 commits fork

  • Merged. torque/7808-the-map-page-has-a-first-button-on-each was merged into main as 41b1a0e.

Review

Sign in to review this change.
10 files changed +399 −13
README.md +2
@@ -69,6 +69,8 @@ In the terminal, `T` on a shipped pull's screen takes it back, as the pull page'
6969
7070 In the terminal, `m` on a local repository's issue list opens the plan screen (`src/tui/screens/plan.ts`): Working, Ready now, Waiting and Parked twice, each headed with its count. It reads what MCP `map` reads (`repoGraph` under the reader's sealed scope, `pillarView`, `mapHeldFor`, the runs in flight), so a sealed issue the reader may not see is left out. Enter opens the issue, `r` reads again, and the headings are skipped over. It is not offered on the pulls list or on a live browse of another forge.
7171
72 A writer puts a ready issue **First**: the dispatcher starts it before the rest, and the map's Ready now list (and the plan screen's) says `first, by <who>`. On the map page it is the First button on each ready row (a same-origin POST to `/:owner/:name/issues/:number/first`, no JavaScript; `Accept: application/json` gets JSON); in the terminal it is `f` on a ready line of the plan screen. Both call `putIssueFirst` (`src/actions/first.ts`). Refused, changing nothing: a reader or an agent ("Only someone with write access can put an issue first."), a signed-out post (sent to sign in), a read-only token, a cross-origin post, and an issue that is closed or still waits for another ("Only an open issue that nothing waits in front of can go first."). The rank ends when the issue closes, and it is an `issue.first` event, so open maps and plan screens refresh. No MCP tool yet.
73
7274 A hand-over card on `/asks` (an agent's pull the reviewer stopped on after three rounds) has "Give back to the crew": one press assigns the pull, and the open issue it closes if a person holds it, to the agent that opened it, writes "Given back to the crew by @who", and the card leaves; the next tick starts its run. It is for a writer of the repository, and a pull a person opened, or by an agent gone from the crew, gets no button. In the terminal, `b` on a handed-over row of the On you pane does the same (`src/actions/give_back.ts`). The repository's planner can do it by comment: one that starts "Planner, one more round" on a handed-over crew pull emits the assignment to its builder, which starts the run (the pull is already the builder's, so nothing changes hands). It is the planner's alone; a reader's, the reviewer's or any other agent's does nothing. It is an agent's word, not a director's, so it does not end the hand-over: the card stays on `/asks` and the pull still counts as handed over during the round.
7375
7476 Home opens with "Since you were here · 2 days": the pulls that landed in public repositories since you last loaded `/` (1 hour to 7 days back, a day on a first visit); the terminal's home shows the same lines, and MCP `feed` answers them as `digest`. See `docs/features/since-you-were-here.md`.
docs/design/areas/map.md +2
@@ -25,3 +25,5 @@ is `data-state` on `.map-node`, not a class.
2525 - `.map-edge-done` — an edge with a closed issue at either end, faded.
2626 - `.map-toggle` — the link beside the counts that swaps between "Show done" (`?done=1`) and "Open only"; padded, 44px tall under `pointer: coarse`. It is a link, not a badge.
2727 - `.map-ask` — a pillar row's lead-time figures in the side column, inline, with the hover title saying what they measure.
28
29 The Ready now list's **First** button (a writer's `method="post"` form in the row's trail) and its "first, by <who>" meta line add no class: they are `row`'s `trail` and `meta` and the outline `btn`. The list is `.setting-list`, so the meta wraps and under 640px the trail drops to its own line.
src/actions/first.ts added +29
@@ -0,0 +1,29 @@
1 /**
2 * "This first" as a person does it (#7808): the issue by number, the writer
3 * gate and the words of a refusal, one for the map page and the terminal.
4 * The rank itself is `putFirst` (core/first.ts).
5 */
6 import type { App } from "../app.ts";
7 import { putFirst } from "../core/first.ts";
8 import { getIssue, type Issue } from "../core/issues.ts";
9 import type { Repo } from "../core/repos.ts";
10 import type { User } from "../core/users.ts";
11 import { err, ok, type Result } from "../kernel/result.ts";
12
13 export const FIRST_FORBIDDEN = "Only someone with write access can put an issue first.";
14 export const FIRST_INVALID = "Only an open issue that nothing waits in front of can go first.";
15
16 export function putIssueFirst(
17 app: App,
18 actor: User,
19 repo: Repo,
20 number: number,
21 ): Result<Issue, { kind: "not_found" | "forbidden" | "invalid"; message: string }> {
22 const issue = Number.isInteger(number) && number > 0 ? getIssue(app.db, repo.id, number) : undefined;
23 if (!issue) return err({ kind: "not_found", message: "No such issue." });
24 const put = putFirst(app, actor, repo, issue);
25 if (put.ok) return ok(issue);
26 if (put.error === "forbidden") return err({ kind: "forbidden", message: FIRST_FORBIDDEN });
27 if (put.error === "not_found") return err({ kind: "not_found", message: "No such issue." });
28 return err({ kind: "invalid", message: FIRST_INVALID });
29 }
src/core/first.ts +15 −6
@@ -62,14 +62,23 @@ export function firstAt(db: Database, issueIds: readonly string[]): Map<string,
6262 return out;
6363 }
6464
65 /** Every rank in one repository. */
66 export function firstInRepo(db: Database, repoId: string): Map<string, string> {
67 const out = new Map<string, string>();
65 /** Every rank in one repository: when it was put first, and by whom (username). */
66 export function firstMarksInRepo(
67 db: Database,
68 repoId: string,
69 ): Map<string, { at: string; by: string }> {
70 const out = new Map<string, { at: string; by: string }>();
6871 for (const r of db
69 .query<{ issue_id: string; at: string }, [string]>(
70 `SELECT f.issue_id, f.at FROM issue_first f JOIN issue i ON i.id = f.issue_id WHERE i.repo_id = ?1`,
72 .query<{ issue_id: string; at: string; username: string }, [string]>(
73 `SELECT f.issue_id, f.at, u.username FROM issue_first f
74 JOIN issue i ON i.id = f.issue_id JOIN user u ON u.id = f.actor_id WHERE i.repo_id = ?1`,
7175 )
7276 .all(repoId))
73 out.set(r.issue_id, r.at);
77 out.set(r.issue_id, { at: r.at, by: r.username });
7478 return out;
7579 }
80
81 /** Every rank in one repository, as when each was put. */
82 export function firstInRepo(db: Database, repoId: string): Map<string, string> {
83 return new Map([...firstMarksInRepo(db, repoId)].map(([id, m]) => [id, m.at]));
84 }
src/http/server.ts +2
@@ -240,6 +240,7 @@ import { issuePicture } from "../web/studio/picture.ts";
240240 import { tellHerePage } from "../web/studio/tell_here.ts";
241241 import { globalMapPage } from "../web/pages/global-map.ts";
242242 import { mapPage } from "../web/pages/map.ts";
243 import { firstSubmit } from "../web/pages/first.ts";
243244 import {
244245 milestonePage,
245246 milestonesPage,
@@ -1198,6 +1199,7 @@ export function buildRouter(): Router<Handler> {
11981199 dependencySubmit,
11991200 );
12001201 router.add("GET", "/:owner/:name/map", mapPage);
1202 router.add("POST", "/:owner/:name/issues/:number/first", firstSubmit);
12011203 router.add("GET", "/:owner/:name/milestones", milestonesPage);
12021204 router.add("GET", "/:owner/:name/milestones/:number", milestonePage);
12031205 router.add("POST", "/:owner/:name/milestones/:number/watch", milestoneWatch);
src/tui/screens/plan.ts +26 −2
@@ -6,6 +6,8 @@
66 * is left out here as it is there.
77 */
88
9 import { FIRST_FORBIDDEN, putIssueFirst } from "../../actions/first.ts";
10 import { firstMarksInRepo } from "../../core/first.ts";
911 import { repoGraph, readyNow } from "../../core/dependencies.ts";
1012 import { pillarView } from "../../core/pillars.ts";
1113 import { listActiveRuns } from "../../core/runs.ts";
@@ -24,6 +26,8 @@ import { local } from "./where.ts";
2426 export interface PlanLine {
2527 readonly text: string;
2628 readonly number?: number;
29 /** A ready issue: the one `f` puts first. */
30 readonly ready?: true;
2731 }
2832
2933 export function readPlan(f: Session, repo: Repo): PlanLine[] {
@@ -56,6 +60,7 @@ export function readPlan(f: Session, repo: Repo): PlanLine[] {
5660 return h === undefined ? "" : ` (${h.detail})`;
5761 };
5862
63 const firsts = firstMarksInRepo(f.db, repo.id);
5964 const lines: PlanLine[] = [{ text: `Working ${working.length}` }];
6065 for (const i of working)
6166 lines.push({
@@ -65,8 +70,11 @@ export function readPlan(f: Session, repo: Repo): PlanLine[] {
6570 lines.push({ text: `Ready now ${ready.length}` });
6671 for (const { issue: i } of ready)
6772 lines.push({
68 text: `#${i.number} ${i.title} — ${pillars?.of.get(i.id) ?? "no pillar"}${hold(i.number)}`,
73 text: `#${i.number} ${i.title} — ${pillars?.of.get(i.id) ?? "no pillar"}${hold(i.number)}${
74 firsts.has(i.id) ? ` — first, by ${firsts.get(i.id)!.by}` : ""
75 }`,
6976 number: i.number,
77 ready: true,
7078 });
7179 lines.push({ text: `Waiting ${waiting.length}` });
7280 for (const i of waiting)
@@ -111,7 +119,7 @@ export function planScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
111119 };
112120 return {
113121 title: "Plan",
114 keys: [binding(["j", "k"], "move"), binding(["enter"], "open"), binding(["r"], "refresh")],
122 keys: [binding(["j", "k"], "move"), binding(["enter"], "open"), binding(["f"], "first"), binding(["r"], "refresh")],
115123 load(signal) {
116124 live(f, signal, { repoId: repo.id }, () => runAsync(ctx, async () => reload()));
117125 reload();
@@ -133,6 +141,22 @@ export function planScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
133141 openAt(state.index);
134142 return true;
135143 }
144 if (e.key === "f") {
145 const line = lines[state.index];
146 if (line?.ready === undefined || line.number === undefined) {
147 ctx.toast("f puts a ready issue first", "danger");
148 } else if (f.user === undefined) {
149 ctx.toast(FIRST_FORBIDDEN, "danger");
150 } else {
151 const put = putIssueFirst(f.app, f.user, repo, line.number);
152 if (!put.ok) ctx.toast(put.error.message, "danger");
153 else {
154 ctx.toast(`#${line.number} is first`, "success");
155 reload();
156 }
157 }
158 return true;
159 }
136160 if (e.key === "r") {
137161 reload();
138162 return true;
src/web/pages/first.ts added +21
@@ -0,0 +1,21 @@
1 /** POST /:owner/:name/issues/:number/first — a writer puts a ready issue first (#7808). */
2 import { putIssueFirst } from "../../actions/first.ts";
3 import type { Ctx } from "../../http/context.ts";
4 import { loadRepo, notFoundPage, repoBase, signInFirst } from "../chrome.ts";
5 import { backTo, readForm } from "../form.ts";
6 import { flashOrJson } from "../layout.ts";
7
8 export async function firstSubmit(ctx: Ctx): Promise<Response> {
9 const repo = loadRepo(ctx);
10 if (!repo) return notFoundPage(ctx);
11 if (!ctx.auth.user) return signInFirst(ctx);
12 const form = await readForm(ctx, "rw");
13 if (form instanceof Response) return form;
14 const dest = backTo(form, `${repoBase(repo)}/map`);
15 const put = putIssueFirst(ctx.app, ctx.auth.user, repo, Number(ctx.params.number));
16 if (!put.ok) {
17 if (put.error.kind === "not_found") return notFoundPage(ctx);
18 return flashOrJson(ctx, dest, { tone: "danger", message: put.error.message });
19 }
20 return flashOrJson(ctx, dest, { message: `#${put.value.number} is first.` });
21 }
src/web/pages/map.ts +24 −5
@@ -36,10 +36,8 @@ import type { Issue } from "../../core/issues.ts";
3636 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
3737 import { cachedLayout } from "../map_cache.ts";
3838 import { holdsFor } from "../../core/holds.ts";
39 import {
40 getRepoById,
41 type Repo,
42 } from "../../core/repos.ts";
39 import { firstMarksInRepo } from "../../core/first.ts";
40 import { canWrite, getRepoById, type Repo } from "../../core/repos.ts";
4341 import { listActiveRuns, type Run, type RunState } from "../../core/runs.ts";
4442 import type { Ctx } from "../../http/context.ts";
4543 import {
@@ -960,6 +958,7 @@ function rankedList(
960958 say: (r: Ranked) => string,
961959 empty: string,
962960 issueHref: (base: string, number: number) => string,
961 first?: { writer: boolean; by: ReadonlyMap<string, string> },
963962 ): Html {
964963 const base = repoBase(repo);
965964 return html`<section class="stack-tight">
@@ -973,7 +972,20 @@ function rankedList(
973972 href: issueHref(base, r.issue.number),
974973 title: r.issue.title,
975974 titleAttr: r.issue.title,
976 meta: html`#${r.issue.number} · ${say(r)}`,
975 meta: html`#${r.issue.number} · ${say(r)}${first?.by.has(r.issue.id)
976 ? ` · first, by ${first.by.get(r.issue.id)}`
977 : ""}`,
978 ...(first?.writer
979 ? {
980 trail: html`<form
981 method="post"
982 action="${base}/issues/${r.issue.number}/first"
983 >
984 <input type="hidden" name="back" value="${base}/map" />
985 ${btn("First", { variant: "outline", size: "sm" })}
986 </form>`,
987 }
988 : {}),
977989 }),
978990 )}
979991 </ul>`,
@@ -1444,6 +1456,13 @@ export async function mapPage(ctx: Ctx): Promise<Response> {
14441456 : `finishing it frees ${r.holdsUp}`,
14451457 "Nothing is ready: everything open waits for something.",
14461458 tellHref,
1459 {
1460 writer:
1461 ctx.auth.user !== undefined &&
1462 ctx.auth.user.kind !== "agent" &&
1463 canWrite(db, repo, ctx.auth.user),
1464 by: new Map([...firstMarksInRepo(db, repo.id)].map(([id, m]) => [id, m.by])),
1465 },
14471466 )}
14481467 ${rankedList(
14491468 repo,
test/map-first-page.test.ts added +160
@@ -0,0 +1,160 @@
1 /**
2 * The First button on the map page (#7808): a writer posts a same-origin
3 * form and the ready list puts that issue first, marked "first, by <who>".
4 */
5 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { createApp, type App } from "../src/app.ts";
10 import { openIssue, setIssueState } from "../src/actions/issues.ts";
11 import { createRepo } from "../src/actions/repos.ts";
12 import { addDependency } from "../src/core/dependencies.ts";
13 import { getIssueById } from "../src/core/issues.ts";
14 import { getRepoById } from "../src/core/repos.ts";
15 import { createToken } from "../src/core/tokens.ts";
16 import { createUser, getUserByName, type User } from "../src/core/users.ts";
17 import { serve, type Servers } from "../src/http/server.ts";
18
19 describe("First on the map page", () => {
20 let dataDir: string;
21 let app: App;
22 let servers: Servers;
23 let base: string;
24 let cookie: string;
25 let alpha = 0;
26 let beta = 0;
27 let blocked = 0;
28 let closed = 0;
29
30 const user = async (username: string, first: boolean): Promise<User> => {
31 const made = await createUser(app.db, {
32 origin: "seed",
33 trusted: true,
34 username,
35 email: `${username}@forge.local`,
36 password: "a-good-passphrase",
37 adminIfFirst: first,
38 });
39 if (!made.ok) throw new Error(made.error);
40 return made.value;
41 };
42 const signIn = async (username: string): Promise<string> => {
43 const res = await fetch(`${base}/signin`, {
44 method: "POST",
45 body: new URLSearchParams({ username, password: "a-good-passphrase" }),
46 redirect: "manual",
47 });
48 return (res.headers.get("set-cookie") ?? "").split(";")[0]!;
49 };
50 const ready = async (c: string): Promise<string> => {
51 const text = await (await fetch(`${base}/alice/plan/map`, { headers: { cookie: c } })).text();
52 const at = text.indexOf("Ready now");
53 return text.slice(at, text.indexOf("Blocked on", at));
54 };
55 const order = (section: string): number[] =>
56 [...section.matchAll(/#(\d+) ·/g)].map((m) => Number(m[1])).filter((n) => n === alpha || n === beta);
57
58 beforeAll(async () => {
59 dataDir = await mkdtemp(join(tmpdir(), "forge-map-first-page-"));
60 app = createApp({ dataDir, port: 0 });
61 servers = await serve(app);
62 base = `http://127.0.0.1:${servers.port}`;
63 const alice = await user("alice", true);
64 await user("bob", false);
65 const made = await createRepo(app, alice, { name: "plan", visibility: "public" });
66 if (!made.ok) throw new Error(String(made.error));
67 for (const title of ["Alpha work", "Beta work"]) {
68 const o = openIssue(app, alice, made.value, { title });
69 if (!o.ok) throw new Error(String(o.error));
70 if (title === "Alpha work") alpha = o.value.number;
71 else beta = o.value.number;
72 }
73 const repo = made.value;
74 const gamma = openIssue(app, alice, repo, { title: "Gamma work" });
75 const delta = openIssue(app, alice, repo, { title: "Delta work" });
76 const epsilon = openIssue(app, alice, repo, { title: "Epsilon work" });
77 if (!gamma.ok || !delta.ok || !epsilon.ok) throw new Error("issues");
78 // Delta waits for Gamma; Epsilon is closed.
79 addDependency(app.db, delta.value, gamma.value, alice.id);
80 setIssueState(app, alice, getRepoById(app.db, repo.id)!, getIssueById(app.db, epsilon.value.id)!, "closed");
81 blocked = delta.value.number;
82 closed = epsilon.value.number;
83 cookie = await signIn("alice");
84 });
85
86 afterAll(async () => {
87 await servers.stop();
88 await rm(dataDir, { recursive: true, force: true });
89 });
90
91 const post = (n: number, headers: Record<string, string> = {}) =>
92 fetch(`${base}/alice/plan/issues/${n}/first`, {
93 method: "POST",
94 headers: { cookie, origin: base, ...headers },
95 body: new URLSearchParams({ back: "/alice/plan/map" }),
96 redirect: "manual",
97 });
98
99 test("a reader sees no First button, a writer does", async () => {
100 const reader = await ready(await signIn("bob"));
101 expect(reader).toContain("Alpha work");
102 expect(reader).not.toContain(`/issues/${beta}/first`);
103 const writer = await ready(cookie);
104 expect(writer).toContain(`action="/alice/plan/issues/${beta}/first"`);
105 });
106
107 test("a cross-origin post is refused and changes nothing", async () => {
108 expect((await post(beta, { origin: "https://evil.example" })).status).toBe(403);
109 expect(order(await ready(cookie))).toEqual([alpha, beta]);
110 });
111
112 test("posting the form puts the issue first and says by whom", async () => {
113 expect((await post(beta)).status).toBe(303);
114 const section = await ready(cookie);
115 expect(order(section)).toEqual([beta, alpha]);
116 expect(section).toContain("first, by alice");
117 expect(section.match(/first, by/g)?.length).toBe(1);
118 });
119
120 test("a blocked or closed issue is refused with a flash, and nothing changes", async () => {
121 for (const n of [blocked, closed]) {
122 const res = await post(n);
123 expect(res.status).toBe(303);
124 const flash = decodeURIComponent((res.headers.get("set-cookie") ?? "").split(";")[0]!.split("=").slice(1).join("="));
125 expect(flash).toContain("nothing waits in front of");
126 }
127 expect(order(await ready(cookie))).not.toContain(blocked);
128 expect(app.db.query("SELECT 1 FROM issue_first WHERE issue_id IN (SELECT id FROM issue WHERE number IN (?1, ?2))").all(blocked, closed)).toHaveLength(0);
129 });
130
131 test("a signed-out post goes to sign in; a reader's post is refused; a read-only token too", async () => {
132 const before = order(await ready(cookie));
133 const out = await post(alpha, { cookie: "" });
134 expect(out.status).toBe(303);
135 expect(out.headers.get("location")).toContain("/signin");
136 const bobCookie = await signIn("bob");
137 const reader = await post(alpha, { cookie: bobCookie });
138 expect(reader.status).toBe(303);
139 expect(decodeURIComponent(reader.headers.get("set-cookie") ?? "")).toContain("write access");
140 const ro = createToken(app.db, getUserByName(app.db, "alice")!.id, "ro", "ro").token;
141 const denied = await post(alpha, { cookie: "", authorization: `Bearer ${ro}` });
142 expect(denied.status).toBe(403);
143 expect(order(await ready(cookie))).toEqual(before);
144 });
145
146 test("a script asking for JSON gets JSON, ok or not", async () => {
147 const bad = await post(blocked, { accept: "application/json" });
148 expect(bad.status).toBe(422);
149 expect(((await bad.json()) as { ok: boolean }).ok).toBe(false);
150 const good = await post(alpha, { accept: "application/json" });
151 expect(good.status).toBe(200);
152 expect(await good.json()).toMatchObject({ ok: true });
153 expect(order(await ready(cookie))[0]).toBe(alpha);
154 });
155
156 test("the map page's live region listens for issue.first", async () => {
157 const text = await (await fetch(`${base}/alice/plan/map`, { headers: { cookie } })).text();
158 expect(text).toMatch(/data-live="[^"]*issue\.\*/);
159 });
160 });
test/tui-first.test.ts added +118
@@ -0,0 +1,118 @@
1 /**
2 * "First" in the terminal (#7808): `f` on a ready line of the plan screen puts
3 * that issue first through the same action the map page posts to, a reader is
4 * refused in the same words, and the web hears the write as `issue.first`.
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 { openIssue } from "../src/actions/issues.ts";
11 import { FIRST_FORBIDDEN } from "../src/actions/first.ts";
12 import { createRepo } from "../src/actions/repos.ts";
13 import { createApp, type App as Forge } from "../src/app.ts";
14 import { readyNow, repoGraph } from "../src/core/dependencies.ts";
15 import { getRepoById, type Repo } from "../src/core/repos.ts";
16 import { createUser, getUserByName, type User } from "../src/core/users.ts";
17 import { createForgeApp } from "../src/tui/forge.ts";
18 import type { TuiIdentity } from "../src/tui/session.ts";
19 import { headlessTerminal } from "../src/tui/term/terminal.ts";
20 import { quietLogs } from "./helpers/globals.ts";
21 import { settleApp } from "./helpers/tui.ts";
22
23 quietLogs();
24
25 describe("first, in the terminal", () => {
26 let dataDir: string;
27 let forge: Forge;
28 let repo: Repo;
29 let alpha = 0;
30 let beta = 0;
31
32 const ready = () => readyNow(repoGraph(forge.db, repo.id)).map((r) => r.issue.number);
33
34 function boot(user: User) {
35 const terminal = headlessTerminal(120, 32);
36 const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user };
37 const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false });
38 app.start();
39 const press = async (...keys: string[]) => {
40 for (const key of keys) {
41 const ctrl = key.startsWith("ctrl+");
42 app.dispatch({ type: "key", key: ctrl ? key.slice(5) : key, ctrl, alt: false, shift: false });
43 }
44 await settleApp(app);
45 };
46 const typeText = async (text: string) => {
47 for (const ch of text)
48 app.dispatch({ type: "key", key: ch, ctrl: false, alt: false, shift: false });
49 await settleApp(app);
50 };
51 const toPlan = async () => {
52 await settleApp(app);
53 await press("ctrl+k");
54 await typeText("Issues in a repository");
55 await press("enter");
56 await typeText("alice/plan");
57 await press("enter");
58 await press("m");
59 };
60 return { text: () => app.screen.toText(), press, toPlan, stop: () => app.stop() };
61 }
62
63 beforeAll(async () => {
64 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-first-"));
65 forge = createApp({ dataDir, port: 0 });
66 for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) {
67 const made = await createUser(forge.db, {
68 origin: "seed",
69 trusted: true,
70 username,
71 email: `${username}@forge.local`,
72 password: "a-good-passphrase",
73 adminIfFirst,
74 });
75 if (!made.ok) throw new Error(made.error);
76 }
77 const alice = getUserByName(forge.db, "alice")!;
78 const made = await createRepo(forge, alice, { name: "plan", visibility: "public" });
79 if (!made.ok) throw new Error("repo");
80 repo = made.value;
81 for (const title of ["Alpha work", "Beta work"]) {
82 const o = openIssue(forge, alice, getRepoById(forge.db, repo.id)!, { title });
83 if (!o.ok) throw new Error(String(o.error));
84 if (title === "Alpha work") alpha = o.value.number;
85 else beta = o.value.number;
86 }
87 }, 60_000);
88
89 afterAll(async () => {
90 forge.close();
91 await rm(dataDir, { recursive: true, force: true });
92 });
93
94 test("a reader's f is refused and changes nothing", async () => {
95 const h = boot(getUserByName(forge.db, "bob")!);
96 await h.toPlan();
97 expect(h.text()).toContain("Ready now 2");
98 await h.press("f");
99 expect(h.text()).toContain(FIRST_FORBIDDEN);
100 expect(ready()).toEqual([alpha, beta]);
101 h.stop();
102 });
103
104 test("a writer's f on the second ready line puts it first, by name, and the web hears it", async () => {
105 const heard: string[] = [];
106 const off = forge.bus.subscribe((e) => {
107 if (e.kind === "issue.first" && e.repoId === repo.id) heard.push(e.kind);
108 });
109 const h = boot(getUserByName(forge.db, "alice")!);
110 await h.toPlan();
111 await h.press("j", "f");
112 expect(ready()).toEqual([beta, alpha]);
113 expect(heard).toEqual(["issue.first"]);
114 expect(h.text()).toContain("first, by alice");
115 off();
116 h.stop();
117 });
118 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs