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.

168 linesCodeBlameRaw
1/**
2 * A work item's page, worked out: its keys, the list it was opened from
3 * (for J and K), its Activity's filter, the stage it is in and why, and
4 * what the pickers read from what is typed. Pure, so it is tested under
5 * Node; the page draws it (routes/workspace/work/item.tsx).
6 */
7import type { Appetite, Claim, Horizon, WorkItem, WorkPull, WorkSession } from "@g1t/contracts";
8
9import { STAGE_INFO, type Stage, stageOf } from "./board.ts";
10
11export { ITEM_KEYS, type ItemCommand, itemCommandOf, keyFor } from "./item-keys.ts";
12export { TRAIL_LIMIT, type WorkTrail, neighbours, trailOf, trailState } from "./work-trail.ts";
13
14// --- Activity ---------------------------------------------------------------
15
16export type ActivityFilter = "all" | "comments" | "events";
17
18export const ACTIVITY_FILTERS: { value: ActivityFilter; label: string }[] = [
19 { value: "all", label: "All" },
20 { value: "comments", label: "Comments" },
21 { value: "events", label: "Events" },
22];
23
24/** The filter an address asks for (`?activity=comments`); All otherwise. */
25export function activityFilterOf(value: string | null | undefined): ActivityFilter {
26 return ACTIVITY_FILTERS.find((one) => one.value === value)?.value ?? "all";
27}
28
29/**
30 * The Activity stream, filtered: what people wrote is a comment; what
31 * happened (a note the service wrote, `kind: "event"`, and every extra:
32 * sessions, claims, handoffs) is an event. The conversation draws the
33 * rest, oldest first (lib/comments.ts `interleave`).
34 */
35export function activityParts<C extends { kind: string }, X>(
36 comments: readonly C[],
37 extras: readonly X[],
38 filter: ActivityFilter,
39): { comments: C[]; extras: X[] } {
40 if (filter === "comments") return { comments: comments.filter((one) => one.kind !== "event"), extras: [] };
41 if (filter === "events") return { comments: comments.filter((one) => one.kind === "event"), extras: [...extras] };
42 return { comments: [...comments], extras: [...extras] };
43}
44
45/** How many of each the stream holds, for the filter's buttons. */
46export function activityCounts(comments: readonly { kind: string }[], extras: number): Record<ActivityFilter, number> {
47 const events = comments.filter((one) => one.kind === "event").length + extras;
48 return { all: comments.length + extras, comments: comments.length - (events - extras), events };
49}
50
51// --- Stage, with why ----------------------------------------------------------
52
53/** The stage a work item is in, as Work's board reads it (lib/board.ts), with why in a few words. */
54export type ItemStage = { stage: Stage | "closed"; label: string; why: string };
55
56/**
57 * Its stage, read from what is so, and the facts that put it there: "In
58 * review: #318 open in acme/web". A closed one is Done for 14 days, then
59 * Closed; one closed as not planned is Closed.
60 */
61export function itemStage(
62 item: WorkItem,
63 facts: {
64 repos?: readonly { path: string; pulls: readonly Pick<WorkPull, "number" | "status">[] }[];
65 claims?: readonly Claim[];
66 sessions?: readonly WorkSession[];
67 },
68 now: number,
69): ItemStage {
70 const repos = facts.repos ?? [];
71 const pulls = repos.flatMap((repo) => repo.pulls.map((pull) => ({ ...pull, repo: repo.path })));
72 const claims = (facts.claims ?? []).filter((claim) => claim.state === "active");
73 const live = (facts.sessions ?? []).filter((session) => session.state !== "ended" && !session.parentId);
74 const stage = stageOf(
75 item,
76 {
77 pulls: pulls.map((pull) => ({ key: `${pull.repo}#${pull.number}`.toLowerCase(), status: pull.status, part: false })),
78 claimed: claims.length > 0,
79 live: live.length > 0,
80 },
81 now,
82 );
83 if (stage == null) {
84 return { stage: "closed", label: "Closed", why: item.reason === "not_planned" ? "Closed as not planned" : "Closed" };
85 }
86 const label = STAGE_INFO[stage].label;
87 const pullAt = (pull: (typeof pulls)[number]) => `#${pull.number} ${pull.status === "draft" ? "draft" : pull.status} in ${pull.repo}`;
88 switch (stage) {
89 case "done":
90 return {
91 stage,
92 label,
93 why: item.resolutions.length > 0 ? `Landed in ${[...new Set(item.resolutions.map((one) => one.repo))].join(", ")}` : "Closed as completed",
94 };
95 case "landing": {
96 const missing = item.repos.filter((repo) => repo.role === "target" && !repo.landed).map((repo) => repo.path);
97 return { stage, label, why: missing.length > 0 ? `Still to land in ${missing.join(", ")}` : "Landing" };
98 }
99 case "review": {
100 const open = pulls.filter((pull) => pull.status === "open");
101 return { stage, label, why: open.map(pullAt).join(", ") };
102 }
103 case "progress": {
104 const reasons: string[] = [];
105 if (claims.length > 0) reasons.push(`claimed by ${[...new Set(claims.map((claim) => claim.actor.username))].join(", ")}`);
106 if (live.length > 0) reasons.push(live.length === 1 ? "a session on it" : `${live.length} sessions on it`);
107 const drafts = pulls.filter((pull) => pull.status === "draft");
108 if (drafts.length > 0) reasons.push(drafts.map(pullAt).join(", "));
109 if (reasons.length === 0 && item.assignees.length > 0) reasons.push(`assigned to ${item.assignees.join(", ")}`);
110 if (reasons.length === 0 && pulls.some((pull) => pull.status === "merged")) reasons.push("a pull request merged without closing it");
111 const text = reasons.join(", ");
112 return { stage, label, why: text.charAt(0).toUpperCase() + text.slice(1) };
113 }
114 default:
115 return { stage, label, why: "Nobody is on it yet" };
116 }
117}
118
119// --- What the pickers read ----------------------------------------------------
120
121/** A work item as a picker lists it (routes/workspace/work/items-json.ts). */
122export type ItemChoice = { number: number; title: string; state: "open" | "closed" };
123
124/**
125 * A budget typed in the Plan picker, `5`, `$12.50` or `none`: dollars, or
126 * null to clear it; undefined when what is typed is no budget.
127 */
128export function budgetOf(text: string): number | null | undefined {
129 const word = text.trim().toLowerCase().replace(/^\$/, "");
130 if (word === "none" || word === "no budget") return null;
131 if (!/^\d+(\.\d{1,2})?$/.test(word)) return undefined;
132 const dollars = Number(word);
133 return dollars > 0 && dollars <= 100_000 ? dollars : undefined;
134}
135
136/** A work item's number typed in a picker (`#212`, `212`), or null. */
137export function typedNumber(text: string): number | null {
138 const word = text.trim().replace(/^#/, "");
139 return /^\d{1,9}$/.test(word) && Number(word) > 0 ? Number(word) : null;
140}
141
142/** A username typed in the assignee picker (`@ana`, `ana`), tidied; null for none or anything that cannot be one. */
143export function typedUsername(text: string): string | null {
144 const word = text.trim().replace(/^@/, "").toLowerCase();
145 return /^[a-z0-9](?:[a-z0-9-]{0,38})$/.test(word) ? word : null;
146}
147
148/** A set with `value` taken out when it is in it, or added at the end when it is not. */
149export function toggled<T>(values: readonly T[], value: T): T[] {
150 return values.includes(value) ? values.filter((one) => one !== value) : [...values, value];
151}
152
153/** Whether two lists hold the same values, in any order. */
154export function sameSet<T>(a: readonly T[], b: readonly T[]): boolean {
155 return a.length === b.length && a.every((one) => b.includes(one));
156}
157
158/** The plan as one line for the panel: `Now · M · $5 · Urgent`. */
159export function planSummary(
160 item: { horizon: Horizon; appetite: Appetite | null; budgetUsd: number | null; urgent: boolean },
161 horizonLabel: (h: Horizon) => string,
162): string {
163 const parts = [horizonLabel(item.horizon)];
164 if (item.appetite) parts.push(item.appetite.charAt(0).toUpperCase());
165 if (item.budgetUsd != null) parts.push(`$${Number.isInteger(item.budgetUsd) ? item.budgetUsd : item.budgetUsd.toFixed(2)}`);
166 if (item.urgent) parts.push("Urgent");
167 return parts.join(" · ");
168}