Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

312 linesCodeBlameRaw
1import assert from "node:assert/strict";
2import { test } from "node:test";
3
4import type { Claim, ItemRepo, WorkItem, WorkspaceSession } from "@g1t/contracts";
5
6import {
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
22const NOW = Date.parse("2026-10-17T12:00:00Z");
23const DAY = 86_400_000;
24
25function repo(path: string, role: ItemRepo["role"], landed = false): ItemRepo {
26 return { repoId: `rep_${path}`, path, role, legacyNumber: null, landed };
27}
28
29function 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
60function 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`. */
87const 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
93const 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
96test("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
113test("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
161test("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
168test("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
197test("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
205test("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
216test("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
233test("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
244test("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
258test("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
272test("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
285test("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
304test("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});