| 1 | import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 2 | |
| 3 | import type { ApiComment, ApiUser, CheckStatus } from "./code"; |
| 4 | import type { RefContext, RefTarget } from "./links"; |
| 5 | import { useSignedIn } from "./session"; |
| 6 | |
| 7 | /** |
| 8 | * Work, through the REST API (docs.g1t.sh/reference/api/work-items/): a |
| 9 | * workspace's work items, each linked to the repositories it concerns. |
| 10 | * As in lib/code.ts these are the API's snake_case shapes, and only the |
| 11 | * fields the app shows are named. |
| 12 | */ |
| 13 | |
| 14 | export type LinkRole = "origin" | "target" | "touched"; |
| 15 | export type CloseReason = "completed" | "not_planned"; |
| 16 | |
| 17 | /** A repository a work item is linked to. */ |
| 18 | export type ApiItemRepo = { |
| 19 | repo_id: string; |
| 20 | /** `owner/name`. */ |
| 21 | path: string; |
| 22 | role: LinkRole; |
| 23 | /** For an item that was an issue of this repository: its number there. */ |
| 24 | legacy_number: number | null; |
| 25 | /** What closes it landed here. */ |
| 26 | landed: boolean; |
| 27 | }; |
| 28 | |
| 29 | export type ApiWorkItem = { |
| 30 | id: string; |
| 31 | workspace: string; |
| 32 | number: number; |
| 33 | title: string; |
| 34 | body: string; |
| 35 | labels: string[]; |
| 36 | state: "open" | "closed"; |
| 37 | reason: CloseReason | null; |
| 38 | author: ApiUser; |
| 39 | assignees: string[]; |
| 40 | milestone?: { number: number; title: string } | null; |
| 41 | created_at: string; |
| 42 | updated_at: string; |
| 43 | closed_at: string | null; |
| 44 | comment_count: number; |
| 45 | pull_count: number; |
| 46 | repos: ApiItemRepo[]; |
| 47 | }; |
| 48 | |
| 49 | /** A pull request for a work item, in the repository it is in. */ |
| 50 | export type ApiWorkPull = { |
| 51 | repo: string; |
| 52 | number: number; |
| 53 | title: string; |
| 54 | status: "draft" | "open" | "merged" | "closed"; |
| 55 | check_status: CheckStatus; |
| 56 | }; |
| 57 | |
| 58 | export type ApiWorkRepo = ApiItemRepo & { pulls: ApiWorkPull[] }; |
| 59 | |
| 60 | /** A repository the work may belong in, and why g1t thinks so. */ |
| 61 | export type ApiSuggestedTarget = { repo_id: string; path: string; why: string }; |
| 62 | |
| 63 | /** `GET …/work/{number}/work`: the item with its repositories, their pull requests, and what is missing. */ |
| 64 | export type ApiWorkContext = { |
| 65 | item: ApiWorkItem; |
| 66 | repos: ApiWorkRepo[]; |
| 67 | missing: string[]; |
| 68 | suggested_targets: ApiSuggestedTarget[]; |
| 69 | }; |
| 70 | |
| 71 | export type ApiLabel = { name: string; color: string; description: string }; |
| 72 | export type ApiMilestone = { number: number; title: string; state: "open" | "closed"; due_on: string | null }; |
| 73 | |
| 74 | /** What the list shows; each field is one of the API's list filters. */ |
| 75 | export type WorkFilter = { |
| 76 | state: "open" | "closed" | "all"; |
| 77 | label?: string; |
| 78 | milestone?: number; |
| 79 | assignee?: string; |
| 80 | /** A repository of the workspace, by name. */ |
| 81 | repo?: string; |
| 82 | role?: LinkRole; |
| 83 | }; |
| 84 | |
| 85 | export const PAGE = 30; |
| 86 | |
| 87 | const workPath = (workspace: string) => `/workspaces/${encodeURIComponent(workspace)}/work`; |
| 88 | const itemPath = (workspace: string, number: number) => `${workPath(workspace)}/${number}`; |
| 89 | |
| 90 | /** The query string for a filter and a page. */ |
| 91 | export function filterQuery(filter: WorkFilter, page: number): string { |
| 92 | const params = new URLSearchParams({ state: filter.state, page: String(page), per_page: String(PAGE) }); |
| 93 | if (filter.label) params.set("label", filter.label); |
| 94 | if (filter.milestone) params.set("milestone", String(filter.milestone)); |
| 95 | if (filter.assignee) params.set("assignee", filter.assignee); |
| 96 | if (filter.repo) params.set("repo", filter.repo); |
| 97 | if (filter.repo && filter.role) params.set("role", filter.role); |
| 98 | return params.toString(); |
| 99 | } |
| 100 | |
| 101 | /** |
| 102 | * A workspace's work items, newest first, a page at a time. A page that |
| 103 | * brings nothing new ends the list, so an answer that ignores paging is |
| 104 | * shown once. |
| 105 | */ |
| 106 | export function useWorkItems(workspace: string | undefined, filter: WorkFilter) { |
| 107 | const { client } = useSignedIn(); |
| 108 | const query = useInfiniteQuery({ |
| 109 | queryKey: ["work", "items", workspace?.toLowerCase(), filter], |
| 110 | enabled: Boolean(workspace), |
| 111 | initialPageParam: 1, |
| 112 | queryFn: ({ pageParam }) => client.api<ApiWorkItem[]>(`${workPath(workspace!)}?${filterQuery(filter, pageParam)}`), |
| 113 | getNextPageParam: (last, pages, lastPage) => { |
| 114 | if (last.length < PAGE) return undefined; |
| 115 | const seen = new Set(pages.slice(0, -1).flatMap((page) => page.map((item) => item.id))); |
| 116 | return last.some((item) => !seen.has(item.id)) ? lastPage + 1 : undefined; |
| 117 | }, |
| 118 | }); |
| 119 | const items = (() => { |
| 120 | const seen = new Set<string>(); |
| 121 | return (query.data?.pages ?? []).flat().filter((item) => (seen.has(item.id) ? false : (seen.add(item.id), true))); |
| 122 | })(); |
| 123 | return { ...query, items }; |
| 124 | } |
| 125 | |
| 126 | export function useWorkItem(workspace: string, number: number) { |
| 127 | const { client } = useSignedIn(); |
| 128 | return useQuery({ |
| 129 | queryKey: ["work", "item", workspace.toLowerCase(), number], |
| 130 | queryFn: () => client.api<{ item: ApiWorkItem; comments: ApiComment[] }>(itemPath(workspace, number)), |
| 131 | }); |
| 132 | } |
| 133 | |
| 134 | /** The item's repositories with their pull requests, what has not landed, and suggested targets. */ |
| 135 | export function useWorkContext(workspace: string, number: number) { |
| 136 | const { client } = useSignedIn(); |
| 137 | return useQuery({ |
| 138 | queryKey: ["work", "context", workspace.toLowerCase(), number], |
| 139 | queryFn: () => client.api<ApiWorkContext>(`${itemPath(workspace, number)}/work`), |
| 140 | retry: false, |
| 141 | }); |
| 142 | } |
| 143 | |
| 144 | export function useLabels(workspace: string | undefined) { |
| 145 | const { client } = useSignedIn(); |
| 146 | return useQuery({ |
| 147 | queryKey: ["work", "labels", workspace?.toLowerCase()], |
| 148 | enabled: Boolean(workspace), |
| 149 | queryFn: () => client.api<ApiLabel[]>(`/workspaces/${encodeURIComponent(workspace!)}/labels`), |
| 150 | staleTime: 5 * 60_000, |
| 151 | }); |
| 152 | } |
| 153 | |
| 154 | export function useMilestones(workspace: string | undefined) { |
| 155 | const { client } = useSignedIn(); |
| 156 | return useQuery({ |
| 157 | queryKey: ["work", "milestones", workspace?.toLowerCase()], |
| 158 | enabled: Boolean(workspace), |
| 159 | queryFn: () => client.api<ApiMilestone[]>(`/workspaces/${encodeURIComponent(workspace!)}/milestones?state=open`), |
| 160 | staleTime: 5 * 60_000, |
| 161 | }); |
| 162 | } |
| 163 | |
| 164 | /** Everything that changes an item: comment, close, reopen, link and unlink a repository. */ |
| 165 | export function useItemActions(workspace: string, number: number) { |
| 166 | const { client } = useSignedIn(); |
| 167 | const queries = useQueryClient(); |
| 168 | const path = itemPath(workspace, number); |
| 169 | const refresh = () => |
| 170 | Promise.all([ |
| 171 | queries.invalidateQueries({ queryKey: ["work", "item", workspace.toLowerCase(), number] }), |
| 172 | queries.invalidateQueries({ queryKey: ["work", "context", workspace.toLowerCase(), number] }), |
| 173 | queries.invalidateQueries({ queryKey: ["work", "items", workspace.toLowerCase()] }), |
| 174 | ]); |
| 175 | const post = <T>(suffix: string, body: unknown, method = "POST") => client.api<T>(`${path}${suffix}`, { method, body: JSON.stringify(body) }); |
| 176 | return { |
| 177 | comment: useMutation({ mutationFn: (body: string) => post<ApiComment>("/comments", { body }), onSuccess: refresh }), |
| 178 | close: useMutation({ mutationFn: (reason: CloseReason) => post<ApiWorkItem>("/close", { reason }), onSuccess: refresh }), |
| 179 | reopen: useMutation({ mutationFn: () => post<ApiWorkItem>("/reopen", {}), onSuccess: refresh }), |
| 180 | link: useMutation({ |
| 181 | mutationFn: ({ repo, role = "target" }: { repo: string; role?: LinkRole }) => post<ApiWorkItem>("/links", { repo, role }), |
| 182 | onSuccess: refresh, |
| 183 | }), |
| 184 | unlink: useMutation({ |
| 185 | mutationFn: (repo: string) => client.api<ApiWorkItem>(`${path}/links?repo=${encodeURIComponent(repo)}`, { method: "DELETE" }), |
| 186 | onSuccess: refresh, |
| 187 | }), |
| 188 | }; |
| 189 | } |
| 190 | |
| 191 | export type NewWorkItem = { |
| 192 | title: string; |
| 193 | body: string; |
| 194 | labels: string[]; |
| 195 | /** Repositories where the work is meant to land, `owner/name`. */ |
| 196 | targets: string[]; |
| 197 | /** The repository it is filed from, `owner/name`, when started from one. */ |
| 198 | repo?: string; |
| 199 | }; |
| 200 | |
| 201 | export function useOpenWorkItem(workspace: string) { |
| 202 | const { client } = useSignedIn(); |
| 203 | const queries = useQueryClient(); |
| 204 | return useMutation({ |
| 205 | mutationFn: (input: NewWorkItem) => client.api<ApiWorkItem>(workPath(workspace), { method: "POST", body: JSON.stringify(input) }), |
| 206 | onSuccess: () => queries.invalidateQueries({ queryKey: ["work", "items", workspace.toLowerCase()] }), |
| 207 | }); |
| 208 | } |
| 209 | |
| 210 | /** |
| 211 | * What `#N` names in a context, by g1t's one set of rules |
| 212 | * (crates/contracts/src/work/refs.rs). |
| 213 | */ |
| 214 | export function useRefTarget(context: RefContext | null, number: number) { |
| 215 | const { client } = useSignedIn(); |
| 216 | return useQuery({ |
| 217 | queryKey: ["work", "ref", context, number], |
| 218 | enabled: Boolean(context), |
| 219 | retry: false, |
| 220 | queryFn: () => client.api<RefTarget>(`${refPath(context!)}/refs/${number}`), |
| 221 | }); |
| 222 | } |
| 223 | |
| 224 | function refPath(context: RefContext): string { |
| 225 | if ("repo" in context) return `/repos/${context.repo.split("/").map(encodeURIComponent).join("/")}`; |
| 226 | return `/workspaces/${encodeURIComponent(context.workspace)}`; |
| 227 | } |