| 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 | */ |
| 7 | import type { Horizon, ItemRepo, LinkRole, OpenSubItems, SubItemProgress } from "@g1t/contracts"; |
| 8 | |
| 9 | import { tidyLabelName } from "./labels.ts"; |
| 10 | |
| 11 | export { 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 | */ |
| 26 | export 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. */ |
| 57 | export 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. */ |
| 62 | export 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 | */ |
| 71 | export 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. */ |
| 79 | export 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`. */ |
| 84 | export 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). */ |
| 90 | export 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 | */ |
| 103 | export 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 | |
| 109 | export 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. */ |
| 112 | export 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). */ |
| 119 | export 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). */ |
| 122 | export 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. */ |
| 125 | export 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 | */ |
| 149 | export 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 | |
| 186 | function 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. */ |
| 191 | function 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 | */ |
| 201 | export 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. */ |
| 211 | export 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. */ |
| 216 | export 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. */ |
| 221 | export 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`. */ |
| 236 | export 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 | */ |
| 247 | export type ListPage = { before: number | null; newer: number[] }; |
| 248 | |
| 249 | const cursor = (value: string) => (/^\d+$/.test(value) && Number(value) > 0 ? Number(value) : null); |
| 250 | |
| 251 | export 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 | */ |
| 266 | export 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`. */ |
| 284 | export 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. */ |
| 293 | export const DELETED_REPO = "deleted repository"; |
| 294 | |
| 295 | export 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 | */ |
| 306 | export 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. */ |
| 311 | export 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". */ |
| 316 | export 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. */ |
| 321 | export 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. */ |
| 326 | export 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 | } |