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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/11619-next-reads-as-one-numbered-queue-folded → main 3 commits fork 1 thread · 1 unresolved
11 files
changed +245
−61
docs/design/areas/next-up.md +4
| @@ -7,3 +7,7 @@ | ||
| 7 | 7 | ## Rows under headings, not boxes (#9764) |
| 8 | 8 | |
| 9 | 9 | 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 @@ | ||
| 1 | 1 | # Next up |
| 2 | 2 | |
| 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. | |
| 4 | 4 | |
| 5 | 5 | - **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. |
| 6 | 6 | - **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 { | ||
| 44 | 44 | readonly why: string | null; |
| 45 | 45 | /** One word: "ready" (a builder starts it in its turn), "blocked" (waits on open work) or "held" (anything else that passes it over). */ |
| 46 | 46 | 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[]; | |
| 47 | 49 | } |
| 48 | 50 | |
| 49 | 51 | export interface NextUp { |
| @@ -152,6 +154,7 @@ export function nextUp( | ||
| 152 | 154 | onIt: crew.get(issue.id) ?? issue.assigneeName, |
| 153 | 155 | why, |
| 154 | 156 | state: why === null ? "ready" : blockedByOpen ? "blocked" : "held", |
| 157 | waits: blockedByOpen ? waitsOn(graph, issue) : [], | |
| 155 | 158 | }; |
| 156 | 159 | }; |
| 157 | 160 | 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( | ||
| 79 | 79 | const issues = ready.map((r) => r.issue); |
| 80 | 80 | const small = smallIssues(app.db, issues); |
| 81 | 81 | 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) }; | |
| 83 | 85 | const raised = raisedAt(app.db, issues.map((i) => i.id)); |
| 84 | 86 | const bets = betsInUse(app.db, repo.id) ? backedBetOf(app.db, repo.id, issues.map((i) => i.id)) : undefined; |
| 85 | 87 | const polishLabels = labelsForIssues(app.db, issues.map((i) => i.id)); |
src/flow/read.ts +57 −2
| @@ -7,9 +7,9 @@ | ||
| 7 | 7 | import type { Database } from "bun:sqlite"; |
| 8 | 8 | import { getIssuesByIds, LIST_LIMIT_MAX, listIssues, type Issue } from "../core/issues.ts"; |
| 9 | 9 | 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"; | |
| 11 | 11 | import type { Repo } from "../core/repos.ts"; |
| 12 | import { verdictForHead } from "../core/runs.ts"; | |
| 12 | import { checksForHeads, verdictForHead, type Run } from "../core/runs.ts"; | |
| 13 | 13 | import { |
| 14 | 14 | flowStage, |
| 15 | 15 | type FlowChecks, |
| @@ -235,3 +235,58 @@ function openBlockers( | ||
| 235 | 235 | } |
| 236 | 236 | return out; |
| 237 | 237 | } |
| 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"; | ||
| 17 | 17 | import { sealedScope } from "../../core/sealed.ts"; |
| 18 | 18 | import { notStartable } from "../../crew/not_startable.ts"; |
| 19 | 19 | 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"; | |
| 20 | 23 | import type { Ctx, Screen } from "../app/app.ts"; |
| 21 | 24 | import { binding } from "../app/keys.ts"; |
| 22 | 25 | import { fill, len, split } from "../ui/layout.ts"; |
| @@ -35,6 +38,7 @@ export function nextScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 35 | 38 | let rows: readonly NextRow[] = []; |
| 36 | 39 | let away = 0; |
| 37 | 40 | let week: WeekView | undefined; |
| 41 | let stages = new Map<number, Stage>(); | |
| 38 | 42 | let loaded = false; |
| 39 | 43 | // Read at use, not at open: access can change under a session. |
| 40 | 44 | const may = (): boolean => |
| @@ -51,6 +55,7 @@ export function nextScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 51 | 55 | rows = next.rows; |
| 52 | 56 | away = next.notNowTotal; |
| 53 | 57 | 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))); | |
| 54 | 59 | } |
| 55 | 60 | loaded = true; |
| 56 | 61 | if (follow !== undefined) { |
| @@ -91,9 +96,9 @@ export function nextScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 91 | 96 | const cut = week?.cut; |
| 92 | 97 | const ends = cut !== undefined && cut.unsure === null && cut.fits.length > 0 && at === cut.fits.length - 1; |
| 93 | 98 | return { |
| 94 | title: `${r.place === null ? "·" : r.place}. #${r.issue.number} ${r.issue.title}`, | |
| 99 | title: `${at + 1}. #${r.issue.number} ${r.issue.title}`, | |
| 95 | 100 | 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 ?? ""] | |
| 97 | 102 | .filter((x) => x !== "") |
| 98 | 103 | .join(" · "), |
| 99 | 104 | ...(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 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | import { placeOf, rankIssue, rankedWords, stepTo, type Place } from "../../actions/next.ts"; |
| 10 | 10 | 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"; | |
| 11 | 13 | import { canWrite, type Repo } from "../../core/repos.ts"; |
| 12 | 14 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; |
| 13 | 15 | import { sealedScope } from "../../core/sealed.ts"; |
| @@ -16,7 +18,7 @@ import { CUT_TOP_WORDS, cutWords, FIT_COUNT_JS, rangeWords, rowCosts, weekView, | ||
| 16 | 18 | import type { Ctx } from "../../http/context.ts"; |
| 17 | 19 | import { crumbsFor, loadRepo, notFoundPage, pageHead, repoBase, repoHeader, repoPalette, signInFirst } from "../chrome.ts"; |
| 18 | 20 | import { backTo, field, readForm } from "../form.ts"; |
| 19 | import { html, type Html } from "../html.ts"; | |
| 21 | import { html, raw, type Html } from "../html.ts"; | |
| 20 | 22 | import { flashOrJson, page } from "../layout.ts"; |
| 21 | 23 | import { btn, kbd, row } from "../ui.ts"; |
| 22 | 24 | |
| @@ -34,6 +36,7 @@ export const NEXT_CSS = `[data-next] { overflow-wrap: anywhere; } | ||
| 34 | 36 | [data-next] .list .row { border-radius: var(--radius); } |
| 35 | 37 | [data-next] .list > li.has-after > .row { flex: 1 1 14rem; } |
| 36 | 38 | [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; } | |
| 37 | 40 | [data-next] .cut-line { overflow-wrap: anywhere; }`; |
| 38 | 41 | |
| 39 | 42 | export const KEYS_JS = ` |
| @@ -146,6 +149,8 @@ function line( | ||
| 146 | 149 | away: boolean, |
| 147 | 150 | priced: readonly Priced[] = [], |
| 148 | 151 | cost: number | undefined = undefined, |
| 152 | position: number | null = null, | |
| 153 | state: string | null = null, | |
| 149 | 154 | ): Html { |
| 150 | 155 | const r = rows[at]!; |
| 151 | 156 | const n = r.issue.number; |
| @@ -163,8 +168,8 @@ function line( | ||
| 163 | 168 | href: `${base}/issues/${n}`, |
| 164 | 169 | title: r.issue.title, |
| 165 | 170 | 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}`}`, | |
| 168 | 173 | nav: true, |
| 169 | 174 | data: { number: String(n), ...(cost === undefined ? {} : { cost: String(cost) }) }, |
| 170 | 175 | ...(controls === null ? {} : { after: controls }), |
| @@ -183,58 +188,46 @@ export function nextPage(ctx: Ctx): Response { | ||
| 183 | 188 | const repo = loadRepo(ctx); |
| 184 | 189 | if (!repo) return notFoundPage(ctx); |
| 185 | 190 | const base = repoBase(repo); |
| 186 | const all = ctx.url.searchParams.get("all") === "1"; | |
| 187 | 191 | const writer = ctx.auth.user !== undefined && ctx.auth.user.kind !== "agent" && canWrite(ctx.app.db, repo, ctx.auth.user); |
| 188 | 192 | const scope = sealedScope(ctx.app.db, repo, ctx.auth.user); |
| 189 | 193 | 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`; | |
| 193 | 196 | 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()); | |
| 195 | 198 | 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>`; | |
| 204 | 213 | const body = html`${repoHeader(ctx, repo, "issues")} |
| 205 | 214 | <div data-next> |
| 206 | 215 | ${pageHead("Next up", { sub: "The crew builds strictly from the top." })} |
| 207 | 216 | <p class="error" id="next-flash" role="alert" hidden></p> |
| 208 | ${next.rows.length === 0 | |
| 217 | ${rows.length === 0 | |
| 209 | 218 | ? 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}`} | |
| 213 | 220 | <p class="muted small mt" id="next-week">${sentence} <a href="${base}/week">The week</a>${writer |
| 214 | 221 | ? " · Drag a row, or press the key on its button; j and k move between rows." |
| 215 | 222 | : 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} | |
| 228 | 228 | ${next.unlisted > 0 |
| 229 | 229 | ? html`<p class="muted small mt">${String(next.unlisted)} more open issues are not listed here; the queue API pages through them.</p>` |
| 230 | 230 | : 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} | |
| 238 | 231 | </div>`; |
| 239 | 232 | const response = page({ |
| 240 | 233 | 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", () => { | ||
| 218 | 218 | expect(order()).toEqual(before); |
| 219 | 219 | }); |
| 220 | 220 | |
| 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 () => { | |
| 222 | 222 | for (let i = 0; i < 12; i++) open(`Extra ${i}`); |
| 223 | 223 | 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"); | |
| 231 | 236 | }); |
| 232 | 237 | }); |
| 233 | 238 | |
| @@ -337,9 +342,9 @@ describe("Next up page shape (#9764)", () => { | ||
| 337 | 342 | expect(res.status).toBe(200); |
| 338 | 343 | const body = (await res.text()).replace(/\s+/g, " "); |
| 339 | 344 | |
| 340 | expect(body).toContain("<h2>Ready, in order</h2>"); | |
| 345 | expect(body).toContain("<h2>Up next · 3</h2>"); | |
| 341 | 346 | // 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"/); | |
| 343 | 348 | const main = body.slice(body.indexOf("<div data-next>")); |
| 344 | 349 | expect(main).not.toMatch(/class="card\b/); |
| 345 | 350 | // 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)", () => { | ||
| 353 | 358 | const rowOf = (n: number) => rows.find((r) => r.includes(`data-number="${n}"`))!; |
| 354 | 359 | for (const [issue, role] of [[first, "Blocker"], [second, "Enabler"]] as const) { |
| 355 | 360 | 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} · `); | |
| 357 | 362 | expect(meta.split("<span")[0]).not.toContain("<"); |
| 358 | 363 | } |
| 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`); | |
| 361 | 366 | |
| 362 | 367 | // Every action is still a form that posts. |
| 363 | 368 | for (const label of ["Top", "Up", "Down", "Not now"]) { |
| @@ -369,9 +374,9 @@ describe("Next up page shape (#9764)", () => { | ||
| 369 | 374 | const gate = open("Later gate: not now"); |
| 370 | 375 | const far = open("Far off"); |
| 371 | 376 | 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">/); | |
| 375 | 380 | expect(notNow).not.toMatch(/class="card\b/); |
| 376 | 381 | expect(notNow).not.toContain("Behind the Later gate. Top lifts"); |
| 377 | 382 | const farRow = /<li[^>]*data-number="(\d+)"[\s\S]*?<\/li>/.exec(notNow)!; |
new above
offline