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.

410 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, Hill, Horizon, Plan, PlanGroup, PlanSuggestion, 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/** Every stage of a hill, in the order work goes over it (the contracts' `HILLS`). */
20export const HILL_STEPS: readonly Hill[] = ["figuring_out", "decided", "building", "landing"];
21
22/** The Plan, Work's first page. */
23export function planPath(workspace: string, query = ""): string {
24 return `${workPath(workspace)}${query ? `?${query}` : ""}`;
25}
26
27/** Triage: new work items nobody has placed yet. */
28export function triagePath(workspace: string): string {
29 return workPath(workspace, "triage");
30}
31
32/** All items: the Work list, with its filters (`state=closed&label=bug`). */
33export function allItemsPath(workspace: string, query = ""): string {
34 return `${workPath(workspace, "all")}${query ? `?${query}` : ""}`;
35}
36
37/** The All items list of what is set aside. */
38export function setAsidePath(workspace: string): string {
39 return allItemsPath(workspace, new URLSearchParams({ q: "horizon:set_aside" }).toString());
40}
41
42/**
43 * The query parameters that belong to the Work list (lib/work-items.ts
44 * `workFilters`, and the paging and sort it keeps): `-/work` with any of
45 * them is an old link to the list, which is at `-/work/all` now.
46 */
47export const LIST_PARAMS = ["q", "label", "milestone", "assignee", "state", "repo", "role", "author", "horizon", "before", "sort", "page"] as const;
48
49/** Where `-/work` with this query leads instead of the Plan: the list with the same query, or null to show the Plan. */
50export function listRedirect(workspace: string, query: URLSearchParams): string | null {
51 const kept = new URLSearchParams(query);
52 kept.delete("_routes");
53 kept.delete("index");
54 if (!LIST_PARAMS.some((key) => kept.has(key))) return null;
55 return allItemsPath(workspace, kept.toString());
56}
57
58/** How full Now is: its share of the cap (at most 1, for the bar) and whether it is at or past it. */
59export type CapState = { count: number; cap: number; share: number; full: boolean; over: boolean };
60
61export function capState(count: number, cap: number): CapState {
62 const limit = Math.max(1, cap);
63 return { count, cap: limit, share: Math.min(1, count / limit), full: count >= limit, over: count > limit };
64}
65
66/**
67 * Where a move puts a work item among another horizon's (or its own)
68 * items: before or after another, first, or (none set) where the horizon
69 * puts it. At most one is set.
70 */
71export type Position = { before?: number; after?: number; top?: true };
72
73/**
74 * The position for dropping `dragged` into `list` (the numbers as shown,
75 * top first, which may include `dragged` itself) at `index`, the gap it
76 * lands in (0 is above the first, `list.length` below the last): before
77 * the row it lands above, else after the last, else none (an empty list,
78 * where the horizon alone says where). Null when it lands where it is.
79 */
80export function dropPosition(list: readonly number[], index: number, dragged: number): Position | null {
81 const from = list.indexOf(dragged);
82 if (from !== -1 && (index === from || index === from + 1)) return null;
83 const rest = list.filter((number) => number !== dragged);
84 const at = from !== -1 && from < index ? index - 1 : index;
85 const clamped = Math.max(0, Math.min(at, rest.length));
86 if (clamped < rest.length) return { before: rest[clamped]! };
87 if (rest.length > 0) return { after: rest[rest.length - 1]! };
88 return {};
89}
90
91/** Moving `number` one place up in `list`: before the one above it. Null at the top, or when it is not there. */
92export function moveUp(list: readonly number[], number: number): Position | null {
93 const at = list.indexOf(number);
94 return at > 0 ? { before: list[at - 1]! } : null;
95}
96
97/** Moving `number` one place down in `list`: after the one below it. Null at the bottom, or when it is not there. */
98export function moveDown(list: readonly number[], number: number): Position | null {
99 const at = list.indexOf(number);
100 return at !== -1 && at < list.length - 1 ? { after: list[at + 1]! } : null;
101}
102
103/** A move in the plan, as a row's menu, a drop or Triage's buttons make it. */
104export type Move = { number: number; horizon: Horizon } & Position;
105
106/** What the Plan page shows: the plan, as `getPlan` read it. */
107export type PlanView = Pick<Plan, "now" | "next" | "nextTotal" | "laterTotal" | "later" | "setAsideTotal" | "triageTotal">;
108
109/**
110 * The plan as it will be once `move` is made, to show before the work
111 * service answers: the item leaves wherever it was and goes where the
112 * move says, on Now or Next (or, past the top of Next shown, only into
113 * its count), or into the count of the horizon it went to.
114 */
115export function applyMove(view: PlanView, move: Move, from?: WorkItem): PlanView {
116 const found = view.now.find((item) => item.number === move.number) ?? view.next.find((item) => item.number === move.number) ?? from;
117 const now = view.now.filter((item) => item.number !== move.number);
118 const next = view.next.filter((item) => item.number !== move.number);
119 const out: PlanView = { ...view, now, next };
120 if (found?.horizon === move.horizon && !view.now.includes(found) && !view.next.includes(found)) return view;
121 if (found?.horizon === "next") out.nextTotal -= 1;
122 if (found?.horizon === "later") {
123 out.laterTotal -= 1;
124 out.later = bump(out.later, originPath(found), -1);
125 }
126 if (found?.horizon === "triage") out.triageTotal -= 1;
127 if (move.horizon === "next") out.nextTotal += 1;
128 if (move.horizon === "later") {
129 out.laterTotal += 1;
130 if (found) out.later = bump(out.later, originPath(found), 1);
131 }
132 if (move.horizon === "set_aside") out.setAsideTotal += 1;
133 if (move.horizon === "triage") out.triageTotal += 1;
134 if (!found || (move.horizon !== "now" && move.horizon !== "next")) return out;
135 const moved: WorkItem = { ...found, horizon: move.horizon };
136 const list = move.horizon === "now" ? now : next;
137 // Next shows only its top: one placed below all of it that is shown is only counted.
138 const whole = move.horizon === "now" || list.length >= out.nextTotal - 1;
139 let at = -1;
140 if (move.top) at = 0;
141 else if (move.before != null) at = list.findIndex((item) => item.number === move.before);
142 else if (move.after != null) {
143 const below = list.findIndex((item) => item.number === move.after);
144 at = below === -1 ? -1 : below + 1;
145 }
146 if (at !== -1) list.splice(at, 0, moved);
147 else if (whole) list.push(moved);
148 return out;
149}
150
151/** Later's groups with `by` more (or fewer) in `repo`'s; a group left with none goes. */
152function bump(groups: readonly PlanGroup[], repo: string | null, by: number): PlanGroup[] {
153 const same = (group: PlanGroup) => (group.repo ?? "").toLowerCase() === (repo ?? "").toLowerCase();
154 if (!groups.some(same)) return by > 0 ? [...groups, { repo, count: by }] : [...groups];
155 return groups.map((group) => (same(group) ? { ...group, count: group.count + by } : group)).filter((group) => group.count > 0);
156}
157
158/** The move a form posts (`moveForm`'s fields), or null when it is not one. */
159export function moveFrom(form: Fields): Move | null {
160 const number = Number(form.get("number"));
161 const horizon = HORIZONS.find((one) => one === form.get("horizon"));
162 if (!Number.isInteger(number) || number < 1 || !horizon) return null;
163 if (form.get("top") === "1") return { number, horizon, top: true };
164 const before = Number(form.get("before"));
165 if (form.has("before") && before > 0) return { number, horizon, before };
166 const after = Number(form.get("after"));
167 if (form.has("after") && after > 0) return { number, horizon, after };
168 return { number, horizon };
169}
170
171/** The repository a work item is filed in (its origin), by path; null for one filed in the workspace. */
172export function originPath(item: Pick<WorkItem, "repos">): string | null {
173 return item.repos.find((repo) => repo.role === "origin")?.path || null;
174}
175
176/** The moves a row's menu offers, given where it is and whether the viewer may move work into or out of Now. */
177export function menuHorizons(horizon: Horizon, canNow: boolean): Horizon[] {
178 return HORIZONS.filter((one) => one !== horizon && (canNow || (one !== "now" && horizon !== "now")));
179}
180
181/** Who or what is on a work item: people (assignees, claimants, whoever runs a live session on it), and its live sessions. */
182export type OnIt = { people: string[]; sessions: WorkspaceSession[] };
183
184/** Who is on each of `items`, by number, from the workspace's active claims and live sessions, as the board says. */
185export function onItems(items: readonly WorkItem[], claims: readonly Claim[], sessions: readonly WorkspaceSession[]): Record<number, OnIt> {
186 const on: Record<number, OnIt> = {};
187 for (const item of items) {
188 const claimed = claims.filter((claim) => claim.item === item.number && claim.state === "active");
189 const live = sessions.filter((session) => session.item === item.number && session.state !== "ended");
190 const people = [...item.assignees, ...claimed.map((claim) => claim.actor.username), ...live.flatMap((session) => (session.actor ? [session.actor] : []))];
191 on[item.number] = { people: [...new Set(people)], sessions: live };
192 }
193 return on;
194}
195
196/**
197 * The work items that `items` wait on and whose state is not known from
198 * `open` (the open ones already read), each once, the first `max`: the
199 * ones to look up.
200 */
201export function blockersToRead(items: readonly Pick<WorkItem, "blockedBy">[], open: ReadonlySet<number>, max = 20): number[] {
202 const wanted = new Set<number>();
203 for (const item of items) for (const number of item.blockedBy) if (!open.has(number)) wanted.add(number);
204 return [...wanted].slice(0, max);
205}
206
207/** What an item waits on: the work items it is blocked by that are not known to be closed. */
208export function waitingOn(item: Pick<WorkItem, "blockedBy">, closed: ReadonlySet<number>): number[] {
209 return item.blockedBy.filter((number) => !closed.has(number));
210}
211
212/** "waiting on #12", "waiting on #12, #14". */
213export function waitingLabel(numbers: readonly number[]): string {
214 return `waiting on ${numbers.map((number) => `#${number}`).join(", ")}`;
215}
216
217/** What each appetite means, in a few words. */
218export const APPETITE_HINTS: Record<Appetite, string> = {
219 small: "Small: one session",
220 medium: "Medium: a few sessions, about a week",
221 large: "Large: a phase, broken into smaller items before it moves to Now",
222};
223
224/** The appetites at once, for a hint beside a choice of them. */
225export const APPETITE_SUMMARY = "S: one session. M: a few sessions, about a week. L: a phase, broken down before it moves to Now.";
226
227/** What each horizon is for, in a few words. */
228export const HORIZON_HINTS: Record<Horizon, string> = {
229 triage: "New, and not placed yet",
230 now: "What the workspace is on",
231 next: "Ranked: the top is taken first",
232 later: "Wanted, not ranked",
233 set_aside: "Out of every view but search",
234};
235
236/** The most a budget may be, in dollars, as the work service allows. */
237export const MAX_BUDGET_USD = 100_000;
238
239/** A form's fields, as `FormData` has them. */
240type Fields = { get(name: string): FormDataEntryValue | null; has(name: string): boolean };
241
242/**
243 * A move in the plan from a form: `horizon`, then one of `before`, `after`
244 * (work item numbers) or `top=1`; `appetite` (`small`, `medium`, `large`,
245 * or `none` to clear it); `budget` in dollars (empty clears it); `urgent`
246 * (`1` or `0`). Only the fields given change. A string when the form says
247 * something the plan cannot take.
248 */
249export function planInput(form: Fields): PlanWorkItemInput | string {
250 const input: PlanWorkItemInput = {};
251 if (form.has("horizon")) {
252 const horizon = HORIZONS.find((one) => one === form.get("horizon"));
253 if (!horizon) return "Choose where it goes: Triage, Now, Next, Later or Set aside.";
254 input.horizon = horizon;
255 }
256 const number = (name: string) => {
257 const value = Number(form.get(name));
258 return Number.isInteger(value) && value > 0 ? value : null;
259 };
260 if (form.get("top") === "1") input.top = true;
261 else if (form.has("before")) {
262 const before = number("before");
263 if (before == null) return "That position is not a work item.";
264 input.before = before;
265 } else if (form.has("after")) {
266 const after = number("after");
267 if (after == null) return "That position is not a work item.";
268 input.after = after;
269 }
270 if (form.has("appetite")) {
271 const value = String(form.get("appetite"));
272 if (value === "none" || value === "") input.appetite = null;
273 else {
274 const appetite = APPETITES.find((one) => one === value);
275 if (!appetite) return "Choose an appetite: S, M or L.";
276 input.appetite = appetite;
277 }
278 }
279 if (form.has("budget")) {
280 const budget = parseBudget(String(form.get("budget") ?? ""));
281 if (typeof budget === "string") return budget;
282 input.budget_usd = budget;
283 }
284 if (form.has("urgent")) input.urgent = form.get("urgent") === "1";
285 return input;
286}
287
288/** A budget as someone typed it (`40`, `$12.50`, `1,200`): dollars, null for none, or why it is not one. */
289export function parseBudget(text: string): number | null | string {
290 const tidy = text.replace(/[$,\s]/g, "");
291 if (!tidy) return null;
292 const value = Number(tidy);
293 if (!Number.isFinite(value) || value <= 0 || value > MAX_BUDGET_USD) {
294 return `A budget is a number of dollars above 0 and up to ${MAX_BUDGET_USD.toLocaleString("en-US")}.`;
295 }
296 return Math.round(value * 100) / 100;
297}
298
299/** A budget as the plan shows it: `$40`, `$12.50`. */
300export function budgetText(budget: number): string {
301 return `$${budget.toLocaleString("en-US", { minimumFractionDigits: Number.isInteger(budget) ? 0 : 2, maximumFractionDigits: 2 })}`;
302}
303
304/** A move as the form its fetcher posts. */
305export function moveForm(move: Move): Record<string, string> {
306 const form: Record<string, string> = { action: "plan", number: String(move.number), horizon: move.horizon };
307 if (move.top) form.top = "1";
308 else if (move.before != null) form.before = String(move.before);
309 else if (move.after != null) form.after = String(move.after);
310 return form;
311}
312
313/** How many more there are than are shown, as "n more"; null for none. */
314export function moreText(shown: number, total: number): string | null {
315 return total > shown ? `${(total - shown).toLocaleString("en-US")} more` : null;
316}
317
318// --- How Now stands -----------------------------------------------------------
319
320const DAY_MS = 86_400_000;
321
322/** Where `hill` is among its four stages: 0 (figuring it out) to 3 (landing). */
323export function hillStep(hill: Hill): number {
324 return Math.max(0, HILL_STEPS.indexOf(hill));
325}
326
327/** Whole days since `at` (RFC 3339), never fewer than 0; null when there is no time. */
328export function daysSince(at: string | null | undefined, now: number): number | null {
329 const time = at ? Date.parse(at) : Number.NaN;
330 if (Number.isNaN(time)) return null;
331 return Math.max(0, Math.floor((now - time) / DAY_MS));
332}
333
334/**
335 * What a quiet row says: "Quiet for 6 days", counted from the last time
336 * anything happened on it; "Quiet" alone when that is not known (or is
337 * today, as clocks differ). Null
338 * for one that is not quiet.
339 */
340export function quietText(progress: { quiet: boolean; activeAt: string | null }, now: number): string | null {
341 if (!progress.quiet) return null;
342 const days = daysSince(progress.activeAt, now);
343 if (days == null || days < 1) return "Quiet";
344 return `Quiet for ${days === 1 ? "1 day" : `${days.toLocaleString("en-US")} days`}`;
345}
346
347/** How each work item in Now stands, by number. */
348export function progressOf<P extends { number: number }>(progress: readonly P[]): Record<number, P> {
349 return Object.fromEntries(progress.map((one) => [one.number, one]));
350}
351
352/** Days with nothing happening before a work item in Now has gone quiet (the contracts' `QUIET_DAYS`). */
353export const QUIET_AFTER_DAYS = 5;
354
355/** The week's line about Now going quiet; null for none. */
356export function quietLine(count: number): string | null {
357 if (count <= 0) return null;
358 const one = count === 1;
359 return `${count.toLocaleString("en-US")} in Now ${one ? "has" : "have"} gone quiet, with nothing happening on ${one ? "it" : "them"} for ${QUIET_AFTER_DAYS} days or more.`;
360}
361
362/** The week's line about letting go; null for none set aside lately. */
363export function setAsideLine(count: number): string | null {
364 if (count <= 0) return null;
365 return `${count.toLocaleString("en-US")} set aside in the last 30 days, untouched for 90 days in Later`;
366}
367
368/** Triage's banner on the Plan: how many wait, and how many of them have waited more than a week. */
369export function triageLine(total: number, waiting: number): string {
370 const wait = total === 1 ? "1 new work item waits" : `${total.toLocaleString("en-US")} new work items wait`;
371 const long =
372 waiting <= 0
373 ? ""
374 : waiting >= total
375 ? total === 1
376 ? " It has waited more than a week."
377 : " All have waited more than a week."
378 : ` ${waiting.toLocaleString("en-US")} of them have waited more than a week.`;
379 return `${wait} in Triage to be placed.${long}`;
380}
381
382// --- Triage's suggestions -------------------------------------------------------
383
384/** A suggestion in a few words: "Next · M". */
385export function suggestionText(suggestion: Pick<PlanSuggestion, "horizon" | "appetite">): string {
386 const horizon = { triage: "Triage", now: "Now", next: "Next", later: "Later", set_aside: "Set aside" }[suggestion.horizon];
387 const size = suggestion.appetite ? ` · ${suggestion.appetite === "small" ? "S" : suggestion.appetite === "medium" ? "M" : "L"}` : "";
388 return `${horizon}${size}`;
389}
390
391/**
392 * Accepting a suggestion, as the form a fetcher posts (`planInput` reads
393 * it): its horizon, where the horizon puts new work (the bottom of Next),
394 * and its appetite when it has one.
395 */
396export function acceptForm(suggestion: Pick<PlanSuggestion, "horizon" | "appetite">): Record<string, string> {
397 const form: Record<string, string> = { horizon: suggestion.horizon };
398 if (suggestion.appetite) form.appetite = suggestion.appetite;
399 return form;
400}
401
402/** Whether the viewer may accept a suggestion: moving into Now takes more than placing work elsewhere. */
403export function canAccept(suggestion: Pick<PlanSuggestion, "horizon">, canNow: boolean): boolean {
404 return suggestion.horizon !== "triage" && (suggestion.horizon !== "now" || canNow);
405}
406
407/** The comment a work item closed as a duplicate gets. */
408export function duplicateComment(of: number): string {
409 return `Duplicate of #${of}`;
410}