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.

207 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 { 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 the Work list 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.replace(/\.data$/, "").split("/").filter(Boolean);
28 const [owner, repo, section, rest, more] = parts;
29 if (!owner || !repo || owner === "-" || repo === "-" || parts.length > 5) return null;
30 const workspace = owner.toLowerCase();
31 const old = new URLSearchParams(search);
32 old.delete("_routes");
33 switch (section) {
34 case "issues": {
35 if (rest === undefined) {
36 const query = new URLSearchParams({ repo });
37 for (const [key, value] of old) if (key !== "repo") query.append(key, value);
38 return `${workPath(workspace)}?${query.toString()}`;
39 }
40 if (more !== undefined) return null;
41 if (rest === "new") return newItemPath(workspace, repo);
42 const number = Number(rest);
43 return /^\d+$/.test(rest) && number > 0 ? { resolve: number } : null;
44 }
45 case "labels":
46 return rest === undefined ? workPath(workspace, "labels") : null;
47 case "milestones":
48 return more === undefined ? workPath(workspace, "milestones") : null;
49 }
50 return null;
51}
52
53/** Work's own pages, under the workspace. */
54export function workPath(workspace: string, page = ""): string {
55 return `/${workspace.toLowerCase()}/-/work${page ? `/${page}` : ""}`;
56}
57
58/** The Work list filtered to one repository of the workspace. */
59export function repoWorkPath(workspace: string, repo: string): string {
60 return `${workPath(workspace)}?repo=${encodeURIComponent(repo)}`;
61}
62
63/** Work → Sessions: what is cooking in the workspace, with filters (`?repo=web&person=ana`). */
64export function sessionsPath(workspace: string, filters: Record<string, string | null | undefined> = {}): string {
65 const query = new URLSearchParams();
66 for (const [key, value] of Object.entries(filters)) if (value) query.set(key, value);
67 const text = query.toString();
68 return `${workPath(workspace, "sessions")}${text ? `?${text}` : ""}`;
69}
70
71/** One session's page, in whichever of the workspace's repositories it runs. */
72export function sessionPath(workspace: string, id: string): string {
73 return `${workPath(workspace, "sessions")}/${encodeURIComponent(id)}`;
74}
75
76/** A new work item, started from a repository (its origin) when `repo` is given. */
77export function newItemPath(workspace: string, repo?: string | null): string {
78 return `${workPath(workspace, "new")}${repo ? `?repo=${encodeURIComponent(repo)}` : ""}`;
79}
80
81export const LINK_ROLES: readonly LinkRole[] = ["origin", "target", "touched"];
82
83/** What each role means, in a few words, as the list's filter and the item page say. */
84export const ROLE_LABELS: Record<LinkRole, string> = {
85 origin: "Origin",
86 target: "Target",
87 touched: "Touched",
88};
89
90/** The filters on the Work list, as its address keeps them. */
91export type WorkFilters = {
92 state: "open" | "closed";
93 label: string;
94 milestone: number | null;
95 /** A username, or `@me` for the viewer. */
96 assignee: string;
97 /** A repository of the workspace, by name (`web`) or path (`acme/web`). */
98 repo: string;
99 role: LinkRole | null;
100};
101
102/**
103 * The Work list's filters from its address: `?state=closed&label=bug&
104 * milestone=3&assignee=ana&repo=web&role=target`, and the same written in
105 * `q` (`is:closed label:bug repo:web role:target`), which wins.
106 */
107export function workFilters(query: URLSearchParams): WorkFilters {
108 let label = query.get("label") ?? "";
109 let milestone = Number(query.get("milestone")) || null;
110 let state: "open" | "closed" = query.get("state") === "closed" ? "closed" : "open";
111 let assignee = query.get("assignee") ?? "";
112 let repo = query.get("repo") ?? "";
113 let role = roleOf(query.get("role"));
114 for (const [, key, quoted, plain] of (query.get("q") ?? "").matchAll(/(\w+):(?:"([^"]*)"|(\S+))/g)) {
115 const value = quoted ?? plain ?? "";
116 if (key === "label") label = value;
117 else if (key === "milestone") milestone = Number(value) || null;
118 else if (key === "is" && (value === "open" || value === "closed")) state = value;
119 else if (key === "assignee") assignee = value;
120 else if (key === "repo") repo = value;
121 else if (key === "role") role = roleOf(value);
122 }
123 return {
124 state,
125 label: label ? tidyLabelName(label) : "",
126 milestone,
127 assignee: assignee.replace(/^@(?!me$)/, "").trim(),
128 repo: repo.trim(),
129 // A role says how a repository is linked: without one it means nothing.
130 role: repo.trim() ? role : null,
131 };
132}
133
134function roleOf(value: string | null | undefined): LinkRole | null {
135 return LINK_ROLES.find((role) => role === value) ?? null;
136}
137
138/**
139 * What an empty Work list says: the repository it is filtered to, when
140 * that is its only filter; that nothing matches, for any other; else that
141 * there are none in this state.
142 */
143export function emptyListTitle(filters: WorkFilters, repoName: string | null): string {
144 if (repoName && !filters.label && !filters.milestone && !filters.assignee) return `No ${filters.state} work items linked to ${repoName}`;
145 if (filtered(filters)) return `No ${filters.state} work items match these filters`;
146 return filters.state === "open" ? "No open work items" : "No closed work items";
147}
148
149/** Whether any filter but the state is set. */
150export function filtered(filters: WorkFilters): boolean {
151 return Boolean(filters.label || filters.milestone || filters.assignee || filters.repo);
152}
153
154/** How many filters but the state are set: the phone's Filters button shows it. */
155export function filterCount(filters: WorkFilters): number {
156 return [filters.label, filters.milestone, filters.assignee, filters.repo].filter(Boolean).length;
157}
158
159/** The filters as an address's query, in a steady order, leaving out what is unset. */
160export function workQuery(filters: Partial<WorkFilters>): string {
161 const params = new URLSearchParams();
162 if (filters.state === "closed") params.set("state", "closed");
163 if (filters.repo) params.set("repo", filters.repo);
164 if (filters.repo && filters.role) params.set("role", filters.role);
165 if (filters.label) params.set("label", filters.label);
166 if (filters.milestone) params.set("milestone", String(filters.milestone));
167 if (filters.assignee) params.set("assignee", filters.assignee);
168 return params.toString();
169}
170
171/** The Work list with `change` made to `filters`. */
172export function workListPath(workspace: string, filters: Partial<WorkFilters>, change: Partial<WorkFilters> = {}): string {
173 const query = workQuery({ ...filters, ...change });
174 return `${workPath(workspace)}${query ? `?${query}` : ""}`;
175}
176
177/** A repository filter as the work service takes it: `web` is the workspace's `web`. */
178export function repoFilter(workspace: string, repo: string): { namespace: string; name: string } | null {
179 const parts = repo.split("/").filter(Boolean);
180 if (parts.length === 1) return { namespace: workspace.toLowerCase(), name: parts[0]! };
181 if (parts.length === 2) return { namespace: parts[0]!.toLowerCase(), name: parts[1]! };
182 return null;
183}
184
185/** A repository's name in its workspace: `web` for `acme/web` in `acme`, the whole path elsewhere. */
186/** What a link to a repository that is gone, with no path kept, is called. */
187export const DELETED_REPO = "deleted repository";
188
189export function shortPath(path: string, workspace: string): string {
190 // A repository deleted before work items had no path left to keep.
191 if (!path) return DELETED_REPO;
192 const [namespace, name] = path.split("/");
193 return name && namespace?.toLowerCase() === workspace.toLowerCase() ? name : path;
194}
195
196/**
197 * An item's repositories as a row of chips that fits a phone: the first
198 * `max` (origin first, as the service lists them), then how many more.
199 */
200export function repoChips(repos: ItemRepo[], max = 2): { shown: ItemRepo[]; more: number } {
201 return { shown: repos.slice(0, max), more: Math.max(0, repos.length - max) };
202}
203
204/** An item's origin repository, if it has one. */
205export function originOf(repos: ItemRepo[]): ItemRepo | null {
206 return repos.find((repo) => repo.role === "origin") ?? null;
207}