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.

329 linesCodeBlameRaw
1import { useInfiniteQuery, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
2
3import { boardOf } from "./board";
4import type { Client } from "./client";
5import type { ApiComment, ApiPull, ApiUser, CheckStatus } from "./code";
6import type { RefContext, RefTarget } from "./links";
7import { useSignedIn } from "./session";
8import { 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
17export type LinkRole = "origin" | "target" | "touched";
18export type CloseReason = "completed" | "not_planned";
19
20/** A repository a work item is linked to. */
21export 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
32export 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. */
53export type ApiWorkPull = {
54 repo: string;
55 number: number;
56 title: string;
57 status: "draft" | "open" | "merged" | "closed";
58 check_status: CheckStatus;
59};
60
61export type ApiWorkRepo = ApiItemRepo & { pulls: ApiWorkPull[] };
62
63/** A repository the work may belong in, and why g1t thinks so. */
64export 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. */
67export type ApiWorkContext = {
68 item: ApiWorkItem;
69 repos: ApiWorkRepo[];
70 missing: string[];
71 suggested_targets: ApiSuggestedTarget[];
72};
73
74export type ApiLabel = { name: string; color: string; description: string };
75export 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. */
78export 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
88export const PAGE = 30;
89
90const workPath = (workspace: string) => `/workspaces/${encodeURIComponent(workspace)}/work`;
91const itemPath = (workspace: string, number: number) => `${workPath(workspace)}/${number}`;
92
93/** The query string for a filter and a page. */
94export 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 */
109export 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
129export 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. */
138export 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
147export 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
157export 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. */
168export 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
194export 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
204export 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 */
217export 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
227function 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
232const sessionsPath = (workspace: string) => `/workspaces/${encodeURIComponent(workspace)}/sessions`;
233
234/** How often Sessions and a live session are read again while on screen. */
235export 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 */
241export 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 */
255export 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. */
278async 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. */
292const 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 */
299export 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({
304 queryKey: ["work", "board", key, "closed"],
305 enabled: Boolean(workspace),
306 queryFn: () => allItems(client, workspace!, "closed", 3),
307 });
308 const sessions = useWorkspaceSessions(workspace, { state: "live" }, poll);
309 const repos = (sessions.data?.repos ?? []).slice(0, BOARD_REPOS);
310 const pulls = useQueries({
311 queries: repos.map((repo) => {
312 const [owner, name] = repo.path.split("/", 2);
313 return {
314 queryKey: ["code", "pulls", owner, name, "open"],
315 queryFn: () => client.api<ApiPull[]>(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pulls?state=open`),
316 retry: false,
317 };
318 }),
319 });
320 const read = pulls.flatMap((one) => one.data ?? []);
321 const board = boardOf([...(open.data ?? []), ...(closed.data ?? [])], { pulls: read, sessions: sessions.data?.sessions ?? [] });
322 return {
323 board,
324 isPending: open.isPending || closed.isPending,
325 error: open.error ?? closed.error,
326 isRefetching: open.isRefetching || closed.isRefetching || sessions.isRefetching,
327 refetch: () => Promise.all([open.refetch(), closed.refetch(), sessions.refetch(), ...pulls.map((one) => one.refetch())]),
328 };
329}