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.

218 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): Backlog, In progress, In review,
4 * Landing and Done. Pure, so it is tested under Node.
5 */
6import type { Claim, PullStatus, WorkItem, WorkspaceSession } from "@g1t/contracts";
7
8export type Stage = "backlog" | "progress" | "review" | "landing" | "done";
9
10/** The columns, left to right. */
11export const STAGES: Stage[] = ["backlog", "progress", "review", "landing", "done"];
12
13export const STAGE_INFO: Record<Stage, { label: string; hint: string }> = {
14 backlog: { label: "Backlog", hint: "Open, and nobody is on it" },
15 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" },
16 review: { label: "In review", hint: "A pull request for it is open" },
17 landing: { label: "Landing", hint: "Landed or merged somewhere, and a target repository is still missing it" },
18 done: { label: "Done", hint: "Closed as completed in the last 14 days" },
19};
20
21/** How long a closed work item stays in Done, and how far back the board reads merged pull requests. */
22export const DONE_DAYS = 14;
23
24/**
25 * One of a work item's pull requests, as the board counts it: one for the
26 * item (`item`), one that closes it (`closes`), or one that is only part
27 * of it (`parts`), which merging does not close.
28 */
29export type ItemPull = {
30 /** `owner/name#number`, any case, so one counted twice is counted once. */
31 key: string;
32 status: PullStatus;
33 /** It is only part of the work item: merging it lands nothing. */
34 part: boolean;
35};
36
37/** What is known of each work item's pull requests, by the item's number. */
38export type ItemPulls = Record<number, ItemPull[]>;
39
40/** Who or what is on a work item, for its card. */
41export type OnIt = {
42 /** People: assignees, claimants and whoever runs a live session on it, each once. */
43 people: string[];
44 /** Live sessions on it. */
45 sessions: WorkspaceSession[];
46};
47
48/**
49 * The stage a work item is in, the first that holds:
50 *
51 * - **Done**: closed as completed in the last [`DONE_DAYS`]. Closed as not
52 * planned (which duplicates are) did not ship, and is left out, as is
53 * one closed longer ago: null.
54 * - **Landing**: landed in some target repositories and not others; or a
55 * pull request that closes it merged and a target is still missing it
56 * (it merged somewhere that is not a target).
57 * - **In review**: a pull request for it is open.
58 * - **In progress**: claimed, assigned, a live session, a draft, or a pull
59 * request merged without closing it (part of it, or into a branch that
60 * is not the default).
61 * - **Backlog**: anything else open.
62 */
63export function stageOf(item: WorkItem, facts: { pulls?: ItemPull[]; claimed?: boolean; live?: boolean }, now: number): Stage | null {
64 if (item.state === "closed") {
65 if (item.reason === "not_planned") return null;
66 const closed = item.closedAt ? Date.parse(item.closedAt) : 0;
67 return now - closed <= DONE_DAYS * 86_400_000 ? "done" : null;
68 }
69 const targets = item.repos.filter((repo) => repo.role === "target");
70 const missing = targets.some((repo) => !repo.landed);
71 if (missing && targets.some((repo) => repo.landed)) return "landing";
72 const pulls = facts.pulls ?? [];
73 if (missing && pulls.some((pull) => pull.status === "merged" && !pull.part)) return "landing";
74 if (pulls.some((pull) => pull.status === "open")) return "review";
75 const merged = pulls.some((pull) => pull.status === "merged");
76 if (facts.claimed || facts.live || item.assignees.length > 0 || merged || pulls.some((pull) => pull.status === "draft")) return "progress";
77 return "backlog";
78}
79
80export type BoardCard = {
81 item: WorkItem;
82 stage: Stage;
83 on: OnIt;
84 /** How many of an open work item's pull requests merged in the last [`DONE_DAYS`] (none for a closed one). */
85 merged: number;
86};
87
88export type Board = Record<Stage, BoardCard[]>;
89
90/**
91 * The board of `items`, given the workspace's active claims, its live
92 * sessions and what is known of each item's pull requests. Each column
93 * keeps the most recently changed first; Done the most recently closed.
94 */
95export function boardOf(
96 items: WorkItem[],
97 { claims = [], sessions = [], pulls = {} }: { claims?: Claim[]; sessions?: WorkspaceSession[]; pulls?: ItemPulls },
98 now: number,
99): Board {
100 const board: Board = { backlog: [], progress: [], review: [], landing: [], done: [] };
101 const seen = new Set<number>();
102 for (const item of items) {
103 if (seen.has(item.number)) continue;
104 seen.add(item.number);
105 const claimed = claims.filter((claim) => claim.item === item.number && claim.state === "active");
106 const live = sessions.filter((session) => session.item === item.number && session.state !== "ended");
107 // A live session's pull request counts as one of the item's, once.
108 const known = new Map((pulls[item.number] ?? []).map((pull) => [pull.key, pull]));
109 for (const session of live) {
110 if (session.pull == null || !session.pullStatus) continue;
111 const key = `${session.repo}#${session.pull}`.toLowerCase();
112 if (!known.has(key)) known.set(key, { key, status: session.pullStatus, part: false });
113 }
114 const stage = stageOf(item, { pulls: [...known.values()], claimed: claimed.length > 0, live: live.length > 0 }, now);
115 if (!stage) continue;
116 const people = [...item.assignees, ...claimed.map((claim) => claim.actor.username), ...live.flatMap((session) => (session.actor ? [session.actor] : []))];
117 const merged = item.state === "open" ? [...known.values()].filter((pull) => pull.status === "merged").length : 0;
118 board[stage].push({ item, stage, on: { people: [...new Set(people)], sessions: live }, merged });
119 }
120 for (const stage of STAGES) {
121 const at = (card: BoardCard) => Date.parse(stage === "done" ? (card.item.closedAt ?? card.item.updatedAt) : card.item.updatedAt);
122 board[stage].sort((a, b) => at(b) - at(a));
123 }
124 return board;
125}
126
127/**
128 * The repositories whose pull requests the board reads: every one an open
129 * work item is linked to, each once, so an item's pull request moves it
130 * wherever it was opened. A deleted one (which can still be restored) has
131 * none anyone can read, nor one purged with no path kept.
132 */
133export function pullRepos(items: readonly { state: string; repos: readonly { path: string; deleted?: boolean }[] }[]): string[] {
134 const seen = new Map<string, string>();
135 for (const item of items) {
136 if (item.state !== "open") continue;
137 for (const repo of item.repos) {
138 if (!repo.path || repo.deleted || seen.has(repo.path.toLowerCase())) continue;
139 seen.set(repo.path.toLowerCase(), repo.path);
140 }
141 }
142 return [...seen.values()];
143}
144
145/** A pull request as the board reads it, with the repository it is in. */
146export type ReadPull = { repo: string; number: number; item: number | null; status: PullStatus; closes?: number[]; parts?: number[] };
147
148/**
149 * Each work item's pull requests: one counts for every work item it is
150 * for (`item`), closes (`closes`) or is part of (`parts`), and only as a
151 * part when it does not also close it.
152 */
153export function pullsByItem(pulls: readonly ReadPull[]): ItemPulls {
154 const by: ItemPulls = {};
155 const add = (number: number, pull: ItemPull) => {
156 const known = (by[number] ??= []);
157 if (!known.some((one) => one.key === pull.key)) known.push(pull);
158 };
159 for (const pull of pulls) {
160 const key = `${pull.repo}#${pull.number}`.toLowerCase();
161 const closing = new Set([...(pull.item == null ? [] : [pull.item]), ...(pull.closes ?? [])]);
162 for (const number of closing) add(number, { key, status: pull.status, part: false });
163 for (const number of pull.parts ?? []) if (!closing.has(number)) add(number, { key, status: pull.status, part: true });
164 }
165 return by;
166}
167
168/** A list read page after page: its rows, and whether there may be more than were read. */
169export type Paged<T> = { rows: T[]; capped: boolean };
170
171/**
172 * Every page of a list that comes newest first by number: `read` gives the
173 * page below `before` (none for the first), and a page shorter than `size`
174 * is the last. At most `pages` are read; `capped` says there may be more.
175 */
176export async function readPages<T extends { number: number }>(
177 read: (before: number | undefined) => Promise<T[]>,
178 size: number,
179 pages: number,
180): Promise<Paged<T>> {
181 const rows: T[] = [];
182 let before: number | undefined;
183 for (let page = 0; page < pages; page++) {
184 const found = await read(before);
185 rows.push(...found);
186 if (found.length < size) return { rows, capped: false };
187 before = Math.min(...found.map((row) => row.number));
188 }
189 return { rows, capped: true };
190}
191
192/** Pages of open work items the board reads at most: 1,000 at 100 a page. */
193export const ITEM_PAGES = 10;
194/** Pages of each repository's open, and of its recently merged, pull requests the board reads at most. */
195export const PULL_PAGES = 3;
196
197/**
198 * What the board read only in part, in sentences: the open work items
199 * when there were more than it reads (`items`, how many it showed), Done
200 * when it holds a full page (`done`), and the repositories with more pull
201 * requests than it reads (`pulls`).
202 */
203export function cappedNotes({ items, done, pulls }: { items: number | null; done: number | null; pulls: string[] }): string[] {
204 const notes: string[] = [];
205 if (items != null) notes.push(`Showing the newest ${items.toLocaleString("en-US")} open work items. Older ones are on the Work list.`);
206 if (done != null) notes.push(`Done shows the ${done} most recently closed.`);
207 if (pulls.length > 0) {
208 const where = pulls.length === 1 ? pulls[0] : `${pulls.length} repositories`;
209 notes.push(`Only the newest pull requests in ${where} were read, so some work items may stand a column early.`);
210 }
211 return notes;
212}
213
214/** The stage a phone shows, from the address: a known one, else the first with anything in it, else Backlog. */
215export function shownStage(asked: string | null, board: Board): Stage {
216 if (asked && (STAGES as string[]).includes(asked)) return asked as Stage;
217 return STAGES.find((stage) => board[stage].length > 0) ?? "backlog";
218}