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.

296 linesCodeBlameRaw
1import assert from "node:assert/strict";
2import { test } from "node:test";
3
4import type { Claim, WorkItem, WorkspaceSession } from "@g1t/contracts";
5
6import {
7 type PlanView,
8 acceptForm,
9 allItemsPath,
10 applyMove,
11 blockersToRead,
12 budgetText,
13 canAccept,
14 capState,
15 daysSince,
16 duplicateComment,
17 dropPosition,
18 hillStep,
19 listRedirect,
20 menuHorizons,
21 moreText,
22 moveDown,
23 moveForm,
24 moveFrom,
25 moveUp,
26 onItems,
27 parseBudget,
28 planInput,
29 planPath,
30 progressOf,
31 quietLine,
32 quietText,
33 setAsideLine,
34 setAsidePath,
35 suggestionText,
36 triageLine,
37 triagePath,
38 waitingLabel,
39 waitingOn,
40} from "./plan.ts";
41
42function item(number: number, more: Partial<WorkItem> = {}): WorkItem {
43 return {
44 id: `wki_${number}`,
45 workspace: "acme",
46 number,
47 title: `Item ${number}`,
48 body: "",
49 labels: [],
50 state: "open",
51 reason: null,
52 resolutions: [],
53 author: { id: "usr_1", username: "ana" } as WorkItem["author"],
54 requestedBy: null,
55 createdAt: "2026-10-01T00:00:00Z",
56 updatedAt: "2026-10-01T00:00:00Z",
57 closedAt: null,
58 pullCount: 0,
59 commentCount: 0,
60 assignees: [],
61 blockedBy: [],
62 agent: null,
63 repos: [],
64 horizon: "next",
65 appetite: null,
66 budgetUsd: null,
67 urgent: false,
68 plannedAt: null,
69 ...more,
70 };
71}
72
73function view(more: Partial<PlanView> = {}): PlanView {
74 return {
75 now: [item(1, { horizon: "now" }), item(2, { horizon: "now" })],
76 next: [item(3), item(4), item(5)],
77 nextTotal: 3,
78 laterTotal: 4,
79 later: [
80 { repo: "acme/web", count: 3 },
81 { repo: null, count: 1 },
82 ],
83 setAsideTotal: 0,
84 triageTotal: 2,
85 ...more,
86 };
87}
88
89const numbers = (items: WorkItem[]) => items.map((one) => one.number);
90
91test("the Plan, Triage and All items have their own addresses", () => {
92 assert.equal(planPath("Acme"), "/acme/-/work");
93 assert.equal(planPath("acme", "all=1"), "/acme/-/work?all=1");
94 assert.equal(triagePath("acme"), "/acme/-/work/triage");
95 assert.equal(allItemsPath("acme", "state=closed"), "/acme/-/work/all?state=closed");
96 assert.equal(setAsidePath("acme"), "/acme/-/work/all?q=horizon%3Aset_aside");
97});
98
99test("an old link to the list with filters goes to All items with the same query", () => {
100 const at = (query: string) => listRedirect("acme", new URLSearchParams(query));
101 assert.equal(at(""), null);
102 assert.equal(at("all=1"), null);
103 assert.equal(at("_routes=x"), null);
104 assert.equal(at("state=closed"), "/acme/-/work/all?state=closed");
105 assert.equal(at("repo=web&label=bug"), "/acme/-/work/all?repo=web&label=bug");
106 assert.equal(at("q=is%3Aclosed+label%3Abug"), "/acme/-/work/all?q=is%3Aclosed+label%3Abug");
107 assert.equal(at("assignee=ana"), "/acme/-/work/all?assignee=ana");
108 assert.equal(at("milestone=3&_routes=x"), "/acme/-/work/all?milestone=3");
109});
110
111test("Now's cap says how full it is and warns only past it", () => {
112 assert.deepEqual(capState(3, 6), { count: 3, cap: 6, share: 0.5, full: false, over: false });
113 assert.deepEqual(capState(6, 6), { count: 6, cap: 6, share: 1, full: true, over: false });
114 assert.deepEqual(capState(8, 6), { count: 8, cap: 6, share: 1, full: true, over: true });
115 assert.equal(capState(0, 0).cap, 1);
116});
117
118test("a drop lands before the row under it, or after the last", () => {
119 const list = [3, 4, 5];
120 // From outside the list.
121 assert.deepEqual(dropPosition(list, 0, 9), { before: 3 });
122 assert.deepEqual(dropPosition(list, 2, 9), { before: 5 });
123 assert.deepEqual(dropPosition(list, 3, 9), { after: 5 });
124 assert.deepEqual(dropPosition([], 0, 9), {});
125 // Within the list: where it is already is no move.
126 assert.equal(dropPosition(list, 0, 3), null);
127 assert.equal(dropPosition(list, 1, 3), null);
128 assert.deepEqual(dropPosition(list, 3, 3), { after: 5 });
129 assert.deepEqual(dropPosition(list, 2, 3), { before: 5 });
130 assert.deepEqual(dropPosition(list, 0, 5), { before: 3 });
131 assert.deepEqual(dropPosition(list, 1, 5), { before: 4 });
132 assert.equal(dropPosition([3], 1, 3), null);
133});
134
135test("moving up and down is before the one above, after the one below", () => {
136 assert.deepEqual(moveUp([3, 4, 5], 4), { before: 3 });
137 assert.equal(moveUp([3, 4, 5], 3), null);
138 assert.deepEqual(moveDown([3, 4, 5], 4), { after: 5 });
139 assert.equal(moveDown([3, 4, 5], 5), null);
140 assert.equal(moveDown([3, 4, 5], 9), null);
141});
142
143test("a move shows at once where it lands", () => {
144 // Within Next.
145 assert.deepEqual(numbers(applyMove(view(), { number: 5, horizon: "next", top: true }).next), [5, 3, 4]);
146 assert.deepEqual(numbers(applyMove(view(), { number: 3, horizon: "next", after: 4 }).next), [4, 3, 5]);
147 // From Next into Now, before a row.
148 const into = applyMove(view(), { number: 4, horizon: "now", before: 2 });
149 assert.deepEqual(numbers(into.now), [1, 4, 2]);
150 assert.equal(into.now[1]!.horizon, "now");
151 assert.deepEqual(numbers(into.next), [3, 5]);
152 assert.equal(into.nextTotal, 2);
153 // Out of Now to the bottom of Next, all of which is shown.
154 const out = applyMove(view(), { number: 1, horizon: "next" });
155 assert.deepEqual(numbers(out.next), [3, 4, 5, 1]);
156 assert.equal(out.nextTotal, 4);
157 // To the bottom of a Next that shows only its top: only counted.
158 const deep = applyMove(view({ nextTotal: 30 }), { number: 1, horizon: "next" });
159 assert.deepEqual(numbers(deep.next), [3, 4, 5]);
160 assert.equal(deep.nextTotal, 31);
161 // To Later: counted in its group.
162 const later = applyMove(view(), { number: 3, horizon: "later" });
163 assert.deepEqual(numbers(later.next), [4, 5]);
164 assert.equal(later.laterTotal, 5);
165 assert.deepEqual(later.later, [
166 { repo: "acme/web", count: 3 },
167 { repo: null, count: 2 },
168 ]);
169 const filed = item(6, { repos: [{ repoId: "rep_1", path: "acme/api", role: "origin", legacyNumber: null, landed: false }] });
170 assert.deepEqual(applyMove(view({ next: [filed], nextTotal: 1 }), { number: 6, horizon: "later" }).later.at(-1), { repo: "acme/api", count: 1 });
171 // From an open Later group into Next: it leaves Later's count and its group.
172 const fromLater = item(7, { horizon: "later" });
173 const pulled = applyMove(view(), { number: 7, horizon: "next", before: 4 }, fromLater);
174 assert.deepEqual(numbers(pulled.next), [3, 7, 4, 5]);
175 assert.equal(pulled.laterTotal, 3);
176 assert.deepEqual(pulled.later, [{ repo: "acme/web", count: 3 }]);
177 const same = view();
178 assert.equal(applyMove(same, { number: 7, horizon: "later" }, fromLater), same);
179 // Set aside and Triage.
180 assert.equal(applyMove(view(), { number: 3, horizon: "set_aside" }).setAsideTotal, 1);
181 assert.equal(applyMove(view(), { number: 2, horizon: "triage" }).triageTotal, 3);
182});
183
184test("a row's menu offers Now only to who may move work into or out of it", () => {
185 assert.deepEqual(menuHorizons("next", true), ["triage", "now", "later", "set_aside"]);
186 assert.deepEqual(menuHorizons("next", false), ["triage", "later", "set_aside"]);
187 assert.deepEqual(menuHorizons("now", false), []);
188 assert.deepEqual(menuHorizons("now", true), ["triage", "next", "later", "set_aside"]);
189});
190
191test("who is on an item: assignees, claimants and live sessions, each once", () => {
192 const claim = { item: 3, state: "active", actor: { id: "usr_2", username: "bo" } } as Claim;
193 const ended = { item: 3, state: "released", actor: { id: "usr_3", username: "cy" } } as Claim;
194 const live = { id: "ses_1", item: 3, state: "active", actor: "ana" } as WorkspaceSession;
195 const gone = { id: "ses_2", item: 3, state: "ended", actor: "di" } as WorkspaceSession;
196 const on = onItems([item(3, { assignees: ["ana"] }), item(4)], [claim, ended], [live, gone]);
197 assert.deepEqual(on[3]!.people, ["ana", "bo"]);
198 assert.deepEqual(on[3]!.sessions, [live]);
199 assert.deepEqual(on[4], { people: [], sessions: [] });
200});
201
202test("what an item waits on", () => {
203 const items = [item(3, { blockedBy: [1, 7] }), item(4, { blockedBy: [7, 8] })];
204 assert.deepEqual(blockersToRead(items, new Set([1])), [7, 8]);
205 assert.deepEqual(blockersToRead(items, new Set(), 2), [1, 7]);
206 assert.deepEqual(waitingOn(items[1]!, new Set([8])), [7]);
207 assert.equal(waitingLabel([7, 8]), "waiting on #7, #8");
208});
209
210test("a move from a form", () => {
211 const form = (fields: Record<string, string>) => {
212 const data = new FormData();
213 for (const [key, value] of Object.entries(fields)) data.set(key, value);
214 return data;
215 };
216 assert.deepEqual(planInput(form({ horizon: "now", before: "4" })), { horizon: "now", before: 4 });
217 assert.deepEqual(planInput(form({ horizon: "next", after: "9" })), { horizon: "next", after: 9 });
218 assert.deepEqual(planInput(form({ horizon: "next", top: "1", before: "4" })), { horizon: "next", top: true });
219 assert.deepEqual(planInput(form({ appetite: "none" })), { appetite: null });
220 assert.deepEqual(planInput(form({ appetite: "medium", budget: "$1,200" })), { appetite: "medium", budget_usd: 1200 });
221 assert.deepEqual(planInput(form({ budget: "" })), { budget_usd: null });
222 assert.deepEqual(planInput(form({ urgent: "1" })), { urgent: true });
223 assert.deepEqual(planInput(form({ urgent: "0" })), { urgent: false });
224 assert.equal(typeof planInput(form({ horizon: "someday" })), "string");
225 assert.equal(typeof planInput(form({ before: "x" })), "string");
226 assert.equal(typeof planInput(form({ appetite: "huge" })), "string");
227 // And back: a move as its fetcher posts it.
228 assert.deepEqual(moveForm({ number: 4, horizon: "now", before: 2 }), { action: "plan", number: "4", horizon: "now", before: "2" });
229 assert.deepEqual(moveForm({ number: 4, horizon: "next", top: true }), { action: "plan", number: "4", horizon: "next", top: "1" });
230 assert.deepEqual(moveForm({ number: 4, horizon: "later" }), { action: "plan", number: "4", horizon: "later" });
231 assert.deepEqual(moveFrom(form(moveForm({ number: 4, horizon: "now", before: 2 }))), { number: 4, horizon: "now", before: 2 });
232 assert.deepEqual(moveFrom(form(moveForm({ number: 4, horizon: "next", after: 9 }))), { number: 4, horizon: "next", after: 9 });
233 assert.deepEqual(moveFrom(form(moveForm({ number: 4, horizon: "next", top: true }))), { number: 4, horizon: "next", top: true });
234 assert.deepEqual(moveFrom(form({ number: "4", horizon: "later" })), { number: 4, horizon: "later" });
235 assert.equal(moveFrom(form({ number: "4", appetite: "small" })), null);
236});
237
238test("budgets", () => {
239 assert.equal(parseBudget("40"), 40);
240 assert.equal(parseBudget(" $12.345 "), 12.35);
241 assert.equal(parseBudget(""), null);
242 assert.equal(typeof parseBudget("0"), "string");
243 assert.equal(typeof parseBudget("-5"), "string");
244 assert.equal(typeof parseBudget("200000"), "string");
245 assert.equal(typeof parseBudget("ten"), "string");
246 assert.equal(budgetText(40), "$40");
247 assert.equal(budgetText(12.5), "$12.50");
248 assert.equal(budgetText(1200), "$1,200");
249});
250
251test("n more", () => {
252 assert.equal(moreText(10, 10), null);
253 assert.equal(moreText(10, 1234), "1,224 more");
254});
255
256test("a hill's stage, in its four steps", () => {
257 assert.deepEqual((["figuring_out", "decided", "building", "landing"] as const).map(hillStep), [0, 1, 2, 3]);
258});
259
260test("a quiet row says for how long, from the last time anything happened on it", () => {
261 const now = Date.parse("2026-10-19T12:00:00Z");
262 assert.equal(daysSince("2026-10-13T11:00:00Z", now), 6);
263 assert.equal(daysSince(null, now), null);
264 assert.equal(daysSince("2026-10-20T00:00:00Z", now), 0);
265 assert.equal(quietText({ quiet: true, activeAt: "2026-10-13T11:00:00Z" }, now), "Quiet for 6 days");
266 assert.equal(quietText({ quiet: true, activeAt: "2026-10-18T11:00:00Z" }, now), "Quiet for 1 day");
267 assert.equal(quietText({ quiet: true, activeAt: null }, now), "Quiet");
268 assert.equal(quietText({ quiet: false, activeAt: "2026-10-01T00:00:00Z" }, now), null);
269});
270
271test("the week: what went quiet, what was let go, and Triage waiting", () => {
272 assert.equal(quietLine(0), null);
273 assert.equal(quietLine(1), "1 in Now has gone quiet, with nothing happening on it for 5 days or more.");
274 assert.equal(quietLine(3), "3 in Now have gone quiet, with nothing happening on them for 5 days or more.");
275 assert.equal(setAsideLine(0), null);
276 assert.equal(setAsideLine(4), "4 set aside in the last 30 days, untouched for 90 days in Later");
277 assert.equal(triageLine(1, 0), "1 new work item waits in Triage to be placed.");
278 assert.equal(triageLine(5, 2), "5 new work items wait in Triage to be placed. 2 of them have waited more than a week.");
279 assert.equal(triageLine(3, 3), "3 new work items wait in Triage to be placed. All have waited more than a week.");
280 assert.equal(triageLine(1, 1), "1 new work item waits in Triage to be placed. It has waited more than a week.");
281 assert.deepEqual(progressOf([{ number: 4, hill: "building" }]), { 4: { number: 4, hill: "building" } });
282});
283
284test("accepting a suggestion moves it where it says, at the bottom, with its appetite", () => {
285 const next = { horizon: "next" as const, appetite: "medium" as const };
286 assert.equal(suggestionText(next), "Next · M");
287 assert.equal(suggestionText({ horizon: "later", appetite: null }), "Later");
288 assert.deepEqual(acceptForm(next), { horizon: "next", appetite: "medium" });
289 assert.deepEqual(acceptForm({ horizon: "later", appetite: null }), { horizon: "later" });
290 // It is a plan move as the form posts it: no position, so the horizon puts it at the bottom.
291 assert.deepEqual(planInput(new URLSearchParams(acceptForm(next))), { horizon: "next", appetite: "medium" });
292 assert.equal(canAccept({ horizon: "now" }, false), false);
293 assert.equal(canAccept({ horizon: "now" }, true), true);
294 assert.equal(canAccept({ horizon: "next" }, false), true);
295 assert.equal(duplicateComment(12), "Duplicate of #12");
296});