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.

294 linesCodeBlameRaw
1/**
2 * Work's Board: the workspace's work items as they move, in columns read
3 * from what is so (nothing is dragged): Up next, In progress, In review,
4 * Landing and Done. Up next is the plan's, and bounded: Now's work nobody
5 * has started, then the top of Next. Everything else waits in the plan.
6 * Pure, so it is tested under Node.
7 */
8import type { Claim, Plan, PullStatus, WorkItem, WorkspaceSession } from "@g1t/contracts";
9
10export type Stage = "next" | "progress" | "review" | "landing" | "done";
11
12/** The columns, left to right. */
13export const STAGES: Stage[] = ["next", "progress", "review", "landing", "done"];
14
15export const STAGE_INFO: Record<Stage, { label: string; hint: string }> = {
16 next: { label: "Up next", hint: "Nobody is on it yet: Now's work first, then the top of the plan's Next, in the plan's order" },
17 progress: { label: "In progress", hint: "Claimed or assigned, a live session on it, a draft pull request, or a pull request merged that did not close it" },
18 review: { label: "In review", hint: "A pull request for it is open" },
19 landing: { label: "Landing", hint: "Landed or merged somewhere, and a target repository is still missing it" },
20 done: { label: "Done", hint: "Closed as completed in the last 14 days" },
21};
22
23/** How long a closed work item stays in Done, and how far back the board reads merged pull requests. */
24export const DONE_DAYS = 14;
25
26/**
27 * One of a work item's pull requests, as the board counts it: one for the
28 * item (`item`), one that closes it (`closes`), or one that is only part
29 * of it (`parts`), which merging does not close.
30 */
31export type ItemPull = {
32 /** `owner/name#number`, any case, so one counted twice is counted once. */
33 key: string;
34 status: PullStatus;
35 /** It is only part of the work item: merging it lands nothing. */
36 part: boolean;
37};
38
39/** What is known of each work item's pull requests, by the item's number. */
40export type ItemPulls = Record<number, ItemPull[]>;
41
42/** Who or what is on a work item, for its card. */
43export type OnIt = {
44 /** People: assignees, claimants and whoever runs a live session on it, each once. */
45 people: string[];
46 /** Live sessions on it. */
47 sessions: WorkspaceSession[];
48};
49
50/**
51 * The stage a work item is in, the first that holds:
52 *
53 * - **Done**: closed as completed in the last [`DONE_DAYS`]. Closed as not
54 * planned (which duplicates are) did not ship, and is left out, as is
55 * one closed longer ago: null.
56 * - **Landing**: landed in some target repositories and not others; or a
57 * pull request that closes it merged and a target is still missing it
58 * (it merged somewhere that is not a target).
59 * - **In review**: a pull request for it is open.
60 * - **In progress**: claimed, assigned, a live session, a draft, or a pull
61 * request merged without closing it (part of it, or into a branch that
62 * is not the default).
63 * - **Up next**: anything else open, which the board shows only when it is
64 * in Now or at the top of Next ([`boardOf`]).
65 */
66export function stageOf(item: WorkItem, facts: { pulls?: ItemPull[]; claimed?: boolean; live?: boolean }, now: number): Stage | null {
67 if (item.state === "closed") {
68 if (item.reason === "not_planned") return null;
69 const closed = item.closedAt ? Date.parse(item.closedAt) : 0;
70 return now - closed <= DONE_DAYS * 86_400_000 ? "done" : null;
71 }
72 const targets = item.repos.filter((repo) => repo.role === "target");
73 const missing = targets.some((repo) => !repo.landed);
74 if (missing && targets.some((repo) => repo.landed)) return "landing";
75 const pulls = facts.pulls ?? [];
76 if (missing && pulls.some((pull) => pull.status === "merged" && !pull.part)) return "landing";
77 if (pulls.some((pull) => pull.status === "open")) return "review";
78 const merged = pulls.some((pull) => pull.status === "merged");
79 if (facts.claimed || facts.live || item.assignees.length > 0 || merged || pulls.some((pull) => pull.status === "draft")) return "progress";
80 return "next";
81}
82
83export type BoardCard = {
84 item: WorkItem;
85 stage: Stage;
86 on: OnIt;
87 /** How many of an open work item's pull requests merged in the last [`DONE_DAYS`] (none for a closed one). */
88 merged: number;
89};
90
91export type Board = Record<Stage, BoardCard[]>;
92
93/**
94 * The board of `items`, given the workspace's active claims, its live
95 * sessions, what is known of each item's pull requests, and `upNext`: the
96 * numbers Up next may show, in the plan's order ([`upNextOf`]). An open
97 * item nobody is on and not among them is left off: it is in the plan.
98 * Up next keeps the plan's order; every other column the most recently
99 * changed first, Done the most recently closed.
100 */
101export function boardOf(
102 items: WorkItem[],
103 { claims = [], sessions = [], pulls = {}, upNext = [] }: { claims?: Claim[]; sessions?: WorkspaceSession[]; pulls?: ItemPulls; upNext?: number[] },
104 now: number,
105): Board {
106 const board: Board = { next: [], progress: [], review: [], landing: [], done: [] };
107 const place = new Map(upNext.map((number, index) => [number, index]));
108 const seen = new Set<number>();
109 for (const item of items) {
110 if (seen.has(item.number)) continue;
111 seen.add(item.number);
112 const claimed = claims.filter((claim) => claim.item === item.number && claim.state === "active");
113 const live = sessions.filter((session) => session.item === item.number && session.state !== "ended");
114 // A live session's pull request counts as one of the item's, once.
115 const known = new Map((pulls[item.number] ?? []).map((pull) => [pull.key, pull]));
116 for (const session of live) {
117 if (session.pull == null || !session.pullStatus) continue;
118 const key = `${session.repo}#${session.pull}`.toLowerCase();
119 if (!known.has(key)) known.set(key, { key, status: session.pullStatus, part: false });
120 }
121 const stage = stageOf(item, { pulls: [...known.values()], claimed: claimed.length > 0, live: live.length > 0 }, now);
122 if (!stage || (stage === "next" && !place.has(item.number))) continue;
123 const people = [...item.assignees, ...claimed.map((claim) => claim.actor.username), ...live.flatMap((session) => (session.actor ? [session.actor] : []))];
124 const merged = item.state === "open" ? [...known.values()].filter((pull) => pull.status === "merged").length : 0;
125 board[stage].push({ item, stage, on: { people: [...new Set(people)], sessions: live }, merged });
126 }
127 board.next.sort((a, b) => place.get(a.item.number)! - place.get(b.item.number)!);
128 for (const stage of STAGES) {
129 if (stage === "next") continue;
130 const at = (card: BoardCard) => Date.parse(stage === "done" ? (card.item.closedAt ?? card.item.updatedAt) : card.item.updatedAt);
131 board[stage].sort((a, b) => at(b) - at(a));
132 }
133 return board;
134}
135
136/**
137 * The repositories whose pull requests the board reads: every one an open
138 * work item is linked to, each once, so an item's pull request moves it
139 * wherever it was opened. A deleted one (which can still be restored) has
140 * none anyone can read, nor one purged with no path kept.
141 */
142export function pullRepos(items: readonly { state: string; repos: readonly { path: string; deleted?: boolean }[] }[]): string[] {
143 const seen = new Map<string, string>();
144 for (const item of items) {
145 if (item.state !== "open") continue;
146 for (const repo of item.repos) {
147 if (!repo.path || repo.deleted || seen.has(repo.path.toLowerCase())) continue;
148 seen.set(repo.path.toLowerCase(), repo.path);
149 }
150 }
151 return [...seen.values()];
152}
153
154/** A pull request as the board reads it, with the repository it is in. */
155export type ReadPull = { repo: string; number: number; item: number | null; status: PullStatus; closes?: number[]; parts?: number[] };
156
157/**
158 * Each work item's pull requests: one counts for every work item it is
159 * for (`item`), closes (`closes`) or is part of (`parts`), and only as a
160 * part when it does not also close it.
161 */
162export function pullsByItem(pulls: readonly ReadPull[]): ItemPulls {
163 const by: ItemPulls = {};
164 const add = (number: number, pull: ItemPull) => {
165 const known = (by[number] ??= []);
166 if (!known.some((one) => one.key === pull.key)) known.push(pull);
167 };
168 for (const pull of pulls) {
169 const key = `${pull.repo}#${pull.number}`.toLowerCase();
170 const closing = new Set([...(pull.item == null ? [] : [pull.item]), ...(pull.closes ?? [])]);
171 for (const number of closing) add(number, { key, status: pull.status, part: false });
172 for (const number of pull.parts ?? []) if (!closing.has(number)) add(number, { key, status: pull.status, part: true });
173 }
174 return by;
175}
176
177/** A list read page after page: its rows, and whether there may be more than were read. */
178export type Paged<T> = { rows: T[]; capped: boolean };
179
180/**
181 * Every page of a list that comes newest first by number: `read` gives the
182 * page below `before` (none for the first), and a page shorter than `size`
183 * is the last. At most `pages` are read; `capped` says there may be more.
184 */
185export async function readPages<T extends { number: number }>(
186 read: (before: number | undefined) => Promise<T[]>,
187 size: number,
188 pages: number,
189): Promise<Paged<T>> {
190 const rows: T[] = [];
191 let before: number | undefined;
192 for (let page = 0; page < pages; page++) {
193 const found = await read(before);
194 rows.push(...found);
195 if (found.length < size) return { rows, capped: false };
196 before = Math.min(...found.map((row) => row.number));
197 }
198 return { rows, capped: true };
199}
200
201/**
202 * What Up next may show, in the plan's order: all of Now (bounded by its
203 * cap, and at most a page), then the top of Next (at most `NEXT_SHOWN`,
204 * which is all `getPlan` reads of it). Only those nobody is on stand there.
205 */
206export function upNextOf(plan: Pick<Plan, "now" | "next"> | null): number[] {
207 return plan ? [...plan.now, ...plan.next].map((item) => item.number) : [];
208}
209
210/**
211 * The open work items the board reads, each once: the plan's Now and the
212 * top of its Next, the newest open ones (where most work in progress is),
213 * and any other a claim or a live session is on.
214 */
215export function boardItems(...parts: (readonly WorkItem[] | null | undefined)[]): WorkItem[] {
216 const seen = new Map<number, WorkItem>();
217 for (const part of parts) for (const item of part ?? []) if (!seen.has(item.number)) seen.set(item.number, item);
218 return [...seen.values()];
219}
220
221/**
222 * The numbers of the work items an active claim or a live session is on
223 * that the board has not read, at most `max`, to read each on its own.
224 */
225export function onItNotRead(read: readonly { number: number }[], claims: readonly Claim[], sessions: readonly WorkspaceSession[], max: number): number[] {
226 const known = new Set(read.map((item) => item.number));
227 const wanted = new Set<number>();
228 for (const claim of claims) if (claim.state === "active") wanted.add(claim.item);
229 for (const session of sessions) if (session.item != null && session.state !== "ended") wanted.add(session.item);
230 return [...wanted].filter((number) => !known.has(number)).slice(0, max);
231}
232
233/**
234 * Pages of the newest open work items the board reads: 200 at 100 a page.
235 * Most work in progress is recent; an older item is on the board when it
236 * is in the plan's Now or Next, claimed, or has a live session.
237 */
238export const ITEM_PAGES = 2;
239/** The most work items a claim or a live session is on that the board reads one by one. */
240export const ON_IT_READS = 20;
241/** Pages of each repository's open, and of its recently merged, pull requests the board reads at most. */
242export const PULL_PAGES = 3;
243
244/**
245 * What the board read only in part, in sentences: the newest open work
246 * items when there were more than it reads (`items`, how many it read),
247 * Done when it holds a full page (`done`), and the repositories with more
248 * pull requests than it reads (`pulls`).
249 */
250export function cappedNotes({ items, done, pulls }: { items: number | null; done: number | null; pulls: string[] }): string[] {
251 const notes: string[] = [];
252 if (items != null)
253 notes.push(
254 `Past the newest ${items.toLocaleString("en-US")} open work items, the board shows only those in the plan's Now or Next, claimed, or with a live session.`,
255 );
256 if (done != null) notes.push(`Done shows the ${done} most recently closed.`);
257 if (pulls.length > 0) {
258 const where = pulls.length === 1 ? pulls[0] : `${pulls.length} repositories`;
259 notes.push(`Only the newest pull requests in ${where} were read, so some work items may stand a column early.`);
260 }
261 return notes;
262}
263
264/** The stage a phone shows, from the address: a known one, else the first with anything in it, else Up next. */
265export function shownStage(asked: string | null, board: Board): Stage {
266 if (asked && (STAGES as string[]).includes(asked)) return asked as Stage;
267 return STAGES.find((stage) => board[stage].length > 0) ?? "next";
268}
269
270/** How much of the plan Up next leaves out: the rest of Next, then Later and Triage. */
271export type PlanRest = { next: number; later: number; triage: number };
272
273/**
274 * What of the plan the board leaves out: Next past what Up next shows of
275 * it, and Later and Triage (some of which may be on the board for being
276 * in progress). Null without a plan.
277 */
278export function planRest(plan: Pick<Plan, "next" | "nextTotal" | "laterTotal" | "triageTotal"> | null, board: Board): PlanRest | null {
279 if (!plan) return null;
280 const shown = new Set(board.next.map((card) => card.item.number));
281 const nextShown = plan.next.filter((item) => shown.has(item.number)).length;
282 return { next: Math.max(0, plan.nextTotal - nextShown), later: plan.laterTotal, triage: plan.triageTotal };
283}
284
285/** "14 in Next, 32 in Later and 8 in Triage", leaving out what is empty; null when all are. */
286export function planRestWords(rest: PlanRest): string | null {
287 const parts = [
288 rest.next > 0 ? `${rest.next.toLocaleString("en-US")} in Next` : null,
289 rest.later > 0 ? `${rest.later.toLocaleString("en-US")} in Later` : null,
290 rest.triage > 0 ? `${rest.triage.toLocaleString("en-US")} in Triage` : null,
291 ].filter((part): part is string => part != null);
292 if (parts.length === 0) return null;
293 return parts.length === 1 ? parts[0]! : `${parts.slice(0, -1).join(", ")} and ${parts.at(-1)}`;
294}