| 1 | import assert from "node:assert/strict"; |
| 2 | import { test } from "node:test"; |
| 3 | |
| 4 | import type { Claim, ItemRepo, WorkItem, WorkspaceSession } from "@g1t/contracts"; |
| 5 | |
| 6 | import { |
| 7 | type ItemPull, |
| 8 | boardItems, |
| 9 | boardOf, |
| 10 | cappedNotes, |
| 11 | onItNotRead, |
| 12 | planRest, |
| 13 | planRestWords, |
| 14 | pullRepos, |
| 15 | pullsByItem, |
| 16 | readPages, |
| 17 | shownStage, |
| 18 | stageOf, |
| 19 | upNextOf, |
| 20 | } from "./board.ts"; |
| 21 | |
| 22 | const NOW = Date.parse("2026-10-17T12:00:00Z"); |
| 23 | const DAY = 86_400_000; |
| 24 | |
| 25 | function repo(path: string, role: ItemRepo["role"], landed = false): ItemRepo { |
| 26 | return { repoId: `rep_${path}`, path, role, legacyNumber: null, landed }; |
| 27 | } |
| 28 | |
| 29 | function item(number: number, more: Partial<WorkItem> = {}): WorkItem { |
| 30 | return { |
| 31 | id: `wki_${number}`, |
| 32 | workspace: "acme", |
| 33 | number, |
| 34 | title: `Item ${number}`, |
| 35 | body: "", |
| 36 | labels: [], |
| 37 | state: "open", |
| 38 | reason: null, |
| 39 | resolutions: [], |
| 40 | author: { id: "usr_1", username: "ana" } as WorkItem["author"], |
| 41 | requestedBy: null, |
| 42 | createdAt: new Date(NOW - 5 * DAY).toISOString(), |
| 43 | updatedAt: new Date(NOW - number * 1000).toISOString(), |
| 44 | closedAt: null, |
| 45 | pullCount: 0, |
| 46 | commentCount: 0, |
| 47 | assignees: [], |
| 48 | blockedBy: [], |
| 49 | agent: null, |
| 50 | repos: [repo("acme/web", "origin")], |
| 51 | horizon: "triage", |
| 52 | appetite: null, |
| 53 | budgetUsd: null, |
| 54 | urgent: false, |
| 55 | plannedAt: null, |
| 56 | ...more, |
| 57 | }; |
| 58 | } |
| 59 | |
| 60 | function live(item: number, more: Partial<WorkspaceSession> = {}): WorkspaceSession { |
| 61 | return { |
| 62 | kind: "harness", |
| 63 | id: `ses_${item}`, |
| 64 | repo: "acme/web", |
| 65 | repoId: "rep_acme/web", |
| 66 | title: null, |
| 67 | actor: "bo", |
| 68 | harness: "claude_code", |
| 69 | agent: null, |
| 70 | branch: "main", |
| 71 | item, |
| 72 | itemTitle: null, |
| 73 | pull: null, |
| 74 | pullTitle: null, |
| 75 | pullStatus: null, |
| 76 | state: "active", |
| 77 | startedAt: new Date(NOW).toISOString(), |
| 78 | lastActivityAt: new Date(NOW).toISOString(), |
| 79 | subagents: 0, |
| 80 | step: null, |
| 81 | session: null, |
| 82 | ...more, |
| 83 | }; |
| 84 | } |
| 85 | |
| 86 | /** Pull requests of these statuses, each its own, closing the item unless `part`. */ |
| 87 | const pr = (...statuses: (ItemPull["status"] | `part:${ItemPull["status"]}`)[]): ItemPull[] => |
| 88 | statuses.map((status, index) => { |
| 89 | const part = status.startsWith("part:"); |
| 90 | return { key: `acme/web#${index + 100}`, status: (part ? status.slice(5) : status) as ItemPull["status"], part }; |
| 91 | }); |
| 92 | |
| 93 | const claim = (number: number, who: string): Claim => |
| 94 | ({ id: `clm_${number}`, item: number, workspace: "acme", state: "active", actor: { id: "usr_9", username: who } }) as Claim; |
| 95 | |
| 96 | test("each work item's stage is the first that holds: done, landing, review, progress, up next", () => { |
| 97 | assert.equal(stageOf(item(1), {}, NOW), "next"); |
| 98 | assert.equal(stageOf(item(2, { assignees: ["ana"] }), {}, NOW), "progress"); |
| 99 | assert.equal(stageOf(item(3), { claimed: true }, NOW), "progress"); |
| 100 | assert.equal(stageOf(item(4), { live: true }, NOW), "progress"); |
| 101 | assert.equal(stageOf(item(5), { pulls: pr("draft") }, NOW), "progress"); |
| 102 | assert.equal(stageOf(item(6, { assignees: ["ana"] }), { pulls: pr("draft", "open") }, NOW), "review"); |
| 103 | // Landed in one target, not yet the other: Landing, even with a pull request open. |
| 104 | const landing = item(7, { repos: [repo("acme/web", "target", true), repo("acme/api", "target")] }); |
| 105 | assert.equal(stageOf(landing, { pulls: pr("open") }, NOW), "landing"); |
| 106 | // A touched repository's landing is not a target's. |
| 107 | assert.equal(stageOf(item(8, { repos: [repo("acme/web", "touched", true), repo("acme/api", "target")] }), {}, NOW), "next"); |
| 108 | assert.equal(stageOf(item(9, { state: "closed", closedAt: new Date(NOW - 3 * DAY).toISOString() }), {}, NOW), "done"); |
| 109 | // Closed long ago: off the board. |
| 110 | assert.equal(stageOf(item(10, { state: "closed", closedAt: new Date(NOW - 40 * DAY).toISOString() }), {}, NOW), null); |
| 111 | }); |
| 112 | |
| 113 | test("the board puts each item in its column with who and which session is on it", () => { |
| 114 | const items = [ |
| 115 | item(1), |
| 116 | item(2), |
| 117 | item(3), |
| 118 | item(4), |
| 119 | item(5, { state: "closed", closedAt: new Date(NOW - DAY).toISOString() }), |
| 120 | item(6, { state: "closed", closedAt: new Date(NOW - 2 * DAY).toISOString() }), |
| 121 | ]; |
| 122 | const board = boardOf( |
| 123 | items, |
| 124 | { |
| 125 | claims: [claim(2, "cy")], |
| 126 | sessions: [live(3, { pull: 9, pullStatus: "open" }), live(1, { state: "ended" })], |
| 127 | pulls: pullsByItem([ |
| 128 | { repo: "acme/web", number: 20, item: 4, status: "draft" }, |
| 129 | { repo: "acme/web", number: 21, item: null, status: "open" }, |
| 130 | ]), |
| 131 | upNext: [1], |
| 132 | }, |
| 133 | NOW, |
| 134 | ); |
| 135 | assert.deepEqual( |
| 136 | board.next.map((card) => card.item.number), |
| 137 | [1], |
| 138 | ); |
| 139 | assert.deepEqual( |
| 140 | board.progress.map((card) => card.item.number), |
| 141 | [2, 4], |
| 142 | ); |
| 143 | assert.deepEqual(board.progress[0]!.on.people, ["cy"]); |
| 144 | // A live session's open pull request puts its item in review, the session on the card. |
| 145 | assert.deepEqual( |
| 146 | board.review.map((card) => card.item.number), |
| 147 | [3], |
| 148 | ); |
| 149 | assert.deepEqual( |
| 150 | board.review[0]!.on.sessions.map((session) => session.id), |
| 151 | ["ses_3"], |
| 152 | ); |
| 153 | assert.deepEqual(board.review[0]!.on.people, ["bo"]); |
| 154 | // Done: the most recently closed first. |
| 155 | assert.deepEqual( |
| 156 | board.done.map((card) => card.item.number), |
| 157 | [5, 6], |
| 158 | ); |
| 159 | }); |
| 160 | |
| 161 | test("a phone shows the stage asked for, else the first with something in it", () => { |
| 162 | const board = boardOf([item(1, { assignees: ["ana"] })], {}, NOW); |
| 163 | assert.equal(shownStage("review", board), "review"); |
| 164 | assert.equal(shownStage("nonsense", board), "progress"); |
| 165 | assert.equal(shownStage(null, boardOf([], {}, NOW)), "next"); |
| 166 | }); |
| 167 | |
| 168 | test("Up next is bounded by the plan: Now nobody is on, then the top of Next, in the plan's order", () => { |
| 169 | const now = [item(7, { horizon: "now", urgent: true }), item(3, { horizon: "now" })]; |
| 170 | const next = [item(5, { horizon: "next" }), item(9, { horizon: "next" })]; |
| 171 | const plan = { now, next }; |
| 172 | assert.deepEqual(upNextOf(plan), [7, 3, 5, 9]); |
| 173 | assert.deepEqual(upNextOf(null), []); |
| 174 | const items = boardItems( |
| 175 | now, |
| 176 | next, |
| 177 | // The newest open ones: one in Next past its top, one in Triage, one claimed in Later, and Now's 3 again. |
| 178 | [item(12, { horizon: "next" }), item(11), item(10, { horizon: "later" }), item(3, { horizon: "now" })], |
| 179 | ); |
| 180 | assert.deepEqual( |
| 181 | items.map((one) => one.number), |
| 182 | [7, 3, 5, 9, 12, 11, 10], |
| 183 | ); |
| 184 | const board = boardOf(items, { claims: [claim(10, "cy"), claim(5, "bo")], upNext: upNextOf(plan) }, NOW); |
| 185 | // Nobody on 12 or 11: they are in the plan, not on the board. |
| 186 | assert.deepEqual( |
| 187 | board.next.map((card) => card.item.number), |
| 188 | [7, 3, 9], |
| 189 | ); |
| 190 | // Someone on it keeps its stage whatever its horizon. |
| 191 | assert.deepEqual( |
| 192 | board.progress.map((card) => card.item.number), |
| 193 | [5, 10], |
| 194 | ); |
| 195 | }); |
| 196 | |
| 197 | test("an older work item someone is on is read on its own, each once and at most so many", () => { |
| 198 | const read = [{ number: 1 }, { number: 2 }]; |
| 199 | const claims = [claim(2, "ana"), claim(30, "bo"), { ...claim(31, "cy"), state: "released" } as Claim]; |
| 200 | const sessions = [live(30), live(32), live(33, { state: "ended" }), live(0, { item: null })]; |
| 201 | assert.deepEqual(onItNotRead(read, claims, sessions, 10), [30, 32]); |
| 202 | assert.deepEqual(onItNotRead(read, claims, sessions, 1), [30]); |
| 203 | }); |
| 204 | |
| 205 | test("under the board, what of the plan it leaves out", () => { |
| 206 | const plan = { next: [item(5), item(9)], nextTotal: 14, laterTotal: 32, triageTotal: 8 }; |
| 207 | const board = boardOf([item(5), item(9, { assignees: ["ana"] })], { upNext: [5, 9] }, NOW); |
| 208 | // 9 is in progress, so only 5 of Next's 14 is in Up next. |
| 209 | assert.deepEqual(planRest(plan, board), { next: 13, later: 32, triage: 8 }); |
| 210 | assert.equal(planRest(null, board), null); |
| 211 | assert.equal(planRestWords({ next: 13, later: 32, triage: 1_200 }), "13 in Next, 32 in Later and 1,200 in Triage"); |
| 212 | assert.equal(planRestWords({ next: 0, later: 2, triage: 0 }), "2 in Later"); |
| 213 | assert.equal(planRestWords({ next: 0, later: 0, triage: 0 }), null); |
| 214 | }); |
| 215 | |
| 216 | test("the board reads open pull requests in every repository an open work item is linked to, each once", () => { |
| 217 | const items = [ |
| 218 | { state: "open", repos: [{ path: "acme/web" }, { path: "acme/api" }] }, |
| 219 | { state: "open", repos: [{ path: "Acme/Web" }, { path: "acme/repo-21" }] }, |
| 220 | // A closed item's repositories are not read: Done does not need its pull requests. |
| 221 | { state: "closed", repos: [{ path: "acme/old" }] }, |
| 222 | // A repository deleted before work items kept no path. |
| 223 | { state: "open", repos: [{ path: "" }] }, |
| 224 | // One deleted and not yet purged keeps its path, and has nothing to read. |
| 225 | { state: "open", repos: [{ path: "acme/hello", deleted: true }] }, |
| 226 | ]; |
| 227 | assert.deepEqual(pullRepos(items), ["acme/web", "acme/api", "acme/repo-21"]); |
| 228 | // However many there are: none is left out past a limit. |
| 229 | const many = Array.from({ length: 30 }, (_, index) => ({ state: "open", repos: [{ path: `acme/r${index}` }] })); |
| 230 | assert.equal(pullRepos(many).length, 30); |
| 231 | }); |
| 232 | |
| 233 | test("closed as not planned, a duplicate among them, is not Done: it did not ship", () => { |
| 234 | const closed = { state: "closed" as const, closedAt: new Date(NOW - DAY).toISOString() }; |
| 235 | assert.equal(stageOf(item(1, { ...closed, reason: "completed" }), {}, NOW), "done"); |
| 236 | assert.equal(stageOf(item(2, { ...closed, reason: "not_planned" }), {}, NOW), null); |
| 237 | const board = boardOf([item(1, { ...closed, reason: "completed" }), item(2, { ...closed, reason: "not_planned" })], {}, NOW); |
| 238 | assert.deepEqual( |
| 239 | board.done.map((card) => card.item.number), |
| 240 | [1], |
| 241 | ); |
| 242 | }); |
| 243 | |
| 244 | test("a merged pull request keeps an open item on the board: Landing with a target to land, else In progress", () => { |
| 245 | const target = [repo("acme/web", "origin"), repo("acme/api", "target")]; |
| 246 | // It closes the item and merged, but not in the target: still to land there. |
| 247 | assert.equal(stageOf(item(1, { repos: target }), { pulls: pr("merged") }, NOW), "landing"); |
| 248 | // Only part of it merged: more work to come. |
| 249 | assert.equal(stageOf(item(2, { repos: target }), { pulls: pr("part:merged") }, NOW), "progress"); |
| 250 | // No target left to land, still open (merged into another branch, or reopened). |
| 251 | assert.equal(stageOf(item(3), { pulls: pr("merged") }, NOW), "progress"); |
| 252 | // An open pull request beside a merged part: In review. |
| 253 | assert.equal(stageOf(item(4), { pulls: pr("part:merged", "open") }, NOW), "review"); |
| 254 | // Nothing merged or open: where its claims put it. |
| 255 | assert.equal(stageOf(item(5, { repos: target }), { pulls: pr("closed") }, NOW), "next"); |
| 256 | }); |
| 257 | |
| 258 | test("a pull request counts for its item, the items it closes and those it is part of, once each", () => { |
| 259 | const by = pullsByItem([ |
| 260 | { repo: "acme/web", number: 158, item: 7, status: "merged", closes: [7, 8], parts: [9, 8] }, |
| 261 | { repo: "Acme/Web", number: 158, item: 7, status: "merged", closes: [7] }, |
| 262 | { repo: "acme/api", number: 3, item: null, status: "open", parts: [9] }, |
| 263 | ]); |
| 264 | assert.deepEqual(by[7], [{ key: "acme/web#158", status: "merged", part: false }]); |
| 265 | assert.deepEqual(by[8], [{ key: "acme/web#158", status: "merged", part: false }]); |
| 266 | assert.deepEqual(by[9], [ |
| 267 | { key: "acme/web#158", status: "merged", part: true }, |
| 268 | { key: "acme/api#3", status: "open", part: true }, |
| 269 | ]); |
| 270 | }); |
| 271 | |
| 272 | test("a card says how many of its pull requests merged, counting a live session's once", () => { |
| 273 | const items = [item(1), item(2, { state: "closed", closedAt: new Date(NOW).toISOString() })]; |
| 274 | const pulls = pullsByItem([ |
| 275 | { repo: "acme/web", number: 30, item: 1, status: "merged" }, |
| 276 | { repo: "acme/web", number: 31, item: null, status: "merged", parts: [1] }, |
| 277 | { repo: "acme/web", number: 32, item: 2, status: "merged" }, |
| 278 | ]); |
| 279 | const board = boardOf(items, { sessions: [live(1, { pull: 30, pullStatus: "merged" })], pulls }, NOW); |
| 280 | assert.equal(board.progress[0]!.merged, 2); |
| 281 | // A closed one's are not counted: it is Done. |
| 282 | assert.equal(board.done[0]!.merged, 0); |
| 283 | }); |
| 284 | |
| 285 | test("every page is read, each below the last one's lowest number, up to a ceiling the board then names", async () => { |
| 286 | const numbers = Array.from({ length: 250 }, (_, index) => ({ number: 250 - index })); |
| 287 | const asked: (number | undefined)[] = []; |
| 288 | const read = async (before: number | undefined) => { |
| 289 | asked.push(before); |
| 290 | return numbers.filter((row) => before == null || row.number < before).slice(0, 100); |
| 291 | }; |
| 292 | const all = await readPages(read, 100, 10); |
| 293 | assert.equal(all.rows.length, 250); |
| 294 | assert.equal(all.capped, false); |
| 295 | assert.deepEqual(asked, [undefined, 151, 51]); |
| 296 | const some = await readPages(read, 100, 2); |
| 297 | assert.equal(some.rows.length, 200); |
| 298 | assert.equal(some.capped, true); |
| 299 | // An exact page then nothing: one more read says it is the end. |
| 300 | const exact = await readPages(async (before) => (before == null ? numbers.slice(0, 100) : []), 100, 10); |
| 301 | assert.deepEqual([exact.rows.length, exact.capped], [100, false]); |
| 302 | }); |
| 303 | |
| 304 | test("what the board read only in part, it says", () => { |
| 305 | assert.deepEqual(cappedNotes({ items: null, done: null, pulls: [] }), []); |
| 306 | assert.deepEqual(cappedNotes({ items: 200, done: 100, pulls: ["acme/web"] }), [ |
| 307 | "Past the newest 200 open work items, the board shows only those in the plan's Now or Next, claimed, or with a live session.", |
| 308 | "Done shows the 100 most recently closed.", |
| 309 | "Only the newest pull requests in acme/web were read, so some work items may stand a column early.", |
| 310 | ]); |
| 311 | assert.match(cappedNotes({ items: null, done: null, pulls: ["acme/web", "acme/api"] })[0]!, /in 2 repositories/); |
| 312 | }); |