| 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, 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. */ |
| 22 | export const DONE_DAYS = 14; |
| 23 | |
| 24 | /** What is known of a work item's pull requests: their statuses. */ |
| 25 | export type ItemPulls = Record<number, PullStatus[]>; |
| 26 | |
| 27 | /** Who or what is on a work item, for its card. */ |
| 28 | export 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 | */ |
| 42 | export 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 | |
| 55 | export type BoardCard = { item: WorkItem; stage: Stage; on: OnIt }; |
| 56 | |
| 57 | export 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 | */ |
| 64 | export 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 | /** Each work item's pull requests' statuses, from pull requests that name their item. */ |
| 91 | export function pullsByItem(pulls: { item: number | null; status: PullStatus }[]): ItemPulls { |
| 92 | const by: ItemPulls = {}; |
| 93 | for (const pull of pulls) { |
| 94 | if (pull.item == null) continue; |
| 95 | (by[pull.item] ??= []).push(pull.status); |
| 96 | } |
| 97 | return by; |
| 98 | } |
| 99 | |
| 100 | /** The stage a phone shows, from the address: a known one, else the first with anything in it, else Backlog. */ |
| 101 | export function shownStage(asked: string | null, board: Board): Stage { |
| 102 | if (asked && (STAGES as string[]).includes(asked)) return asked as Stage; |
| 103 | return STAGES.find((stage) => board[stage].length > 0) ?? "backlog"; |
| 104 | } |