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.

112 linesCodeBlameRaw
1import type { ApiWorkspaceSession, Harness, PullStatus } from "./sessions";
2import type { ApiWorkItem } from "./work";
3
4/**
5 * Work's board: where each work item stands as it moves, read from the
6 * item, its pull requests and the sessions on it. Done is closed as
7 * completed in the last two weeks (not planned is left out); Landing has landed in some targets but not all; In
8 * review has an open pull request; In progress has someone on it (an
9 * assignee, a claim, a live session) or a draft; Backlog has nobody.
10 * The first that holds, in that order, is its stage.
11 *
12 * Kept free of React Native imports so `npm test` runs it under Node.
13 */
14
15export type Stage = "backlog" | "in_progress" | "in_review" | "landing" | "done";
16
17/** The stages in the order work moves through them. */
18export const STAGES: Stage[] = ["backlog", "in_progress", "in_review", "landing", "done"];
19
20const NAMES: Record<Stage, string> = {
21 backlog: "Backlog",
22 in_progress: "In progress",
23 in_review: "In review",
24 landing: "Landing",
25 done: "Done",
26};
27
28export function stageName(stage: Stage): string {
29 return NAMES[stage];
30}
31
32/** How long a closed item stays in Done. */
33export const DONE_DAYS = 14;
34
35export type BoardItem = Pick<ApiWorkItem, "number" | "state" | "assignees" | "repos" | "closed_at"> &
36 Partial<Pick<ApiWorkItem, "reason">> & {
37 /** Someone has claimed it. */
38 claimed?: boolean;
39 };
40
41/** A pull request for a work item, by the item's number in the workspace. */
42export type BoardPull = { item: number | null; status: PullStatus };
43
44export type BoardSession = Pick<ApiWorkspaceSession, "item" | "state" | "actor" | "harness" | "pull" | "pull_status">;
45
46export type BoardContext = {
47 pulls: BoardPull[];
48 sessions: BoardSession[];
49 now?: number;
50};
51
52/** Someone on an item: an assignee, or who runs a live session on it and in what. */
53export type OnIt = { name: string; harness: Harness | null };
54
55export type Card<T extends BoardItem> = { item: T; stage: Stage; people: OnIt[] };
56
57/** The stage of one item, or null for one closed too long ago to show. */
58export function stageOf(item: BoardItem, context: BoardContext): Stage | null {
59 const now = context.now ?? Date.now();
60 if (item.state === "closed") {
61 // Closed as not planned (a duplicate, say) did not ship: not Done.
62 if (item.reason === "not_planned") return null;
63 const closed = item.closed_at ? Date.parse(item.closed_at) : NaN;
64 return !Number.isNaN(closed) && now - closed <= DONE_DAYS * 86_400_000 ? "done" : null;
65 }
66 const targets = item.repos.filter((repo) => repo.role === "target");
67 if (targets.some((repo) => repo.landed) && targets.some((repo) => !repo.landed)) return "landing";
68 const statuses = pullsOf(item.number, context);
69 if (statuses.includes("open")) return "in_review";
70 const live = context.sessions.some((session) => session.item === item.number && session.state !== "ended");
71 if (item.assignees.length > 0 || item.claimed || live || statuses.includes("draft")) return "in_progress";
72 return "backlog";
73}
74
75/** Who is on an item: its assignees, then whoever runs a live session on it. */
76export function peopleOn(item: BoardItem, sessions: BoardSession[]): OnIt[] {
77 const people: OnIt[] = item.assignees.map((name) => ({ name, harness: null }));
78 for (const session of sessions) {
79 if (session.item !== item.number || session.state === "ended" || !session.actor) continue;
80 const known = people.find((one) => one.name.toLowerCase() === session.actor!.toLowerCase() && (one.harness === null || one.harness === session.harness));
81 if (known) known.harness ??= session.harness;
82 else people.push({ name: session.actor, harness: session.harness });
83 }
84 return people;
85}
86
87/**
88 * The board: every item in its stage, in the order given, Done newest
89 * closed first. Items closed too long ago are left out; an item given
90 * twice is shown once.
91 */
92export function boardOf<T extends BoardItem>(items: T[], context: BoardContext): Record<Stage, Card<T>[]> {
93 const board: Record<Stage, Card<T>[]> = { backlog: [], in_progress: [], in_review: [], landing: [], done: [] };
94 const seen = new Set<number>();
95 for (const item of items) {
96 if (seen.has(item.number)) continue;
97 seen.add(item.number);
98 const stage = stageOf(item, context);
99 if (stage) board[stage].push({ item, stage, people: peopleOn(item, context.sessions) });
100 }
101 board.done.sort((a, b) => (b.item.closed_at ?? "").localeCompare(a.item.closed_at ?? ""));
102 return board;
103}
104
105/** An item's pull requests' statuses: from the pull requests read, and those sessions name. */
106function pullsOf(number: number, context: BoardContext): PullStatus[] {
107 const statuses = context.pulls.filter((pull) => pull.item === number).map((pull) => pull.status);
108 for (const session of context.sessions) {
109 if (session.item === number && session.pull && session.pull_status) statuses.push(session.pull_status);
110 }
111 return statuses;
112}