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