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

Below the ranked rows, the crew picks in the order Next up shows, so the page is the truth #8840

Merged opened this

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

Merged

scout/forge:scout/8826-below-the-ranked-rows-the-crew-picks-in → main 4 commits fork

  • Merged. scout/8826-below-the-ranked-rows-the-crew-picks-in was merged into main as 73cefae.

Review

Sign in to review this change.
8 files changed +172 −88
docs/CREW.md +5 −6
@@ -282,14 +282,13 @@ the planner's, found done on main and waiting for a person to close it
282282 unticked and waiting for a person to answer (`landed`, `crew/landed.ts`), stalled, at its run cap, parked twice, covered by a red-main
283283 fix, or a setting's finding. The flow panel on `/boards` draws the same per autonomous app as one
284284 bar, and the terminal's Flow command prints it as a line per app
285 (`flow/held.ts`, kept 5 s). Of the startable, what the owner raised again
286 stays on top, then the roadmap is picked
287 (#7571, `crew/small_share.ts`): an issue under an open epic or a P0/P1 pillar,
288 then other work, then small issues — a title like "A test pins …" or
289 "Tests: …", or a label `test`, `tests`, `tests-only` or `small` — and small issues hold
285 (`flow/held.ts`, kept 5 s). Of the startable, the picker takes the order Next up shows
286 (#8826, `pickOrder` in `core/next_up.ts`): the owner's ranked issues by place,
287 then the ready order, what the owner raised again first. Small issues — a title like "A test pins …" or
288 "Tests: …", or a label `test`, `tests`, `tests-only` or `small` (#7571, `crew/small_share.ts`) — hold
290289 at most `slots.smallShare` percent of the builder slots (default 30, rounded up:
291290 2 of 5; an admin sets it on `/settings/capacity`). At the cap a small issue is
292 skipped, not waited on, raised or not. The tick line's `small: 1/2`, the
291 skipped, not waited on, unless the owner ranked it. The tick line's `small: 1/2`, the
293292 Flow command's `small 1/2`, the app's row in Today on `/boards` (`small
294293 issues: 1 of 2 slots`) and the studio's crew panel show small issues in
295294 flight of what they may hold. When nothing is startable while builder slots are free and some of the
src/core/capacity.ts +1 −1
@@ -114,7 +114,7 @@ export const CAPACITY: Record<CapacityKey, Definition> = {
114114 env: "FORGE_SLOTS_SMALL_SHARE",
115115 kind: { type: "count", min: 0, max: 100 },
116116 fallback: "30",
117 help: "The most of a repository's builder slots that tests-only and small-addition issues (\"A test pins …\", or a label test, tests, tests-only or small) hold at once, as a percent rounded up: 30 lets 2 of 5 slots go to them. At the cap a small issue is skipped and the slot goes to roadmap work; roadmap work is picked first either way. 100 lifts the cap; 0 starts none. Read on every pick.",
117 help: "The most of a repository's builder slots that tests-only and small-addition issues (\"A test pins …\", or a label test, tests, tests-only or small) hold at once, as a percent rounded up: 30 lets 2 of 5 slots go to them. At the cap a small issue is skipped and the slot goes to the next issue in the Next up order; a ranked small issue is still taken. 100 lifts the cap; 0 starts none. Read on every pick.",
118118 },
119119 };
120120
src/core/next_up.ts +20 −4
@@ -85,6 +85,24 @@ export function roleOf(
8585 };
8686 }
8787
88 /**
89 * The whole order, once (#8826): the ranked issues by place, then the ready
90 * ones nobody ranked, in the ready order. Next up shows it and the picker
91 * takes from it; neither keeps an order of its own. `blocked` is the open
92 * ranked issues that are not ready: the page shows them in their place, and
93 * the picker, which can only start what is ready, passes none.
94 */
95 export function pickOrder<T>(
96 ready: readonly T[],
97 ranks: ReadonlyMap<string, number>,
98 idOf: (t: T) => string,
99 blocked: readonly T[] = [],
100 ): T[] {
101 const place = (t: T) => ranks.get(idOf(t));
102 const ranked = [...ready, ...blocked].filter((t) => place(t) !== undefined).sort((a, b) => place(a)! - place(b)!);
103 return [...ranked, ...ready.filter((t) => place(t) === undefined)];
104 }
105
88106 /** The list for one repository; `scope` is what `sealedScope` says the viewer may see. */
89107 export function nextUp(
90108 db: Database,
@@ -95,9 +113,6 @@ export function nextUp(
95113 const ranks = graph.ranked ?? new Map<string, number>();
96114 const ready = readyNow(graph).map((r) => r.issue);
97115 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)!);
101116 // Who is on an issue: the crew running it right now, else whoever it is assigned to.
102117 const crew = new Map<string, string>();
103118 for (const run of listActiveRuns(db, [repo.id])) {
@@ -113,7 +128,8 @@ export function nextUp(
113128 onIt: crew.get(issue.id) ?? issue.assigneeName,
114129 why: readyIds.has(issue.id) ? null : whyNot(graph, issue),
115130 });
116 const rows = [...ranked, ...ready.filter((i) => !ranks.has(i.id))].map(rowOf);
131 const blocked = graph.issues.filter((i) => i.state === "open" && ranks.has(i.id) && !readyIds.has(i.id));
132 const rows = pickOrder(ready, ranks, (i) => i.id, blocked).map(rowOf);
117133 const listed = new Set(rows.map((r) => r.issue.id));
118134 const away = graph.issues.filter(
119135 (i) => i.state === "open" && graph.deferred.has(i.id) && !listed.has(i.id) && !graph.structural.has(i.id),
src/crew/autonomy.ts +6 −7
@@ -31,7 +31,7 @@ import { addLearned } from "../checks/learned.ts";
3131 import { batchWords, dropFromLine, enqueue, type GateCandidate, type Queued, type QueueOptions, queueClashes, queueWords, waitingPulls } from "../checks/queue.ts";
3232 import { checksFor } from "../checks/service.ts";
3333 import { queueBatch, queueDepth, smallShare } from "../core/capacity.ts";
34 import { roadmapIssues, shareWords, slotOrder, smallCap, smallIssues, smallOfIds } from "./small_share.ts";
34 import { shareWords, slotOrder, smallCap, smallIssues, smallOfIds } from "./small_share.ts";
3535 import { backedBetOf, betsInUse } from "../core/bets.ts";
3636 import { mayPick } from "./bets.ts";
3737 import { raisedAt } from "../core/thought_links.ts";
@@ -48,6 +48,7 @@ import {
4848 } from "../core/agents.ts";
4949 import { pickBuilder, routeIssue } from "./route.ts";
5050 import { firstAt } from "../core/first.ts";
51 import { pickOrder } from "../core/next_up.ts";
5152 import { ranksInRepo } from "../core/rank.ts";
5253 import { ensurePlanner } from "./planner.ts";
5354 import { readyNow, repoGraph, type Ranked } from "../core/dependencies.ts";
@@ -404,11 +405,10 @@ function freeSlots(app: App, repo: Repo, now: Date): number {
404405 return Math.max(0, repoPace(app, repo, now).slots - slotted.length);
405406 }
406407
407 /** The pickable issues in slot order: the owner's raised on top, then the roadmap, small last and none past their share (#7571). */
408 function inSlotOrder(app: App, repo: Repo, ready: readonly Ranked[], slotted: readonly Run[], slots: number, picked: ReadonlySet<string> = new Set()): Ranked[] {
408 /** The pickable issues in slot order: the order Next up shows, small work held to its share (#7571). */
409 export function inSlotOrder(app: App, repo: Repo, ready: readonly Ranked[], slotted: readonly Run[], slots: number, picked: ReadonlySet<string> = new Set()): Ranked[] {
409410 const issues = ready.map((r) => r.issue);
410411 const small = smallIssues(app.db, issues);
411 const roadmap = roadmapIssues(app.db, repo, issues.map((i) => i.id));
412412 const first = firstAt(app.db, issues.map((i) => i.id));
413413 const ranked = ranksInRepo(app.db, repo.id);
414414 const raised = raisedAt(app.db, issues.map((i) => i.id));
@@ -426,12 +426,11 @@ function inSlotOrder(app: App, repo: Repo, ready: readonly Ranked[], slotted: re
426426 bet: bets.get(r.issue.id),
427427 }),
428428 );
429 // The order is the one Next up shows (#8826): ranked by place, then the ready order.
429430 return slotOrder(
430 allowed,
431 pickOrder(allowed, ranked, (r) => r.issue.id),
431432 (r) => ({
432433 ranked: ranked.has(r.issue.id),
433 raised: raised.has(r.issue.id) || first.has(r.issue.id),
434 roadmap: roadmap.has(r.issue.id),
435434 small: small.has(r.issue.id) && !first.has(r.issue.id),
436435 }),
437436 running,
src/crew/small_share.ts +11 −50
@@ -4,10 +4,8 @@
44 * quick to land, so left alone they fill every slot and the open-pull limit
55 * while the roadmap waits. Two rules keep them in their place:
66 *
7 * - **Pick order:** what the owner raised again stays on top, as the map
8 * ranks it (#654); then an issue under an open epic or a P0/P1 pillar,
9 * then other roadmap-sized work, then the small ones; each keeps the
10 * map's order among its own.
7 * - **Pick order:** the order Next up shows, ranked rows then the ready tail
8 * (`pickOrder` in core/next_up.ts); the picker has no order of its own.
119 * - **A cap:** small issues hold at most `slots.smallShare` percent of the
1210 * builder slots (rounded up, so one slot may still take one). At the cap a
1311 * small issue is skipped, never waited on: the slot goes to other work or
@@ -20,16 +18,11 @@
2018
2119 import type { Database } from "bun:sqlite";
2220 import { labelsForIssues } from "../core/issues.ts";
23 import { isStructural, pillarsOf, servedBy } from "../core/pillars.ts";
24 import type { Repo } from "../core/repos.ts";
2521
2622 /** Titles that say an issue only pins a behaviour with a test, or is a small addition. */
2723 const SMALL_TITLE = /^(a test (pins|proves|shows|names|covers)\b|tests?( only)?:|small:)/i;
2824 /** Labels that say the same. */
2925 const SMALL_LABELS = new Set(["test", "tests", "tests-only", "small"]);
30 /** The pillars whose work comes first. */
31 const FIRST_PILLAR = /^P[01] /;
32
3326 /** Whether an issue's title or labels mark it tests-only or a small addition. */
3427 export function isSmall(title: string, labels: readonly string[]): boolean {
3528 return SMALL_TITLE.test(title.trim()) || labels.some((l) => SMALL_LABELS.has(l.toLowerCase()));
@@ -52,38 +45,27 @@ export function smallIssuesWords(share: { readonly running: number; readonly cap
5245 }
5346
5447 export interface Classed {
55 /** On the owner's ranked list (#8694): before everything, small or not. */
48 /** On the owner's ranked list (#8694): never held back by the cap, small or not. */
5649 readonly ranked?: boolean;
57 /** Raised again by the owner: on top whatever else it is. */
58 readonly raised: boolean;
59 /** Under an open epic or a P0/P1 pillar. */
60 readonly roadmap: boolean;
6150 readonly small: boolean;
6251 }
6352
6453 /**
65 * The ready list in pick order: the owner's ranked list strictly first (#8694), then what the owner raised again, then
66 * roadmap work, then other, small work last, the map's order kept within
67 * each; small work — raised or not — is left out once `running` small
68 * issues fill `cap`.
54 * The order is not decided here: `ordered` arrives in the order Next up shows
55 * (#8826) and keeps it. All this does is leave small work out — raised or
56 * not, unless the owner ranked it — once `running` small issues fill `cap`.
6957 */
7058 export function slotOrder<T>(
71 ready: readonly T[],
59 ordered: readonly T[],
7260 classOf: (t: T) => Classed,
7361 running: number,
7462 cap: number,
7563 ): T[] {
76 const rank = (t: T) => {
77 const c = classOf(t);
78 if (c.ranked === true) return { r: -2, small: false };
79 return { r: c.raised ? -1 : c.small ? 2 : c.roadmap ? 0 : 1, small: c.small };
80 };
8164 const open = running < cap;
82 return ready
83 .map((t, at) => ({ t, at, ...rank(t) }))
84 .filter((x) => open || !x.small)
85 .sort((a, b) => a.r - b.r || a.at - b.at)
86 .map((x) => x.t);
65 return ordered.filter((t) => {
66 const c = classOf(t);
67 return open || c.ranked === true || !c.small;
68 });
8769 }
8870
8971 /** Which of these issues are small, from their titles and labels. */
@@ -110,24 +92,3 @@ export function smallOfIds(db: Database, ids: readonly string[]): number {
11092 .all(...unique);
11193 return smallIssues(db, rows).size;
11294 }
113
114 /**
115 * Which of these issues are roadmap work: served by a P0 or P1 pillar, or
116 * waited for by an open epic — an open issue other than the plan's furniture.
117 */
118 export function roadmapIssues(db: Database, repo: Repo, ids: readonly string[]): Set<string> {
119 const out = new Set<string>();
120 if (ids.length === 0) return out;
121 const set = pillarsOf(db, repo);
122 const served = servedBy(db, set);
123 for (const id of ids) if (FIRST_PILLAR.test(served.get(id) ?? "")) out.add(id);
124 const rows = db
125 .query<{ id: string; parent: string }, string[]>(
126 `SELECT d.blocked_by_id AS id, d.issue_id AS parent
127 FROM issue_dependency d JOIN issue i ON i.id = d.issue_id
128 WHERE i.state = 'open' AND d.blocked_by_id IN (${ids.map((_, n) => `?${n + 1}`).join(",")})`,
129 )
130 .all(...ids);
131 for (const row of rows) if (!isStructural(set, row.parent)) out.add(row.id);
132 return out;
133 }
test/crew-small-share.test.ts +38 −13
@@ -14,11 +14,14 @@ import { linkIssue } from "../src/actions/dependencies.ts";
1414 import { openIssue } from "../src/actions/issues.ts";
1515 import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/repos.ts";
1616 import { smallShare } from "../src/core/capacity.ts";
17 import { readyNow, repoGraph } from "../src/core/dependencies.ts";
18 import { nextUp } from "../src/core/next_up.ts";
19 import { putFirst } from "../src/core/first.ts";
1720 import { getIssueById, type Issue } from "../src/core/issues.ts";
1821 import { getRepoById, type Repo } from "../src/core/repos.ts";
1922 import { createRun } from "../src/core/runs.ts";
2023 import { createUser, type User } from "../src/core/users.ts";
21 import { driveRepo, smallShareWords } from "../src/crew/autonomy.ts";
24 import { driveRepo, inSlotOrder, smallShareWords } from "../src/crew/autonomy.ts";
2225 import { isSmall, shareWords, slotOrder, smallCap, smallIssuesWords } from "../src/crew/small_share.ts";
2326 import { readHeld } from "../src/flow/held.ts";
2427 import { serve, type Servers } from "../src/http/server.ts";
@@ -39,16 +42,10 @@ describe("the rules", () => {
3942 expect(smallCap(5, 100)).toBe(5);
4043 });
4144
42 test("roadmap first, small last, and small left out at the cap", () => {
43 const c = (t: string) => ({ raised: false, roadmap: t.startsWith("r"), small: t.startsWith("s") });
44 expect(slotOrder(["s1", "o1", "r1", "s2", "r2"], c, 0, 2)).toEqual(["r1", "r2", "o1", "s1", "s2"]);
45 expect(slotOrder(["s1", "o1", "r1"], c, 2, 2)).toEqual(["r1", "o1"]);
46 });
47
48 test("what the owner raised again stays on top, in the map's order; a raised small one still keeps to the cap", () => {
49 const c = (t: string) => ({ raised: t.endsWith("!"), roadmap: t.startsWith("r"), small: t.startsWith("s") });
50 expect(slotOrder(["r1", "o1!", "s1!", "r2"], c, 0, 2)).toEqual(["o1!", "s1!", "r1", "r2"]);
51 expect(slotOrder(["r1", "o1!", "s1!"], c, 2, 2)).toEqual(["o1!", "r1"]);
45 test("the order given is kept, and small work is left out at the cap unless ranked", () => {
46 const c = (t: string) => ({ ranked: t.endsWith("^"), small: t.startsWith("s") });
47 expect(slotOrder(["s1", "o1", "r1", "s2"], c, 0, 2)).toEqual(["s1", "o1", "r1", "s2"]);
48 expect(slotOrder(["s1", "o1", "s2^", "r1"], c, 2, 2)).toEqual(["o1", "s2^", "r1"]);
5249 });
5350
5451 test("each label the docs name is small, and the words read as the doors print them", () => {
@@ -181,16 +178,44 @@ test("one small issue of two slots shows on /board's Today, the studio's crew pa
181178 expect(flow).toMatch(/^alice\/notes startable=\d+.* small 2\/2$/m);
182179 });
183180
184 test("what the owner raised again starts before an epic's issue", async () => {
185 const plain = open("Rename a note");
181 test("what the owner raised again is first in the page's order and in the picker's, ahead of an older epic issue", async () => {
186182 const epic = open("Notes export");
187183 const child = open("Export notes as text");
184 const plain = open("Rename a note");
188185 expect(linkIssue(app, alice, fresh(), epic, child).ok).toBe(true);
189186 app.db.query("INSERT INTO issue_raise (issue_id, raised_at) VALUES (?1, ?2)").run(plain.id, new Date().toISOString());
187 const mine = new Set([plain.id, child.id]);
188 const ready = readyNow(repoGraph(app.db, repo.id, { sealed: true }));
189 const picker = inSlotOrder(app, fresh(), ready, [], 5).map((r) => r.issue.id).filter((id) => mine.has(id));
190 const page = nextUp(app.db, fresh()).rows.map((r) => r.issue.id).filter((id) => mine.has(id));
191 expect(picker).toEqual([plain.id, child.id]);
192 expect(page).toEqual(picker);
190193 // Both start in the one tick: slots are free for the owner's issue and the epic's.
191194 expect((await drive([plain, child])).map((i) => i.id).sort()).toEqual([plain.id, child.id].sort());
192195 });
193196
197 test("an epic's issue leads older plain work only as the page's ready order has it, and what a person put first leads both", async () => {
198 const older = open("Sort notes by date");
199 const epic = open("Notes import");
200 const child = open("Import notes from text");
201 expect(linkIssue(app, alice, fresh(), epic, child).ok).toBe(true);
202 const order = () => {
203 const mine = new Set([older.id, child.id]);
204 const ready = readyNow(repoGraph(app.db, repo.id, { sealed: true }));
205 const picker = inSlotOrder(app, fresh(), ready, [], 5).map((r) => r.issue.id).filter((id) => mine.has(id));
206 const page = nextUp(app.db, fresh()).rows.map((r) => r.issue.id).filter((id) => mine.has(id));
207 expect(page).toEqual(picker);
208 return picker;
209 };
210 // Finishing the child frees the epic, so the ready order (readyNow) puts it first; no second rule does.
211 expect(order()).toEqual([child.id, older.id]);
212 expect(putFirst(app, alice, fresh(), getIssueById(app.db, older.id)!).ok).toBe(true);
213 expect(order()).toEqual([older.id, child.id]);
214 // Clean up so the drives below see the same open work as before.
215 for (const i of [older, epic, child]) app.db.query("UPDATE issue SET state = 'closed' WHERE id = ?1").run(i.id);
216 opened.splice(opened.length - 3, 3);
217 });
218
194219 test("slots.smallShare set on /settings/capacity changes the pick: 0 starts no small issue, 100 does; out of range is refused", async () => {
195220 // The earlier ticks filled the slots; those runs end so this pick has room.
196221 app.db.query("UPDATE run SET state = 'ok' WHERE repo_id = ?1 AND state IN ('queued','running')").run(repo.id);
test/mcp-next-up.test.ts +44 −2
@@ -8,7 +8,10 @@ import { openIssue } from "../src/actions/issues.ts";
88 import { createRepo } from "../src/actions/repos.ts";
99 import { createApp as insertApp } from "../src/core/apps.ts";
1010 import { createToken } from "../src/core/tokens.ts";
11 import { createUser } from "../src/core/users.ts";
11 import { readyNow, repoGraph } from "../src/core/dependencies.ts";
12 import type { Repo } from "../src/core/repos.ts";
13 import { createUser, type User } from "../src/core/users.ts";
14 import { inSlotOrder } from "../src/crew/autonomy.ts";
1215 import { serve, type Servers } from "../src/http/server.ts";
1316
1417 describe("next_up over MCP", () => {
@@ -19,6 +22,7 @@ describe("next_up over MCP", () => {
1922 let ro: string;
2023 let id = 1;
2124 const numbers: number[] = [];
25 let alice: User;
2226
2327 const tool = async (token: string, args: unknown) => {
2428 const res = await fetch(`${servers.url}/mcp`, {
@@ -48,7 +52,7 @@ describe("next_up over MCP", () => {
4852 adminIfFirst: true,
4953 });
5054 if (!made.ok) throw new Error(made.error);
51 const alice = made.value;
55 alice = made.value;
5256 const repo = await createRepo(app, alice, { name: "game", visibility: "public" });
5357 if (!repo.ok) throw new Error(String(repo.error));
5458 insertApp(app.db, { repoId: repo.value.id, slug: "game", access: "private" });
@@ -81,4 +85,42 @@ describe("next_up over MCP", () => {
8185 expect((await tool(rw, { app: "alice/game", issue: numbers[0], to: "sideways" })).error).toBeDefined();
8286 expect((await tool(rw, { app: "alice/game", issue: 9999, to: "top" })).error).toBeDefined();
8387 });
88
89 /** A repository of its own, so this order touches no rank the tests above rely on. */
90 async function orderRepo(): Promise<{ repo: Repo; numbers: number[] }> {
91 const made = await createRepo(app, alice, { name: "order", visibility: "public" });
92 if (!made.ok) throw new Error(String(made.error));
93 insertApp(app.db, { repoId: made.value.id, slug: "order", access: "private" });
94 const numbers: number[] = [];
95 for (const title of ["One", "Two", "Three", "Four", "A test pins five"]) {
96 const o = openIssue(app, alice, made.value, { title });
97 if (!o.ok) throw new Error(String(o.error));
98 numbers.push(o.value.number);
99 }
100 return { repo: made.value, numbers };
101 }
102
103 const listed = async (): Promise<number[]> => {
104 const answer = await tool(rw, { app: "alice/order", all: true });
105 return (answer.value.next as { issue: number }[]).map((r) => r.issue);
106 };
107
108 const picked = (repo: Repo, slots: number): number[] =>
109 inSlotOrder(app, repo, readyNow(repoGraph(app.db, repo.id, { sealed: true })), [], slots).map((r) => r.issue.number);
110
111 test("the list lists the order the picker starts in, ranked first and then the rest, and a ranked small issue survives a full small share", async () => {
112 const { repo, numbers } = await orderRepo();
113 // Rank two (the fourth ends on top, the third below it); three stay unranked.
114 await tool(rw, { app: "alice/order", issue: numbers[2], to: "top" });
115 await tool(rw, { app: "alice/order", issue: numbers[3], to: "top" });
116 const before = await listed();
117 expect(before.slice(0, 2)).toEqual([numbers[3]!, numbers[2]!]);
118 expect(picked(repo, 5)).toEqual(before);
119 // With no small slot at all, the unranked small issue is dropped; ranking it keeps it, in its place.
120 expect(picked(repo, 0)).toEqual(before.filter((n) => n !== numbers[4]));
121 await tool(rw, { app: "alice/order", issue: numbers[4], to: "top" });
122 const after = await listed();
123 expect(after[0]).toBe(numbers[4]!);
124 expect(picked(repo, 0)).toEqual(after);
125 });
84126 });
test/next-up.test.ts +47 −5
@@ -261,14 +261,56 @@ describe("the ranked list is bounded", () => {
261261 });
262262
263263 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 () => {
264 test("at the small cap a ranked small issue survives and keeps its place; an unranked one is left out", async () => {
265265 const { slotOrder } = await import("../src/crew/small_share.ts");
266266 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 },
267 { id: "a", small: false },
268 { id: "ranked-small", ranked: true, small: true },
269 { id: "b", small: true },
270270 ];
271271 const picks = slotOrder(items, (t) => t, 1, 1).map((t) => t.id);
272 expect(picks).toEqual(["ranked-small", "raised", "roadmap"]);
272 expect(picks).toEqual(["a", "ranked-small"]);
273 });
274 });
275
276 describe("the picker follows the page (#8826)", () => {
277 test("with two ranked and three unranked issues, five ticks start the page's rows top to bottom", async () => {
278 const { inSlotOrder } = await import("../src/crew/autonomy.ts");
279 const dir = await mkdtemp(join(tmpdir(), "forge-next-order-"));
280 const app = createApp({ dataDir: dir, port: 0 });
281 try {
282 const made = await createUser(app.db, {
283 origin: "seed", trusted: true, username: "alice", email: "[email protected]",
284 password: "a-good-passphrase", adminIfFirst: true,
285 });
286 if (!made.ok) throw new Error(made.error);
287 const alice = made.value;
288 const r = await createRepo(app, alice, { name: "plan", visibility: "public" });
289 if (!r.ok) throw new Error(String(r.error));
290 const repo = r.value;
291 const num: number[] = [];
292 for (const t of ["one", "two", "three", "four", "five"]) {
293 const o = openIssue(app, alice, repo, { title: `Work ${t}` });
294 if (!o.ok) throw new Error(String(o.error));
295 num.push(o.value.number);
296 }
297 // Rank the fifth, then the third above it; the rest stay unranked.
298 expect(rankIssue(app, alice, repo, num[4]!, placeOf("top")!).ok).toBe(true);
299 expect(rankIssue(app, alice, repo, num[2]!, placeOf("top")!).ok).toBe(true);
300 const page = nextUp(app.db, repo).rows.map((x) => x.issue.number);
301 expect(page.slice(0, 2)).toEqual([num[2]!, num[4]!]);
302 const started: number[] = [];
303 for (let tick = 0; tick < 5; tick++) {
304 const ready = readyNow(repoGraph(app.db, repo.id, { sealed: true })).filter(
305 (x) => !started.includes(x.issue.number),
306 );
307 const next = inSlotOrder(app, repo, ready, [], 5)[0]!;
308 started.push(next.issue.number);
309 }
310 expect(started).toEqual(page);
311 } finally {
312 app.db.close();
313 await rm(dir, { recursive: true, force: true });
314 }
273315 });
274316 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs