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.

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