Public: anyone can read this; the crew can change it.
Next up: one ranked list the owner drags, and the crew builds strictly from the top #8710
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8694-next-up-one-ranked-list-the-owner-drags → main 8 commits fork
24 files
changed +1256
−6
docs/features/next-up.md added +13
| @@ -0,0 +1,13 @@ | ||
| 1 | # Next up | |
| 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. | |
| 4 | ||
| 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 | - **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. | |
| 7 | - **Top lifts the gate:** `Top` on a deferred issue removes its `wait_for` edge to the Later gate (and the Later umbrella's edge to it) in the same action. Work that only deferred work needs stays deferred, and the refusal says so. | |
| 8 | - **Not now** links the issue to wait for the open Later gate and drops its rank. | |
| 9 | - **The picker** takes the highest ranked startable issue before anything else, small work included (`readyNow`, `slotOrder`). A ranked issue something still blocks is skipped, never waited on. Same state, same pick. | |
| 10 | - **MCP:** `next_up { app, issue?, to?, all? }` reads the list, or moves one issue with the same words. | |
| 11 | - A rank is cleared when the issue closes. Dependencies order within the list and are shown, never edited here. | |
| 12 | ||
| 13 | **Terminal:** the palette's "Next up in a repository" opens the same list; `t`, `u`, `d` and `n` are Top, Up, Down and Not now on the row under the cursor, through the same action as the page and MCP. A reader sees the order and the keys do nothing; a move made anywhere reorders an open screen live. At most 200 issues are ranked; a move past that is refused and says so. |
src/actions/issues.ts +5 −1
| @@ -68,6 +68,7 @@ import { | ||
| 68 | 68 | } from "../origins/import.ts"; |
| 69 | 69 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 70 | 70 | import { clearFirst } from "../core/first.ts"; |
| 71 | import { clearRank } from "../core/rank.ts"; | |
| 71 | 72 | import { followLabel } from "./gate.ts"; |
| 72 | 73 | import { inheritPillar } from "./pillars.ts"; |
| 73 | 74 | import { |
| @@ -458,7 +459,10 @@ export function setIssueState( | ||
| 458 | 459 | : []; |
| 459 | 460 | const changed = app.db.transaction(() => { |
| 460 | 461 | const written = writeState(app.db, issue.id, state, reason); |
| 461 | if (written && state === "closed") clearFirst(app.db, issue.id); | |
| 462 | if (written && state === "closed") { | |
| 463 | clearFirst(app.db, issue.id); | |
| 464 | clearRank(app.db, issue.id); | |
| 465 | } | |
| 462 | 466 | if (written && doneBy.length > 0) |
| 463 | 467 | insertComment(app.db, { |
| 464 | 468 | issueId: issue.id, |
src/actions/next.ts added +107
| @@ -0,0 +1,107 @@ | ||
| 1 | /** | |
| 2 | * The owner's ranked list as a person does it (#8694): `Top`, `Not now` and | |
| 3 | * a place under another row are each one action that saves at once, for the | |
| 4 | * page and for MCP `next_up`, in the same words. The rank itself is | |
| 5 | * `core/rank.ts`; the list it orders is `core/next_up.ts`. | |
| 6 | */ | |
| 7 | import type { App } from "../app.ts"; | |
| 8 | import { addDependency, repoGraph } from "../core/dependencies.ts"; | |
| 9 | import { getIssue, type Issue } from "../core/issues.ts"; | |
| 10 | import { isStructural, pillarsOf } from "../core/pillars.ts"; | |
| 11 | import { clearRank, lift, MAX_RANKED, ranksInRepo, writeOrder } from "../core/rank.ts"; | |
| 12 | import { nextUp } from "../core/next_up.ts"; | |
| 13 | import { canWrite, type Repo } from "../core/repos.ts"; | |
| 14 | import type { User } from "../core/users.ts"; | |
| 15 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 16 | ||
| 17 | export const NEXT_FORBIDDEN = "Only someone with write access can rank the list."; | |
| 18 | export const NEXT_INVALID = "Only an open issue that is work can be ranked."; | |
| 19 | export const NEXT_NO_GATE = "This repository has no open Later gate to put it behind."; | |
| 20 | export const NEXT_ONLY_DEFERRED = "Only deferred work needs it, so it stays deferred until what needs it comes back."; | |
| 21 | export const NEXT_ALREADY_AWAY = "It is already behind the Later gate; Top brings it back."; | |
| 22 | export const NEXT_FULL = `The list holds at most ${MAX_RANKED} ranked issues; take one off first.`; | |
| 23 | export const NEXT_NO_PLACE = "There is no such place in the list."; | |
| 24 | ||
| 25 | /** Where an issue goes: to the top, under another row, or away behind the Later gate. */ | |
| 26 | export type Place = { readonly to: "top" } | { readonly to: "after"; readonly number: number } | { readonly to: "not_now" }; | |
| 27 | ||
| 28 | export type NextError = { kind: "not_found" | "forbidden" | "invalid"; message: string }; | |
| 29 | ||
| 30 | /** Read a place from its one-line form: `top`, `not-now`, or `after:<number>`. */ | |
| 31 | export function placeOf(text: string): Place | undefined { | |
| 32 | if (text === "top") return { to: "top" }; | |
| 33 | if (text === "not-now") return { to: "not_now" }; | |
| 34 | const after = /^after:([1-9][0-9]{0,8})$/.exec(text); | |
| 35 | return after === null ? undefined : { to: "after", number: Number(after[1]) }; | |
| 36 | } | |
| 37 | ||
| 38 | export function rankIssue( | |
| 39 | app: App, | |
| 40 | actor: User, | |
| 41 | repo: Repo, | |
| 42 | number: number, | |
| 43 | place: Place, | |
| 44 | ): Result<Issue, NextError> { | |
| 45 | const issue = Number.isInteger(number) && number > 0 ? getIssue(app.db, repo.id, number) : undefined; | |
| 46 | if (!issue || issue.kind !== "issue") return err({ kind: "not_found", message: "No such issue." }); | |
| 47 | if (actor.kind === "agent" || !canWrite(app.db, repo, actor)) | |
| 48 | return err({ kind: "forbidden", message: NEXT_FORBIDDEN }); | |
| 49 | const set = pillarsOf(app.db, repo); | |
| 50 | if (issue.state !== "open" || isStructural(set, issue.id)) | |
| 51 | return err({ kind: "invalid", message: NEXT_INVALID }); | |
| 52 | const invalid = (message: string) => err<NextError>({ kind: "invalid", message }); | |
| 53 | ||
| 54 | if (place.to === "not_now" && (set.gate === undefined || set.gate.state !== "open")) | |
| 55 | return invalid(NEXT_NO_GATE); | |
| 56 | if (place.to === "not_now" && repoGraph(app.db, repo.id, { sealed: true, lean: true }).deferred.has(issue.id)) | |
| 57 | return invalid(NEXT_ALREADY_AWAY); | |
| 58 | if (place.to !== "not_now") { | |
| 59 | // Work only deferred work needs has no hold of its own to lift. | |
| 60 | const why = repoGraph(app.db, repo.id, { sealed: true, lean: true }).deferred.get(issue.id); | |
| 61 | if (why?.startsWith("only deferred work") === true) return invalid(NEXT_ONLY_DEFERRED); | |
| 62 | } | |
| 63 | // Checked before anything is written: a place that is not there, or a list | |
| 64 | // too long, changes nothing. | |
| 65 | const rest = nextUp(app.db, repo).rows.map((r) => r.issue).filter((i) => i.id !== issue.id); | |
| 66 | const at = place.to === "after" ? rest.findIndex((i) => i.number === place.number) + 1 : 0; | |
| 67 | if (place.to === "after" && at === 0) return invalid(NEXT_NO_PLACE); | |
| 68 | // The list is ranked down to where it was dropped, and whatever was ranked stays. | |
| 69 | const ranked = ranksInRepo(app.db, repo.id); | |
| 70 | const order = [...rest.slice(0, at), issue, ...rest.slice(at)] | |
| 71 | .filter((i, n) => n <= at || ranked.has(i.id)) | |
| 72 | .map((i) => i.id); | |
| 73 | if (place.to !== "not_now" && order.length > MAX_RANKED) return invalid(NEXT_FULL); | |
| 74 | const moved = app.db.transaction((): Result<void, NextError> => { | |
| 75 | if (place.to === "not_now") { | |
| 76 | const linked = addDependency(app.db, issue, set.gate!, actor.id); | |
| 77 | if (!linked.ok) return invalid(NEXT_INVALID); | |
| 78 | clearRank(app.db, issue.id); | |
| 79 | return ok(undefined); | |
| 80 | } | |
| 81 | lift(app.db, repo, issue.id); | |
| 82 | writeOrder(app.db, repo, order, actor.id); | |
| 83 | return ok(undefined); | |
| 84 | }).immediate(); | |
| 85 | if (!moved.ok) return moved; | |
| 86 | app.bus.append({ | |
| 87 | kind: "issue.ranked", | |
| 88 | repoId: repo.id, | |
| 89 | actorId: actor.id, | |
| 90 | payload: { number: issue.number, to: place.to }, | |
| 91 | }); | |
| 92 | return ok(issue); | |
| 93 | } | |
| 94 | ||
| 95 | /** What the page and MCP say after a move. */ | |
| 96 | export function rankedWords(number: number, place: Place): string { | |
| 97 | if (place.to === "top") return `#${number} is on top.`; | |
| 98 | if (place.to === "not_now") return `#${number} is not now.`; | |
| 99 | return `#${number} moved under #${place.number}.`; | |
| 100 | } | |
| 101 | ||
| 102 | /** The place that puts row `at` one step up (`-1`) or down (`1`) in `numbers`, the list's issue numbers in order. */ | |
| 103 | export function stepTo(numbers: readonly number[], at: number, step: -1 | 1): string | undefined { | |
| 104 | if (step === 1) return at < numbers.length - 1 ? `after:${numbers[at + 1]}` : undefined; | |
| 105 | if (at < 1) return undefined; | |
| 106 | return at === 1 ? "top" : `after:${numbers[at - 2]}`; | |
| 107 | } |
src/core/dependencies.ts +14 −3
| @@ -17,6 +17,7 @@ import { unadoptedAmong } from "../crew/direct.ts"; | ||
| 17 | 17 | import { flowLabelled } from "./flow_label.ts"; |
| 18 | 18 | import { raisedAt, raisedInRepo } from "./thought_links.ts"; |
| 19 | 19 | import { firstAt, firstInRepo } from "./first.ts"; |
| 20 | import { ranksInRepo } from "./rank.ts"; | |
| 20 | 21 | import { limbRanks, limbTree, readPlaces } from "./limb_order.ts"; |
| 21 | 22 | |
| 22 | 23 | export type DependencyError = "self" | "cross_repo" | "cycle" | "not_an_issue"; |
| @@ -255,6 +256,8 @@ export interface Graph { | ||
| 255 | 256 | readonly flow?: ReadonlySet<string>; |
| 256 | 257 | /** When a writer put an issue first (#7806): ready ahead of everything else. */ |
| 257 | 258 | readonly first?: ReadonlyMap<string, string>; |
| 259 | /** The owner's ranked list (#8694): an issue's place, 1 at the top. Ready, ranked issues go before all else. */ | |
| 260 | readonly ranked?: ReadonlyMap<string, number>; | |
| 258 | 261 | } |
| 259 | 262 | |
| 260 | 263 | /** |
| @@ -460,6 +463,7 @@ export function repoGraph( | ||
| 460 | 463 | ? raisedAt(db, issues.map((i) => i.id)) |
| 461 | 464 | : raisedInRepo(db, repoId), |
| 462 | 465 | flow: flowLabelled(db, repoId), |
| 466 | ranked: ranksInRepo(db, repoId), | |
| 463 | 467 | first: |
| 464 | 468 | whole === undefined |
| 465 | 469 | ? firstAt(db, issues.map((i) => i.id)) |
| @@ -700,9 +704,16 @@ export function readyNow(graph: Graph): Ranked[] { | ||
| 700 | 704 | // What a person put first comes before all of it, the latest first. It only |
| 701 | 705 | // orders what is already ready: no rank lifts the Later gate or a blocker. |
| 702 | 706 | const first = graph.first; |
| 703 | if (first === undefined || first.size === 0) return ready; | |
| 704 | const when = (r: Ranked) => first.get(r.issue.id) ?? ""; | |
| 705 | return ready.sort((a, b) => when(b).localeCompare(when(a))); | |
| 707 | if (first !== undefined && first.size > 0) { | |
| 708 | const when = (r: Ranked) => first.get(r.issue.id) ?? ""; | |
| 709 | ready.sort((a, b) => when(b).localeCompare(when(a))); | |
| 710 | } | |
| 711 | // The owner's ranked list is the last word, by place; the rest keep their | |
| 712 | // order, so the same state always gives the same pick (#8694). | |
| 713 | const ranked = graph.ranked; | |
| 714 | if (ranked === undefined || ranked.size === 0) return ready; | |
| 715 | const place = (r: Ranked) => ranked.get(r.issue.id) ?? Number.MAX_SAFE_INTEGER; | |
| 716 | return ready.sort((a, b) => place(a) - place(b)); | |
| 706 | 717 | } |
| 707 | 718 | |
| 708 | 719 | /** |
src/core/event_audience.ts +1
| @@ -37,6 +37,7 @@ const READER_KINDS: ReadonlySet<string> = new Set([ | ||
| 37 | 37 | "repo.following", |
| 38 | 38 | "issue.labeled", |
| 39 | 39 | "issue.first", |
| 40 | "issue.ranked", | |
| 40 | 41 | "issue.closed", |
| 41 | 42 | "issue.gate_reviewed", |
| 42 | 43 | "issue.commented", |
src/core/migration-pins/20261102200000-issue-rank.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | 1c7a3ee7aa743424067a69ff27da875a5394cf67e2e02c945b34720bfbfa4790 |
src/core/migrations/20261102200000-issue-rank.ts added +11
| @@ -0,0 +1,11 @@ | ||
| 1 | // The owner's one ranked list (#8694): where an issue stands in "Next up", | |
| 2 | // 1 at the top. One row per ranked issue; deleted when it closes or is sent | |
| 3 | // to "Not now". | |
| 4 | export default ` | |
| 5 | CREATE TABLE IF NOT EXISTS issue_rank ( | |
| 6 | issue_id TEXT PRIMARY KEY REFERENCES issue(id) ON DELETE CASCADE, | |
| 7 | pos INTEGER NOT NULL, | |
| 8 | actor_id TEXT NOT NULL, | |
| 9 | at TEXT NOT NULL | |
| 10 | ); | |
| 11 | `; |
src/core/next_up.ts added +122
| @@ -0,0 +1,122 @@ | ||
| 1 | /** | |
| 2 | * "Next up" as data (#8694): the owner's one ranked list, read from the | |
| 3 | * graph. The list is the ranked open issues in rank order, then what is | |
| 4 | * ready and unranked in the ready order; each row says who is on it and, in | |
| 5 | * one line, why it cannot start yet. What is deferred behind the Later gate | |
| 6 | * is "Not now": counted, and listed only when asked. Plain data in and out. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { Database } from "bun:sqlite"; | |
| 10 | import { type Graph, reachOf, readyNow, repoGraph } from "./dependencies.ts"; | |
| 11 | import type { Issue } from "./issues.ts"; | |
| 12 | import { pillarsOf, servedBy } from "./pillars.ts"; | |
| 13 | import type { Repo } from "./repos.ts"; | |
| 14 | import { listActiveRuns } from "./runs.ts"; | |
| 15 | import { getUserById } from "./users.ts"; | |
| 16 | ||
| 17 | /** How many rows the page shows before the rest is one line. */ | |
| 18 | export const TOP = 10; | |
| 19 | /** The most "Not now" rows one page lists. */ | |
| 20 | export const NOT_NOW_MAX = 100; | |
| 21 | /** How many blockers a reason names before it counts the rest. */ | |
| 22 | const NAMED = 3; | |
| 23 | ||
| 24 | /** | |
| 25 | * A blocker holds other open work up; an enabler holds nothing up and is | |
| 26 | * work for what it makes possible. A label for the reader; the order is the | |
| 27 | * owner's rank, then the ready order. | |
| 28 | */ | |
| 29 | export type Role = "blocker" | "enabler"; | |
| 30 | ||
| 31 | export interface NextRow { | |
| 32 | readonly issue: Issue; | |
| 33 | readonly role: Role; | |
| 34 | /** In one line, what the role means here: what finishing a blocker frees, why an enabler is now. */ | |
| 35 | readonly roleWhy: string; | |
| 36 | /** Place on the owner's list, 1 at the top; null for ready work nobody ranked. */ | |
| 37 | readonly place: number | null; | |
| 38 | /** Who is on it: the crew member running it, else its assignee. */ | |
| 39 | readonly onIt: string | null; | |
| 40 | /** In one line, why it cannot start yet; null when it can. */ | |
| 41 | readonly why: string | null; | |
| 42 | } | |
| 43 | ||
| 44 | export interface NextUp { | |
| 45 | /** Ranked first, then ready; every row is a candidate for the picker. */ | |
| 46 | readonly rows: readonly NextRow[]; | |
| 47 | /** Deferred work, "Not now", the oldest first, at most {@link NOT_NOW_MAX}. */ | |
| 48 | readonly notNow: readonly NextRow[]; | |
| 49 | /** All deferred open issues, listed or not. */ | |
| 50 | readonly notNowTotal: number; | |
| 51 | } | |
| 52 | ||
| 53 | /** In one line, why this open issue cannot start yet; null when nothing stands in front of it. */ | |
| 54 | export function whyNot(graph: Graph, issue: Issue): string | null { | |
| 55 | const deferred = graph.deferred.get(issue.id); | |
| 56 | if (deferred !== undefined) return `not now: ${deferred}`; | |
| 57 | const open = new Map(graph.issues.filter((i) => i.state === "open").map((i) => [i.id, i])); | |
| 58 | const waits = graph.edges | |
| 59 | .filter((e) => e.issueId === issue.id && open.has(e.blockedById)) | |
| 60 | .map((e) => open.get(e.blockedById)!.number) | |
| 61 | .sort((a, b) => a - b); | |
| 62 | if (waits.length > 0) { | |
| 63 | const named = waits.slice(0, NAMED).map((n) => `#${n}`).join(", "); | |
| 64 | return `waits for ${named}${waits.length > NAMED ? `, and ${waits.length - NAMED} more` : ""}`; | |
| 65 | } | |
| 66 | const held = graph.held.find((h) => h.issueId === issue.id); | |
| 67 | if (held !== undefined) return `a blocker closed as ${held.reason}: a person confirms the plan`; | |
| 68 | if (graph.unadopted.has(issue.id)) return "a reader's issue nobody who directs the crew has adopted"; | |
| 69 | if (graph.structural.has(issue.id)) return "the plan's own: a person closes it"; | |
| 70 | return null; | |
| 71 | } | |
| 72 | ||
| 73 | /** The role of an open issue and its one line. */ | |
| 74 | export function roleOf( | |
| 75 | holdsUp: number, | |
| 76 | serves: string | undefined, | |
| 77 | ranked: boolean, | |
| 78 | ): { role: Role; roleWhy: string } { | |
| 79 | if (holdsUp > 0) | |
| 80 | return { role: "blocker", roleWhy: `finishing it frees ${holdsUp}` }; | |
| 81 | if (serves !== undefined) return { role: "enabler", roleWhy: `serves ${serves}` }; | |
| 82 | return { | |
| 83 | role: "enabler", | |
| 84 | roleWhy: ranked ? "the owner ranked it" : "holds nothing up", | |
| 85 | }; | |
| 86 | } | |
| 87 | ||
| 88 | /** The list for one repository; `scope` is what `sealedScope` says the viewer may see. */ | |
| 89 | export function nextUp( | |
| 90 | db: Database, | |
| 91 | repo: Repo, | |
| 92 | scope: { sealed?: true; sealedBy?: string } = { sealed: true }, | |
| 93 | ): NextUp { | |
| 94 | const graph = repoGraph(db, repo.id, { ...scope, lean: true }); | |
| 95 | const ranks = graph.ranked ?? new Map<string, number>(); | |
| 96 | const ready = readyNow(graph).map((r) => r.issue); | |
| 97 | const readyIds = new Set(ready.map((i) => i.id)); | |
| 98 | const ranked = graph.issues | |
| 99 | .filter((i) => i.state === "open" && ranks.has(i.id)) | |
| 100 | .sort((a, b) => ranks.get(a.id)! - ranks.get(b.id)!); | |
| 101 | // Who is on an issue: the crew running it right now, else whoever it is assigned to. | |
| 102 | const crew = new Map<string, string>(); | |
| 103 | for (const run of listActiveRuns(db, [repo.id])) { | |
| 104 | const who = run.actorId === null ? undefined : getUserById(db, run.actorId)?.username; | |
| 105 | if (run.issueId !== null && who !== undefined) crew.set(run.issueId, who); | |
| 106 | } | |
| 107 | const holds = reachOf(graph).holds; | |
| 108 | const serves = servedBy(db, pillarsOf(db, repo)); | |
| 109 | const rowOf = (issue: Issue): NextRow => ({ | |
| 110 | issue, | |
| 111 | ...roleOf(holds.get(issue.id)?.size ?? 0, serves.get(issue.id), ranks.has(issue.id)), | |
| 112 | place: ranks.get(issue.id) ?? null, | |
| 113 | onIt: crew.get(issue.id) ?? issue.assigneeName, | |
| 114 | why: readyIds.has(issue.id) ? null : whyNot(graph, issue), | |
| 115 | }); | |
| 116 | const rows = [...ranked, ...ready.filter((i) => !ranks.has(i.id))].map(rowOf); | |
| 117 | const listed = new Set(rows.map((r) => r.issue.id)); | |
| 118 | const away = graph.issues.filter( | |
| 119 | (i) => i.state === "open" && graph.deferred.has(i.id) && !listed.has(i.id) && !graph.structural.has(i.id), | |
| 120 | ); | |
| 121 | return { rows, notNow: away.slice(0, NOT_NOW_MAX).map(rowOf), notNowTotal: away.length }; | |
| 122 | } |
src/core/rank.ts added +68
| @@ -0,0 +1,68 @@ | ||
| 1 | /** | |
| 2 | * "Next up" (#8694): the owner's one ranked list. A rank is a place in line, | |
| 3 | * 1 at the top, one row per issue; the dispatcher starts the highest ranked | |
| 4 | * issue that is startable before any other (`readyNow`), and a rank never | |
| 5 | * lifts a blocker — only `lift` removes the Later gate's hold, and only when | |
| 6 | * a person asks for the issue on top. The rank is cleared when the issue | |
| 7 | * closes or goes to "Not now". | |
| 8 | */ | |
| 9 | ||
| 10 | import type { Database } from "bun:sqlite"; | |
| 11 | import { removeDependency } from "./dependencies.ts"; | |
| 12 | import { pillarsOf } from "./pillars.ts"; | |
| 13 | import type { Repo } from "./repos.ts"; | |
| 14 | ||
| 15 | /** The most issues one repository ranks: a move rewrites the whole list, so it is bounded. */ | |
| 16 | export const MAX_RANKED = 200; | |
| 17 | ||
| 18 | /** Every rank in one repository: the issue id and its place, 1 at the top. */ | |
| 19 | export function ranksInRepo(db: Database, repoId: string): Map<string, number> { | |
| 20 | return new Map( | |
| 21 | db | |
| 22 | .query<{ issue_id: string; pos: number }, [string]>( | |
| 23 | `SELECT r.issue_id, r.pos FROM issue_rank r JOIN issue i ON i.id = r.issue_id | |
| 24 | WHERE i.repo_id = ?1 AND i.state = 'open'`, | |
| 25 | ) | |
| 26 | .all(repoId) | |
| 27 | .map((r) => [r.issue_id, r.pos]), | |
| 28 | ); | |
| 29 | } | |
| 30 | ||
| 31 | /** Forget the rank: the issue closed, or the owner said "Not now". */ | |
| 32 | export function clearRank(db: Database, issueId: string): void { | |
| 33 | db.query("DELETE FROM issue_rank WHERE issue_id = ?1").run(issueId); | |
| 34 | } | |
| 35 | ||
| 36 | /** | |
| 37 | * Make `order` the whole ranking of a repository: places 1..n in that order, | |
| 38 | * every other rank dropped. The caller's transaction makes it one write. | |
| 39 | */ | |
| 40 | export function writeOrder( | |
| 41 | db: Database, | |
| 42 | repo: Repo, | |
| 43 | order: readonly string[], | |
| 44 | actorId: string, | |
| 45 | at = new Date().toISOString(), | |
| 46 | ): void { | |
| 47 | db.query( | |
| 48 | "DELETE FROM issue_rank WHERE issue_id IN (SELECT id FROM issue WHERE repo_id = ?1)", | |
| 49 | ).run(repo.id); | |
| 50 | const put = db.query( | |
| 51 | "INSERT INTO issue_rank (issue_id, pos, actor_id, at) VALUES (?1, ?2, ?3, ?4)", | |
| 52 | ); | |
| 53 | order.forEach((id, n) => put.run(id, n + 1, actorId, at)); | |
| 54 | } | |
| 55 | ||
| 56 | /** | |
| 57 | * Take an issue past the Later gate: delete the edge that makes it wait for | |
| 58 | * the gate, and the Later umbrella's edge to it. Returns whether any hold | |
| 59 | * was removed. What only deferred work needs stays deferred; `deferred` | |
| 60 | * says so after. | |
| 61 | */ | |
| 62 | export function lift(db: Database, repo: Repo, issueId: string): boolean { | |
| 63 | const set = pillarsOf(db, repo); | |
| 64 | let lifted = false; | |
| 65 | if (set.gate !== undefined) lifted = removeDependency(db, issueId, set.gate.id) || lifted; | |
| 66 | if (set.later !== undefined) lifted = removeDependency(db, set.later.id, issueId) || lifted; | |
| 67 | return lifted; | |
| 68 | } |
src/crew/autonomy.ts +3
| @@ -45,6 +45,7 @@ import { | ||
| 45 | 45 | } from "../core/agents.ts"; |
| 46 | 46 | import { pickBuilder, routeIssue } from "./route.ts"; |
| 47 | 47 | import { firstAt } from "../core/first.ts"; |
| 48 | import { ranksInRepo } from "../core/rank.ts"; | |
| 48 | 49 | import { ensurePlanner } from "./planner.ts"; |
| 49 | 50 | import { readyNow, repoGraph, type Ranked } from "../core/dependencies.ts"; |
| 50 | 51 | import { |
| @@ -405,11 +406,13 @@ function inSlotOrder(app: App, repo: Repo, ready: readonly Ranked[], slotted: re | ||
| 405 | 406 | const small = smallIssues(app.db, issues); |
| 406 | 407 | const roadmap = roadmapIssues(app.db, repo, issues.map((i) => i.id)); |
| 407 | 408 | const first = firstAt(app.db, issues.map((i) => i.id)); |
| 409 | const ranked = ranksInRepo(app.db, repo.id); | |
| 408 | 410 | const raised = raisedAt(app.db, issues.map((i) => i.id)); |
| 409 | 411 | const running = smallOfIds(app.db, slotted.flatMap((r) => (r.issueId === null ? [] : [r.issueId]))); |
| 410 | 412 | return slotOrder( |
| 411 | 413 | ready, |
| 412 | 414 | (r) => ({ |
| 415 | ranked: ranked.has(r.issue.id), | |
| 413 | 416 | raised: raised.has(r.issue.id) || first.has(r.issue.id), |
| 414 | 417 | roadmap: roadmap.has(r.issue.id), |
| 415 | 418 | small: small.has(r.issue.id) && !first.has(r.issue.id), |
src/http/mcp/tools/index.ts +1
| @@ -90,6 +90,7 @@ export { tool as model_ceiling } from "./model_ceiling.ts"; | ||
| 90 | 90 | export { tool as model_grant } from "./model_grant.ts"; |
| 91 | 91 | export { tool as new_app } from "./new_app.ts"; |
| 92 | 92 | export { tool as next_steps } from "./next_steps.ts"; |
| 93 | export { tool as next_up } from "./next_up.ts"; | |
| 93 | 94 | export { tool as notify } from "./notify.ts"; |
| 94 | 95 | export { tool as now } from "./now.ts"; |
| 95 | 96 | export { tool as org_create } from "./org_create.ts"; |
src/http/mcp/tools/next_up.ts added +53
| @@ -0,0 +1,53 @@ | ||
| 1 | import { placeOf, rankIssue } from "../../../actions/next.ts"; | |
| 2 | import { nextUp, TOP, type NextRow } from "../../../core/next_up.ts"; | |
| 3 | import { sealedScope } from "../../../core/sealed.ts"; | |
| 4 | import { bad, good, type Tool, APP_ARG, planRepo, isAnswer, issueArg } from "../shared.ts"; | |
| 5 | ||
| 6 | const brief = (r: NextRow) => ({ | |
| 7 | issue: r.issue.number, | |
| 8 | title: r.issue.title, | |
| 9 | place: r.place, | |
| 10 | role: r.role, | |
| 11 | roleWhy: r.roleWhy, | |
| 12 | onIt: r.onIt, | |
| 13 | why: r.why, | |
| 14 | }); | |
| 15 | ||
| 16 | export const tool: Tool = { | |
| 17 | name: "next_up", | |
| 18 | doc: "an app's one ranked list, the crew builds strictly from the top (`app`; with `issue` and `to` — `top`, `not-now` or `after:<number>` — a writer moves that issue and it saves at once; `top` lifts a gated issue past the Later gate; `all` lists beyond the top ten)", | |
| 19 | description: "The ranked list the crew builds from; `issue` + `to` (top, not-now, after:N) moves one.", | |
| 20 | inputSchema: { | |
| 21 | type: "object", | |
| 22 | properties: { | |
| 23 | app: APP_ARG, | |
| 24 | issue: { type: "integer", minimum: 1 }, | |
| 25 | to: { type: "string", pattern: "^(top|not-now|after:[1-9][0-9]{0,8})$" }, | |
| 26 | all: { type: "boolean" }, | |
| 27 | }, | |
| 28 | required: ["app"], | |
| 29 | }, | |
| 30 | writes: true, | |
| 31 | readsWithout: "issue", | |
| 32 | run(ctx, user, args) { | |
| 33 | const repo = planRepo(ctx, user, args); | |
| 34 | if (isAnswer(repo)) return repo; | |
| 35 | if (args["issue"] !== undefined) { | |
| 36 | const issue = issueArg(ctx, repo, args, "issue"); | |
| 37 | if ("ok" in issue) return issue; | |
| 38 | const place = typeof args["to"] === "string" ? placeOf(args["to"]) : undefined; | |
| 39 | if (place === undefined) return bad("invalid_to", "to is top, not-now or after:<number>"); | |
| 40 | const moved = rankIssue(ctx.app, user, repo, issue.number, place); | |
| 41 | if (!moved.ok) | |
| 42 | return bad(moved.error.kind === "not_found" ? "no_such_issue" : moved.error.kind, moved.error.message); | |
| 43 | } | |
| 44 | const next = nextUp(ctx.app.db, repo, sealedScope(ctx.app.db, repo, user)); | |
| 45 | const all = args["all"] === true; | |
| 46 | return good({ | |
| 47 | next: (all ? next.rows : next.rows.slice(0, TOP)).map(brief), | |
| 48 | more: all ? 0 : Math.max(0, next.rows.length - TOP), | |
| 49 | notNow: next.notNowTotal, | |
| 50 | ...(all ? { notNowRows: next.notNow.map(brief) } : {}), | |
| 51 | }); | |
| 52 | }, | |
| 53 | }; |
src/http/server.ts +3
| @@ -199,6 +199,7 @@ import { tellHerePage } from "../web/studio/tell_here.ts"; | ||
| 199 | 199 | import { globalMapPage } from "../web/pages/global-map.ts"; |
| 200 | 200 | import { mapPage } from "../web/pages/map.ts"; |
| 201 | 201 | import { firstSubmit } from "../web/pages/first.ts"; |
| 202 | import { nextPage, nextSubmit } from "../web/pages/next.ts"; | |
| 202 | 203 | import { |
| 203 | 204 | milestonePage, |
| 204 | 205 | milestonesPage, |
| @@ -1025,6 +1026,8 @@ export function buildRouter(): Router<Handler> { | ||
| 1025 | 1026 | router.add("GET", "/:owner/:name/held", heldPage); |
| 1026 | 1027 | router.add("POST", "/:owner/:name/held/apply", heldApply); |
| 1027 | 1028 | router.add("GET", "/:owner/:name/map", mapPage); |
| 1029 | router.add("GET", "/:owner/:name/next", nextPage); | |
| 1030 | router.add("POST", "/:owner/:name/next", nextSubmit); | |
| 1028 | 1031 | router.add("POST", "/:owner/:name/issues/:number/first", firstSubmit); |
| 1029 | 1032 | router.add("GET", "/:owner/:name/milestones", milestonesPage); |
| 1030 | 1033 | router.add("GET", "/:owner/:name/milestones/:number", milestonePage); |
src/tui/forge.ts +7
| @@ -77,6 +77,7 @@ import { heldScreen } from "./screens/held.ts"; | ||
| 77 | 77 | import { stalledScreen } from "./screens/stalled.ts"; |
| 78 | 78 | import { issuesScreen, newIssueScreen } from "./screens/issues.ts"; |
| 79 | 79 | import { limbsScreen } from "./screens/limbs.ts"; |
| 80 | import { nextScreen } from "./screens/next.ts"; | |
| 80 | 81 | import { newPullScreen } from "./screens/newpull.ts"; |
| 81 | 82 | import { newForgeScreen } from "./screens/newforge.ts"; |
| 82 | 83 | import { newOrgScreen } from "./screens/neworg.ts"; |
| @@ -126,6 +127,12 @@ const REPO_VERBS: readonly { | ||
| 126 | 127 | title: "Milestones in a repository", |
| 127 | 128 | open: (f, ctx, repo) => limbsScreen(f, ctx, repo), |
| 128 | 129 | }, |
| 130 | { | |
| 131 | // The owner's one ranked list the crew builds from (#8694). | |
| 132 | id: "next-up", | |
| 133 | title: "Next up in a repository", | |
| 134 | open: (f, ctx, repo) => nextScreen(f, ctx, repo), | |
| 135 | }, | |
| 129 | 136 | { |
| 130 | 137 | // What the crew's tokens bought on one app, as MCP `returns` with `repo` (#7271). |
| 131 | 138 | id: "repo-usage", |
src/tui/screens/next.ts added +131
| @@ -0,0 +1,131 @@ | ||
| 1 | /** | |
| 2 | * Next up in the terminal (#8694): the owner's one ranked list, the page's | |
| 3 | * door and not a second implementation. A move is `rankIssue`, the action the | |
| 4 | * page and MCP `next_up` reach, so who may move what is decided there: a | |
| 5 | * reader sees the order and no keys to change it, an agent likewise, and a | |
| 6 | * guest sees what `canRead` lets the web show, sealed issues left to the same | |
| 7 | * scope the page uses. `t` puts the issue under the cursor on top, `u` and | |
| 8 | * `d` move it a place, `n` puts it behind the Later gate; each saves at once. | |
| 9 | * Subscribed to the bus while on the stack, so a move made on the web, over | |
| 10 | * MCP or in another terminal reorders the list here, and one made here | |
| 11 | * (`issue.ranked`) reaches the web the same way. | |
| 12 | */ | |
| 13 | ||
| 14 | import { rankedWords, rankIssue, stepTo, type Place, placeOf } from "../../actions/next.ts"; | |
| 15 | import { nextUp, type NextRow } from "../../core/next_up.ts"; | |
| 16 | import { canRead, canWrite, type Repo } from "../../core/repos.ts"; | |
| 17 | import { sealedScope } from "../../core/sealed.ts"; | |
| 18 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 19 | import { binding } from "../app/keys.ts"; | |
| 20 | import { fill, len, split } from "../ui/layout.ts"; | |
| 21 | import { style } from "../term/style.ts"; | |
| 22 | import { truncate } from "../term/width.ts"; | |
| 23 | import type { Session } from "../session.ts"; | |
| 24 | import { issueScreen } from "./issue.ts"; | |
| 25 | import { local } from "./where.ts"; | |
| 26 | import { ListState, drawList, hitList, live, type ListRow } from "./common.ts"; | |
| 27 | ||
| 28 | /** What reorders or reshapes the list: a rank, or an issue opening, closing or changing what it waits for. */ | |
| 29 | const LIVE_KINDS = ["issue.ranked", "issue.opened", "issue.closed", "issue.reopened", "issue.blocked", "issue.unblocked"]; | |
| 30 | ||
| 31 | export function nextScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | |
| 32 | const state = new ListState(); | |
| 33 | let rows: readonly NextRow[] = []; | |
| 34 | let away = 0; | |
| 35 | let loaded = false; | |
| 36 | // Read at use, not at open: access can change under a session. | |
| 37 | const may = (): boolean => | |
| 38 | f.user !== undefined && f.user.kind === "user" && canWrite(f.db, repo, f.user); | |
| 39 | ||
| 40 | const reload = (follow?: number): void => { | |
| 41 | if (!canRead(f.db, repo, f.user)) { | |
| 42 | rows = []; | |
| 43 | away = 0; | |
| 44 | } else { | |
| 45 | const next = nextUp(f.db, repo, sealedScope(f.db, repo, f.user)); | |
| 46 | rows = next.rows; | |
| 47 | away = next.notNowTotal; | |
| 48 | } | |
| 49 | loaded = true; | |
| 50 | if (follow !== undefined) { | |
| 51 | const at = rows.findIndex((r) => r.issue.number === follow); | |
| 52 | if (at >= 0) state.index = at; | |
| 53 | } | |
| 54 | state.clamp(rows.length); | |
| 55 | ctx.invalidate(); | |
| 56 | }; | |
| 57 | ||
| 58 | const current = (): NextRow | undefined => rows[state.index]; | |
| 59 | ||
| 60 | const rank = (to: Place | undefined): void => { | |
| 61 | const row = current(); | |
| 62 | if (row === undefined || to === undefined || !may()) return; | |
| 63 | const done = rankIssue(f.app, f.user!, repo, row.issue.number, to); | |
| 64 | if (!done.ok) { | |
| 65 | ctx.toast(done.error.message, "danger"); | |
| 66 | return; | |
| 67 | } | |
| 68 | reload(row.issue.number); | |
| 69 | ctx.toast(rankedWords(row.issue.number, to), "success"); | |
| 70 | }; | |
| 71 | const step = (by: -1 | 1): void => { | |
| 72 | const to = stepTo(rows.map((r) => r.issue.number), state.index, by); | |
| 73 | rank(to === undefined ? undefined : placeOf(to)); | |
| 74 | }; | |
| 75 | ||
| 76 | const open = (): void => { | |
| 77 | const row = current(); | |
| 78 | if (row !== undefined) ctx.push(issueScreen(f, ctx, local(f, repo), row.issue.number)); | |
| 79 | }; | |
| 80 | ||
| 81 | const listRows = (): ListRow[] => | |
| 82 | rows.map((r) => ({ | |
| 83 | title: `${r.place === null ? "·" : r.place}. #${r.issue.number} ${r.issue.title}`, | |
| 84 | secondary: r.role === "blocker" ? "Blocker" : "Enabler", | |
| 85 | meta: [r.onIt === null ? "" : `on it: ${r.onIt}`, r.why ?? ""].filter((x) => x !== "").join(" · "), | |
| 86 | })); | |
| 87 | ||
| 88 | const screen: Screen = { | |
| 89 | title: "Next up", | |
| 90 | get keys() { | |
| 91 | const on = may() && current() !== undefined; | |
| 92 | return [ | |
| 93 | binding(["j", "k"], "move cursor"), | |
| 94 | binding(["enter"], "open"), | |
| 95 | binding(["t"], "top", { disabled: !on || state.index === 0 }), | |
| 96 | binding(["u"], "up", { disabled: !on || state.index === 0 }), | |
| 97 | binding(["d"], "down", { disabled: !on || state.index >= rows.length - 1 }), | |
| 98 | binding(["n"], "not now", { disabled: !on }), | |
| 99 | ]; | |
| 100 | }, | |
| 101 | load(signal) { | |
| 102 | live(f, signal, { repoId: repo.id, kinds: LIVE_KINDS }, () => reload()); | |
| 103 | reload(); | |
| 104 | return Promise.resolve(); | |
| 105 | }, | |
| 106 | render(area, s) { | |
| 107 | const c = ctx.theme.c; | |
| 108 | const [head, list] = split(area, "v", [len(1), fill(1)]); | |
| 109 | const note = !loaded | |
| 110 | ? "Loading…" | |
| 111 | : rows.length === 0 | |
| 112 | ? "Nothing is ready: everything open waits for something." | |
| 113 | : `The crew builds strictly from the top${may() ? "" : " · you can read this order, not change it"}${away > 0 ? ` · ${away} not now` : ""}`; | |
| 114 | s.text(head!.x, head!.y, truncate(note, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); | |
| 115 | drawList(s, list!, c, state, listRows()); | |
| 116 | hitList(ctx, list!, state, rows.length, { open }); | |
| 117 | }, | |
| 118 | key(e) { | |
| 119 | if (state.key(e, rows.length)) return true; | |
| 120 | if (e.ctrl || e.alt) return false; | |
| 121 | if (e.key === "enter") open(); | |
| 122 | else if (e.key === "t") rank({ to: "top" }); | |
| 123 | else if (e.key === "u") step(-1); | |
| 124 | else if (e.key === "d") step(1); | |
| 125 | else if (e.key === "n") rank({ to: "not_now" }); | |
| 126 | else return false; | |
| 127 | return true; | |
| 128 | }, | |
| 129 | }; | |
| 130 | return screen; | |
| 131 | } |
src/web/pages/map.ts +1
| @@ -1425,6 +1425,7 @@ export async function mapPage(ctx: Ctx): Promise<Response> { | ||
| 1425 | 1425 | What waits for what. The top edge is ready now; the chain in the accent |
| 1426 | 1426 | is the least work that still has to happen in order. |
| 1427 | 1427 | <a href="${repoBase(repo)}/milestones">How far each milestone is</a>. |
| 1428 | <a href="${repoBase(repo)}/next">Next up</a> is the one ranked list the crew builds from. | |
| 1428 | 1429 | </p> |
| 1429 | 1430 | </div> |
| 1430 | 1431 | <div |
src/web/pages/next.ts added +190
| @@ -0,0 +1,190 @@ | ||
| 1 | /** | |
| 2 | * Next up (#8694): `/:owner/:name/next`, the owner's one ranked list. The top | |
| 3 | * ten in rank order with who is on each and, in a line, why one cannot start | |
| 4 | * yet; everything else is one line that opens the rest. `Top`, `Up`, `Down` | |
| 5 | * and `Not now` are plain forms that save at once, so the keyboard and a | |
| 6 | * phone need no script; a row can also be dragged, or moved by the key shown | |
| 7 | * beside each action (`j`/`k` choose the row), which post the same form. | |
| 8 | */ | |
| 9 | import { placeOf, rankIssue, rankedWords, stepTo, type Place } from "../../actions/next.ts"; | |
| 10 | import { nextUp, TOP, type NextRow } from "../../core/next_up.ts"; | |
| 11 | import { canWrite, type Repo } from "../../core/repos.ts"; | |
| 12 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; | |
| 13 | import { sealedScope } from "../../core/sealed.ts"; | |
| 14 | import type { Ctx } from "../../http/context.ts"; | |
| 15 | import { crumbsFor, loadRepo, notFoundPage, repoBase, repoHeader, repoPalette, signInFirst } from "../chrome.ts"; | |
| 16 | import { backTo, field, readForm } from "../form.ts"; | |
| 17 | import { html, type Html } from "../html.ts"; | |
| 18 | import { flashOrJson, page } from "../layout.ts"; | |
| 19 | import { badge, btn, card, kbd, row } from "../ui.ts"; | |
| 20 | ||
| 21 | const KEYS_JS = ` | |
| 22 | (() => { | |
| 23 | const list = document.getElementById("next-list"); | |
| 24 | const remembered = sessionStorage.getItem("next-focus"); | |
| 25 | if (remembered) { | |
| 26 | sessionStorage.removeItem("next-focus"); | |
| 27 | const again = document.querySelector('li[data-number="' + remembered + '"] [data-nav]'); | |
| 28 | if (again) again.focus(); | |
| 29 | } | |
| 30 | const send = async (data, number) => { | |
| 31 | const res = await fetch(location.pathname, { method: "POST", body: new URLSearchParams(data), headers: { accept: "application/json" } }); | |
| 32 | if (!res.ok) { | |
| 33 | // A refusal says why and changes nothing: the list stays as it is. | |
| 34 | const said = await res.json().catch(() => ({})); | |
| 35 | const note = document.getElementById("next-flash"); | |
| 36 | note.textContent = said.error || "That move was refused."; | |
| 37 | note.hidden = false; | |
| 38 | return; | |
| 39 | } | |
| 40 | sessionStorage.setItem("next-focus", number); | |
| 41 | location.reload(); | |
| 42 | }; | |
| 43 | // One key, one action, on the row that has focus: the key is shown on the button. | |
| 44 | document.addEventListener("keydown", (e) => { | |
| 45 | if (e.metaKey || e.ctrlKey || e.altKey || e.target.closest("input, textarea, select, [contenteditable]")) return; | |
| 46 | const row = document.activeElement && document.activeElement.closest("li[data-number]"); | |
| 47 | const form = row && row.querySelector('form[data-key="' + e.key + '"]'); | |
| 48 | if (!form) return; | |
| 49 | e.preventDefault(); | |
| 50 | send(new FormData(form), row.dataset.number); | |
| 51 | }); | |
| 52 | if (!list) return; | |
| 53 | let held = null; | |
| 54 | list.querySelectorAll("li[data-number]").forEach((li) => { li.draggable = true; }); | |
| 55 | list.addEventListener("dragstart", (e) => { held = e.target.closest("li[data-number]"); }); | |
| 56 | list.addEventListener("dragover", (e) => { if (held) e.preventDefault(); }); | |
| 57 | list.addEventListener("drop", (e) => { | |
| 58 | const over = e.target.closest("li[data-number]"); | |
| 59 | if (!held || !over || over === held) return; | |
| 60 | e.preventDefault(); | |
| 61 | const rows = [...list.querySelectorAll("li[data-number]")]; | |
| 62 | const rest = rows.filter((r) => r !== held); | |
| 63 | const at = rest.indexOf(over) + (rows.indexOf(over) > rows.indexOf(held) ? 1 : 0); | |
| 64 | send({ number: held.dataset.number, to: at === 0 ? "top" : "after:" + rest[at - 1].dataset.number }, held.dataset.number); | |
| 65 | }); | |
| 66 | })();`; | |
| 67 | ||
| 68 | /** The one form a button is: a move for one issue, saved on submit. */ | |
| 69 | function move(base: string, number: number, to: string, label: string, key: string, back: string): Html { | |
| 70 | return html`<form method="post" action="${base}/next" data-key="${key}"> | |
| 71 | <input type="hidden" name="number" value="${String(number)}" /> | |
| 72 | <input type="hidden" name="to" value="${to}" /> | |
| 73 | <input type="hidden" name="back" value="${back}" /> | |
| 74 | ${btn(html`${label} ${kbd([key])}`, { variant: "outline", size: "sm" })} | |
| 75 | </form>`; | |
| 76 | } | |
| 77 | ||
| 78 | function line(base: string, rows: readonly NextRow[], at: number, writer: boolean, back: string, away: boolean): Html { | |
| 79 | const r = rows[at]!; | |
| 80 | const n = r.issue.number; | |
| 81 | const numbers = rows.map((x) => x.issue.number); | |
| 82 | const up = stepTo(numbers, at, -1); | |
| 83 | const down = stepTo(numbers, at, 1); | |
| 84 | const controls = writer | |
| 85 | ? html`${at > 0 || away ? move(base, n, "top", "Top", "t", back) : null}${away | |
| 86 | ? null | |
| 87 | : html`${up === undefined ? null : move(base, n, up, "Up", "u", back)}${down === undefined | |
| 88 | ? null | |
| 89 | : move(base, n, down, "Down", "d", back)}${move(base, n, "not-now", "Not now", "n", back)}`}` | |
| 90 | : null; | |
| 91 | return row({ | |
| 92 | href: `${base}/issues/${n}`, | |
| 93 | title: r.issue.title, | |
| 94 | titleAttr: r.issue.title, | |
| 95 | lead: html`${r.place === null ? "·" : String(r.place)}`, | |
| 96 | meta: html`${badge(r.role === "blocker" ? "Blocker" : "Enabler", { variant: r.role === "blocker" ? "secondary" : "outline" })} | |
| 97 | #${n} · ${r.roleWhy}${r.onIt === null ? "" : ` · on it: ${r.onIt}`}${r.why === null ? "" : ` · ${r.why}`}`, | |
| 98 | nav: true, | |
| 99 | data: { number: String(n) }, | |
| 100 | ...(controls === null ? {} : { after: controls }), | |
| 101 | }); | |
| 102 | } | |
| 103 | ||
| 104 | export function nextPage(ctx: Ctx): Response { | |
| 105 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 106 | const repo = loadRepo(ctx); | |
| 107 | if (!repo) return notFoundPage(ctx); | |
| 108 | const base = repoBase(repo); | |
| 109 | const all = ctx.url.searchParams.get("all") === "1"; | |
| 110 | const writer = ctx.auth.user !== undefined && ctx.auth.user.kind !== "agent" && canWrite(ctx.app.db, repo, ctx.auth.user); | |
| 111 | const next = nextUp(ctx.app.db, repo, sealedScope(ctx.app.db, repo, ctx.auth.user)); | |
| 112 | const shown = all ? next.rows : next.rows.slice(0, TOP); | |
| 113 | const rest = next.rows.length - shown.length; | |
| 114 | const back = all ? `${base}/next?all=1` : `${base}/next`; | |
| 115 | const rendering = performance.now(); | |
| 116 | const body = html`${repoHeader(ctx, repo, "issues")} | |
| 117 | <div class="page-head mt"> | |
| 118 | <h1>Next up</h1> | |
| 119 | <p class="muted"> | |
| 120 | The crew builds strictly from the top. | |
| 121 | ${writer | |
| 122 | ? "Drag a row, or press the key beside an action on the row you are on (j and k move between rows): each saves at once." | |
| 123 | : "Only someone who writes here can change the order."} | |
| 124 | Dependencies order work within the list; they are shown, not edited. | |
| 125 | </p> | |
| 126 | </div> | |
| 127 | <p class="error" id="next-flash" role="alert" hidden></p> | |
| 128 | ${next.rows.length === 0 | |
| 129 | ? html`<p class="muted">Nothing is ready: everything open waits for something. <a href="${base}/map">See the map</a>.</p>` | |
| 130 | : card( | |
| 131 | html`<ul class="list setting-list" id="next-list"> | |
| 132 | ${shown.map((_, at) => line(base, next.rows, at, writer, back, false))} | |
| 133 | </ul>`, | |
| 134 | { pad: false }, | |
| 135 | )} | |
| 136 | ${all | |
| 137 | ? null | |
| 138 | : rest > 0 || next.notNowTotal > 0 | |
| 139 | ? html`<p class="mt"> | |
| 140 | <a href="${base}/next?all=1">${[ | |
| 141 | rest > 0 ? `${rest} more ready` : null, | |
| 142 | next.notNowTotal > 0 ? `${next.notNowTotal} not now` : null, | |
| 143 | ] | |
| 144 | .filter((x) => x !== null) | |
| 145 | .join(" · ")}: open the rest</a> | |
| 146 | </p>` | |
| 147 | : null} | |
| 148 | ${all && next.notNow.length > 0 | |
| 149 | ? html`<section class="stack-tight mt"> | |
| 150 | <h2>Not now</h2> | |
| 151 | <p class="muted small">Behind the Later gate. Top lifts one past it in one action.</p> | |
| 152 | ${card( | |
| 153 | html`<ul class="list setting-list"> | |
| 154 | ${next.notNow.map((_, at) => line(base, next.notNow, at, writer, back, true))} | |
| 155 | </ul>`, | |
| 156 | { pad: false }, | |
| 157 | )} | |
| 158 | </section>` | |
| 159 | : null}`; | |
| 160 | const response = page({ | |
| 161 | ctx, | |
| 162 | title: `Next up · ${repo.name}`, | |
| 163 | crumbs: crumbsFor(repo), | |
| 164 | palette: repoPalette(ctx, repo), | |
| 165 | body, | |
| 166 | ...(writer ? { scriptJs: KEYS_JS } : {}), | |
| 167 | }); | |
| 168 | notePhase(ctx.request, "render", performance.now() - rendering); | |
| 169 | return response; | |
| 170 | } | |
| 171 | ||
| 172 | /** POST /:owner/:name/next — `number` and `to` (`top`, `not-now`, `after:<number>`): one move, saved at once. */ | |
| 173 | export async function nextSubmit(ctx: Ctx): Promise<Response> { | |
| 174 | const repo: Repo | undefined = loadRepo(ctx); | |
| 175 | if (!repo) return notFoundPage(ctx); | |
| 176 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 177 | const form = await readForm(ctx, "rw"); | |
| 178 | if (form instanceof Response) return form; | |
| 179 | const dest = backTo(form, `${repoBase(repo)}/next`); | |
| 180 | const place: Place | undefined = placeOf(field(form, "to")); | |
| 181 | const number = /^[1-9][0-9]{0,8}$/.test(field(form, "number")) ? Number(field(form, "number")) : 0; | |
| 182 | if (place === undefined || number === 0) | |
| 183 | return flashOrJson(ctx, dest, { tone: "danger", message: "Say which issue and where it goes." }); | |
| 184 | const moved = rankIssue(ctx.app, ctx.auth.user, repo, number, place); | |
| 185 | if (!moved.ok) { | |
| 186 | if (moved.error.kind === "not_found") return notFoundPage(ctx); | |
| 187 | return flashOrJson(ctx, dest, { tone: "danger", message: moved.error.message }); | |
| 188 | } | |
| 189 | return flashOrJson(ctx, dest, { message: rankedWords(number, place) }); | |
| 190 | } |
test/conversation-confirm.test.ts +1 −1
| @@ -124,7 +124,7 @@ describe("the one list of what asks first", () => { | ||
| 124 | 124 | "assign", "attach_look", "board_pins", "board_write", "branches", "capacity", "cloud_accounts", "close", "confirm_wait", |
| 125 | 125 | "crew", "decisions_by", "detach", "edit", "email_verify", "feed_chips", "feed_dismiss", "flag_set", "flag_winner", |
| 126 | 126 | "follow_mode", "front_page", "hold", "issue_teams", "labels", "lift", "limb", "make_it_yours", "map", "merge", "new_app", |
| 127 | "notify", "org_create", "origin_apps", "pins", "plan", "profile", "propose_to_parent_forge", "repo_labels", "reports", | |
| 127 | "next_up", "notify", "org_create", "origin_apps", "pins", "plan", "profile", "propose_to_parent_forge", "repo_labels", "reports", | |
| 128 | 128 | "restore_drill", "review", "say", "signup", "snapshot_now", "spend", "steer", "sync", "take_back", "take_updates", |
| 129 | 129 | "teams", "tell", "threads", "trail", "upstream", "variant", "wait_for", "watch", "whoami_can", "write_file", |
| 130 | 130 | ]); |
test/mcp-next-up.test.ts added +84
| @@ -0,0 +1,84 @@ | ||
| 1 | /** `next_up` over MCP: the ranked list, and the same one-action moves as the page. */ | |
| 2 | import { afterAll, beforeAll, describe, 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 { createRepo } from "../src/actions/repos.ts"; | |
| 9 | import { createApp as insertApp } from "../src/core/apps.ts"; | |
| 10 | import { createToken } from "../src/core/tokens.ts"; | |
| 11 | import { createUser } from "../src/core/users.ts"; | |
| 12 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 13 | ||
| 14 | describe("next_up over MCP", () => { | |
| 15 | let dataDir: string; | |
| 16 | let app: App; | |
| 17 | let servers: Servers; | |
| 18 | let rw: string; | |
| 19 | let ro: string; | |
| 20 | let id = 1; | |
| 21 | const numbers: number[] = []; | |
| 22 | ||
| 23 | const tool = async (token: string, args: unknown) => { | |
| 24 | const res = await fetch(`${servers.url}/mcp`, { | |
| 25 | method: "POST", | |
| 26 | headers: { | |
| 27 | "content-type": "application/json", | |
| 28 | accept: "application/json, text/event-stream", | |
| 29 | authorization: `Bearer ${token}`, | |
| 30 | }, | |
| 31 | body: JSON.stringify({ jsonrpc: "2.0", id: id++, method: "tools/call", params: { name: "next_up", arguments: args } }), | |
| 32 | }); | |
| 33 | const body = (await res.json()) as any; | |
| 34 | const text: string = body.result.content[0].text; | |
| 35 | return body.result.isError === true ? { error: text } : { value: JSON.parse(text) }; | |
| 36 | }; | |
| 37 | ||
| 38 | beforeAll(async () => { | |
| 39 | dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-next-up-")); | |
| 40 | app = createApp({ dataDir, port: 0 }); | |
| 41 | servers = await serve(app); | |
| 42 | const made = await createUser(app.db, { | |
| 43 | origin: "seed", | |
| 44 | trusted: true, | |
| 45 | username: "alice", | |
| 46 | email: "[email protected]", | |
| 47 | password: "a-good-passphrase", | |
| 48 | adminIfFirst: true, | |
| 49 | }); | |
| 50 | if (!made.ok) throw new Error(made.error); | |
| 51 | const alice = made.value; | |
| 52 | const repo = await createRepo(app, alice, { name: "game", visibility: "public" }); | |
| 53 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 54 | insertApp(app.db, { repoId: repo.value.id, slug: "game", access: "private" }); | |
| 55 | for (const title of ["One", "Two", "Three"]) { | |
| 56 | const o = openIssue(app, alice, repo.value, { title }); | |
| 57 | if (!o.ok) throw new Error(String(o.error)); | |
| 58 | numbers.push(o.value.number); | |
| 59 | } | |
| 60 | rw = createToken(app.db, alice.id, "rw", "rw").token; | |
| 61 | ro = createToken(app.db, alice.id, "ro", "ro").token; | |
| 62 | }); | |
| 63 | ||
| 64 | afterAll(async () => { | |
| 65 | await servers.stop(); | |
| 66 | await rm(dataDir, { recursive: true, force: true }); | |
| 67 | }); | |
| 68 | ||
| 69 | test("a writer moves an issue to the top and the list answers in the new order", async () => { | |
| 70 | const moved = await tool(rw, { app: "alice/game", issue: numbers[2], to: "top" }); | |
| 71 | expect(moved.value.next[0].issue).toBe(numbers[2]); | |
| 72 | expect(moved.value.next[0].place).toBe(1); | |
| 73 | }); | |
| 74 | ||
| 75 | test("a read-only token reads the list and cannot move", async () => { | |
| 76 | expect((await tool(ro, { app: "alice/game" })).value.next[0].issue).toBe(numbers[2]); | |
| 77 | expect((await tool(ro, { app: "alice/game", issue: numbers[0], to: "top" })).error).toBeDefined(); | |
| 78 | }); | |
| 79 | ||
| 80 | test("bad input is refused at the boundary", async () => { | |
| 81 | expect((await tool(rw, { app: "alice/game", issue: numbers[0], to: "sideways" })).error).toBeDefined(); | |
| 82 | expect((await tool(rw, { app: "alice/game", issue: 9999, to: "top" })).error).toBeDefined(); | |
| 83 | }); | |
| 84 | }); |
test/mcp.test.ts +1
| @@ -329,6 +329,7 @@ describe("the forge over MCP", () => { | ||
| 329 | 329 | "land_past_gate", |
| 330 | 330 | "repo_labels", |
| 331 | 331 | "wait_for", |
| 332 | "next_up", | |
| 332 | 333 | "lift", |
| 333 | 334 | "held", |
| 334 | 335 | "confirm_wait", |
test/next-up.test.ts added +274
| @@ -0,0 +1,274 @@ | ||
| 1 | /** | |
| 2 | * Next up (#8694): the owner's one ranked list. Top, Up/Down and Not now save | |
| 3 | * at once; a gated issue lifts past the Later gate in the one action; the | |
| 4 | * picker takes the highest ranked startable issue, the same pick every time; | |
| 5 | * a row says in a line why it cannot start. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { openIssue, setIssueState } from "../src/actions/issues.ts"; | |
| 13 | import { NEXT_ALREADY_AWAY, NEXT_FULL, placeOf, rankIssue } from "../src/actions/next.ts"; | |
| 14 | import { createRepo } from "../src/actions/repos.ts"; | |
| 15 | import { addDependency, readyNow, repoGraph } from "../src/core/dependencies.ts"; | |
| 16 | import { getIssue, getIssueById } from "../src/core/issues.ts"; | |
| 17 | import { MAX_RANKED, writeOrder } from "../src/core/rank.ts"; | |
| 18 | import { nextUp } from "../src/core/next_up.ts"; | |
| 19 | import type { Repo } from "../src/core/repos.ts"; | |
| 20 | import { createUser, type User } from "../src/core/users.ts"; | |
| 21 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 22 | ||
| 23 | describe("Next up", () => { | |
| 24 | let dataDir: string; | |
| 25 | let app: App; | |
| 26 | let servers: Servers; | |
| 27 | let base: string; | |
| 28 | let alice: User; | |
| 29 | let repo: Repo; | |
| 30 | let cookie: string; | |
| 31 | const n: Record<string, number> = {}; | |
| 32 | ||
| 33 | const user = async (username: string, first: boolean): Promise<User> => { | |
| 34 | const made = await createUser(app.db, { | |
| 35 | origin: "seed", | |
| 36 | trusted: true, | |
| 37 | username, | |
| 38 | email: `${username}@forge.local`, | |
| 39 | password: "a-good-passphrase", | |
| 40 | adminIfFirst: first, | |
| 41 | }); | |
| 42 | if (!made.ok) throw new Error(made.error); | |
| 43 | return made.value; | |
| 44 | }; | |
| 45 | const signIn = async (username: string): Promise<string> => { | |
| 46 | const res = await fetch(`${base}/signin`, { | |
| 47 | method: "POST", | |
| 48 | body: new URLSearchParams({ username, password: "a-good-passphrase" }), | |
| 49 | redirect: "manual", | |
| 50 | }); | |
| 51 | return (res.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 52 | }; | |
| 53 | const open = (title: string): number => { | |
| 54 | const o = openIssue(app, alice, repo, { title }); | |
| 55 | if (!o.ok) throw new Error(String(o.error)); | |
| 56 | return o.value.number; | |
| 57 | }; | |
| 58 | const issue = (number: number) => getIssue(app.db, repo.id, number)!; | |
| 59 | const move = (number: number, to: string) => { | |
| 60 | const place = placeOf(to)!; | |
| 61 | return rankIssue(app, alice, repo, number, place); | |
| 62 | }; | |
| 63 | const order = () => nextUp(app.db, repo).rows.map((r) => r.issue.number); | |
| 64 | const picked = () => readyNow(repoGraph(app.db, repo.id, { sealed: true })).map((r) => r.issue.number); | |
| 65 | ||
| 66 | beforeAll(async () => { | |
| 67 | dataDir = await mkdtemp(join(tmpdir(), "forge-next-up-")); | |
| 68 | app = createApp({ dataDir, port: 0 }); | |
| 69 | servers = await serve(app); | |
| 70 | base = `http://127.0.0.1:${servers.port}`; | |
| 71 | alice = await user("alice", true); | |
| 72 | await user("bob", false); | |
| 73 | const made = await createRepo(app, alice, { name: "plan", visibility: "public" }); | |
| 74 | if (!made.ok) throw new Error(String(made.error)); | |
| 75 | repo = made.value; | |
| 76 | n.gate = open("Later gate: not now"); | |
| 77 | for (const name of ["a", "b", "c", "d", "e"]) n[name] = open(`Work ${name}`); | |
| 78 | n.wait = open("Waits for a"); | |
| 79 | n.far = open("Far off"); | |
| 80 | addDependency(app.db, issue(n.wait!), issue(n.a!), alice.id); | |
| 81 | addDependency(app.db, issue(n.far!), issue(n.gate!), alice.id); | |
| 82 | cookie = await signIn("alice"); | |
| 83 | }); | |
| 84 | ||
| 85 | afterAll(async () => { | |
| 86 | await servers.stop(); | |
| 87 | await rm(dataDir, { recursive: true, force: true }); | |
| 88 | }); | |
| 89 | ||
| 90 | test("with nothing ranked, the list is the ready order, and the gated issue is not now", () => { | |
| 91 | const next = nextUp(app.db, repo); | |
| 92 | expect(next.rows.map((r) => r.issue.number)).toEqual(picked()); | |
| 93 | expect(next.rows.map((r) => r.issue.number)).not.toContain(n.far!); | |
| 94 | expect(next.notNow.map((r) => r.issue.number)).toEqual([n.far!]); | |
| 95 | expect(next.notNow[0]!.why).toContain("behind the Later gate"); | |
| 96 | }); | |
| 97 | ||
| 98 | test("Top puts an issue first, and the picker takes it", () => { | |
| 99 | expect(move(n.c!, "top").ok).toBe(true); | |
| 100 | expect(order()[0]).toBe(n.c!); | |
| 101 | expect(picked()[0]).toBe(n.c!); | |
| 102 | }); | |
| 103 | ||
| 104 | test("a place under another row ranks down to it; Not now drops it behind the gate", () => { | |
| 105 | expect(move(n.e!, `after:${n.c}`).ok).toBe(true); | |
| 106 | expect(order().slice(0, 2)).toEqual([n.c!, n.e!]); | |
| 107 | expect(move(n.e!, "not-now").ok).toBe(true); | |
| 108 | expect(order()).not.toContain(n.e!); | |
| 109 | expect(nextUp(app.db, repo).notNow.map((r) => r.issue.number)).toContain(n.e!); | |
| 110 | expect(picked()).not.toContain(n.e!); | |
| 111 | }); | |
| 112 | ||
| 113 | test("Top on a gated issue lifts it past the Later gate in that one action", () => { | |
| 114 | expect(move(n.far!, "top").ok).toBe(true); | |
| 115 | expect(order()[0]).toBe(n.far!); | |
| 116 | expect(picked()[0]).toBe(n.far!); | |
| 117 | const waits = app.db | |
| 118 | .query("SELECT 1 FROM issue_dependency WHERE issue_id = ?1 AND blocked_by_id = ?2") | |
| 119 | .all(issue(n.far!).id, issue(n.gate!).id); | |
| 120 | expect(waits).toHaveLength(0); | |
| 121 | }); | |
| 122 | ||
| 123 | test("a ranked issue that is not startable says why in one line, and the picker skips it", () => { | |
| 124 | expect(move(n.wait!, "top").ok).toBe(true); | |
| 125 | const row = nextUp(app.db, repo).rows.find((r) => r.issue.number === n.wait!)!; | |
| 126 | expect(row.why).toBe(`waits for #${n.a}`); | |
| 127 | expect(picked()).not.toContain(n.wait!); | |
| 128 | expect(picked()[0]).toBe(n.far!); | |
| 129 | }); | |
| 130 | ||
| 131 | test("the same state gives the same pick, every time", () => { | |
| 132 | const first = picked(); | |
| 133 | for (let i = 0; i < 5; i++) expect(picked()).toEqual(first); | |
| 134 | // A copy of the state in a second graph read agrees: no clock, no randomness. | |
| 135 | expect(readyNow(repoGraph(app.db, repo.id, { sealed: true })).map((r) => r.issue.number)).toEqual(first); | |
| 136 | }); | |
| 137 | ||
| 138 | test("a place that is not in the list changes nothing", () => { | |
| 139 | const before = order(); | |
| 140 | const refused = move(n.b!, "after:99999"); | |
| 141 | expect(refused.ok).toBe(false); | |
| 142 | expect(order()).toEqual(before); | |
| 143 | }); | |
| 144 | ||
| 145 | test("closing an issue takes it off the ranking", () => { | |
| 146 | const far = issue(n.far!); | |
| 147 | const rank = () => app.db.query("SELECT 1 FROM issue_rank WHERE issue_id = ?1").all(far.id); | |
| 148 | expect(rank()).toHaveLength(1); | |
| 149 | expect(setIssueState(app, alice, repo, far, "closed").ok).toBe(true); | |
| 150 | // The row itself is gone, not only hidden by the open filter. | |
| 151 | expect(rank()).toHaveLength(0); | |
| 152 | expect(order()).not.toContain(n.far!); | |
| 153 | }); | |
| 154 | ||
| 155 | test("each row is a blocker or an enabler, in one line; the tag never orders the picker", () => { | |
| 156 | const rows = nextUp(app.db, repo).rows; | |
| 157 | const wait = rows.find((r) => r.issue.number === n.wait!)!; | |
| 158 | expect(wait.role).toBe("enabler"); | |
| 159 | expect(wait.roleWhy).toBe("the owner ranked it"); | |
| 160 | // Work a holds up "Waits for a": a blocker. | |
| 161 | const a = rows.find((r) => r.issue.number === n.a!)!; | |
| 162 | expect(a.role).toBe("blocker"); | |
| 163 | expect(a.roleWhy).toBe("finishing it frees 1"); | |
| 164 | // The unranked rows keep the ready order, blocker or not. | |
| 165 | const unranked = rows.filter((r) => r.place === null).map((r) => r.issue.number); | |
| 166 | expect(unranked).toEqual(picked().filter((x) => unranked.includes(x))); | |
| 167 | }); | |
| 168 | ||
| 169 | test("Not now on an issue already behind the gate says so", () => { | |
| 170 | const refused = move(n.e!, "not-now"); | |
| 171 | expect(refused.ok).toBe(false); | |
| 172 | if (!refused.ok) expect(refused.error.message).toBe(NEXT_ALREADY_AWAY); | |
| 173 | }); | |
| 174 | ||
| 175 | test("the page lists the top ten with a Top button for a writer, and none for a reader", async () => { | |
| 176 | const writer = await (await fetch(`${base}/alice/plan/next`, { headers: { cookie } })).text(); | |
| 177 | expect(writer).toContain("Next up"); | |
| 178 | expect(writer).toContain(`value="${n.b}"`); | |
| 179 | expect(writer).toContain("Not now"); | |
| 180 | // The key is shown beside each action, and the rows take j/k. | |
| 181 | for (const key of ["t", "u", "d", "n"]) expect(writer).toContain(`data-key="${key}"`); | |
| 182 | expect(writer).toContain("<kbd>t</kbd>"); | |
| 183 | expect(writer).toContain("data-nav"); | |
| 184 | expect(writer).toContain("Blocker"); | |
| 185 | expect(writer).toContain("Enabler"); | |
| 186 | const reader = await (await fetch(`${base}/alice/plan/next`, { headers: { cookie: await signIn("bob") } })).text(); | |
| 187 | expect(reader).toContain(`Work b`); | |
| 188 | expect(reader).not.toContain('name="to"'); | |
| 189 | }); | |
| 190 | ||
| 191 | test("posting the form moves the issue and answers with the flash; another origin is refused", async () => { | |
| 192 | const post = (headers: Record<string, string>, to: string) => | |
| 193 | fetch(`${base}/alice/plan/next`, { | |
| 194 | method: "POST", | |
| 195 | headers: { cookie, origin: base, ...headers }, | |
| 196 | body: new URLSearchParams({ number: String(n.d), to }), | |
| 197 | redirect: "manual", | |
| 198 | }); | |
| 199 | expect((await post({ origin: "https://evil.example" }, "top")).status).toBe(403); | |
| 200 | expect(order()[0]).not.toBe(n.d!); | |
| 201 | expect((await post({}, "top")).status).toBe(303); | |
| 202 | expect(order()[0]).toBe(n.d!); | |
| 203 | const bad = await post({ accept: "application/json" }, "sideways"); | |
| 204 | expect(bad.status).toBe(422); | |
| 205 | }); | |
| 206 | ||
| 207 | test("a reader's post is refused and changes nothing", async () => { | |
| 208 | const bob = await signIn("bob"); | |
| 209 | const before = order(); | |
| 210 | const res = await fetch(`${base}/alice/plan/next`, { | |
| 211 | method: "POST", | |
| 212 | headers: { cookie: bob, origin: base, accept: "application/json" }, | |
| 213 | body: new URLSearchParams({ number: String(n.b), to: "top" }), | |
| 214 | }); | |
| 215 | expect(res.status).toBe(422); | |
| 216 | expect(order()).toEqual(before); | |
| 217 | }); | |
| 218 | ||
| 219 | test("beyond ten rows the rest is one line that opens it", async () => { | |
| 220 | for (let i = 0; i < 12; i++) open(`Extra ${i}`); | |
| 221 | const page = await (await fetch(`${base}/alice/plan/next`, { headers: { cookie } })).text(); | |
| 222 | expect(page).toContain("next?all=1"); | |
| 223 | expect(page).toContain("open the rest"); | |
| 224 | const rows = (html: string) => (html.match(/data-number="/g) ?? []).length; | |
| 225 | expect(rows(page)).toBe(10); | |
| 226 | const full = await (await fetch(`${base}/alice/plan/next?all=1`, { headers: { cookie } })).text(); | |
| 227 | expect(rows(full)).toBeGreaterThan(10); | |
| 228 | expect(full).toContain("Not now"); | |
| 229 | }); | |
| 230 | }); | |
| 231 | ||
| 232 | describe("the ranked list is bounded", () => { | |
| 233 | test("a move that would rank more than the cap is refused and changes nothing", async () => { | |
| 234 | const dataDir = await mkdtemp(join(tmpdir(), "forge-next-cap-")); | |
| 235 | const app = createApp({ dataDir, port: 0 }); | |
| 236 | try { | |
| 237 | const made = await createUser(app.db, { | |
| 238 | origin: "seed", trusted: true, username: "carol", email: "[email protected]", | |
| 239 | password: "a-good-passphrase", adminIfFirst: true, | |
| 240 | }); | |
| 241 | if (!made.ok) throw new Error(made.error); | |
| 242 | const repo = await createRepo(app, made.value, { name: "big", visibility: "public" }); | |
| 243 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 244 | const ids: string[] = []; | |
| 245 | for (let i = 0; i < MAX_RANKED + 1; i++) { | |
| 246 | const o = openIssue(app, made.value, repo.value, { title: `Item ${i}` }); | |
| 247 | if (!o.ok) throw new Error(String(o.error)); | |
| 248 | ids.push(o.value.id); | |
| 249 | } | |
| 250 | writeOrder(app.db, repo.value, ids.slice(0, MAX_RANKED), made.value.id); | |
| 251 | const last = getIssueById(app.db, ids[MAX_RANKED]!)!; | |
| 252 | const refused = rankIssue(app, made.value, repo.value, last.number, { to: "top" }); | |
| 253 | expect(refused.ok).toBe(false); | |
| 254 | if (!refused.ok) expect(refused.error.message).toBe(NEXT_FULL); | |
| 255 | expect(app.db.query("SELECT COUNT(*) AS n FROM issue_rank").get()).toEqual({ n: MAX_RANKED }); | |
| 256 | } finally { | |
| 257 | app.db.close(); | |
| 258 | await rm(dataDir, { recursive: true, force: true }); | |
| 259 | } | |
| 260 | }); | |
| 261 | }); | |
| 262 | ||
| 263 | describe("the picker's order", () => { | |
| 264 | test("a ranked issue comes before raised and roadmap work, and small work at its cap is still taken when ranked", async () => { | |
| 265 | const { slotOrder } = await import("../src/crew/small_share.ts"); | |
| 266 | const items = [ | |
| 267 | { id: "roadmap", raised: false, roadmap: true, small: false }, | |
| 268 | { id: "raised", raised: true, roadmap: false, small: false }, | |
| 269 | { id: "ranked-small", ranked: true, raised: false, roadmap: false, small: true }, | |
| 270 | ]; | |
| 271 | const picks = slotOrder(items, (t) => t, 1, 1).map((t) => t.id); | |
| 272 | expect(picks).toEqual(["ranked-small", "raised", "roadmap"]); | |
| 273 | }); | |
| 274 | }); |
test/palette-roster.test.ts +4
| @@ -53,6 +53,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 53 | 53 | ["held", "Held by the Later gate", "owner/name"], |
| 54 | 54 | ["pulls", "Pull requests in a repository", "owner/name"], |
| 55 | 55 | ["milestones", "Milestones in a repository", "owner/name"], |
| 56 | ["next-up", "Next up in a repository", "owner/name"], | |
| 56 | 57 | ["repo-usage", "Usage of a repository", "owner/name"], |
| 57 | 58 | ["new-issue", "New issue", "owner/name"], |
| 58 | 59 | ["new-pull", "New pull request", "owner/name"], |
| @@ -136,6 +137,7 @@ test("a site-admin agent is offered the roster without the person-only entries", | ||
| 136 | 137 | ["held", "Held by the Later gate", "owner/name"], |
| 137 | 138 | ["pulls", "Pull requests in a repository", "owner/name"], |
| 138 | 139 | ["milestones", "Milestones in a repository", "owner/name"], |
| 140 | ["next-up", "Next up in a repository", "owner/name"], | |
| 139 | 141 | ["repo-usage", "Usage of a repository", "owner/name"], |
| 140 | 142 | ["new-issue", "New issue", "owner/name"], |
| 141 | 143 | ["new-pull", "New pull request", "owner/name"], |
| @@ -193,6 +195,7 @@ test("a member person is offered the signed-in roster without the admin entries" | ||
| 193 | 195 | ["held", "Held by the Later gate", "owner/name"], |
| 194 | 196 | ["pulls", "Pull requests in a repository", "owner/name"], |
| 195 | 197 | ["milestones", "Milestones in a repository", "owner/name"], |
| 198 | ["next-up", "Next up in a repository", "owner/name"], | |
| 196 | 199 | ["repo-usage", "Usage of a repository", "owner/name"], |
| 197 | 200 | ["new-issue", "New issue", "owner/name"], |
| 198 | 201 | ["new-pull", "New pull request", "owner/name"], |
| @@ -244,6 +247,7 @@ test("signed out, the palette offers sign-in and the public entries", () => { | ||
| 244 | 247 | ["held", "Held by the Later gate", "owner/name"], |
| 245 | 248 | ["pulls", "Pull requests in a repository", "owner/name"], |
| 246 | 249 | ["milestones", "Milestones in a repository", "owner/name"], |
| 250 | ["next-up", "Next up in a repository", "owner/name"], | |
| 247 | 251 | ["repo-usage", "Usage of a repository", "owner/name"], |
| 248 | 252 | ["new-issue", "New issue", "owner/name"], |
| 249 | 253 | ["new-pull", "New pull request", "owner/name"], |
test/tui-next-up.test.ts added +157
| @@ -0,0 +1,157 @@ | ||
| 1 | /** | |
| 2 | * Next up in the terminal (#8694): the ranked list, and Top, Up, Down and Not | |
| 3 | * now as keys, through the palette against a real forge. A reader sees the | |
| 4 | * order and has no keys to change it, a guest sees nothing private, and a | |
| 5 | * move made here reaches the bus as `issue.ranked`, so a page open on the web | |
| 6 | * hears it. | |
| 7 | */ | |
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { openIssue } from "../src/actions/issues.ts"; | |
| 13 | import { createRepo } from "../src/actions/repos.ts"; | |
| 14 | import { rankIssue } from "../src/actions/next.ts"; | |
| 15 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 16 | import { addDependency } from "../src/core/dependencies.ts"; | |
| 17 | import { getIssue } from "../src/core/issues.ts"; | |
| 18 | import { nextUp } from "../src/core/next_up.ts"; | |
| 19 | import type { Repo } from "../src/core/repos.ts"; | |
| 20 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 21 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 22 | import type { TuiIdentity } from "../src/tui/session.ts"; | |
| 23 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 24 | import { quietLogs } from "./helpers/globals.ts"; | |
| 25 | import { settleApp } from "./helpers/tui.ts"; | |
| 26 | ||
| 27 | quietLogs(); | |
| 28 | ||
| 29 | describe("next up in the terminal", () => { | |
| 30 | let dataDir: string; | |
| 31 | let forge: Forge; | |
| 32 | let alice: User; | |
| 33 | let bob: User; | |
| 34 | let repo: Repo; | |
| 35 | let vault: Repo; | |
| 36 | const n: Record<string, number> = {}; | |
| 37 | ||
| 38 | const open = (r: Repo, title: string): number => { | |
| 39 | const o = openIssue(forge, alice, r, { title }); | |
| 40 | if (!o.ok) throw new Error(String(o.error)); | |
| 41 | return o.value.number; | |
| 42 | }; | |
| 43 | const order = (r: Repo = repo): number[] => nextUp(forge.db, r).rows.map((x) => x.issue.number); | |
| 44 | ||
| 45 | function boot(user?: User) { | |
| 46 | const terminal = headlessTerminal(120, 32); | |
| 47 | const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user }; | |
| 48 | const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false }); | |
| 49 | app.start(); | |
| 50 | const press = async (...keys: string[]) => { | |
| 51 | for (const key of keys) | |
| 52 | app.dispatch({ type: "key", key: key.startsWith("ctrl+") ? key.slice(5) : key, ctrl: key.startsWith("ctrl+"), alt: false, shift: false }); | |
| 53 | await settleApp(app); | |
| 54 | }; | |
| 55 | const typeText = async (text: string) => { | |
| 56 | for (const ch of text) app.dispatch({ type: "key", key: ch, ctrl: false, alt: false, shift: false }); | |
| 57 | await settleApp(app); | |
| 58 | }; | |
| 59 | const toNext = async (spec: string) => { | |
| 60 | await settleApp(app); | |
| 61 | await press("ctrl+k"); | |
| 62 | await typeText("Next up in a repository"); | |
| 63 | await press("enter"); | |
| 64 | await typeText(spec); | |
| 65 | await press("enter"); | |
| 66 | }; | |
| 67 | return { text: () => app.screen.toText(), press, toNext, stop: () => app.stop() }; | |
| 68 | } | |
| 69 | ||
| 70 | beforeAll(async () => { | |
| 71 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-next-")); | |
| 72 | forge = createApp({ dataDir, port: 0 }); | |
| 73 | for (const [username, first] of [["alice", true], ["bob", false]] as const) { | |
| 74 | const made = await createUser(forge.db, { | |
| 75 | origin: "seed", trusted: true, username, email: `${username}@forge.local`, | |
| 76 | password: "a-good-passphrase", adminIfFirst: first, | |
| 77 | }); | |
| 78 | if (!made.ok) throw new Error(made.error); | |
| 79 | } | |
| 80 | alice = getUserByName(forge.db, "alice")!; | |
| 81 | bob = getUserByName(forge.db, "bob")!; | |
| 82 | const made = await createRepo(forge, alice, { name: "plan", visibility: "public" }); | |
| 83 | const sealed = await createRepo(forge, alice, { name: "vault", visibility: "private" }); | |
| 84 | if (!made.ok || !sealed.ok) throw new Error("repo"); | |
| 85 | repo = made.value; | |
| 86 | vault = sealed.value; | |
| 87 | n.gate = open(repo, "Later gate: not now"); | |
| 88 | for (const name of ["a", "b", "c"]) n[name] = open(repo, `Work ${name}`); | |
| 89 | addDependency(forge.db, getIssue(forge.db, repo.id, n.c!)!, getIssue(forge.db, repo.id, n.gate!)!, alice.id); | |
| 90 | open(vault, "Secret work"); | |
| 91 | }); | |
| 92 | ||
| 93 | afterAll(async () => { | |
| 94 | forge.close(); | |
| 95 | await rm(dataDir, { recursive: true, force: true }); | |
| 96 | }); | |
| 97 | ||
| 98 | test("a writer moves a row with the keys, each saves at once, and the web hears it", async () => { | |
| 99 | const heard: string[] = []; | |
| 100 | const off = forge.bus.subscribe((e) => { | |
| 101 | if (e.kind === "issue.ranked" && e.repoId === repo.id) heard.push(e.kind); | |
| 102 | }); | |
| 103 | const h = boot(alice); | |
| 104 | await h.toNext("alice/plan"); | |
| 105 | expect(h.text()).toContain("Work a"); | |
| 106 | expect(h.text()).toContain("Enabler"); | |
| 107 | const before = order(); | |
| 108 | expect(before).toEqual([n.a!, n.b!]); | |
| 109 | ||
| 110 | // Down, then Top on the row the cursor followed. | |
| 111 | await h.press("d"); | |
| 112 | expect(order()[1]).toBe(before[0]!); | |
| 113 | expect(heard).toHaveLength(1); | |
| 114 | await h.press("t"); | |
| 115 | expect(order()[0]).toBe(before[0]!); | |
| 116 | expect(heard).toHaveLength(2); | |
| 117 | ||
| 118 | // Not now puts the row behind the Later gate. | |
| 119 | const first = order()[0]!; | |
| 120 | await h.press("n"); | |
| 121 | expect(order()).not.toContain(first); | |
| 122 | expect(nextUp(forge.db, repo).notNow.map((r) => r.issue.number)).toContain(first); | |
| 123 | ||
| 124 | // Top on a Not now row lifts it again, from the web or here. | |
| 125 | expect(rankIssue(forge, alice, repo, first, { to: "top" }).ok).toBe(true); | |
| 126 | off(); | |
| 127 | h.stop(); | |
| 128 | }); | |
| 129 | ||
| 130 | test("a move made elsewhere reorders the open list without a key", async () => { | |
| 131 | const h = boot(alice); | |
| 132 | await h.toNext("alice/plan"); | |
| 133 | const last = order().at(-1)!; | |
| 134 | expect(rankIssue(forge, alice, repo, last, { to: "top" }).ok).toBe(true); | |
| 135 | await new Promise((r) => setTimeout(r, 120)); | |
| 136 | await h.press("j", "k"); | |
| 137 | expect(h.text()).toMatch(new RegExp(`1\\. #${last} `)); | |
| 138 | h.stop(); | |
| 139 | }); | |
| 140 | ||
| 141 | test("a reader sees the order and the keys change nothing", async () => { | |
| 142 | const h = boot(bob); | |
| 143 | await h.toNext("alice/plan"); | |
| 144 | expect(h.text()).toContain("you can read this order, not change it"); | |
| 145 | const before = order(); | |
| 146 | await h.press("j", "t", "n", "d"); | |
| 147 | expect(order()).toEqual(before); | |
| 148 | h.stop(); | |
| 149 | }); | |
| 150 | ||
| 151 | test("a guest sees nothing private", async () => { | |
| 152 | const h = boot(undefined); | |
| 153 | await h.toNext("alice/vault"); | |
| 154 | expect(h.text()).not.toContain("Secret work"); | |
| 155 | h.stop(); | |
| 156 | }); | |
| 157 | }); |
new above
offline