| 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 | */ |
| 6 | import type { Claim, PullStatus, WorkItem, WorkspaceSession } from "@g1t/contracts"; |
| 7 | |
| 8 | export type Stage = "backlog" | "progress" | "review" | "landing" | "done"; |
| 9 | |
| 10 | /** The columns, left to right. */ |
| 11 | export const STAGES: Stage[] = ["backlog", "progress", "review", "landing", "done"]; |
| 12 | |
| 13 | export 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. */ |
| 22 | export 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 | */ |
| 29 | export 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. */ |
| 38 | export type ItemPulls = Record<number, ItemPull[]>; |
| 39 | |
| 40 | /** Who or what is on a work item, for its card. */ |
| 41 | export 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 | */ |
| 63 | export 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 | |
| 80 | export 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 | |
| 88 | export 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 | */ |
| 95 | export 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 | */ |
| 133 | export 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. */ |
| 146 | export 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 | */ |
| 153 | export 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. */ |
| 169 | export 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 | */ |
| 176 | export 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. */ |
| 193 | export const ITEM_PAGES = 10; |
| 194 | /** Pages of each repository's open, and of its recently merged, pull requests the board reads at most. */ |
| 195 | export 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 | */ |
| 203 | export 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. */ |
| 215 | export 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 | } |