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.

314 linesCodeBlameRaw
1/**
2 * Work's Plan: what the workspace is on now, what comes next and what can
3 * wait, and the moves that change it. Where its pages are, how a drop or
4 * a menu's move becomes a position (`before`, `after` or `top`), what a
5 * move looks like before the work service answers, and how full Now is.
6 * Pure, so it is tested under Node.
7 */
8import type { Appetite, Claim, Horizon, Plan, PlanGroup, PlanWorkItemInput, WorkItem, WorkspaceSession } from "@g1t/contracts";
9
10import { workPath } from "./work-items.ts";
11
12// The contracts' `HORIZONS` and `APPETITES`, here so the tests run under
13// Node, which imports only types from @g1t/contracts.
14
15/** Every horizon, in the order work moves through them. */
16export const HORIZONS: readonly Horizon[] = ["triage", "now", "next", "later", "set_aside"];
17/** Every appetite, smallest first. */
18export const APPETITES: readonly Appetite[] = ["small", "medium", "large"];
19
20/** The Plan, Work's first page. */
21export function planPath(workspace: string, query = ""): string {
22 return `${workPath(workspace)}${query ? `?${query}` : ""}`;
23}
24
25/** Triage: new work items nobody has placed yet. */
26export function triagePath(workspace: string): string {
27 return workPath(workspace, "triage");
28}
29
30/** All items: the Work list, with its filters (`state=closed&label=bug`). */
31export function allItemsPath(workspace: string, query = ""): string {
32 return `${workPath(workspace, "all")}${query ? `?${query}` : ""}`;
33}
34
35/** The All items list of what is set aside. */
36export function setAsidePath(workspace: string): string {
37 return allItemsPath(workspace, new URLSearchParams({ q: "horizon:set_aside" }).toString());
38}
39
40/**
41 * The query parameters that belong to the Work list (lib/work-items.ts
42 * `workFilters`, and the paging and sort it keeps): `-/work` with any of
43 * them is an old link to the list, which is at `-/work/all` now.
44 */
45export const LIST_PARAMS = ["q", "label", "milestone", "assignee", "state", "repo", "role", "author", "horizon", "before", "sort", "page"] as const;
46
47/** Where `-/work` with this query leads instead of the Plan: the list with the same query, or null to show the Plan. */
48export function listRedirect(workspace: string, query: URLSearchParams): string | null {
49 const kept = new URLSearchParams(query);
50 kept.delete("_routes");
51 kept.delete("index");
52 if (!LIST_PARAMS.some((key) => kept.has(key))) return null;
53 return allItemsPath(workspace, kept.toString());
54}
55
56/** How full Now is: its share of the cap (at most 1, for the bar) and whether it is at or past it. */
57export type CapState = { count: number; cap: number; share: number; full: boolean; over: boolean };
58
59export function capState(count: number, cap: number): CapState {
60 const limit = Math.max(1, cap);
61 return { count, cap: limit, share: Math.min(1, count / limit), full: count >= limit, over: count > limit };
62}
63
64/**
65 * Where a move puts a work item among another horizon's (or its own)
66 * items: before or after another, first, or (none set) where the horizon
67 * puts it. At most one is set.
68 */
69export type Position = { before?: number; after?: number; top?: true };
70
71/**
72 * The position for dropping `dragged` into `list` (the numbers as shown,
73 * top first, which may include `dragged` itself) at `index`, the gap it
74 * lands in (0 is above the first, `list.length` below the last): before
75 * the row it lands above, else after the last, else none (an empty list,
76 * where the horizon alone says where). Null when it lands where it is.
77 */
78export function dropPosition(list: readonly number[], index: number, dragged: number): Position | null {
79 const from = list.indexOf(dragged);
80 if (from !== -1 && (index === from || index === from + 1)) return null;
81 const rest = list.filter((number) => number !== dragged);
82 const at = from !== -1 && from < index ? index - 1 : index;
83 const clamped = Math.max(0, Math.min(at, rest.length));
84 if (clamped < rest.length) return { before: rest[clamped]! };
85 if (rest.length > 0) return { after: rest[rest.length - 1]! };
86 return {};
87}
88
89/** Moving `number` one place up in `list`: before the one above it. Null at the top, or when it is not there. */
90export function moveUp(list: readonly number[], number: number): Position | null {
91 const at = list.indexOf(number);
92 return at > 0 ? { before: list[at - 1]! } : null;
93}
94
95/** Moving `number` one place down in `list`: after the one below it. Null at the bottom, or when it is not there. */
96export function moveDown(list: readonly number[], number: number): Position | null {
97 const at = list.indexOf(number);
98 return at !== -1 && at < list.length - 1 ? { after: list[at + 1]! } : null;
99}
100
101/** A move in the plan, as a row's menu, a drop or Triage's buttons make it. */
102export type Move = { number: number; horizon: Horizon } & Position;
103
104/** What the Plan page shows: the plan, as `getPlan` read it. */
105export type PlanView = Pick<Plan, "now" | "next" | "nextTotal" | "laterTotal" | "later" | "setAsideTotal" | "triageTotal">;
106
107/**
108 * The plan as it will be once `move` is made, to show before the work
109 * service answers: the item leaves wherever it was and goes where the
110 * move says, on Now or Next (or, past the top of Next shown, only into
111 * its count), or into the count of the horizon it went to.
112 */
113export function applyMove(view: PlanView, move: Move, from?: WorkItem): PlanView {
114 const found = view.now.find((item) => item.number === move.number) ?? view.next.find((item) => item.number === move.number) ?? from;
115 const now = view.now.filter((item) => item.number !== move.number);
116 const next = view.next.filter((item) => item.number !== move.number);
117 const out: PlanView = { ...view, now, next };
118 if (found?.horizon === move.horizon && !view.now.includes(found) && !view.next.includes(found)) return view;
119 if (found?.horizon === "next") out.nextTotal -= 1;
120 if (found?.horizon === "later") {
121 out.laterTotal -= 1;
122 out.later = bump(out.later, originPath(found), -1);
123 }
124 if (found?.horizon === "triage") out.triageTotal -= 1;
125 if (move.horizon === "next") out.nextTotal += 1;
126 if (move.horizon === "later") {
127 out.laterTotal += 1;
128 if (found) out.later = bump(out.later, originPath(found), 1);
129 }
130 if (move.horizon === "set_aside") out.setAsideTotal += 1;
131 if (move.horizon === "triage") out.triageTotal += 1;
132 if (!found || (move.horizon !== "now" && move.horizon !== "next")) return out;
133 const moved: WorkItem = { ...found, horizon: move.horizon };
134 const list = move.horizon === "now" ? now : next;
135 // Next shows only its top: one placed below all of it that is shown is only counted.
136 const whole = move.horizon === "now" || list.length >= out.nextTotal - 1;
137 let at = -1;
138 if (move.top) at = 0;
139 else if (move.before != null) at = list.findIndex((item) => item.number === move.before);
140 else if (move.after != null) {
141 const below = list.findIndex((item) => item.number === move.after);
142 at = below === -1 ? -1 : below + 1;
143 }
144 if (at !== -1) list.splice(at, 0, moved);
145 else if (whole) list.push(moved);
146 return out;
147}
148
149/** Later's groups with `by` more (or fewer) in `repo`'s; a group left with none goes. */
150function bump(groups: readonly PlanGroup[], repo: string | null, by: number): PlanGroup[] {
151 const same = (group: PlanGroup) => (group.repo ?? "").toLowerCase() === (repo ?? "").toLowerCase();
152 if (!groups.some(same)) return by > 0 ? [...groups, { repo, count: by }] : [...groups];
153 return groups.map((group) => (same(group) ? { ...group, count: group.count + by } : group)).filter((group) => group.count > 0);
154}
155
156/** The move a form posts (`moveForm`'s fields), or null when it is not one. */
157export function moveFrom(form: Fields): Move | null {
158 const number = Number(form.get("number"));
159 const horizon = HORIZONS.find((one) => one === form.get("horizon"));
160 if (!Number.isInteger(number) || number < 1 || !horizon) return null;
161 if (form.get("top") === "1") return { number, horizon, top: true };
162 const before = Number(form.get("before"));
163 if (form.has("before") && before > 0) return { number, horizon, before };
164 const after = Number(form.get("after"));
165 if (form.has("after") && after > 0) return { number, horizon, after };
166 return { number, horizon };
167}
168
169/** The repository a work item is filed in (its origin), by path; null for one filed in the workspace. */
170export function originPath(item: Pick<WorkItem, "repos">): string | null {
171 return item.repos.find((repo) => repo.role === "origin")?.path || null;
172}
173
174/** The moves a row's menu offers, given where it is and whether the viewer may move work into or out of Now. */
175export function menuHorizons(horizon: Horizon, canNow: boolean): Horizon[] {
176 return HORIZONS.filter((one) => one !== horizon && (canNow || (one !== "now" && horizon !== "now")));
177}
178
179/** Who or what is on a work item: people (assignees, claimants, whoever runs a live session on it), and its live sessions. */
180export type OnIt = { people: string[]; sessions: WorkspaceSession[] };
181
182/** Who is on each of `items`, by number, from the workspace's active claims and live sessions, as the board says. */
183export function onItems(items: readonly WorkItem[], claims: readonly Claim[], sessions: readonly WorkspaceSession[]): Record<number, OnIt> {
184 const on: Record<number, OnIt> = {};
185 for (const item of items) {
186 const claimed = claims.filter((claim) => claim.item === item.number && claim.state === "active");
187 const live = sessions.filter((session) => session.item === item.number && session.state !== "ended");
188 const people = [...item.assignees, ...claimed.map((claim) => claim.actor.username), ...live.flatMap((session) => (session.actor ? [session.actor] : []))];
189 on[item.number] = { people: [...new Set(people)], sessions: live };
190 }
191 return on;
192}
193
194/**
195 * The work items that `items` wait on and whose state is not known from
196 * `open` (the open ones already read), each once, the first `max`: the
197 * ones to look up.
198 */
199export function blockersToRead(items: readonly Pick<WorkItem, "blockedBy">[], open: ReadonlySet<number>, max = 20): number[] {
200 const wanted = new Set<number>();
201 for (const item of items) for (const number of item.blockedBy) if (!open.has(number)) wanted.add(number);
202 return [...wanted].slice(0, max);
203}
204
205/** What an item waits on: the work items it is blocked by that are not known to be closed. */
206export function waitingOn(item: Pick<WorkItem, "blockedBy">, closed: ReadonlySet<number>): number[] {
207 return item.blockedBy.filter((number) => !closed.has(number));
208}
209
210/** "waiting on #12", "waiting on #12, #14". */
211export function waitingLabel(numbers: readonly number[]): string {
212 return `waiting on ${numbers.map((number) => `#${number}`).join(", ")}`;
213}
214
215/** What each appetite means, in a few words. */
216export const APPETITE_HINTS: Record<Appetite, string> = {
217 small: "Small: one session",
218 medium: "Medium: a few sessions, about a week",
219 large: "Large: a phase, broken into smaller items before it moves to Now",
220};
221
222/** The appetites at once, for a hint beside a choice of them. */
223export const APPETITE_SUMMARY = "S: one session. M: a few sessions, about a week. L: a phase, broken down before it moves to Now.";
224
225/** What each horizon is for, in a few words. */
226export const HORIZON_HINTS: Record<Horizon, string> = {
227 triage: "New, and not placed yet",
228 now: "What the workspace is on",
229 next: "Ranked: the top is taken first",
230 later: "Wanted, not ranked",
231 set_aside: "Out of every view but search",
232};
233
234/** The most a budget may be, in dollars, as the work service allows. */
235export const MAX_BUDGET_USD = 100_000;
236
237/** A form's fields, as `FormData` has them. */
238type Fields = { get(name: string): FormDataEntryValue | null; has(name: string): boolean };
239
240/**
241 * A move in the plan from a form: `horizon`, then one of `before`, `after`
242 * (work item numbers) or `top=1`; `appetite` (`small`, `medium`, `large`,
243 * or `none` to clear it); `budget` in dollars (empty clears it); `urgent`
244 * (`1` or `0`). Only the fields given change. A string when the form says
245 * something the plan cannot take.
246 */
247export function planInput(form: Fields): PlanWorkItemInput | string {
248 const input: PlanWorkItemInput = {};
249 if (form.has("horizon")) {
250 const horizon = HORIZONS.find((one) => one === form.get("horizon"));
251 if (!horizon) return "Choose where it goes: Triage, Now, Next, Later or Set aside.";
252 input.horizon = horizon;
253 }
254 const number = (name: string) => {
255 const value = Number(form.get(name));
256 return Number.isInteger(value) && value > 0 ? value : null;
257 };
258 if (form.get("top") === "1") input.top = true;
259 else if (form.has("before")) {
260 const before = number("before");
261 if (before == null) return "That position is not a work item.";
262 input.before = before;
263 } else if (form.has("after")) {
264 const after = number("after");
265 if (after == null) return "That position is not a work item.";
266 input.after = after;
267 }
268 if (form.has("appetite")) {
269 const value = String(form.get("appetite"));
270 if (value === "none" || value === "") input.appetite = null;
271 else {
272 const appetite = APPETITES.find((one) => one === value);
273 if (!appetite) return "Choose an appetite: S, M or L.";
274 input.appetite = appetite;
275 }
276 }
277 if (form.has("budget")) {
278 const budget = parseBudget(String(form.get("budget") ?? ""));
279 if (typeof budget === "string") return budget;
280 input.budget_usd = budget;
281 }
282 if (form.has("urgent")) input.urgent = form.get("urgent") === "1";
283 return input;
284}
285
286/** A budget as someone typed it (`40`, `$12.50`, `1,200`): dollars, null for none, or why it is not one. */
287export function parseBudget(text: string): number | null | string {
288 const tidy = text.replace(/[$,\s]/g, "");
289 if (!tidy) return null;
290 const value = Number(tidy);
291 if (!Number.isFinite(value) || value <= 0 || value > MAX_BUDGET_USD) {
292 return `A budget is a number of dollars above 0 and up to ${MAX_BUDGET_USD.toLocaleString("en-US")}.`;
293 }
294 return Math.round(value * 100) / 100;
295}
296
297/** A budget as the plan shows it: `$40`, `$12.50`. */
298export function budgetText(budget: number): string {
299 return `$${budget.toLocaleString("en-US", { minimumFractionDigits: Number.isInteger(budget) ? 0 : 2, maximumFractionDigits: 2 })}`;
300}
301
302/** A move as the form its fetcher posts. */
303export function moveForm(move: Move): Record<string, string> {
304 const form: Record<string, string> = { action: "plan", number: String(move.number), horizon: move.horizon };
305 if (move.top) form.top = "1";
306 else if (move.before != null) form.before = String(move.before);
307 else if (move.after != null) form.after = String(move.after);
308 return form;
309}
310
311/** How many more there are than are shown, as "n more"; null for none. */
312export function moreText(shown: number, total: number): string | null {
313 return total > shown ? `${(total - shown).toLocaleString("en-US")} more` : null;
314}