pr_01m47d15m3e54sn21z27rpy5n9/apps/api/src/operations.ts

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