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.

119 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, or a draft pull request" },
16 review: { label: "In review", hint: "A pull request for it is open" },
17 landing: { label: "Landing", hint: "Landed in some of its repositories, still missing in others" },
18 done: { label: "Done", hint: "Closed in the last 14 days" },
19};
20
21/** How long a closed work item stays in Done. */
22export const DONE_DAYS = 14;
23
24/** What is known of a work item's pull requests: their statuses. */
25export type ItemPulls = Record<number, PullStatus[]>;
26
27/** Who or what is on a work item, for its card. */
28export type OnIt = {
29 /** People: assignees, claimants and whoever runs a live session on it, each once. */
30 people: string[];
31 /** Live sessions on it. */
32 sessions: WorkspaceSession[];
33};
34
35/**
36 * The stage a work item is in, the first that holds: closed recently is
37 * Done; landed in some target repositories and not others is Landing; an
38 * open pull request is In review; claimed, assigned, a live session or a
39 * draft is In progress; anything else open is Backlog. Null for one closed
40 * longer ago than [`DONE_DAYS`], which the board leaves out.
41 */
42export function stageOf(item: WorkItem, facts: { pulls?: PullStatus[]; claimed?: boolean; live?: boolean }, now: number): Stage | null {
43 if (item.state === "closed") {
44 const closed = item.closedAt ? Date.parse(item.closedAt) : 0;
45 return now - closed <= DONE_DAYS * 86_400_000 ? "done" : null;
46 }
47 const targets = item.repos.filter((repo) => repo.role === "target");
48 if (targets.some((repo) => repo.landed) && targets.some((repo) => !repo.landed)) return "landing";
49 const pulls = facts.pulls ?? [];
50 if (pulls.includes("open")) return "review";
51 if (facts.claimed || facts.live || item.assignees.length > 0 || pulls.includes("draft")) return "progress";
52 return "backlog";
53}
54
55export type BoardCard = { item: WorkItem; stage: Stage; on: OnIt };
56
57export type Board = Record<Stage, BoardCard[]>;
58
59/**
60 * The board of `items`, given the workspace's active claims, its live
61 * sessions and what is known of each item's pull requests. Each column
62 * keeps the most recently changed first; Done the most recently closed.
63 */
64export function boardOf(
65 items: WorkItem[],
66 { claims = [], sessions = [], pulls = {} }: { claims?: Claim[]; sessions?: WorkspaceSession[]; pulls?: ItemPulls },
67 now: number,
68): Board {
69 const board: Board = { backlog: [], progress: [], review: [], landing: [], done: [] };
70 const seen = new Set<number>();
71 for (const item of items) {
72 if (seen.has(item.number)) continue;
73 seen.add(item.number);
74 const claimed = claims.filter((claim) => claim.item === item.number && claim.state === "active");
75 const live = sessions.filter((session) => session.item === item.number && session.state !== "ended");
76 // A live session's pull request counts as one of the item's.
77 const known = [...(pulls[item.number] ?? []), ...live.flatMap((session) => (session.pullStatus ? [session.pullStatus] : []))];
78 const stage = stageOf(item, { pulls: known, claimed: claimed.length > 0, live: live.length > 0 }, now);
79 if (!stage) continue;
80 const people = [...item.assignees, ...claimed.map((claim) => claim.actor.username), ...live.flatMap((session) => (session.actor ? [session.actor] : []))];
81 board[stage].push({ item, stage, on: { people: [...new Set(people)], sessions: live } });
82 }
83 for (const stage of STAGES) {
84 const at = (card: BoardCard) => Date.parse(stage === "done" ? (card.item.closedAt ?? card.item.updatedAt) : card.item.updatedAt);
85 board[stage].sort((a, b) => at(b) - at(a));
86 }
87 return board;
88}
89
90/**
91 * The repositories whose open pull requests the board reads: every one an
92 * open work item is linked to, each once, so an item's pull request moves
93 * it wherever it was opened. A repository deleted before work items, with
94 * no path kept, has none to read.
95 */
96export function pullRepos(items: readonly { state: string; repos: readonly { path: string }[] }[]): string[] {
97 const seen = new Map<string, string>();
98 for (const item of items) {
99 if (item.state !== "open") continue;
100 for (const repo of item.repos) if (repo.path && !seen.has(repo.path.toLowerCase())) seen.set(repo.path.toLowerCase(), repo.path);
101 }
102 return [...seen.values()];
103}
104
105/** Each work item's pull requests' statuses, from pull requests that name their item. */
106export function pullsByItem(pulls: { item: number | null; status: PullStatus }[]): ItemPulls {
107 const by: ItemPulls = {};
108 for (const pull of pulls) {
109 if (pull.item == null) continue;
110 (by[pull.item] ??= []).push(pull.status);
111 }
112 return by;
113}
114
115/** The stage a phone shows, from the address: a known one, else the first with anything in it, else Backlog. */
116export function shownStage(asked: string | null, board: Board): Stage {
117 if (asked && (STAGES as string[]).includes(asked)) return asked as Stage;
118 return STAGES.find((stage) => board[stage].length > 0) ?? "backlog";
119}