g1t/apps/api/src/operations.ts

509 lines17,175 bytesCodeBlame
1import {
2 type EventsApi,
3 type IdentityApi,
4 type NewSessionEntry,
5 type RepoPath,
6 type ReposApi,
7 type Result,
8 type User,
9 type Viewer,
10 type WorkApi,
11 fail,
12 ok,
13} from "@g1t/contracts";
14
15export interface ApiEnv {
16 IDENTITY: IdentityApi;
17 REPOS: ReposApi;
18 WORK: WorkApi;
19 EVENTS: EventsApi;
20}
21
22type Input = Record<string, unknown>;
23
24type JsonSchema = {
25 type: "object";
26 properties: Record<string, object>;
27 required?: string[];
28};
29
30/**
31 * One thing a client can do. REST routes and MCP tools are both generated
32 * from this list, so the two surfaces cannot drift apart.
33 */
34export type Operation = {
35 name: string;
36 description: string;
37 input: JsonSchema;
38 run(env: ApiEnv, viewer: Viewer, input: Input): Promise<Result<unknown>>;
39};
40
41const SIGN_IN = fail("unauthenticated", "This needs a g1t access token.");
42
43const REPO = {
44 type: "string",
45 description: 'Repository as "owner/name", e.g. "syntaqx/hello".',
46};
47
48function text(input: Input, key: string): string {
49 const value = input[key];
50 return typeof value === "string" ? value : "";
51}
52
53function repoPath(input: Input): RepoPath | null {
54 const [namespace, name, ...rest] = text(input, "repo").split("/");
55 return namespace && name && rest.length === 0 ? { namespace, name } : null;
56}
57
58const BAD_REPO = fail("invalid", 'Give the repository as "owner/name".');
59
60const NUMBER = {
61 type: "integer",
62 description: "The number shown after the #. Issues and pull requests share one sequence.",
63};
64
65const numbered = { repo: REPO, number: NUMBER };
66
67function strings(input: Input, key: string): string[] | undefined {
68 const value = input[key];
69 return Array.isArray(value) ? value.map(String) : undefined;
70}
71
72function state(input: Input): "open" | "closed" | undefined {
73 const value = text(input, "state");
74 return value === "open" || value === "closed" ? value : undefined;
75}
76
77/** Wraps an operation on a repository that anyone who can see it may call. */
78function onRepo(
79 run: (env: ApiEnv, path: RepoPath, viewer: Viewer, input: Input) => Promise<Result<unknown>>,
80): Operation["run"] {
81 return (env, viewer, input) => {
82 const path = repoPath(input);
83 return path ? run(env, path, viewer, input) : Promise.resolve(BAD_REPO);
84 };
85}
86
87/** Wraps an operation on a repository that needs a signed-in user. */
88function onRepoAs(
89 run: (env: ApiEnv, path: RepoPath, user: User, input: Input) => Promise<Result<unknown>>,
90): Operation["run"] {
91 return onRepo((env, path, viewer, input) =>
92 viewer ? run(env, path, viewer, input) : Promise.resolve(SIGN_IN),
93 );
94}
95
96/** Wraps an operation that needs a signed-in user. */
97function authed(
98 run: (env: ApiEnv, user: User, input: Input) => Promise<Result<unknown>>,
99): Operation["run"] {
100 return (env, viewer, input) =>
101 viewer ? run(env, viewer, input) : Promise.resolve(SIGN_IN);
102}
103
104export const operations: Operation[] = [
105 {
106 name: "whoami",
107 description: "The account the access token belongs to, and its workspaces.",
108 input: { type: "object", properties: {} },
109 run: authed(async (_env, user) => ok(user)),
110 },
111 {
112 name: "create_workspace",
113 description:
114 "Create a workspace. A workspace owns repositories and is the first part of their address, g1t.sh/<workspace>/<repo>. The whoami tool lists the ones you already belong to.",
115 input: {
116 type: "object",
117 properties: {
118 slug: {
119 type: "string",
120 description: "Its name in URLs: lowercase letters, digits and single hyphens.",
121 },
122 name: { type: "string", description: "A display name." },
123 },
124 required: ["slug"],
125 },
126 run: authed((env, user, input) =>
127 env.IDENTITY.createWorkspace(user, text(input, "slug"), text(input, "name")),
128 ),
129 },
130 {
131 name: "list_repos",
132 description: "Repositories you can see, optionally filtered by a search query.",
133 input: {
134 type: "object",
135 properties: { query: { type: "string", description: "Matches name or description." } },
136 },
137 run: async (env, viewer, input) =>
138 ok(await env.REPOS.list(viewer, { query: text(input, "query") })),
139 },
140 {
141 name: "get_repo",
142 description: "One repository's details.",
143 input: { type: "object", properties: { repo: REPO }, required: ["repo"] },
144 run: async (env, viewer, input) => {
145 const path = repoPath(input);
146 return path ? env.REPOS.get(path, viewer) : BAD_REPO;
147 },
148 },
149 {
150 name: "create_repo",
151 description: "Create a repository in one of your workspaces.",
152 input: {
153 type: "object",
154 properties: {
155 workspace: {
156 type: "string",
157 description:
158 "The workspace to create it in. May be left out if you belong to exactly one.",
159 },
160 name: { type: "string" },
161 description: { type: "string" },
162 private: { type: "boolean" },
163 },
164 required: ["name"],
165 },
166 run: authed((env, user, input) =>
167 env.REPOS.create(user, {
168 namespace:
169 text(input, "workspace") ||
170 (user.workspaces?.length === 1 ? user.workspaces[0].slug : ""),
171 name: text(input, "name"),
172 description: text(input, "description"),
173 isPrivate: input.private === true,
174 }),
175 ),
176 },
177 {
178 name: "list_issues",
179 description:
180 "Issues on a repository, newest first. An issue is something that should change: a bug, a feature, a question. Pull requests are made against it.",
181 input: {
182 type: "object",
183 properties: {
184 repo: REPO,
185 state: { type: "string", enum: ["open", "closed"] },
186 label: { type: "string", description: "Only issues carrying this label." },
187 },
188 required: ["repo"],
189 },
190 run: onRepo((env, path, viewer, input) =>
191 env.WORK.listIssues(path, viewer, {
192 state: state(input),
193 label: text(input, "label") || undefined,
194 }),
195 ),
196 },
197 {
198 name: "get_issue",
199 description:
200 "An issue: its description, labels and acceptance checks, its comments, and every pull request made against it with its status. If the issue is closed, resolvedBy is the number of the pull request that was merged for it. Read this before opening a pull request, to see what others have already tried.",
201 input: { type: "object", properties: numbered, required: ["repo", "number"] },
202 run: onRepo((env, path, viewer, input) =>
203 env.WORK.getIssue(path, Number(input.number), viewer),
204 ),
205 },
206 {
207 name: "create_issue",
208 description: "Open an issue on a repository.",
209 input: {
210 type: "object",
211 properties: {
212 repo: REPO,
213 title: { type: "string", description: "The problem or goal in one line." },
214 body: {
215 type: "string",
216 description:
217 "Markdown. What an agent or a person needs to do the work: what is wrong or wanted, constraints, context.",
218 },
219 labels: {
220 type: "array",
221 items: { type: "string" },
222 description:
223 'What kind of issue this is, e.g. "bug" or "feature". list_labels shows the labels in use; a new name creates a new label.',
224 },
225 checks: {
226 type: "array",
227 items: { type: "string" },
228 description: "Commands that must pass for a pull request to be accepted.",
229 },
230 },
231 required: ["repo", "title"],
232 },
233 run: onRepoAs((env, path, user, input) =>
234 env.WORK.openIssue(user, path, {
235 title: text(input, "title"),
236 body: text(input, "body"),
237 labels: strings(input, "labels"),
238 checks: strings(input, "checks"),
239 }),
240 ),
241 },
242 {
243 name: "update_issue",
244 description:
245 "Change an issue's title, body or labels. Only the fields given are changed; labels replaces the whole set.",
246 input: {
247 type: "object",
248 properties: {
249 ...numbered,
250 title: { type: "string" },
251 body: { type: "string" },
252 labels: { type: "array", items: { type: "string" } },
253 },
254 required: ["repo", "number"],
255 },
256 run: onRepoAs((env, path, user, input) =>
257 env.WORK.updateIssue(user, path, Number(input.number), {
258 title: typeof input.title === "string" ? input.title : undefined,
259 body: typeof input.body === "string" ? input.body : undefined,
260 labels: strings(input, "labels"),
261 }),
262 ),
263 },
264 {
265 name: "close_issue",
266 description:
267 "Close an issue without a pull request. Merging a pull request made for an issue closes it for you.",
268 input: {
269 type: "object",
270 properties: {
271 ...numbered,
272 reason: {
273 type: "string",
274 enum: ["completed", "not_planned"],
275 description: "Defaults to completed.",
276 },
277 },
278 required: ["repo", "number"],
279 },
280 run: onRepoAs((env, path, user, input) =>
281 env.WORK.closeIssue(
282 user,
283 path,
284 Number(input.number),
285 text(input, "reason") === "not_planned" ? "not_planned" : "completed",
286 ),
287 ),
288 },
289 {
290 name: "reopen_issue",
291 description: "Reopen a closed issue.",
292 input: { type: "object", properties: numbered, required: ["repo", "number"] },
293 run: onRepoAs((env, path, user, input) =>
294 env.WORK.reopenIssue(user, path, Number(input.number)),
295 ),
296 },
297 {
298 name: "list_labels",
299 description: "The labels available on a repository's issues.",
300 input: { type: "object", properties: { repo: REPO }, required: ["repo"] },
301 run: onRepo((env, path, viewer) => env.WORK.listLabels(path, viewer)),
302 },
303 {
304 name: "add_comment",
305 description: "Comment on an issue or a pull request.",
306 input: {
307 type: "object",
308 properties: { ...numbered, body: { type: "string", description: "Markdown." } },
309 required: ["repo", "number", "body"],
310 },
311 run: onRepoAs((env, path, user, input) =>
312 env.WORK.addComment(user, path, Number(input.number), text(input, "body")),
313 ),
314 },
315 {
316 name: "list_pull_requests",
317 description:
318 "Pull requests on a repository, newest first. State open covers drafts and those ready for review; closed covers merged and closed.",
319 input: {
320 type: "object",
321 properties: { repo: REPO, state: { type: "string", enum: ["open", "closed"] } },
322 required: ["repo"],
323 },
324 run: onRepo((env, path, viewer, input) => env.WORK.listPulls(path, viewer, state(input))),
325 },
326 {
327 name: "get_pull_request",
328 description:
329 "A pull request's status, head commit, comments and the issue it is for.",
330 input: { type: "object", properties: numbered, required: ["repo", "number"] },
331 run: onRepo((env, path, viewer, input) =>
332 env.WORK.getPull(path, Number(input.number), viewer),
333 ),
334 },
335 {
336 name: "create_pull_request",
337 description:
338 "Start a change. Opens a draft pull request with its own fork of the repository and returns the fork's git remote. Clone it, commit your work there, push, record your session as you go, then call mark_pull_request_ready. Give the issue it is for whenever there is one.",
339 input: {
340 type: "object",
341 properties: {
342 repo: REPO,
343 issue: { type: "integer", description: "The number of the issue this is for." },
344 title: {
345 type: "string",
346 description: "Defaults to the issue's title. Required when there is no issue.",
347 },
348 agent: {
349 type: "string",
350 description: 'A label for the agent doing the work, e.g. "claude-code".',
351 },
352 },
353 required: ["repo"],
354 },
355 run: onRepoAs(async (env, path, user, input) => {
356 const opened = await env.WORK.openPull(user, path, {
357 issue: input.issue == null ? undefined : Number(input.issue),
358 title: text(input, "title"),
359 agent: text(input, "agent") || "agent",
360 runtime: "external",
361 });
362 if (!opened.ok) return opened;
363 const { fork } = opened.value;
364 return ok({
365 pull: opened.value,
366 git: {
367 remote: `https://g1t.sh/${fork.namespace}/${fork.name}.git`,
368 username: user.username,
369 password: "your g1t access token",
370 },
371 });
372 }),
373 },
374 {
375 name: "record_session",
376 description:
377 "Append entries to a pull request's session: the prompt you were given, your reasoning, the tools you ran. This is how people later see why a change was made, so record as you work, not only at the end.",
378 input: {
379 type: "object",
380 properties: {
381 ...numbered,
382 entries: {
383 type: "array",
384 items: {
385 type: "object",
386 properties: {
387 kind: {
388 type: "string",
389 enum: ["prompt", "message", "tool_call", "tool_result", "note"],
390 },
391 text: { type: "string" },
392 tool: { type: "string", description: "Tool name, for tool entries." },
393 },
394 required: ["kind", "text"],
395 },
396 },
397 },
398 required: ["repo", "number", "entries"],
399 },
400 run: onRepoAs(async (env, path, user, input) => {
401 if (!Array.isArray(input.entries)) {
402 return fail("invalid", "entries must be an array.");
403 }
404 return env.WORK.appendSession(
405 user,
406 path,
407 Number(input.number),
408 input.entries as NewSessionEntry[],
409 );
410 }),
411 },
412 {
413 name: "read_session",
414 description: "The recorded session of a pull request, oldest entry first.",
415 input: {
416 type: "object",
417 properties: {
418 ...numbered,
419 after: { type: "integer", description: "Only entries after this sequence number." },
420 },
421 required: ["repo", "number"],
422 },
423 run: onRepo((env, path, viewer, input) =>
424 env.WORK.readSession(path, Number(input.number), viewer, Number(input.after) || 0),
425 ),
426 },
427 {
428 name: "mark_pull_request_ready",
429 description:
430 "Mark a draft pull request ready for review. Push your commits first. The summary becomes its description and should say what changed and why.",
431 input: {
432 type: "object",
433 properties: { ...numbered, summary: { type: "string", description: "Markdown." } },
434 required: ["repo", "number", "summary"],
435 },
436 run: onRepoAs((env, path, user, input) =>
437 env.WORK.readyPull(user, path, Number(input.number), text(input, "summary")),
438 ),
439 },
440 {
441 name: "close_pull_request",
442 description: "Close a pull request without merging it.",
443 input: { type: "object", properties: numbered, required: ["repo", "number"] },
444 run: onRepoAs((env, path, user, input) =>
445 env.WORK.closePull(user, path, Number(input.number)),
446 ),
447 },
448 {
449 name: "get_pull_request_changes",
450 description:
451 "What a pull request changes: the files it touches and their line-by-line diff against the commit it started from. Use it to review a pull request or to compare several made for the same issue.",
452 input: { type: "object", properties: numbered, required: ["repo", "number"] },
453 run: onRepo(async (env, path, viewer, input) => {
454 const found = await env.WORK.getPull(path, Number(input.number), viewer);
455 if (!found.ok) return found;
456 const { forkRepoId, mergeBase } = found.value.pull;
457 return env.REPOS.compare(forkRepoId, viewer, mergeBase);
458 }),
459 },
460 {
461 name: "merge_pull_request",
462 description:
463 "Land a pull request on the repository's main branch. Only members of the repository's workspace can merge, and only once it is marked ready. Merging resolves the issue it was made for: the issue closes recording this pull request, and the other pull requests still in progress for that issue close as superseded. Fails if main has moved since the pull request was opened; pull main into its fork and push, then merge again.",
464 input: {
465 type: "object",
466 properties: {
467 ...numbered,
468 keep_issue_open: {
469 type: "boolean",
470 description:
471 "Set when this pull request is only part of the work: the issue stays open and the other pull requests for it are left alone.",
472 },
473 },
474 required: ["repo", "number"],
475 },
476 run: onRepoAs((env, path, user, input) =>
477 env.WORK.mergePull(user, path, Number(input.number), input.keep_issue_open === true),
478 ),
479 },
480 {
481 name: "list_events",
482 description:
483 "The timeline of a repository: pushes, issues, pull requests, comments and session activity, newest first.",
484 input: {
485 type: "object",
486 properties: {
487 repo: REPO,
488 before: { type: "string", description: "Event id to page back from." },
489 },
490 required: ["repo"],
491 },
492 run: async (env, viewer, input) => {
493 const path = repoPath(input);
494 if (!path) return BAD_REPO;
495 const repo = await env.REPOS.get(path, viewer);
496 if (!repo.ok) return repo;
497 return ok(
498 await env.EVENTS.list({
499 repoId: repo.value.id,
500 before: text(input, "before") || undefined,
501 }),
502 );
503 },
504 },
505];
506
507export const operationsByName = new Map(
508 operations.map((operation) => [operation.name, operation]),
509);