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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7808-the-map-page-has-a-first-button-on-each → main 3 commits fork
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' | ||
| 69 | 69 | |
| 70 | 70 | 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. |
| 71 | 71 | |
| 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 | ||
| 72 | 74 | 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. |
| 73 | 75 | |
| 74 | 76 | 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. | ||
| 25 | 25 | - `.map-edge-done` — an edge with a closed issue at either end, faded. |
| 26 | 26 | - `.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. |
| 27 | 27 | - `.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, | ||
| 62 | 62 | return out; |
| 63 | 63 | } |
| 64 | 64 | |
| 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 }>(); | |
| 68 | 71 | 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`, | |
| 71 | 75 | ) |
| 72 | 76 | .all(repoId)) |
| 73 | out.set(r.issue_id, r.at); | |
| 77 | out.set(r.issue_id, { at: r.at, by: r.username }); | |
| 74 | 78 | return out; |
| 75 | 79 | } |
| 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"; | ||
| 240 | 240 | import { tellHerePage } from "../web/studio/tell_here.ts"; |
| 241 | 241 | import { globalMapPage } from "../web/pages/global-map.ts"; |
| 242 | 242 | import { mapPage } from "../web/pages/map.ts"; |
| 243 | import { firstSubmit } from "../web/pages/first.ts"; | |
| 243 | 244 | import { |
| 244 | 245 | milestonePage, |
| 245 | 246 | milestonesPage, |
| @@ -1198,6 +1199,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1198 | 1199 | dependencySubmit, |
| 1199 | 1200 | ); |
| 1200 | 1201 | router.add("GET", "/:owner/:name/map", mapPage); |
| 1202 | router.add("POST", "/:owner/:name/issues/:number/first", firstSubmit); | |
| 1201 | 1203 | router.add("GET", "/:owner/:name/milestones", milestonesPage); |
| 1202 | 1204 | router.add("GET", "/:owner/:name/milestones/:number", milestonePage); |
| 1203 | 1205 | router.add("POST", "/:owner/:name/milestones/:number/watch", milestoneWatch); |
src/tui/screens/plan.ts +26 −2
| @@ -6,6 +6,8 @@ | ||
| 6 | 6 | * is left out here as it is there. |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | import { FIRST_FORBIDDEN, putIssueFirst } from "../../actions/first.ts"; | |
| 10 | import { firstMarksInRepo } from "../../core/first.ts"; | |
| 9 | 11 | import { repoGraph, readyNow } from "../../core/dependencies.ts"; |
| 10 | 12 | import { pillarView } from "../../core/pillars.ts"; |
| 11 | 13 | import { listActiveRuns } from "../../core/runs.ts"; |
| @@ -24,6 +26,8 @@ import { local } from "./where.ts"; | ||
| 24 | 26 | export interface PlanLine { |
| 25 | 27 | readonly text: string; |
| 26 | 28 | readonly number?: number; |
| 29 | /** A ready issue: the one `f` puts first. */ | |
| 30 | readonly ready?: true; | |
| 27 | 31 | } |
| 28 | 32 | |
| 29 | 33 | export function readPlan(f: Session, repo: Repo): PlanLine[] { |
| @@ -56,6 +60,7 @@ export function readPlan(f: Session, repo: Repo): PlanLine[] { | ||
| 56 | 60 | return h === undefined ? "" : ` (${h.detail})`; |
| 57 | 61 | }; |
| 58 | 62 | |
| 63 | const firsts = firstMarksInRepo(f.db, repo.id); | |
| 59 | 64 | const lines: PlanLine[] = [{ text: `Working ${working.length}` }]; |
| 60 | 65 | for (const i of working) |
| 61 | 66 | lines.push({ |
| @@ -65,8 +70,11 @@ export function readPlan(f: Session, repo: Repo): PlanLine[] { | ||
| 65 | 70 | lines.push({ text: `Ready now ${ready.length}` }); |
| 66 | 71 | for (const { issue: i } of ready) |
| 67 | 72 | 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 | }`, | |
| 69 | 76 | number: i.number, |
| 77 | ready: true, | |
| 70 | 78 | }); |
| 71 | 79 | lines.push({ text: `Waiting ${waiting.length}` }); |
| 72 | 80 | for (const i of waiting) |
| @@ -111,7 +119,7 @@ export function planScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 111 | 119 | }; |
| 112 | 120 | return { |
| 113 | 121 | 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")], | |
| 115 | 123 | load(signal) { |
| 116 | 124 | live(f, signal, { repoId: repo.id }, () => runAsync(ctx, async () => reload())); |
| 117 | 125 | reload(); |
| @@ -133,6 +141,22 @@ export function planScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 133 | 141 | openAt(state.index); |
| 134 | 142 | return true; |
| 135 | 143 | } |
| 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 | } | |
| 136 | 160 | if (e.key === "r") { |
| 137 | 161 | reload(); |
| 138 | 162 | 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"; | ||
| 36 | 36 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 37 | 37 | import { cachedLayout } from "../map_cache.ts"; |
| 38 | 38 | 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"; | |
| 43 | 41 | import { listActiveRuns, type Run, type RunState } from "../../core/runs.ts"; |
| 44 | 42 | import type { Ctx } from "../../http/context.ts"; |
| 45 | 43 | import { |
| @@ -960,6 +958,7 @@ function rankedList( | ||
| 960 | 958 | say: (r: Ranked) => string, |
| 961 | 959 | empty: string, |
| 962 | 960 | issueHref: (base: string, number: number) => string, |
| 961 | first?: { writer: boolean; by: ReadonlyMap<string, string> }, | |
| 963 | 962 | ): Html { |
| 964 | 963 | const base = repoBase(repo); |
| 965 | 964 | return html`<section class="stack-tight"> |
| @@ -973,7 +972,20 @@ function rankedList( | ||
| 973 | 972 | href: issueHref(base, r.issue.number), |
| 974 | 973 | title: r.issue.title, |
| 975 | 974 | 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 | : {}), | |
| 977 | 989 | }), |
| 978 | 990 | )} |
| 979 | 991 | </ul>`, |
| @@ -1444,6 +1456,13 @@ export async function mapPage(ctx: Ctx): Promise<Response> { | ||
| 1444 | 1456 | : `finishing it frees ${r.holdsUp}`, |
| 1445 | 1457 | "Nothing is ready: everything open waits for something.", |
| 1446 | 1458 | 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 | }, | |
| 1447 | 1466 | )} |
| 1448 | 1467 | ${rankedList( |
| 1449 | 1468 | 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 | }); |
new above
offline