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.

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