| 1 | import { useInfiniteQuery, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 2 | |
| 3 | import { boardOf } from "./board"; |
| 4 | import type { Client } from "./client"; |
| 5 | import type { ApiComment, ApiPull, ApiUser, CheckStatus } from "./code"; |
| 6 | import type { RefContext, RefTarget } from "./links"; |
| 7 | import { useSignedIn } from "./session"; |
| 8 | import { type ApiSessionDetail, type ApiWorkspaceSessions, ENTRY_PAGE, type SessionsFilter, isLive, sessionsQuery } from "./sessions"; |
| 9 | |
| 10 | /** |
| 11 | * Work, through the REST API (docs.g1t.sh/reference/api/work-items/): a |
| 12 | * workspace's work items, each linked to the repositories it concerns. |
| 13 | * As in lib/code.ts these are the API's snake_case shapes, and only the |
| 14 | * fields the app shows are named. |
| 15 | */ |
| 16 | |
| 17 | export type LinkRole = "origin" | "target" | "touched"; |
| 18 | export type CloseReason = "completed" | "not_planned"; |
| 19 | |
| 20 | /** A repository a work item is linked to. */ |
| 21 | export type ApiItemRepo = { |
| 22 | repo_id: string; |
| 23 | /** `owner/name`. */ |
| 24 | path: string; |
| 25 | role: LinkRole; |
| 26 | /** For an item that was an issue of this repository: its number there. */ |
| 27 | legacy_number: number | null; |
| 28 | /** What closes it landed here. */ |
| 29 | landed: boolean; |
| 30 | }; |
| 31 | |
| 32 | export type ApiWorkItem = { |
| 33 | id: string; |
| 34 | workspace: string; |
| 35 | number: number; |
| 36 | title: string; |
| 37 | body: string; |
| 38 | labels: string[]; |
| 39 | state: "open" | "closed"; |
| 40 | reason: CloseReason | null; |
| 41 | author: ApiUser; |
| 42 | assignees: string[]; |
| 43 | milestone?: { number: number; title: string } | null; |
| 44 | created_at: string; |
| 45 | updated_at: string; |
| 46 | closed_at: string | null; |
| 47 | comment_count: number; |
| 48 | pull_count: number; |
| 49 | repos: ApiItemRepo[]; |
| 50 | }; |
| 51 | |
| 52 | /** A pull request for a work item, in the repository it is in. */ |
| 53 | export type ApiWorkPull = { |
| 54 | repo: string; |
| 55 | number: number; |
| 56 | title: string; |
| 57 | status: "draft" | "open" | "merged" | "closed"; |
| 58 | check_status: CheckStatus; |
| 59 | }; |
| 60 | |
| 61 | export type ApiWorkRepo = ApiItemRepo & { pulls: ApiWorkPull[] }; |
| 62 | |
| 63 | /** A repository the work may belong in, and why g1t thinks so. */ |
| 64 | export type ApiSuggestedTarget = { repo_id: string; path: string; why: string }; |
| 65 | |
| 66 | /** `GET …/work/{number}/work`: the item with its repositories, their pull requests, and what is missing. */ |
| 67 | export type ApiWorkContext = { |
| 68 | item: ApiWorkItem; |
| 69 | repos: ApiWorkRepo[]; |
| 70 | missing: string[]; |
| 71 | suggested_targets: ApiSuggestedTarget[]; |
| 72 | }; |
| 73 | |
| 74 | export type ApiLabel = { name: string; color: string; description: string }; |
| 75 | export type ApiMilestone = { number: number; title: string; state: "open" | "closed"; due_on: string | null }; |
| 76 | |
| 77 | /** What the list shows; each field is one of the API's list filters. */ |
| 78 | export type WorkFilter = { |
| 79 | state: "open" | "closed" | "all"; |
| 80 | label?: string; |
| 81 | milestone?: number; |
| 82 | assignee?: string; |
| 83 | /** A repository of the workspace, by name. */ |
| 84 | repo?: string; |
| 85 | role?: LinkRole; |
| 86 | }; |
| 87 | |
| 88 | export const PAGE = 30; |
| 89 | |
| 90 | const workPath = (workspace: string) => `/workspaces/${encodeURIComponent(workspace)}/work`; |
| 91 | const itemPath = (workspace: string, number: number) => `${workPath(workspace)}/${number}`; |
| 92 | |
| 93 | /** The query string for a filter and a page. */ |
| 94 | export function filterQuery(filter: WorkFilter, page: number): string { |
| 95 | const params = new URLSearchParams({ state: filter.state, page: String(page), per_page: String(PAGE) }); |
| 96 | if (filter.label) params.set("label", filter.label); |
| 97 | if (filter.milestone) params.set("milestone", String(filter.milestone)); |
| 98 | if (filter.assignee) params.set("assignee", filter.assignee); |
| 99 | if (filter.repo) params.set("repo", filter.repo); |
| 100 | if (filter.repo && filter.role) params.set("role", filter.role); |
| 101 | return params.toString(); |
| 102 | } |
| 103 | |
| 104 | /** |
| 105 | * A workspace's work items, newest first, a page at a time. A page that |
| 106 | * brings nothing new ends the list, so an answer that ignores paging is |
| 107 | * shown once. |
| 108 | */ |
| 109 | export function useWorkItems(workspace: string | undefined, filter: WorkFilter) { |
| 110 | const { client } = useSignedIn(); |
| 111 | const query = useInfiniteQuery({ |
| 112 | queryKey: ["work", "items", workspace?.toLowerCase(), filter], |
| 113 | enabled: Boolean(workspace), |
| 114 | initialPageParam: 1, |
| 115 | queryFn: ({ pageParam }) => client.api<ApiWorkItem[]>(`${workPath(workspace!)}?${filterQuery(filter, pageParam)}`), |
| 116 | getNextPageParam: (last, pages, lastPage) => { |
| 117 | if (last.length < PAGE) return undefined; |
| 118 | const seen = new Set(pages.slice(0, -1).flatMap((page) => page.map((item) => item.id))); |
| 119 | return last.some((item) => !seen.has(item.id)) ? lastPage + 1 : undefined; |
| 120 | }, |
| 121 | }); |
| 122 | const items = (() => { |
| 123 | const seen = new Set<string>(); |
| 124 | return (query.data?.pages ?? []).flat().filter((item) => (seen.has(item.id) ? false : (seen.add(item.id), true))); |
| 125 | })(); |
| 126 | return { ...query, items }; |
| 127 | } |
| 128 | |
| 129 | export function useWorkItem(workspace: string, number: number) { |
| 130 | const { client } = useSignedIn(); |
| 131 | return useQuery({ |
| 132 | queryKey: ["work", "item", workspace.toLowerCase(), number], |
| 133 | queryFn: () => client.api<{ item: ApiWorkItem; comments: ApiComment[] }>(itemPath(workspace, number)), |
| 134 | }); |
| 135 | } |
| 136 | |
| 137 | /** The item's repositories with their pull requests, what has not landed, and suggested targets. */ |
| 138 | export function useWorkContext(workspace: string, number: number) { |
| 139 | const { client } = useSignedIn(); |
| 140 | return useQuery({ |
| 141 | queryKey: ["work", "context", workspace.toLowerCase(), number], |
| 142 | queryFn: () => client.api<ApiWorkContext>(`${itemPath(workspace, number)}/work`), |
| 143 | retry: false, |
| 144 | }); |
| 145 | } |
| 146 | |
| 147 | export function useLabels(workspace: string | undefined) { |
| 148 | const { client } = useSignedIn(); |
| 149 | return useQuery({ |
| 150 | queryKey: ["work", "labels", workspace?.toLowerCase()], |
| 151 | enabled: Boolean(workspace), |
| 152 | queryFn: () => client.api<ApiLabel[]>(`/workspaces/${encodeURIComponent(workspace!)}/labels`), |
| 153 | staleTime: 5 * 60_000, |
| 154 | }); |
| 155 | } |
| 156 | |
| 157 | export function useMilestones(workspace: string | undefined) { |
| 158 | const { client } = useSignedIn(); |
| 159 | return useQuery({ |
| 160 | queryKey: ["work", "milestones", workspace?.toLowerCase()], |
| 161 | enabled: Boolean(workspace), |
| 162 | queryFn: () => client.api<ApiMilestone[]>(`/workspaces/${encodeURIComponent(workspace!)}/milestones?state=open`), |
| 163 | staleTime: 5 * 60_000, |
| 164 | }); |
| 165 | } |
| 166 | |
| 167 | /** Everything that changes an item: comment, close, reopen, link and unlink a repository. */ |
| 168 | export function useItemActions(workspace: string, number: number) { |
| 169 | const { client } = useSignedIn(); |
| 170 | const queries = useQueryClient(); |
| 171 | const path = itemPath(workspace, number); |
| 172 | const refresh = () => |
| 173 | Promise.all([ |
| 174 | queries.invalidateQueries({ queryKey: ["work", "item", workspace.toLowerCase(), number] }), |
| 175 | queries.invalidateQueries({ queryKey: ["work", "context", workspace.toLowerCase(), number] }), |
| 176 | queries.invalidateQueries({ queryKey: ["work", "items", workspace.toLowerCase()] }), |
| 177 | ]); |
| 178 | const post = <T>(suffix: string, body: unknown, method = "POST") => client.api<T>(`${path}${suffix}`, { method, body: JSON.stringify(body) }); |
| 179 | return { |
| 180 | comment: useMutation({ mutationFn: (body: string) => post<ApiComment>("/comments", { body }), onSuccess: refresh }), |
| 181 | close: useMutation({ mutationFn: (reason: CloseReason) => post<ApiWorkItem>("/close", { reason }), onSuccess: refresh }), |
| 182 | reopen: useMutation({ mutationFn: () => post<ApiWorkItem>("/reopen", {}), onSuccess: refresh }), |
| 183 | link: useMutation({ |
| 184 | mutationFn: ({ repo, role = "target" }: { repo: string; role?: LinkRole }) => post<ApiWorkItem>("/links", { repo, role }), |
| 185 | onSuccess: refresh, |
| 186 | }), |
| 187 | unlink: useMutation({ |
| 188 | mutationFn: (repo: string) => client.api<ApiWorkItem>(`${path}/links?repo=${encodeURIComponent(repo)}`, { method: "DELETE" }), |
| 189 | onSuccess: refresh, |
| 190 | }), |
| 191 | }; |
| 192 | } |
| 193 | |
| 194 | export type NewWorkItem = { |
| 195 | title: string; |
| 196 | body: string; |
| 197 | labels: string[]; |
| 198 | /** Repositories where the work is meant to land, `owner/name`. */ |
| 199 | targets: string[]; |
| 200 | /** The repository it is filed from, `owner/name`, when started from one. */ |
| 201 | repo?: string; |
| 202 | }; |
| 203 | |
| 204 | export function useOpenWorkItem(workspace: string) { |
| 205 | const { client } = useSignedIn(); |
| 206 | const queries = useQueryClient(); |
| 207 | return useMutation({ |
| 208 | mutationFn: (input: NewWorkItem) => client.api<ApiWorkItem>(workPath(workspace), { method: "POST", body: JSON.stringify(input) }), |
| 209 | onSuccess: () => queries.invalidateQueries({ queryKey: ["work", "items", workspace.toLowerCase()] }), |
| 210 | }); |
| 211 | } |
| 212 | |
| 213 | /** |
| 214 | * What `#N` names in a context, by g1t's one set of rules |
| 215 | * (crates/contracts/src/work/refs.rs). |
| 216 | */ |
| 217 | export function useRefTarget(context: RefContext | null, number: number) { |
| 218 | const { client } = useSignedIn(); |
| 219 | return useQuery({ |
| 220 | queryKey: ["work", "ref", context, number], |
| 221 | enabled: Boolean(context), |
| 222 | retry: false, |
| 223 | queryFn: () => client.api<RefTarget>(`${refPath(context!)}/refs/${number}`), |
| 224 | }); |
| 225 | } |
| 226 | |
| 227 | function refPath(context: RefContext): string { |
| 228 | if ("repo" in context) return `/repos/${context.repo.split("/").map(encodeURIComponent).join("/")}`; |
| 229 | return `/workspaces/${encodeURIComponent(context.workspace)}`; |
| 230 | } |
| 231 | |
| 232 | const sessionsPath = (workspace: string) => `/workspaces/${encodeURIComponent(workspace)}/sessions`; |
| 233 | |
| 234 | /** How often Sessions and a live session are read again while on screen. */ |
| 235 | export const POLL_MS = 15_000; |
| 236 | |
| 237 | /** |
| 238 | * A workspace's sessions, most recently heard from first. With `poll` |
| 239 | * (the screen is in view) they are read again every 15 s while one is live. |
| 240 | */ |
| 241 | export function useWorkspaceSessions(workspace: string | undefined, filter: SessionsFilter, poll = false) { |
| 242 | const { client } = useSignedIn(); |
| 243 | return useQuery({ |
| 244 | queryKey: ["work", "sessions", workspace?.toLowerCase(), filter], |
| 245 | enabled: Boolean(workspace), |
| 246 | queryFn: () => client.api<ApiWorkspaceSessions>(`${sessionsPath(workspace!)}?${sessionsQuery(filter)}`), |
| 247 | refetchInterval: (query) => (poll && query.state.data?.sessions.some(isLive) ? POLL_MS : false), |
| 248 | }); |
| 249 | } |
| 250 | |
| 251 | /** |
| 252 | * One session and what it recorded, 500 steps at a time. With `poll` it |
| 253 | * is read again every 15 s while it is live. |
| 254 | */ |
| 255 | export function useWorkspaceSession(workspace: string, id: string, poll = false) { |
| 256 | const { client } = useSignedIn(); |
| 257 | const query = useInfiniteQuery({ |
| 258 | queryKey: ["work", "session", workspace.toLowerCase(), id], |
| 259 | initialPageParam: undefined as number | undefined, |
| 260 | queryFn: ({ pageParam }) => |
| 261 | client.api<ApiSessionDetail>(`${sessionsPath(workspace)}/${encodeURIComponent(id)}${pageParam === undefined ? "" : `?after=${pageParam}`}`), |
| 262 | getNextPageParam: (last) => (last.entries.length >= ENTRY_PAGE ? last.entries[last.entries.length - 1].seq : undefined), |
| 263 | refetchInterval: (query) => { |
| 264 | const first = query.state.data?.pages[0]; |
| 265 | return poll && first && isLive(first.session) ? POLL_MS : false; |
| 266 | }, |
| 267 | }); |
| 268 | const pages = query.data?.pages ?? []; |
| 269 | const detail = pages.length ? pages[pages.length - 1] : undefined; |
| 270 | const entries = (() => { |
| 271 | const seen = new Set<number>(); |
| 272 | return pages.flatMap((page) => page.entries).filter((entry) => (seen.has(entry.seq) ? false : (seen.add(entry.seq), true))); |
| 273 | })(); |
| 274 | return { ...query, detail, entries }; |
| 275 | } |
| 276 | |
| 277 | /** Every page of a list of items, up to `pages` of them. */ |
| 278 | async function allItems(client: Client, workspace: string, state: "open" | "closed", pages: number): Promise<ApiWorkItem[]> { |
| 279 | const items: ApiWorkItem[] = []; |
| 280 | const seen = new Set<string>(); |
| 281 | for (let page = 1; page <= pages; page++) { |
| 282 | const batch = await client.api<ApiWorkItem[]>(`${workPath(workspace)}?${filterQuery({ state }, page)}`); |
| 283 | const fresh = batch.filter((item) => !seen.has(item.id)); |
| 284 | fresh.forEach((item) => seen.add(item.id)); |
| 285 | items.push(...fresh); |
| 286 | if (batch.length < PAGE || fresh.length === 0) break; |
| 287 | } |
| 288 | return items; |
| 289 | } |
| 290 | |
| 291 | /** The repositories whose open pull requests the board reads, at most. */ |
| 292 | const BOARD_REPOS = 20; |
| 293 | |
| 294 | /** |
| 295 | * Work's board (lib/board.ts): the open items, those closed lately, the |
| 296 | * live sessions, and each repository's open pull requests. With `poll`, |
| 297 | * the sessions are read again every 15 s while one is live. |
| 298 | */ |
| 299 | export function useBoard(workspace: string | undefined, poll = false) { |
| 300 | const { client } = useSignedIn(); |
| 301 | const key = workspace?.toLowerCase(); |
| 302 | const open = useQuery({ queryKey: ["work", "board", key, "open"], enabled: Boolean(workspace), queryFn: () => allItems(client, workspace!, "open", 10) }); |
| 303 | const closed = useQuery({ queryKey: ["work", "board", key, "closed"], enabled: Boolean(workspace), queryFn: () => allItems(client, workspace!, "closed", 3) }); |
| 304 | const sessions = useWorkspaceSessions(workspace, { state: "live" }, poll); |
| 305 | const repos = (sessions.data?.repos ?? []).slice(0, BOARD_REPOS); |
| 306 | const pulls = useQueries({ |
| 307 | queries: repos.map((repo) => { |
| 308 | const [owner, name] = repo.path.split("/", 2); |
| 309 | return { |
| 310 | queryKey: ["code", "pulls", owner, name, "open"], |
| 311 | queryFn: () => client.api<ApiPull[]>(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pulls?state=open`), |
| 312 | retry: false, |
| 313 | }; |
| 314 | }), |
| 315 | }); |
| 316 | const read = pulls.flatMap((one) => one.data ?? []); |
| 317 | const board = boardOf([...(open.data ?? []), ...(closed.data ?? [])], { pulls: read, sessions: sessions.data?.sessions ?? [] }); |
| 318 | return { |
| 319 | board, |
| 320 | isPending: open.isPending || closed.isPending, |
| 321 | error: open.error ?? closed.error, |
| 322 | isRefetching: open.isRefetching || closed.isRefetching || sessions.isRefetching, |
| 323 | refetch: () => Promise.all([open.refetch(), closed.refetch(), sessions.refetch(), ...pulls.map((one) => one.refetch())]), |
| 324 | }; |
| 325 | } |