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

Merged opened this in answer to

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

Merged

mason/forge:mason/8694-next-up-one-ranked-list-the-owner-drags → main 8 commits fork

  • Merged. mason/8694-next-up-one-ranked-list-the-owner-drags was merged into main as 2a83216.

Review

Sign in to review this change.
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 {
6868 } from "../origins/import.ts";
6969 import { err, ok, type Result } from "../kernel/result.ts";
7070 import { clearFirst } from "../core/first.ts";
71 import { clearRank } from "../core/rank.ts";
7172 import { followLabel } from "./gate.ts";
7273 import { inheritPillar } from "./pillars.ts";
7374 import {
@@ -458,7 +459,10 @@ export function setIssueState(
458459 : [];
459460 const changed = app.db.transaction(() => {
460461 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 }
462466 if (written && doneBy.length > 0)
463467 insertComment(app.db, {
464468 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";
1717 import { flowLabelled } from "./flow_label.ts";
1818 import { raisedAt, raisedInRepo } from "./thought_links.ts";
1919 import { firstAt, firstInRepo } from "./first.ts";
20 import { ranksInRepo } from "./rank.ts";
2021 import { limbRanks, limbTree, readPlaces } from "./limb_order.ts";
2122
2223 export type DependencyError = "self" | "cross_repo" | "cycle" | "not_an_issue";
@@ -255,6 +256,8 @@ export interface Graph {
255256 readonly flow?: ReadonlySet<string>;
256257 /** When a writer put an issue first (#7806): ready ahead of everything else. */
257258 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>;
258261 }
259262
260263 /**
@@ -460,6 +463,7 @@ export function repoGraph(
460463 ? raisedAt(db, issues.map((i) => i.id))
461464 : raisedInRepo(db, repoId),
462465 flow: flowLabelled(db, repoId),
466 ranked: ranksInRepo(db, repoId),
463467 first:
464468 whole === undefined
465469 ? firstAt(db, issues.map((i) => i.id))
@@ -700,9 +704,16 @@ export function readyNow(graph: Graph): Ranked[] {
700704 // What a person put first comes before all of it, the latest first. It only
701705 // orders what is already ready: no rank lifts the Later gate or a blocker.
702706 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));
706717 }
707718
708719 /**
src/core/event_audience.ts +1
@@ -37,6 +37,7 @@ const READER_KINDS: ReadonlySet<string> = new Set([
3737 "repo.following",
3838 "issue.labeled",
3939 "issue.first",
40 "issue.ranked",
4041 "issue.closed",
4142 "issue.gate_reviewed",
4243 "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 {
4545 } from "../core/agents.ts";
4646 import { pickBuilder, routeIssue } from "./route.ts";
4747 import { firstAt } from "../core/first.ts";
48 import { ranksInRepo } from "../core/rank.ts";
4849 import { ensurePlanner } from "./planner.ts";
4950 import { readyNow, repoGraph, type Ranked } from "../core/dependencies.ts";
5051 import {
@@ -405,11 +406,13 @@ function inSlotOrder(app: App, repo: Repo, ready: readonly Ranked[], slotted: re
405406 const small = smallIssues(app.db, issues);
406407 const roadmap = roadmapIssues(app.db, repo, issues.map((i) => i.id));
407408 const first = firstAt(app.db, issues.map((i) => i.id));
409 const ranked = ranksInRepo(app.db, repo.id);
408410 const raised = raisedAt(app.db, issues.map((i) => i.id));
409411 const running = smallOfIds(app.db, slotted.flatMap((r) => (r.issueId === null ? [] : [r.issueId])));
410412 return slotOrder(
411413 ready,
412414 (r) => ({
415 ranked: ranked.has(r.issue.id),
413416 raised: raised.has(r.issue.id) || first.has(r.issue.id),
414417 roadmap: roadmap.has(r.issue.id),
415418 small: small.has(r.issue.id) && !first.has(r.issue.id),
src/crew/small_share.ts +4 −1
@@ -52,6 +52,8 @@ export function smallIssuesWords(share: { readonly running: number; readonly cap
5252 }
5353
5454 export interface Classed {
55 /** On the owner's ranked list (#8694): before everything, small or not. */
56 readonly ranked?: boolean;
5557 /** Raised again by the owner: on top whatever else it is. */
5658 readonly raised: boolean;
5759 /** Under an open epic or a P0/P1 pillar. */
@@ -60,7 +62,7 @@ export interface Classed {
6062 }
6163
6264 /**
63 * The ready list in pick order: what the owner raised again first, then
65 * The ready list in pick order: the owner's ranked list strictly first (#8694), then what the owner raised again, then
6466 * roadmap work, then other, small work last, the map's order kept within
6567 * each; small work — raised or not — is left out once `running` small
6668 * issues fill `cap`.
@@ -73,6 +75,7 @@ export function slotOrder<T>(
7375 ): T[] {
7476 const rank = (t: T) => {
7577 const c = classOf(t);
78 if (c.ranked === true) return { r: -2, small: false };
7679 return { r: c.raised ? -1 : c.small ? 2 : c.roadmap ? 0 : 1, small: c.small };
7780 };
7881 const open = running < cap;
src/http/mcp/tools/index.ts +1
@@ -90,6 +90,7 @@ export { tool as model_ceiling } from "./model_ceiling.ts";
9090 export { tool as model_grant } from "./model_grant.ts";
9191 export { tool as new_app } from "./new_app.ts";
9292 export { tool as next_steps } from "./next_steps.ts";
93 export { tool as next_up } from "./next_up.ts";
9394 export { tool as notify } from "./notify.ts";
9495 export { tool as now } from "./now.ts";
9596 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";
199199 import { globalMapPage } from "../web/pages/global-map.ts";
200200 import { mapPage } from "../web/pages/map.ts";
201201 import { firstSubmit } from "../web/pages/first.ts";
202 import { nextPage, nextSubmit } from "../web/pages/next.ts";
202203 import {
203204 milestonePage,
204205 milestonesPage,
@@ -1025,6 +1026,8 @@ export function buildRouter(): Router<Handler> {
10251026 router.add("GET", "/:owner/:name/held", heldPage);
10261027 router.add("POST", "/:owner/:name/held/apply", heldApply);
10271028 router.add("GET", "/:owner/:name/map", mapPage);
1029 router.add("GET", "/:owner/:name/next", nextPage);
1030 router.add("POST", "/:owner/:name/next", nextSubmit);
10281031 router.add("POST", "/:owner/:name/issues/:number/first", firstSubmit);
10291032 router.add("GET", "/:owner/:name/milestones", milestonesPage);
10301033 router.add("GET", "/:owner/:name/milestones/:number", milestonePage);
src/tui/forge.ts +7
@@ -77,6 +77,7 @@ import { heldScreen } from "./screens/held.ts";
7777 import { stalledScreen } from "./screens/stalled.ts";
7878 import { issuesScreen, newIssueScreen } from "./screens/issues.ts";
7979 import { limbsScreen } from "./screens/limbs.ts";
80 import { nextScreen } from "./screens/next.ts";
8081 import { newPullScreen } from "./screens/newpull.ts";
8182 import { newForgeScreen } from "./screens/newforge.ts";
8283 import { newOrgScreen } from "./screens/neworg.ts";
@@ -126,6 +127,12 @@ const REPO_VERBS: readonly {
126127 title: "Milestones in a repository",
127128 open: (f, ctx, repo) => limbsScreen(f, ctx, repo),
128129 },
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 },
129136 {
130137 // What the crew's tokens bought on one app, as MCP `returns` with `repo` (#7271).
131138 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> {
14251425 What waits for what. The top edge is ready now; the chain in the accent
14261426 is the least work that still has to happen in order.
14271427 <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.
14281429 </p>
14291430 </div>
14301431 <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", () => {
124124 "assign", "attach_look", "board_pins", "board_write", "branches", "capacity", "cloud_accounts", "close", "confirm_wait",
125125 "crew", "decisions_by", "detach", "edit", "email_verify", "feed_chips", "feed_dismiss", "flag_set", "flag_winner",
126126 "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",
128128 "restore_drill", "review", "say", "signup", "snapshot_now", "spend", "steer", "sync", "take_back", "take_updates",
129129 "teams", "tell", "threads", "trail", "upstream", "variant", "wait_for", "watch", "whoami_can", "write_file",
130130 ]);
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", () => {
329329 "land_past_gate",
330330 "repo_labels",
331331 "wait_for",
332 "next_up",
332333 "lift",
333334 "held",
334335 "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", () => {
5353 ["held", "Held by the Later gate", "owner/name"],
5454 ["pulls", "Pull requests in a repository", "owner/name"],
5555 ["milestones", "Milestones in a repository", "owner/name"],
56 ["next-up", "Next up in a repository", "owner/name"],
5657 ["repo-usage", "Usage of a repository", "owner/name"],
5758 ["new-issue", "New issue", "owner/name"],
5859 ["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",
136137 ["held", "Held by the Later gate", "owner/name"],
137138 ["pulls", "Pull requests in a repository", "owner/name"],
138139 ["milestones", "Milestones in a repository", "owner/name"],
140 ["next-up", "Next up in a repository", "owner/name"],
139141 ["repo-usage", "Usage of a repository", "owner/name"],
140142 ["new-issue", "New issue", "owner/name"],
141143 ["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"
193195 ["held", "Held by the Later gate", "owner/name"],
194196 ["pulls", "Pull requests in a repository", "owner/name"],
195197 ["milestones", "Milestones in a repository", "owner/name"],
198 ["next-up", "Next up in a repository", "owner/name"],
196199 ["repo-usage", "Usage of a repository", "owner/name"],
197200 ["new-issue", "New issue", "owner/name"],
198201 ["new-pull", "New pull request", "owner/name"],
@@ -244,6 +247,7 @@ test("signed out, the palette offers sign-in and the public entries", () => {
244247 ["held", "Held by the Later gate", "owner/name"],
245248 ["pulls", "Pull requests in a repository", "owner/name"],
246249 ["milestones", "Milestones in a repository", "owner/name"],
250 ["next-up", "Next up in a repository", "owner/name"],
247251 ["repo-usage", "Usage of a repository", "owner/name"],
248252 ["new-issue", "New issue", "owner/name"],
249253 ["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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs