| 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 { ItemRepo, LinkRole } 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 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 | */ |
| 26 | export 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. */ |
| 54 | export 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. */ |
| 59 | export function repoWorkPath(workspace: string, repo: string): string { |
| 60 | return `${workPath(workspace)}?repo=${encodeURIComponent(repo)}`; |
| 61 | } |
| 62 | |
| 63 | /** Sessions, the workspace's: what is cooking in it, with filters (`?repo=web&person=ana`). */ |
| 64 | export 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 `/${workspace.toLowerCase()}/-/sessions${text ? `?${text}` : ""}`; |
| 69 | } |
| 70 | |
| 71 | /** One session's page, in whichever of the workspace's repositories it runs. */ |
| 72 | export function sessionPath(workspace: string, id: string): string { |
| 73 | return `/${workspace.toLowerCase()}/-/sessions/${encodeURIComponent(id)}`; |
| 74 | } |
| 75 | |
| 76 | /** A new work item, started from a repository (its origin) when `repo` is given. */ |
| 77 | export function newItemPath(workspace: string, repo?: string | null): string { |
| 78 | return `${workPath(workspace, "new")}${repo ? `?repo=${encodeURIComponent(repo)}` : ""}`; |
| 79 | } |
| 80 | |
| 81 | export 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. */ |
| 84 | export 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. */ |
| 91 | export 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 | */ |
| 107 | export 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 | |
| 134 | function roleOf(value: string | null | undefined): LinkRole | null { |
| 135 | return LINK_ROLES.find((role) => role === value) ?? null; |
| 136 | } |
| 137 | |
| 138 | /** Whether any filter but the state is set. */ |
| 139 | export function filtered(filters: WorkFilters): boolean { |
| 140 | return Boolean(filters.label || filters.milestone || filters.assignee || filters.repo); |
| 141 | } |
| 142 | |
| 143 | /** How many filters but the state are set: the phone's Filters button shows it. */ |
| 144 | export function filterCount(filters: WorkFilters): number { |
| 145 | return [filters.label, filters.milestone, filters.assignee, filters.repo].filter(Boolean).length; |
| 146 | } |
| 147 | |
| 148 | /** The filters as an address's query, in a steady order, leaving out what is unset. */ |
| 149 | export function workQuery(filters: Partial<WorkFilters>): string { |
| 150 | const params = new URLSearchParams(); |
| 151 | if (filters.state === "closed") params.set("state", "closed"); |
| 152 | if (filters.repo) params.set("repo", filters.repo); |
| 153 | if (filters.repo && filters.role) params.set("role", filters.role); |
| 154 | if (filters.label) params.set("label", filters.label); |
| 155 | if (filters.milestone) params.set("milestone", String(filters.milestone)); |
| 156 | if (filters.assignee) params.set("assignee", filters.assignee); |
| 157 | return params.toString(); |
| 158 | } |
| 159 | |
| 160 | /** The Work list with `change` made to `filters`. */ |
| 161 | export function workListPath(workspace: string, filters: Partial<WorkFilters>, change: Partial<WorkFilters> = {}): string { |
| 162 | const query = workQuery({ ...filters, ...change }); |
| 163 | return `${workPath(workspace)}${query ? `?${query}` : ""}`; |
| 164 | } |
| 165 | |
| 166 | /** A repository filter as the work service takes it: `web` is the workspace's `web`. */ |
| 167 | export function repoFilter(workspace: string, repo: string): { namespace: string; name: string } | null { |
| 168 | const parts = repo.split("/").filter(Boolean); |
| 169 | if (parts.length === 1) return { namespace: workspace.toLowerCase(), name: parts[0]! }; |
| 170 | if (parts.length === 2) return { namespace: parts[0]!.toLowerCase(), name: parts[1]! }; |
| 171 | return null; |
| 172 | } |
| 173 | |
| 174 | /** A repository's name in its workspace: `web` for `acme/web` in `acme`, the whole path elsewhere. */ |
| 175 | export function shortPath(path: string, workspace: string): string { |
| 176 | const [namespace, name] = path.split("/"); |
| 177 | return name && namespace?.toLowerCase() === workspace.toLowerCase() ? name : path; |
| 178 | } |
| 179 | |
| 180 | /** |
| 181 | * An item's repositories as a row of chips that fits a phone: the first |
| 182 | * `max` (origin first, as the service lists them), then how many more. |
| 183 | */ |
| 184 | export function repoChips(repos: ItemRepo[], max = 2): { shown: ItemRepo[]; more: number } { |
| 185 | return { shown: repos.slice(0, max), more: Math.max(0, repos.length - max) }; |
| 186 | } |
| 187 | |
| 188 | /** An item's origin repository, if it has one. */ |
| 189 | export function originOf(repos: ItemRepo[]): ItemRepo | null { |
| 190 | return repos.find((repo) => repo.role === "origin") ?? null; |
| 191 | } |