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.

187 linesCodeBlameRaw
1import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
3import { useSignedIn } from "./session";
4
5/**
6 * Code, through the REST API (docs.g1t.sh/reference/api/). The API
7 * answers in snake_case, so these are its shapes, not the camelCase ones
8 * in @g1t/contracts that the services use among themselves. Only the
9 * fields the app shows are named.
10 */
11
12/** `agent` only on what g1t's own agents wrote before they were retired; shown with an Agent badge. */
13export type ApiUser = { id: string; username: string; display_username?: string; kind?: "user" | "workspace" | "agent" | "system" };
14
15export type ApiRepo = {
16 id: string;
17 namespace: string;
18 name: string;
19 is_private: boolean;
20 default_branch: string;
21 created_at: string;
22 archived_at?: string | null;
23};
24
25export type ApiIssue = {
26 id: string;
27 number: number;
28 title: string;
29 body: string;
30 labels: string[];
31 state: "open" | "closed";
32 reason: "completed" | "not_planned" | null;
33 author: ApiUser;
34 created_at: string;
35 updated_at: string;
36 comment_count: number;
37 pull_count: number;
38};
39
40export type CheckStatus = "queued" | "running" | "passed" | "failed" | "errored" | null;
41
42export type ApiPull = {
43 id: string;
44 number: number;
45 issue: number | null;
46 title: string;
47 body: string | null;
48 /** The coding agent it was made with (`claude-code`, `codex`), from the session recorded on it; null when none was. */
49 agent: string | null;
50 status: "draft" | "open" | "merged" | "closed";
51 branch: string | null;
52 base: string;
53 check_status: CheckStatus;
54 files: { path: string; additions: number; deletions: number }[];
55 reviewers: string[];
56 labels: string[];
57 author: ApiUser;
58 created_at: string;
59 updated_at: string;
60 merged_at: string | null;
61};
62
63export type ApiComment = {
64 id: string;
65 kind: "comment" | "event";
66 author: ApiUser;
67 body: string;
68 path: string | null;
69 line: number | null;
70 verdict: string | null;
71 created_at: string;
72};
73
74export type ApiChanges = {
75 base: string;
76 head: string;
77 truncated: boolean;
78 files: {
79 path: string;
80 status: "added" | "modified" | "deleted";
81 additions: number;
82 deletions: number;
83 binary: boolean;
84 /** Past what one answer reads: listed without its lines or counts. */
85 omitted?: boolean;
86 hunks?: { lines: { kind: "context" | "add" | "delete"; old: number | null; new: number | null; text: string }[] }[];
87 }[];
88};
89
90const repoPath = (owner: string, name: string) => `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`;
91
92/** Repositories you can see, newest first (at most 50), in the workspace chosen. */
93export function useRepos(search: string) {
94 const { client, workspace } = useSignedIn();
95 return useQuery({
96 queryKey: ["code", "repos", search],
97 queryFn: () => client.api<ApiRepo[]>(`/repos${search ? `?q=${encodeURIComponent(search)}` : ""}`),
98 select: (repos) => (workspace ? repos.filter((repo) => repo.namespace.toLowerCase() === workspace.slug.toLowerCase()) : repos),
99 });
100}
101
102/** A workspace's project as the API answers; only what descriptions need. */
103type ApiProject = { repository: string | null; primary: boolean; description: string | null };
104
105/**
106 * The descriptions of a workspace's repositories, by lowercase
107 * `owner/name`. A repository has none of its own: its project's is shown.
108 */
109export function useDescriptions(workspace: string | null | undefined) {
110 const { client } = useSignedIn();
111 return useQuery({
112 queryKey: ["code", "descriptions", workspace?.toLowerCase()],
113 enabled: Boolean(workspace),
114 queryFn: () => client.api<ApiProject[]>(`/workspaces/${encodeURIComponent(workspace!)}/projects`),
115 select: (projects) =>
116 Object.fromEntries(
117 projects
118 .filter((project) => project.primary && project.repository && project.description)
119 .map((project) => [project.repository!.toLowerCase(), project.description!]),
120 ) as Record<string, string>,
121 });
122}
123
124export function usePulls(owner: string, name: string, state: "open" | "closed" | "all") {
125 const { client } = useSignedIn();
126 return useQuery({
127 queryKey: ["code", "pulls", owner, name, state],
128 queryFn: () => client.api<ApiPull[]>(`${repoPath(owner, name)}/pulls?state=${state}`),
129 });
130}
131
132export function useIssues(owner: string, name: string, state: "open" | "closed" | "all") {
133 const { client } = useSignedIn();
134 return useQuery({
135 queryKey: ["code", "issues", owner, name, state],
136 queryFn: () => client.api<ApiIssue[]>(`${repoPath(owner, name)}/issues?state=${state}`),
137 });
138}
139
140export function usePull(owner: string, name: string, number: number) {
141 const { client } = useSignedIn();
142 return useQuery({
143 queryKey: ["code", "pull", owner, name, number],
144 queryFn: () =>
145 client.api<{ pull: ApiPull; issue: ApiIssue | null; comments: ApiComment[] }>(`${repoPath(owner, name)}/pulls/${number}`),
146 });
147}
148
149export function usePullChanges(owner: string, name: string, number: number, enabled: boolean) {
150 const { client } = useSignedIn();
151 return useQuery({
152 queryKey: ["code", "pull-changes", owner, name, number],
153 queryFn: () => client.api<ApiChanges>(`${repoPath(owner, name)}/pulls/${number}/changes`),
154 enabled,
155 });
156}
157
158export function useIssue(owner: string, name: string, number: number) {
159 const { client } = useSignedIn();
160 return useQuery({
161 queryKey: ["code", "issue", owner, name, number],
162 queryFn: () =>
163 client.api<{ issue: ApiIssue; pulls: ApiPull[]; comments: ApiComment[] }>(`${repoPath(owner, name)}/issues/${number}`),
164 });
165}
166
167/** Comments on an issue (`POST /repos/{owner}/{name}/issues/{number}/comments`). */
168export function useComment(owner: string, name: string, number: number) {
169 const { client } = useSignedIn();
170 const queries = useQueryClient();
171 return useMutation({
172 mutationFn: (body: string) =>
173 client.api<ApiComment>(`${repoPath(owner, name)}/issues/${number}/comments`, { method: "POST", body: JSON.stringify({ body }) }),
174 onSuccess: () => queries.invalidateQueries({ queryKey: ["code", "issue", owner, name, number] }),
175 });
176}
177
178/** Reviews a pull request (`POST /repos/{owner}/{name}/pulls/{number}/reviews`). */
179export function useReview(owner: string, name: string, number: number) {
180 const { client } = useSignedIn();
181 const queries = useQueryClient();
182 return useMutation({
183 mutationFn: (review: { verdict: "approve" | "request_changes"; body: string }) =>
184 client.api<ApiComment>(`${repoPath(owner, name)}/pulls/${number}/reviews`, { method: "POST", body: JSON.stringify(review) }),
185 onSuccess: () => queries.invalidateQueries({ queryKey: ["code", "pull", owner, name, number] }),
186 });
187}