| 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 { boardOf, pullsByItem, 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 | const claim = (number: number, who: string): Claim => |
| 68 | ({ id: `clm_${number}`, item: number, workspace: "acme", state: "active", actor: { id: "usr_9", username: who } }) as Claim; |
| 69 | |
| 70 | test("each work item's stage is the first that holds: done, landing, review, progress, backlog", () => { |
| 71 | assert.equal(stageOf(item(1), {}, NOW), "backlog"); |
| 72 | assert.equal(stageOf(item(2, { assignees: ["ana"] }), {}, NOW), "progress"); |
| 73 | assert.equal(stageOf(item(3), { claimed: true }, NOW), "progress"); |
| 74 | assert.equal(stageOf(item(4), { live: true }, NOW), "progress"); |
| 75 | assert.equal(stageOf(item(5), { pulls: ["draft"] }, NOW), "progress"); |
| 76 | assert.equal(stageOf(item(6, { assignees: ["ana"] }), { pulls: ["draft", "open"] }, NOW), "review"); |
| 77 | // Landed in one target, not yet the other: Landing, even with a pull request open. |
| 78 | const landing = item(7, { repos: [repo("acme/web", "target", true), repo("acme/api", "target")] }); |
| 79 | assert.equal(stageOf(landing, { pulls: ["open"] }, NOW), "landing"); |
| 80 | // A touched repository's landing is not a target's. |
| 81 | assert.equal(stageOf(item(8, { repos: [repo("acme/web", "touched", true), repo("acme/api", "target")] }), {}, NOW), "backlog"); |
| 82 | assert.equal(stageOf(item(9, { state: "closed", closedAt: new Date(NOW - 3 * DAY).toISOString() }), {}, NOW), "done"); |
| 83 | // Closed long ago: off the board. |
| 84 | assert.equal(stageOf(item(10, { state: "closed", closedAt: new Date(NOW - 40 * DAY).toISOString() }), {}, NOW), null); |
| 85 | }); |
| 86 | |
| 87 | test("the board puts each item in its column with who and which session is on it", () => { |
| 88 | const items = [ |
| 89 | item(1), |
| 90 | item(2), |
| 91 | item(3), |
| 92 | item(4), |
| 93 | item(5, { state: "closed", closedAt: new Date(NOW - DAY).toISOString() }), |
| 94 | item(6, { state: "closed", closedAt: new Date(NOW - 2 * DAY).toISOString() }), |
| 95 | ]; |
| 96 | const board = boardOf( |
| 97 | items, |
| 98 | { claims: [claim(2, "cy")], sessions: [live(3, { pull: 9, pullStatus: "open" }), live(1, { state: "ended" })], pulls: pullsByItem([{ item: 4, status: "draft" }, { item: null, status: "open" }]) }, |
| 99 | NOW, |
| 100 | ); |
| 101 | assert.deepEqual(board.backlog.map((card) => card.item.number), [1]); |
| 102 | assert.deepEqual(board.progress.map((card) => card.item.number), [2, 4]); |
| 103 | assert.deepEqual(board.progress[0]!.on.people, ["cy"]); |
| 104 | // A live session's open pull request puts its item in review, the session on the card. |
| 105 | assert.deepEqual(board.review.map((card) => card.item.number), [3]); |
| 106 | assert.deepEqual(board.review[0]!.on.sessions.map((session) => session.id), ["ses_3"]); |
| 107 | assert.deepEqual(board.review[0]!.on.people, ["bo"]); |
| 108 | // Done: the most recently closed first. |
| 109 | assert.deepEqual(board.done.map((card) => card.item.number), [5, 6]); |
| 110 | }); |
| 111 | |
| 112 | test("a phone shows the stage asked for, else the first with something in it", () => { |
| 113 | const board = boardOf([item(1, { assignees: ["ana"] })], {}, NOW); |
| 114 | assert.equal(shownStage("review", board), "review"); |
| 115 | assert.equal(shownStage("nonsense", board), "progress"); |
| 116 | assert.equal(shownStage(null, boardOf([], {}, NOW)), "backlog"); |
| 117 | }); |