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.

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