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.

333 linesCodeBlameRaw
1/**
2 * Work items as the site shows them: where Work's pages are, the filters
3 * the Work list keeps in its address, and an item's repositories in a row
4 * (lib/work-access.ts says who may do what to one). Pure, so it is tested
5 * under Node.
6 */
7import type { Horizon, ItemRepo, LinkRole, OpenSubItems, SubItemProgress } from "@g1t/contracts";
8
9import { tidyLabelName } from "./labels.ts";
10
11export { itemPath } from "./refs.ts";
12
13/**
14 * Where an old address under a repository is now, when it named something
15 * that moved to Work; null when it did not:
16 *
17 * - `/<owner>/<repo>/issues` is All work items filtered to the repository,
18 * keeping its filters (`?state=closed&label=bug`);
19 * - `/<owner>/<repo>/issues/new` a new work item with it as the origin;
20 * - `/<owner>/<repo>/issues/<n>` is `#n` read in the repository, which only
21 * the work service can say: `{ resolve: n }`;
22 * - `/<owner>/<repo>/labels` and `/milestones[/<n>]` the workspace's
23 * (milestones were numbered again in the workspace, so an old one's
24 * number leads to the list).
25 */
26export function movedWorkPath(pathname: string, search = ""): string | { resolve: number } | null {
27 const parts = pathname
28 .replace(/\.data$/, "")
29 .split("/")
30 .filter(Boolean);
31 const [owner, repo, section, rest, more] = parts;
32 if (!owner || !repo || owner === "-" || repo === "-" || parts.length > 5) return null;
33 const workspace = owner.toLowerCase();
34 const old = new URLSearchParams(search);
35 old.delete("_routes");
36 switch (section) {
37 case "issues": {
38 if (rest === undefined) {
39 const query = new URLSearchParams({ repo });
40 for (const [key, value] of old) if (key !== "repo") query.append(key, value);
41 return `${workPath(workspace, "all")}?${query.toString()}`;
42 }
43 if (more !== undefined) return null;
44 if (rest === "new") return newItemPath(workspace, repo);
45 const number = Number(rest);
46 return /^\d+$/.test(rest) && number > 0 ? { resolve: number } : null;
47 }
48 case "labels":
49 return rest === undefined ? workPath(workspace, "labels") : null;
50 case "milestones":
51 return more === undefined ? workPath(workspace, "milestones") : null;
52 }
53 return null;
54}
55
56/** Work's own pages, under the workspace. */
57export function workPath(workspace: string, page = ""): string {
58 return `/${workspace.toLowerCase()}/-/work${page ? `/${page}` : ""}`;
59}
60
61/** The Work list filtered to one repository of the workspace. */
62export function repoWorkPath(workspace: string, repo: string): string {
63 return `${workPath(workspace, "all")}?repo=${encodeURIComponent(repo)}`;
64}
65
66/**
67 * Chat → Sessions: what agents are doing in the workspace, with filters
68 * (`?repo=web&person=ana`). Sessions are Chat's, beside the people you
69 * talk to; they were Work's before (`-/work/sessions`, which leads here).
70 */
71export function sessionsPath(workspace: string, filters: Record<string, string | null | undefined> = {}): string {
72 const query = new URLSearchParams();
73 for (const [key, value] of Object.entries(filters)) if (value) query.set(key, value);
74 const text = query.toString();
75 return `/${workspace.toLowerCase()}/-/chat/sessions${text ? `?${text}` : ""}`;
76}
77
78/** One session's page, in whichever of the workspace's repositories it runs. */
79export function sessionPath(workspace: string, id: string): string {
80 return `/${workspace.toLowerCase()}/-/chat/sessions/${encodeURIComponent(id)}`;
81}
82
83/** Where a session's address from when they were Work's is now: `/acme/-/work/sessions/ses_1` is `/acme/-/chat/sessions/ses_1`. */
84export function movedSessionsPath(pathname: string, search = ""): string | null {
85 const match = /^\/([^/]+)\/-\/work\/sessions(\/[^/]+)?\/?$/.exec(pathname);
86 return match ? `/${match[1]!.toLowerCase()}/-/chat/sessions${match[2] ?? ""}${search}` : null;
87}
88
89/** A new work item, started from a repository (its origin) when `repo` is given, as a sub-item of `parent`, and in `project` (a slug). */
90export function newItemPath(workspace: string, repo?: string | null, parent?: number | null, project?: string | null): string {
91 const query = new URLSearchParams();
92 if (repo) query.set("repo", repo);
93 if (parent) query.set("parent", String(parent));
94 if (project) query.set("project", project);
95 const text = query.toString();
96 return `${workPath(workspace, "new")}${text ? `?${text}` : ""}`;
97}
98
99/**
100 * A work item's number as a person types it, `#212` or `212`: null for
101 * nothing typed, undefined for anything that is not a number.
102 */
103export function itemNumberOf(text: string | null | undefined): number | null | undefined {
104 const word = (text ?? "").trim().replace(/^#/, "");
105 if (!word) return null;
106 return /^\d+$/.test(word) && Number(word) > 0 ? Number(word) : undefined;
107}
108
109export const LINK_ROLES: readonly LinkRole[] = ["origin", "target", "touched"];
110
111/** What each role means, in a few words, as the list's filter and the item page say. */
112export const ROLE_LABELS: Record<LinkRole, string> = {
113 origin: "Origin",
114 target: "Target",
115 touched: "Touched",
116};
117
118/** The plan's horizons the list filters by, in the order work moves through them (`HORIZONS` in contracts). */
119export const LIST_HORIZONS: readonly Horizon[] = ["now", "next", "later", "triage", "set_aside"];
120
121/** Each horizon's name, as the list's filter and its rows say it (`horizonLabel` in contracts). */
122export const HORIZON_LABELS: Record<Horizon, string> = { triage: "Triage", now: "Now", next: "Next", later: "Later", set_aside: "Set aside" };
123
124/** The filters on the Work list, as its address keeps them. */
125export type WorkFilters = {
126 state: "open" | "closed";
127 /** Only open ones on this horizon of the plan, in the plan's order. */
128 horizon: Horizon | null;
129 label: string;
130 milestone: number | null;
131 /** A username, or `@me` for the viewer. */
132 assignee: string;
133 /** A repository of the workspace, by name (`web`) or path (`acme/web`). */
134 repo: string;
135 role: LinkRole | null;
136 /** Only the sub-items of the work item of this number. */
137 parent: number | null;
138 /** Only those in the project of this slug. */
139 project: string;
140};
141
142/**
143 * The Work list's filters from its address: `?state=closed&label=bug&
144 * milestone=3&assignee=ana&repo=web&role=target&horizon=now&parent=212`, and
145 * the same written in `q` (`is:closed label:bug repo:web role:target
146 * horizon:now parent:#212`, or `is:now` for a horizon), which wins. A horizon holds only open work
147 * items, so with one the state is open.
148 */
149export function workFilters(query: URLSearchParams): WorkFilters {
150 let horizon = horizonOf(query.get("horizon"));
151 let label = query.get("label") ?? "";
152 let milestone = Number(query.get("milestone")) || null;
153 let state: "open" | "closed" = query.get("state") === "closed" ? "closed" : "open";
154 let assignee = query.get("assignee") ?? "";
155 let repo = query.get("repo") ?? "";
156 let role = roleOf(query.get("role"));
157 let parent = itemNumberOf(query.get("parent")) ?? null;
158 let project = query.get("project") ?? "";
159 for (const [, key, quoted, plain] of (query.get("q") ?? "").matchAll(/(\w+):(?:"([^"]*)"|(\S+))/g)) {
160 const value = quoted ?? plain ?? "";
161 if (key === "label") label = value;
162 else if (key === "milestone") milestone = Number(value) || null;
163 else if (key === "is" && (value === "open" || value === "closed")) state = value;
164 else if (key === "is" && horizonOf(value)) horizon = horizonOf(value);
165 else if (key === "horizon") horizon = horizonOf(value);
166 else if (key === "assignee") assignee = value;
167 else if (key === "repo") repo = value;
168 else if (key === "role") role = roleOf(value);
169 else if (key === "parent") parent = itemNumberOf(value) ?? null;
170 else if (key === "project") project = value;
171 }
172 return {
173 state: horizon ? "open" : state,
174 horizon,
175 label: label ? tidyLabelName(label) : "",
176 milestone,
177 assignee: assignee.replace(/^@(?!me$)/, "").trim(),
178 repo: repo.trim(),
179 // A role says how a repository is linked: without one it means nothing.
180 role: repo.trim() ? role : null,
181 parent,
182 project: project.trim().toLowerCase(),
183 };
184}
185
186function roleOf(value: string | null | undefined): LinkRole | null {
187 return LINK_ROLES.find((role) => role === value) ?? null;
188}
189
190/** A horizon as the address writes it: `now`, `set_aside` (or `set-aside`), any case. */
191function horizonOf(value: string | null | undefined): Horizon | null {
192 const wanted = value?.trim().toLowerCase().replace(/-/g, "_");
193 return LIST_HORIZONS.find((horizon) => horizon === wanted) ?? null;
194}
195
196/**
197 * What an empty Work list says: the repository it is filtered to, when
198 * that is its only filter; that nothing matches, for any other; else that
199 * there are none in this state.
200 */
201export function emptyListTitle(filters: WorkFilters, repoName: string | null): string {
202 const others = filters.label || filters.milestone || filters.assignee || filters.project;
203 if (filters.horizon && !filters.repo && !others && !filters.parent) return `Nothing in ${HORIZON_LABELS[filters.horizon]}`;
204 if (repoName && !filters.horizon && !others && !filters.parent) return `No ${filters.state} work items linked to ${repoName}`;
205 if (filters.parent && !filters.horizon && !filters.repo && !others) return `No ${filters.state} sub-items of #${filters.parent}`;
206 if (filtered(filters)) return `No ${filters.state} work items match these filters`;
207 return filters.state === "open" ? "No open work items" : "No closed work items";
208}
209
210/** Whether any filter but the state is set. */
211export function filtered(filters: WorkFilters): boolean {
212 return Boolean(filters.horizon || filters.label || filters.milestone || filters.assignee || filters.repo || filters.parent || filters.project);
213}
214
215/** How many filters but the state are set: the phone's Filters button shows it. */
216export function filterCount(filters: WorkFilters): number {
217 return [filters.horizon, filters.label, filters.milestone, filters.assignee, filters.repo, filters.parent, filters.project].filter(Boolean).length;
218}
219
220/** The filters as an address's query, in a steady order, leaving out what is unset. */
221export function workQuery(filters: Partial<WorkFilters>): string {
222 const params = new URLSearchParams();
223 if (filters.horizon) params.set("horizon", filters.horizon);
224 else if (filters.state === "closed") params.set("state", "closed");
225 if (filters.repo) params.set("repo", filters.repo);
226 if (filters.repo && filters.role) params.set("role", filters.role);
227 if (filters.label) params.set("label", filters.label);
228 if (filters.milestone) params.set("milestone", String(filters.milestone));
229 if (filters.assignee) params.set("assignee", filters.assignee);
230 if (filters.parent) params.set("parent", String(filters.parent));
231 if (filters.project) params.set("project", filters.project);
232 return params.toString();
233}
234
235/** All work items (Work's list, beside the plan at `-/work`) with `change` made to `filters`. */
236export function workListPath(workspace: string, filters: Partial<WorkFilters>, change: Partial<WorkFilters> = {}): string {
237 const query = workQuery({ ...filters, ...change });
238 return `${workPath(workspace, "all")}${query ? `?${query}` : ""}`;
239}
240
241/**
242 * Where the list is in its pages, from its address: `before`, the lowest
243 * number the page above showed (none on the first page), and `newer`, the
244 * `before` of each page above it, so Newer can step back one page at a
245 * time (the work service pages only downward).
246 */
247export type ListPage = { before: number | null; newer: number[] };
248
249const cursor = (value: string) => (/^\d+$/.test(value) && Number(value) > 0 ? Number(value) : null);
250
251export function listPage(query: URLSearchParams): ListPage {
252 const before = cursor(query.get("before") ?? "");
253 if (before == null) return { before: null, newer: [] };
254 const newer = (query.get("newer") ?? "")
255 .split(/[.,]/)
256 .map(cursor)
257 .filter((value): value is number => value != null && value > before);
258 return { before, newer };
259}
260
261/**
262 * The address of the page below (`older`, from the lowest number shown)
263 * or above (`newer`), as a query that keeps `filters`: relative, so it
264 * works wherever the list is served.
265 */
266export function pageQuery(filters: Partial<WorkFilters>, at: ListPage, to: { older: number } | "newer"): string {
267 const params = new URLSearchParams(workQuery(filters));
268 if (to === "newer") {
269 const before = at.newer.at(-1);
270 if (before != null) {
271 params.set("before", String(before));
272 if (at.newer.length > 1) params.set("newer", at.newer.slice(0, -1).join("."));
273 }
274 } else {
275 params.set("before", String(to.older));
276 const newer = at.before == null ? [] : [...at.newer, at.before];
277 if (newer.length > 0) params.set("newer", newer.join("."));
278 }
279 const text = params.toString();
280 return `?${text}`;
281}
282
283/** A repository filter as the work service takes it: `web` is the workspace's `web`. */
284export function repoFilter(workspace: string, repo: string): { namespace: string; name: string } | null {
285 const parts = repo.split("/").filter(Boolean);
286 if (parts.length === 1) return { namespace: workspace.toLowerCase(), name: parts[0]! };
287 if (parts.length === 2) return { namespace: parts[0]!.toLowerCase(), name: parts[1]! };
288 return null;
289}
290
291/** A repository's name in its workspace: `web` for `acme/web` in `acme`, the whole path elsewhere. */
292/** What a link to a repository that is gone, with no path kept, is called. */
293export const DELETED_REPO = "deleted repository";
294
295export function shortPath(path: string, workspace: string): string {
296 // A repository deleted before work items had no path left to keep.
297 if (!path) return DELETED_REPO;
298 const [namespace, name] = path.split("/");
299 return name && namespace?.toLowerCase() === workspace.toLowerCase() ? name : path;
300}
301
302/**
303 * An item's repositories as a row of chips that fits a phone: the first
304 * `max` (origin first, as the service lists them), then how many more.
305 */
306export function repoChips(repos: ItemRepo[], max = 2): { shown: ItemRepo[]; more: number } {
307 return { shown: repos.slice(0, max), more: Math.max(0, repos.length - max) };
308}
309
310/** An item's origin repository, if it has one. */
311export function originOf(repos: ItemRepo[]): ItemRepo | null {
312 return repos.find((repo) => repo.role === "origin") ?? null;
313}
314
315/** How far along a parent's sub-items are, in words: "3 of 7 done". */
316export function subItemsDone(progress: SubItemProgress): string {
317 return `${progress.done} of ${progress.total} done`;
318}
319
320/** How far along a parent's sub-items are, as a percentage for a bar. */
321export function subItemsShare(progress: SubItemProgress): number {
322 return progress.total > 0 ? Math.round((progress.done / progress.total) * 100) : 0;
323}
324
325/** What closing a work item with open sub-items does with them, from its form: none chosen, or a choice and where to move them. */
326export function closeSubItemsOf(form: { get(name: string): unknown }): { subItems: OpenSubItems; moveTo?: number } | string | null {
327 const choice = String(form.get("sub_items") ?? "");
328 if (choice === "leave" || choice === "close") return { subItems: choice };
329 if (choice !== "move") return null;
330 const to = itemNumberOf(String(form.get("move_to") ?? ""));
331 if (!to) return "Say which work item to move them to, such as #212.";
332 return { subItems: "move", moveTo: to };
333}