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.

274 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 } 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/** Work → Sessions: what is cooking in the workspace, with filters (`?repo=web&person=ana`). */
67export function sessionsPath(workspace: string, filters: Record<string, string | null | undefined> = {}): string {
68 const query = new URLSearchParams();
69 for (const [key, value] of Object.entries(filters)) if (value) query.set(key, value);
70 const text = query.toString();
71 return `${workPath(workspace, "sessions")}${text ? `?${text}` : ""}`;
72}
73
74/** One session's page, in whichever of the workspace's repositories it runs. */
75export function sessionPath(workspace: string, id: string): string {
76 return `${workPath(workspace, "sessions")}/${encodeURIComponent(id)}`;
77}
78
79/** A new work item, started from a repository (its origin) when `repo` is given. */
80export function newItemPath(workspace: string, repo?: string | null): string {
81 return `${workPath(workspace, "new")}${repo ? `?repo=${encodeURIComponent(repo)}` : ""}`;
82}
83
84export const LINK_ROLES: readonly LinkRole[] = ["origin", "target", "touched"];
85
86/** What each role means, in a few words, as the list's filter and the item page say. */
87export const ROLE_LABELS: Record<LinkRole, string> = {
88 origin: "Origin",
89 target: "Target",
90 touched: "Touched",
91};
92
93/** The plan's horizons the list filters by, in the order work moves through them (`HORIZONS` in contracts). */
94export const LIST_HORIZONS: readonly Horizon[] = ["now", "next", "later", "triage", "set_aside"];
95
96/** Each horizon's name, as the list's filter and its rows say it (`horizonLabel` in contracts). */
97export const HORIZON_LABELS: Record<Horizon, string> = { triage: "Triage", now: "Now", next: "Next", later: "Later", set_aside: "Set aside" };
98
99/** The filters on the Work list, as its address keeps them. */
100export type WorkFilters = {
101 state: "open" | "closed";
102 /** Only open ones on this horizon of the plan, in the plan's order. */
103 horizon: Horizon | null;
104 label: string;
105 milestone: number | null;
106 /** A username, or `@me` for the viewer. */
107 assignee: string;
108 /** A repository of the workspace, by name (`web`) or path (`acme/web`). */
109 repo: string;
110 role: LinkRole | null;
111};
112
113/**
114 * The Work list's filters from its address: `?state=closed&label=bug&
115 * milestone=3&assignee=ana&repo=web&role=target&horizon=now`, and the same
116 * written in `q` (`is:closed label:bug repo:web role:target horizon:now`,
117 * or `is:now` for a horizon), which wins. A horizon holds only open work
118 * items, so with one the state is open.
119 */
120export function workFilters(query: URLSearchParams): WorkFilters {
121 let horizon = horizonOf(query.get("horizon"));
122 let label = query.get("label") ?? "";
123 let milestone = Number(query.get("milestone")) || null;
124 let state: "open" | "closed" = query.get("state") === "closed" ? "closed" : "open";
125 let assignee = query.get("assignee") ?? "";
126 let repo = query.get("repo") ?? "";
127 let role = roleOf(query.get("role"));
128 for (const [, key, quoted, plain] of (query.get("q") ?? "").matchAll(/(\w+):(?:"([^"]*)"|(\S+))/g)) {
129 const value = quoted ?? plain ?? "";
130 if (key === "label") label = value;
131 else if (key === "milestone") milestone = Number(value) || null;
132 else if (key === "is" && (value === "open" || value === "closed")) state = value;
133 else if (key === "is" && horizonOf(value)) horizon = horizonOf(value);
134 else if (key === "horizon") horizon = horizonOf(value);
135 else if (key === "assignee") assignee = value;
136 else if (key === "repo") repo = value;
137 else if (key === "role") role = roleOf(value);
138 }
139 return {
140 state: horizon ? "open" : state,
141 horizon,
142 label: label ? tidyLabelName(label) : "",
143 milestone,
144 assignee: assignee.replace(/^@(?!me$)/, "").trim(),
145 repo: repo.trim(),
146 // A role says how a repository is linked: without one it means nothing.
147 role: repo.trim() ? role : null,
148 };
149}
150
151function roleOf(value: string | null | undefined): LinkRole | null {
152 return LINK_ROLES.find((role) => role === value) ?? null;
153}
154
155/** A horizon as the address writes it: `now`, `set_aside` (or `set-aside`), any case. */
156function horizonOf(value: string | null | undefined): Horizon | null {
157 const wanted = value?.trim().toLowerCase().replace(/-/g, "_");
158 return LIST_HORIZONS.find((horizon) => horizon === wanted) ?? null;
159}
160
161/**
162 * What an empty Work list says: the repository it is filtered to, when
163 * that is its only filter; that nothing matches, for any other; else that
164 * there are none in this state.
165 */
166export function emptyListTitle(filters: WorkFilters, repoName: string | null): string {
167 if (filters.horizon && !filters.repo && !filters.label && !filters.milestone && !filters.assignee) return `Nothing in ${HORIZON_LABELS[filters.horizon]}`;
168 if (repoName && !filters.horizon && !filters.label && !filters.milestone && !filters.assignee) return `No ${filters.state} work items linked to ${repoName}`;
169 if (filtered(filters)) return `No ${filters.state} work items match these filters`;
170 return filters.state === "open" ? "No open work items" : "No closed work items";
171}
172
173/** Whether any filter but the state is set. */
174export function filtered(filters: WorkFilters): boolean {
175 return Boolean(filters.horizon || filters.label || filters.milestone || filters.assignee || filters.repo);
176}
177
178/** How many filters but the state are set: the phone's Filters button shows it. */
179export function filterCount(filters: WorkFilters): number {
180 return [filters.horizon, filters.label, filters.milestone, filters.assignee, filters.repo].filter(Boolean).length;
181}
182
183/** The filters as an address's query, in a steady order, leaving out what is unset. */
184export function workQuery(filters: Partial<WorkFilters>): string {
185 const params = new URLSearchParams();
186 if (filters.horizon) params.set("horizon", filters.horizon);
187 else if (filters.state === "closed") params.set("state", "closed");
188 if (filters.repo) params.set("repo", filters.repo);
189 if (filters.repo && filters.role) params.set("role", filters.role);
190 if (filters.label) params.set("label", filters.label);
191 if (filters.milestone) params.set("milestone", String(filters.milestone));
192 if (filters.assignee) params.set("assignee", filters.assignee);
193 return params.toString();
194}
195
196/** All work items (Work's list, beside the plan at `-/work`) with `change` made to `filters`. */
197export function workListPath(workspace: string, filters: Partial<WorkFilters>, change: Partial<WorkFilters> = {}): string {
198 const query = workQuery({ ...filters, ...change });
199 return `${workPath(workspace, "all")}${query ? `?${query}` : ""}`;
200}
201
202/**
203 * Where the list is in its pages, from its address: `before`, the lowest
204 * number the page above showed (none on the first page), and `newer`, the
205 * `before` of each page above it, so Newer can step back one page at a
206 * time (the work service pages only downward).
207 */
208export type ListPage = { before: number | null; newer: number[] };
209
210const cursor = (value: string) => (/^\d+$/.test(value) && Number(value) > 0 ? Number(value) : null);
211
212export function listPage(query: URLSearchParams): ListPage {
213 const before = cursor(query.get("before") ?? "");
214 if (before == null) return { before: null, newer: [] };
215 const newer = (query.get("newer") ?? "")
216 .split(/[.,]/)
217 .map(cursor)
218 .filter((value): value is number => value != null && value > before);
219 return { before, newer };
220}
221
222/**
223 * The address of the page below (`older`, from the lowest number shown)
224 * or above (`newer`), as a query that keeps `filters`: relative, so it
225 * works wherever the list is served.
226 */
227export function pageQuery(filters: Partial<WorkFilters>, at: ListPage, to: { older: number } | "newer"): string {
228 const params = new URLSearchParams(workQuery(filters));
229 if (to === "newer") {
230 const before = at.newer.at(-1);
231 if (before != null) {
232 params.set("before", String(before));
233 if (at.newer.length > 1) params.set("newer", at.newer.slice(0, -1).join("."));
234 }
235 } else {
236 params.set("before", String(to.older));
237 const newer = at.before == null ? [] : [...at.newer, at.before];
238 if (newer.length > 0) params.set("newer", newer.join("."));
239 }
240 const text = params.toString();
241 return `?${text}`;
242}
243
244/** A repository filter as the work service takes it: `web` is the workspace's `web`. */
245export function repoFilter(workspace: string, repo: string): { namespace: string; name: string } | null {
246 const parts = repo.split("/").filter(Boolean);
247 if (parts.length === 1) return { namespace: workspace.toLowerCase(), name: parts[0]! };
248 if (parts.length === 2) return { namespace: parts[0]!.toLowerCase(), name: parts[1]! };
249 return null;
250}
251
252/** A repository's name in its workspace: `web` for `acme/web` in `acme`, the whole path elsewhere. */
253/** What a link to a repository that is gone, with no path kept, is called. */
254export const DELETED_REPO = "deleted repository";
255
256export function shortPath(path: string, workspace: string): string {
257 // A repository deleted before work items had no path left to keep.
258 if (!path) return DELETED_REPO;
259 const [namespace, name] = path.split("/");
260 return name && namespace?.toLowerCase() === workspace.toLowerCase() ? name : path;
261}
262
263/**
264 * An item's repositories as a row of chips that fits a phone: the first
265 * `max` (origin first, as the service lists them), then how many more.
266 */
267export function repoChips(repos: ItemRepo[], max = 2): { shown: ItemRepo[]; more: number } {
268 return { shown: repos.slice(0, max), more: Math.max(0, repos.length - max) };
269}
270
271/** An item's origin repository, if it has one. */
272export function originOf(repos: ItemRepo[]): ItemRepo | null {
273 return repos.find((repo) => repo.role === "origin") ?? null;
274}