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.

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