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.

227 linesCodeBlameRaw
1import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
3import type { ApiComment, ApiUser, CheckStatus } from "./code";
4import type { RefContext, RefTarget } from "./links";
5import { 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
14export type LinkRole = "origin" | "target" | "touched";
15export type CloseReason = "completed" | "not_planned";
16
17/** A repository a work item is linked to. */
18export 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
29export 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. */
50export type ApiWorkPull = {
51 repo: string;
52 number: number;
53 title: string;
54 status: "draft" | "open" | "merged" | "closed";
55 check_status: CheckStatus;
56};
57
58export type ApiWorkRepo = ApiItemRepo & { pulls: ApiWorkPull[] };
59
60/** A repository the work may belong in, and why g1t thinks so. */
61export 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. */
64export type ApiWorkContext = {
65 item: ApiWorkItem;
66 repos: ApiWorkRepo[];
67 missing: string[];
68 suggested_targets: ApiSuggestedTarget[];
69};
70
71export type ApiLabel = { name: string; color: string; description: string };
72export 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. */
75export 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
85export const PAGE = 30;
86
87const workPath = (workspace: string) => `/workspaces/${encodeURIComponent(workspace)}/work`;
88const itemPath = (workspace: string, number: number) => `${workPath(workspace)}/${number}`;
89
90/** The query string for a filter and a page. */
91export 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 */
106export 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
126export 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. */
135export 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
144export 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
154export 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. */
165export 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
191export 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
201export 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 */
214export 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
224function refPath(context: RefContext): string {
225 if ("repo" in context) return `/repos/${context.repo.split("/").map(encodeURIComponent).join("/")}`;
226 return `/workspaces/${encodeURIComponent(context.workspace)}`;
227}