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

/next reads as one numbered queue, folded into the open chapter, what comes after, and Later, with a count on each fold #11681

Merged opened this

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

Merged

torque/forge:torque/11619-next-reads-as-one-numbered-queue-folded → main 3 commits fork 1 thread · 1 unresolved

  • Merged. torque/11619-next-reads-as-one-numbered-queue-folded was merged into main as df8df58.

Review

Sign in to review this change.
11 files changed +245 −61
docs/design/areas/next-up.md +4
@@ -7,3 +7,7 @@
77 ## Rows under headings, not boxes (#9764)
88
99 The head is `pageHead("Next up")` with one sentence: the crew builds strictly from the top. The ranked list is a `<ul class="list setting-list">` straight under `<h2>Ready, in order</h2>`, and Not now (`?all=1`) the same under its own `<h2>`: no card, not even a bare one, since a list is rows (principle 7). Inside `[data-next]` the rows are spaced by a 4px gap, not ruled by hairlines (principle 3); the cut line keeps its dashed rule, as it marks where this week ends. The week's projection, its link, and for a writer the drag and j/k hint sit in one muted line after the list, beside the buttons that show their own keys. Blocker or Enabler is muted text in the row's meta (no badge). Top/Up/Down/Not now stay plain same-origin POST forms; each row still names its issue number, who is on it and what it waits on. No new class.
10
11 ## One numbered queue, folded (#11619)
12
13 `/:owner/:name/next` is the dispatcher's whole order as one numbered list: each row's lead is its position (1 at the top, continuing across folds), and its meta reads `#N · <state> · <who or "next">`. The state is one word from `web/pages/next_state.ts`: building, checking, in review, landing, waiting on #N, held or ready. The rows sit in three native `<details>` (no script, no new class): "Up next" (the first ten, open), "After" (the rest, closed) and "Later" (behind the Later gate, closed), each heading carrying its count. `?all=1` is gone; every fold is already in the page.
docs/features/next-up.md +1 −1
@@ -1,6 +1,6 @@
11 # Next up
22
3 `/:owner/:name/next` is the one ranked list the crew builds from (#8694). It shows the top ten in rank order with who is on each and, in one line, why a row cannot start yet; the rest is one line that opens `?all=1`, with the "Not now" work behind the Later gate below it.
3 `/:owner/:name/next` is the one ranked list the crew builds from (#8694). It reads as one numbered queue in the dispatcher's order (#11619): each row has its position, title, who is on it (or "next"), and one word of state: building, checking, in review, landing, waiting on #N, held or ready. A pull that is open on the issue sets building, checking or in review. Three native folds hold the rows, each with a count: "Up next" (the first ten, open), "After" (the rest) and "Later" (behind the Later gate). There is no script for the folds and no `?all=1`; the folds are keyboard and screen-reader reachable at any width. The terminal's Next up screen shows the same numbers and state words.
44
55 - **Role:** each row is tagged *Blocker* (it holds other open work up: "finishing it frees N") or *Enabler* (it holds nothing up; its line says what it serves, else that the owner ranked it). The tag is a label only: it never changes the order the picker takes work in.
66 - **Move:** `Top` (`t`), `Up` (`u`), `Down` (`d`) and `Not now` (`n`) are forms on each row, with the key shown beside each; `j`/`k` choose the row, and a row can be dragged. Each is one `POST /:owner/:name/next` (`number`, `to` = `top`, `not-now` or `after:<number>`) and saves at once. Writers only; the same-origin check applies.
src/core/next_up.ts +3
@@ -44,6 +44,8 @@ export interface NextRow {
4444 readonly why: string | null;
4545 /** One word: "ready" (a builder starts it in its turn), "blocked" (waits on open work) or "held" (anything else that passes it over). */
4646 readonly state: "ready" | "blocked" | "held";
47 /** The numbers of the open issues this one waits for, lowest first; empty when none (a deferred one too). */
48 readonly waits: readonly number[];
4749 }
4850
4951 export interface NextUp {
@@ -152,6 +154,7 @@ export function nextUp(
152154 onIt: crew.get(issue.id) ?? issue.assigneeName,
153155 why,
154156 state: why === null ? "ready" : blockedByOpen ? "blocked" : "held",
157 waits: blockedByOpen ? waitsOn(graph, issue) : [],
155158 };
156159 };
157160 const blocked = graph.issues.filter((i) => i.state === "open" && ranks.has(i.id) && !readyIds.has(i.id));
src/crew/pick_filter.ts +3 −1
@@ -79,7 +79,9 @@ export function pickFilter(
7979 const issues = ready.map((r) => r.issue);
8080 const small = smallIssues(app.db, issues);
8181 const first = firstAt(app.db, issues.map((i) => i.id));
82 const ranked = ranksInRepo(app.db, repo.id);
82 // Read only when polish is over its share: the one place a rank is asked (#11619 trims a statement from /next).
83 let rankedIds: Map<string, number> | undefined;
84 const ranked = { has: (id: string): boolean => (rankedIds ??= ranksInRepo(app.db, repo.id)).has(id) };
8385 const raised = raisedAt(app.db, issues.map((i) => i.id));
8486 const bets = betsInUse(app.db, repo.id) ? backedBetOf(app.db, repo.id, issues.map((i) => i.id)) : undefined;
8587 const polishLabels = labelsForIssues(app.db, issues.map((i) => i.id));
src/flow/read.ts +57 −2
@@ -7,9 +7,9 @@
77 import type { Database } from "bun:sqlite";
88 import { getIssuesByIds, LIST_LIMIT_MAX, listIssues, type Issue } from "../core/issues.ts";
99 import { closesFromBody, joinMany, listPulls, type Pull } from "../core/pulls.ts";
10 import { listReviews, reviewSummary } from "../core/reviews.ts";
10 import { listReviews, reviewSummariesFor, reviewSummary } from "../core/reviews.ts";
1111 import type { Repo } from "../core/repos.ts";
12 import { verdictForHead } from "../core/runs.ts";
12 import { checksForHeads, verdictForHead, type Run } from "../core/runs.ts";
1313 import {
1414 flowStage,
1515 type FlowChecks,
@@ -235,3 +235,58 @@ function openBlockers(
235235 }
236236 return out;
237237 }
238
239 /**
240 * The stage of each issue an open pull closes, by issue number: the lean read
241 * for a list that only needs to say building, checking or in review (#11619).
242 * The furthest-along pull speaks for the issue, as in `flowStage`.
243 */
244 export function openPullStages(db: Database, repo: Repo, scope: Scope, now: Date, only?: ReadonlySet<number>): Map<number, FlowStage["stage"]> {
245 const out = new Map<number, FlowStage["stage"]>();
246 // One statement for all open pulls; a repository with none costs nothing more.
247 const seal = sealedClause(scope, "issue", 2);
248 const pulls = db
249 .query<{ id: string; body: string; head_oid: string; created_at: string; number: number }, string[]>(
250 `SELECT issue.id, issue.number, issue.body, issue.created_at, pull.head_oid FROM issue
251 JOIN pull ON pull.issue_id = issue.id
252 WHERE issue.repo_id = ?1 AND issue.kind = 'pull' AND issue.state = 'open'${seal.sql}`,
253 )
254 .all(repo.id, ...seal.params);
255 const asked = pulls
256 .map((pull) => ({ pull, closes: closesFromBody(pull.body).filter((n) => only === undefined || only.has(n)) }))
257 .filter((p) => p.closes.length > 0); // a pull that closes nothing asked about costs nothing
258 if (asked.length === 0) return out;
259 // Two reads for all of them, not two per pull.
260 const runs = checksForHeads(db, new Map(asked.map((p) => [p.pull.id, p.pull.head_oid])));
261 const reviews = reviewSummariesFor(db, asked.map((p) => ({ id: p.pull.id, headOid: p.pull.head_oid })));
262 for (const { pull, closes } of asked) {
263 const summary = reviews.get(pull.id);
264 const flowPull: FlowPull = {
265 number: pull.number,
266 state: "open",
267 openedAt: pull.created_at,
268 closedAt: null,
269 mergedAt: null,
270 checks: foldRuns(newestPerKind(runs.get(pull.id) ?? [])),
271 approvals: summary?.approvals ?? 0,
272 changesRequested: summary?.changesRequested ?? 0,
273 requireReview: repo.requireReview,
274 reviewedAt: null,
275 liveAt: null,
276 deployFailed: false,
277 };
278 for (const n of closes) {
279 const stage = flowStage({ number: n, title: "", createdAt: pull.created_at, assignee: null, blockedBy: [], pulls: [flowPull] }, now).stage;
280 const seen = out.get(n);
281 if (seen === undefined || STAGE_ORDER.indexOf(stage) > STAGE_ORDER.indexOf(seen)) out.set(n, stage);
282 }
283 }
284 return out;
285 }
286 /** `verdictForHead` over runs already read, newest first: the newest of each kind and environment. */
287 function newestPerKind(runs: readonly Run[]): Run[] {
288 const newest = new Map<string, Run>();
289 for (const run of runs) if (!newest.has(`${run.kind}:${run.env}`)) newest.set(`${run.kind}:${run.env}`, run);
290 return [...newest.values()];
291 }
292 const STAGE_ORDER = ["issue", "pull", "check", "review", "land", "live"] as const;
src/tui/screens/next.ts +7 −2
@@ -17,6 +17,9 @@ import { canRead, canWrite, type Repo } from "../../core/repos.ts";
1717 import { sealedScope } from "../../core/sealed.ts";
1818 import { notStartable } from "../../crew/not_startable.ts";
1919 import { rangeWords, weekView, type WeekView } from "../../flow/week.ts";
20 import { openPullStages } from "../../flow/read.ts";
21 import type { Stage } from "../../flow/stage.ts";
22 import { stateWord } from "../../web/pages/next_state.ts";
2023 import type { Ctx, Screen } from "../app/app.ts";
2124 import { binding } from "../app/keys.ts";
2225 import { fill, len, split } from "../ui/layout.ts";
@@ -35,6 +38,7 @@ export function nextScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
3538 let rows: readonly NextRow[] = [];
3639 let away = 0;
3740 let week: WeekView | undefined;
41 let stages = new Map<number, Stage>();
3842 let loaded = false;
3943 // Read at use, not at open: access can change under a session.
4044 const may = (): boolean =>
@@ -51,6 +55,7 @@ export function nextScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
5155 rows = next.rows;
5256 away = next.notNowTotal;
5357 week = weekView(f.db, repo.id, rows, new Date());
58 stages = openPullStages(f.db, repo, scope, new Date(), new Set(rows.map((r) => r.issue.number)));
5459 }
5560 loaded = true;
5661 if (follow !== undefined) {
@@ -91,9 +96,9 @@ export function nextScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
9196 const cut = week?.cut;
9297 const ends = cut !== undefined && cut.unsure === null && cut.fits.length > 0 && at === cut.fits.length - 1;
9398 return {
94 title: `${r.place === null ? "·" : r.place}. #${r.issue.number} ${r.issue.title}`,
99 title: `${at + 1}. #${r.issue.number} ${r.issue.title}`,
95100 secondary: r.role === "blocker" ? "Blocker" : "Enabler",
96 meta: [estimate === undefined ? "" : rangeWords(estimate), r.onIt === null ? "" : `on it: ${r.onIt}`, r.why ?? ""]
101 meta: [stateWord(r, { stage: stages.get(r.issue.number) ?? "issue", waits: r.waits, held: r.state === "held", onIt: r.onIt !== null }), r.onIt ?? "next", estimate === undefined ? "" : rangeWords(estimate), r.why ?? ""]
97102 .filter((x) => x !== "")
98103 .join(" · "),
99104 ...(ends ? { note: `── this week ends here (${cut.fits.length} fit): below reads as next week ──` } : {}),
src/web/pages/next.ts +32 −39
@@ -8,6 +8,8 @@
88 */
99 import { placeOf, rankIssue, rankedWords, stepTo, type Place } from "../../actions/next.ts";
1010 import { nextUp, TOP, type NextRow } from "../../core/next_up.ts";
11 import { openPullStages } from "../../flow/read.ts";
12 import { stateWord, type StateFacts } from "./next_state.ts";
1113 import { canWrite, type Repo } from "../../core/repos.ts";
1214 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
1315 import { sealedScope } from "../../core/sealed.ts";
@@ -16,7 +18,7 @@ import { CUT_TOP_WORDS, cutWords, FIT_COUNT_JS, rangeWords, rowCosts, weekView,
1618 import type { Ctx } from "../../http/context.ts";
1719 import { crumbsFor, loadRepo, notFoundPage, pageHead, repoBase, repoHeader, repoPalette, signInFirst } from "../chrome.ts";
1820 import { backTo, field, readForm } from "../form.ts";
19 import { html, type Html } from "../html.ts";
21 import { html, raw, type Html } from "../html.ts";
2022 import { flashOrJson, page } from "../layout.ts";
2123 import { btn, kbd, row } from "../ui.ts";
2224
@@ -34,6 +36,7 @@ export const NEXT_CSS = `[data-next] { overflow-wrap: anywhere; }
3436 [data-next] .list .row { border-radius: var(--radius); }
3537 [data-next] .list > li.has-after > .row { flex: 1 1 14rem; }
3638 [data-next] .row-after { display: flex; flex-wrap: wrap; gap: 6px; max-width: 100%; padding: 0 14px 8px; }
39 [data-next] summary h2 { display: inline; margin: 0; }
3740 [data-next] .cut-line { overflow-wrap: anywhere; }`;
3841
3942 export const KEYS_JS = `
@@ -146,6 +149,8 @@ function line(
146149 away: boolean,
147150 priced: readonly Priced[] = [],
148151 cost: number | undefined = undefined,
152 position: number | null = null,
153 state: string | null = null,
149154 ): Html {
150155 const r = rows[at]!;
151156 const n = r.issue.number;
@@ -163,8 +168,8 @@ function line(
163168 href: `${base}/issues/${n}`,
164169 title: r.issue.title,
165170 titleAttr: r.issue.title,
166 lead: html`${r.place === null ? "·" : String(r.place)}`,
167 meta: html`${r.role === "blocker" ? "Blocker" : "Enabler"} · #${n} · ${r.roleWhy}${away ? null : html` · <span class="estimate">${rangeWords(priced.find((p) => p.number === n)?.estimate ?? { known: false, samples: 0, basis: "no estimate" })}</span>`}${r.onIt === null ? "" : ` · on it: ${r.onIt}`}${r.why === null ? "" : ` · ${r.why}`}`,
171 lead: html`${position === null ? "·" : String(position)}`,
172 meta: html`${r.role === "blocker" ? "Blocker" : "Enabler"} · #${n}${state === null ? "" : ` · ${state} · ${r.onIt ?? "next"}`} · ${r.roleWhy}${away ? null : html` · <span class="estimate">${rangeWords(priced.find((p) => p.number === n)?.estimate ?? { known: false, samples: 0, basis: "no estimate" })}</span>`}${state !== null || r.onIt === null ? "" : ` · on it: ${r.onIt}`}${r.why === null ? "" : ` · ${r.why}`}`,
168173 nav: true,
169174 data: { number: String(n), ...(cost === undefined ? {} : { cost: String(cost) }) },
170175 ...(controls === null ? {} : { after: controls }),
@@ -183,58 +188,46 @@ export function nextPage(ctx: Ctx): Response {
183188 const repo = loadRepo(ctx);
184189 if (!repo) return notFoundPage(ctx);
185190 const base = repoBase(repo);
186 const all = ctx.url.searchParams.get("all") === "1";
187191 const writer = ctx.auth.user !== undefined && ctx.auth.user.kind !== "agent" && canWrite(ctx.app.db, repo, ctx.auth.user);
188192 const scope = sealedScope(ctx.app.db, repo, ctx.auth.user);
189193 const next = nextUp(ctx.app.db, repo, scope, (ready) => notStartable(ctx.app, repo, ready, scope));
190 const shown = all ? next.rows : next.rows.slice(0, TOP);
191 const rest = next.rows.length - shown.length;
192 const back = all ? `${base}/next?all=1` : `${base}/next`;
194 const rows = next.rows;
195 const back = `${base}/next`;
193196 const rendering = performance.now();
194 const { priced, cut, capacity, sentence } = weekView(ctx.app.db, repo.id, next.rows, new Date());
197 const { priced, cut, capacity, sentence } = weekView(ctx.app.db, repo.id, rows, new Date());
195198 const costs = rowCosts(priced);
196 const rowsHtml = (): Html[] => {
197 const out: Html[] = [];
198 shown.forEach((_, at) => {
199 if (at === cut.fits.length && cut.unsure === null) out.push(cutMark(cut, at === 0));
200 out.push(line(base, next.rows, at, writer, back, false, priced, costs?.[at]));
201 });
202 return out;
203 };
199 const stages = openPullStages(ctx.app.db, repo, scope, new Date(), new Set(rows.map((r) => r.issue.number)));
200 const facts = (r: NextRow): StateFacts => ({ stage: stages.get(r.issue.number) ?? "issue", waits: r.waits, held: r.state === "held", onIt: r.onIt !== null });
201 // One numbered order, folded: the first TOP rows open, what comes after, then Later.
202 const first = rows.slice(0, TOP);
203 const after = rows.slice(TOP);
204 const section = (title: string, list: readonly NextRow[], start: number, open: boolean, id?: string): Html =>
205 html`<details class="mt"${open ? raw(" open") : null}><summary><h2>${title} · ${String(list.length)}</h2></summary>
206 <ul class="list setting-list"${id === undefined ? null : html` id="${id}"`}${id !== undefined && costs !== null && capacity !== null && cut.unsure === null ? html` data-capacity="${String(capacity.usd)}"` : null}>
207 ${list.flatMap((_, i) => {
208 const at = start + i;
209 const mark = at === cut.fits.length && cut.unsure === null && id !== undefined && i > 0 ? [cutMark(cut, false)] : [];
210 return [...mark, line(base, rows, at, writer, back, false, priced, costs?.[at], at + 1, stateWord(rows[at]!, facts(rows[at]!)))];
211 })}
212 </ul></details>`;
204213 const body = html`${repoHeader(ctx, repo, "issues")}
205214 <div data-next>
206215 ${pageHead("Next up", { sub: "The crew builds strictly from the top." })}
207216 <p class="error" id="next-flash" role="alert" hidden></p>
208 ${next.rows.length === 0
217 ${rows.length === 0
209218 ? html`<p class="muted">Nothing is ready: everything open waits for something. <a href="${base}/map">See the map</a>.</p>`
210 : html`<section class="stack-tight mt"><h2>Ready, in order</h2><ul class="list setting-list" id="next-list"${costs !== null && capacity !== null && cut.unsure === null ? html` data-capacity="${String(capacity.usd)}"` : null}>
211 ${rowsHtml()}
212 </ul></section>`}
219 : html`${section("Up next", first, 0, true, "next-list")}${after.length > 0 ? section("After", after, TOP, false) : null}`}
213220 <p class="muted small mt" id="next-week">${sentence} <a href="${base}/week">The week</a>${writer
214221 ? " · Drag a row, or press the key on its button; j and k move between rows."
215222 : null}</p>
216 ${all
217 ? null
218 : rest > 0 || next.notNowTotal > 0 || next.unlisted > 0
219 ? html`<p class="mt">
220 <a href="${base}/next?all=1">${[
221 rest > 0 ? `${rest} more ready` : null,
222 next.notNowTotal > 0 ? `${next.notNowTotal} not now` : null,
223 ]
224 .filter((x) => x !== null)
225 .join(" · ")}: open the rest</a>
226 </p>`
227 : null}
223 ${next.notNow.length > 0
224 ? html`<details class="mt"><summary><h2>Later · ${String(next.notNowTotal)}</h2></summary><ul class="list setting-list">
225 ${next.notNow.map((_, at) => line(base, next.notNow, at, writer, back, true))}
226 </ul></details>`
227 : null}
228228 ${next.unlisted > 0
229229 ? html`<p class="muted small mt">${String(next.unlisted)} more open issues are not listed here; the queue API pages through them.</p>`
230230 : null}
231 ${all && next.notNow.length > 0
232 ? html`<section class="stack-tight mt">
233 <h2>Not now</h2><ul class="list setting-list">
234 ${next.notNow.map((_, at) => line(base, next.notNow, at, writer, back, true))}
235 </ul>
236 </section>`
237 : null}
238231 </div>`;
239232 const response = page({
240233 ctx,
src/web/pages/next_state.ts added +31
@@ -0,0 +1,31 @@
1 /**
2 * One word for where a queue row stands (#11619): what /next shows beside its
3 * place. Plain data in, a string out. The stage is the flow's (`flow/stage.ts`):
4 * an open pull is the work being built, then checked, then in review.
5 */
6 import type { NextRow } from "../../core/next_up.ts";
7 import type { Stage } from "../../flow/stage.ts";
8
9 export interface StateFacts {
10 /** The flow's stage for the issue; "issue" when no pull is open on it. */
11 readonly stage: Stage;
12 /** Open issues it waits for. */
13 readonly waits: readonly number[];
14 /** A person or the crew is on it. */
15 readonly held: boolean;
16 readonly onIt: boolean;
17 }
18
19 export function stateWord(_row: NextRow, f: StateFacts): string {
20 switch (f.stage) {
21 case "pull": return "building";
22 case "check": return "checking";
23 case "review": return "in review";
24 case "land": return "landing";
25 case "live": return "live";
26 case "issue":
27 if (f.waits.length > 0) return `waiting on ${f.waits.slice(0, 3).map((n) => `#${n}`).join(", ")}${f.waits.length > 3 ? ` +${f.waits.length - 3}` : ""}`;
28 if (f.held) return "held";
29 return f.onIt ? "building" : "ready";
30 }
31 }
test/next-pull-state.test.ts added +68
@@ -0,0 +1,68 @@
1 /** A real open pull sets its issue's state word on /next (#11619): building, then checking, then in review. */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { openIssue } from "../src/actions/issues.ts";
8 import { openPull } from "../src/actions/pulls.ts";
9 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
10 import { createSession } from "../src/core/sessions.ts";
11 import { createUser } from "../src/core/users.ts";
12 import { serve, type Servers } from "../src/http/server.ts";
13
14 let dir: string;
15 let app: App;
16 let servers: Servers;
17 let cookie: string;
18 let issue: number;
19 let pullId: string;
20 let head: string;
21 let repoId: string;
22
23 beforeAll(async () => {
24 dir = await mkdtemp(join(tmpdir(), "forge-next-pull-"));
25 app = createApp({ dataDir: dir, port: 0 });
26 servers = await serve(app);
27 const made = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
28 if (!made.ok) throw new Error(made.error);
29 const alice = made.value;
30 cookie = `forge_session=${createSession(app.db, alice.id).token}`;
31 const repo = await createRepo(app, alice, { name: "wip", visibility: "public" });
32 if (!repo.ok) throw new Error(JSON.stringify(repo.error));
33 repoId = repo.value.id;
34 const opened = openIssue(app, alice, repo.value, { title: "Build the thing" });
35 if (!opened.ok) throw new Error(String(opened.error));
36 issue = opened.value.number;
37 for (const [branch, file] of [[undefined, "forge.json"], ["topic", "t.txt"]] as const) {
38 const done = await commitToBranch(app, alice, repo.value, { ...(branch === undefined ? {} : { branch }), message: "change", files: [{ path: file, content: file === "forge.json" ? "{}" : "x" }] });
39 if (!done.ok) throw new Error(JSON.stringify(done.error));
40 }
41 const pull = await openPull(app, alice, repo.value, { title: "Build it", body: `Closes #${issue}`, baseBranch: "main", headBranch: "topic" });
42 if (!pull.ok) throw new Error(JSON.stringify(pull.error));
43 const row = app.db.query<{ id: string; head_oid: string }, [number]>("SELECT issue.id, pull.head_oid FROM issue JOIN pull ON pull.issue_id = issue.id WHERE issue.number = ?1").get(pull.value.number)!;
44 pullId = row.id;
45 head = row.head_oid;
46 });
47
48 afterAll(async () => {
49 await servers.stop();
50 app.close();
51 await rm(dir, { recursive: true, force: true });
52 });
53
54 const rowOf = async (): Promise<string> => {
55 const page = await (await fetch(`${servers.url}/alice/wip/next`, { headers: { cookie } })).text();
56 return page.split("<li").find((li) => li.includes(`data-number="${issue}"`))!.replace(/\s+/g, " ");
57 };
58 const check = (id: string, state: string) =>
59 app.db.query("INSERT INTO run (id, repo_id, kind, issue_id, commit_oid, state) VALUES (?, ?, 'check', ?, ?, ?)").run(id, repoId, pullId, head, state);
60
61 test("an open pull that closes the issue reads building, checking, then in review", async () => {
62 expect(await rowOf()).toContain("building");
63 check("zz-run-1", "running");
64 expect(await rowOf()).toContain("checking");
65 app.db.query("UPDATE run SET state = 'ok' WHERE id = 'zz-run-1'").run();
66 app.db.query("UPDATE repo SET require_review = 1 WHERE id = ?").run(repoId);
67 expect(await rowOf()).toContain("in review");
68 });
test/next-state.test.ts added +18
@@ -0,0 +1,18 @@
1 /** One word for a queue row (#11619): the flow's stage, or what the row waits for. */
2 import { expect, test } from "bun:test";
3 import type { NextRow } from "../src/core/next_up.ts";
4 import { stateWord } from "../src/web/pages/next_state.ts";
5
6 const row = {} as NextRow;
7 const f = { stage: "issue", waits: [], held: false, onIt: false } as const;
8
9 test("a row's state is one word from its pull's stage, or what it waits for", () => {
10 expect(stateWord(row, { ...f, stage: "pull" })).toBe("building");
11 expect(stateWord(row, { ...f, stage: "check" })).toBe("checking");
12 expect(stateWord(row, { ...f, stage: "review" })).toBe("in review");
13 expect(stateWord(row, { ...f, waits: [4, 9] })).toBe("waiting on #4, #9");
14 expect(stateWord(row, { ...f, waits: [1, 2, 3, 4, 5] })).toBe("waiting on #1, #2, #3 +2");
15 expect(stateWord(row, { ...f, onIt: true })).toBe("building");
16 expect(stateWord(row, f)).toBe("ready");
17 expect(stateWord(row, { ...f, held: true })).toBe("held");
18 });
test/next-up.test.ts +21 −16
@@ -218,16 +218,21 @@ describe("Next up", () => {
218218 expect(order()).toEqual(before);
219219 });
220220
221 test("beyond ten rows the rest is one line that opens it", async () => {
221 test("beyond ten rows the rest folds under After with its count, in the API's order", async () => {
222222 for (let i = 0; i < 12; i++) open(`Extra ${i}`);
223223 const page = await (await fetch(`${base}/alice/plan/next`, { headers: { cookie } })).text();
224 expect(page).toContain("next?all=1");
225 expect(page).toContain("open the rest");
226 const rows = (html: string) => (html.match(/data-number="/g) ?? []).length;
227 expect(rows(page)).toBe(10);
228 const full = await (await fetch(`${base}/alice/plan/next?all=1`, { headers: { cookie } })).text();
229 expect(rows(full)).toBeGreaterThan(10);
230 expect(full).toContain("Not now");
224 const rows = [...page.slice(0, page.indexOf("<h2>Later")).matchAll(/data-number="(\d+)"/g)].map((m) => Number(m[1]));
225 const queue = (await (await fetch(`${base}/api/v1/repos/alice/plan/queue`, { headers: { cookie } })).json()) as { queue: { number: number }[] };
226 expect(rows.length).toBeGreaterThan(10);
227 expect(rows).toEqual(queue.queue.map((q) => q.number).slice(0, rows.length));
228 // Positions run 1.. down the page, across the folds.
229 const leads = [...page.slice(0, page.indexOf("<h2>Later")).matchAll(/<span class="row-lead">(\d+)<\/span>/g)].map((m) => Number(m[1]));
230 expect(leads).toEqual(rows.map((_, i) => i + 1));
231 const folds = page.replace(/\s+/g, " ");
232 expect(folds).toMatch(/<details class="mt" open><summary><h2>Up next · 10<\/h2>/);
233 expect(folds).toContain(`<h2>After · ${rows.length - 10}</h2>`);
234 expect(folds).toContain("<h2>Later · ");
235 expect(page).not.toContain("all=1");
231236 });
232237 });
233238
@@ -337,9 +342,9 @@ describe("Next up page shape (#9764)", () => {
337342 expect(res.status).toBe(200);
338343 const body = (await res.text()).replace(/\s+/g, " ");
339344
340 expect(body).toContain("<h2>Ready, in order</h2>");
345 expect(body).toContain("<h2>Up next · 3</h2>");
341346 // The list is rows straight under its heading: no card, bare or boxed.
342 expect(body).toMatch(/<h2>Ready, in order<\/h2> ?<ul class="list setting-list" id="next-list"/);
347 expect(body).toMatch(/<h2>Up next · 3<\/h2><\/summary> ?<ul class="list setting-list" id="next-list"/);
343348 const main = body.slice(body.indexOf("<div data-next>"));
344349 expect(main).not.toMatch(/class="card\b/);
345350 // One sentence in the head, from pageHead; the key hint sits after the list, not in the lead.
@@ -353,11 +358,11 @@ describe("Next up page shape (#9764)", () => {
353358 const rowOf = (n: number) => rows.find((r) => r.includes(`data-number="${n}"`))!;
354359 for (const [issue, role] of [[first, "Blocker"], [second, "Enabler"]] as const) {
355360 const meta = /<span class="row-meta">([\s\S]*?)<\/span>/.exec(rowOf(issue.number))![1]!;
356 expect(meta).toContain(`${role} · #${issue.number}`);
361 expect(meta).toContain(`${role} · #${issue.number} · `);
357362 expect(meta.split("<span")[0]).not.toContain("<");
358363 }
359 expect(rowOf(second.number)).toContain("on it: bob");
360 expect(rowOf(waits.number)).toContain(`waits for #${first.number}`);
364 expect(rowOf(second.number)).toContain("held · bob");
365 expect(rowOf(waits.number)).toContain(`waiting on #${first.number} · next`);
361366
362367 // Every action is still a form that posts.
363368 for (const label of ["Top", "Up", "Down", "Not now"]) {
@@ -369,9 +374,9 @@ describe("Next up page shape (#9764)", () => {
369374 const gate = open("Later gate: not now");
370375 const far = open("Far off");
371376 addDependency(app.db, far, gate, alice.id);
372 const all = (await (await fetch(`${servers.url}/alice/shape/next?all=1`, { headers: { cookie } })).text()).replace(/\s+/g, " ");
373 const notNow = all.slice(all.indexOf("<h2>Not now</h2>"));
374 expect(notNow).toMatch(/^<h2>Not now<\/h2> ?<ul class="list setting-list">/);
377 const all = (await (await fetch(`${servers.url}/alice/shape/next`, { headers: { cookie } })).text()).replace(/\s+/g, " ");
378 const notNow = all.slice(all.indexOf("<h2>Later · "));
379 expect(notNow).toMatch(/^<h2>Later · 1<\/h2><\/summary> ?<ul class="list setting-list">/);
375380 expect(notNow).not.toMatch(/class="card\b/);
376381 expect(notNow).not.toContain("Behind the Later gate. Top lifts");
377382 const farRow = /<li[^>]*data-number="(\d+)"[\s\S]*?<\/li>/.exec(notNow)!;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs