g1t/apps/api/src/operations.ts

509 lines17,175 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

API and MCP server, Rust identity service, registration, site redesign1import {
2 type EventsApi,
Rust repos service with shipping; pull requests kept in the model3 type IdentityApi,
API and MCP server, Rust identity service, registration, site redesign4 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 {
Rust repos service with shipping; pull requests kept in the model16 IDENTITY: IdentityApi;
API and MCP server, Rust identity service, registration, site redesign17 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
Issues and pull requests replace intents and attempts60const 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
API and MCP server, Rust identity service, registration, site redesign96/** 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",
Workspaces own repositories107 description: "The account the access token belongs to, and its workspaces.",
API and MCP server, Rust identity service, registration, site redesign108 input: { type: "object", properties: {} },
109 run: authed(async (_env, user) => ok(user)),
110 },
111 {
Workspaces own repositories112 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 {
API and MCP server, Rust identity service, registration, site redesign131 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",
Workspaces own repositories151 description: "Create a repository in one of your workspaces.",
API and MCP server, Rust identity service, registration, site redesign152 input: {
153 type: "object",
154 properties: {
Workspaces own repositories155 workspace: {
156 type: "string",
157 description:
158 "The workspace to create it in. May be left out if you belong to exactly one.",
159 },
API and MCP server, Rust identity service, registration, site redesign160 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, {
Workspaces own repositories168 namespace:
169 text(input, "workspace") ||
170 (user.workspaces?.length === 1 ? user.workspaces[0].slug : ""),
API and MCP server, Rust identity service, registration, site redesign171 name: text(input, "name"),
172 description: text(input, "description"),
173 isPrivate: input.private === true,
174 }),
175 ),
176 },
177 {
Issues and pull requests replace intents and attempts178 name: "list_issues",
API and MCP server, Rust identity service, registration, site redesign179 description:
Issues and pull requests replace intents and attempts180 "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.",
API and MCP server, Rust identity service, registration, site redesign181 input: {
182 type: "object",
183 properties: {
184 repo: REPO,
Issues and pull requests replace intents and attempts185 state: { type: "string", enum: ["open", "closed"] },
186 label: { type: "string", description: "Only issues carrying this label." },
API and MCP server, Rust identity service, registration, site redesign187 },
188 required: ["repo"],
189 },
Issues and pull requests replace intents and attempts190 run: onRepo((env, path, viewer, input) =>
191 env.WORK.listIssues(path, viewer, {
192 state: state(input),
193 label: text(input, "label") || undefined,
194 }),
195 ),
API and MCP server, Rust identity service, registration, site redesign196 },
197 {
Issues and pull requests replace intents and attempts198 name: "get_issue",
API and MCP server, Rust identity service, registration, site redesign199 description:
Issues and pull requests replace intents and attempts200 "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 ),
API and MCP server, Rust identity service, registration, site redesign205 },
206 {
Issues and pull requests replace intents and attempts207 name: "create_issue",
208 description: "Open an issue on a repository.",
API and MCP server, Rust identity service, registration, site redesign209 input: {
210 type: "object",
211 properties: {
212 repo: REPO,
Issues and pull requests replace intents and attempts213 title: { type: "string", description: "The problem or goal in one line." },
214 body: {
API and MCP server, Rust identity service, registration, site redesign215 type: "string",
Issues and pull requests replace intents and attempts216 description:
217 "Markdown. What an agent or a person needs to do the work: what is wrong or wanted, constraints, context.",
API and MCP server, Rust identity service, registration, site redesign218 },
Issues and pull requests replace intents and attempts219 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 },
API and MCP server, Rust identity service, registration, site redesign225 checks: {
226 type: "array",
227 items: { type: "string" },
Issues and pull requests replace intents and attempts228 description: "Commands that must pass for a pull request to be accepted.",
API and MCP server, Rust identity service, registration, site redesign229 },
230 },
Issues and pull requests replace intents and attempts231 required: ["repo", "title"],
API and MCP server, Rust identity service, registration, site redesign232 },
Issues and pull requests replace intents and attempts233 run: onRepoAs((env, path, user, input) =>
234 env.WORK.openIssue(user, path, {
API and MCP server, Rust identity service, registration, site redesign235 title: text(input, "title"),
Issues and pull requests replace intents and attempts236 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 ),
API and MCP server, Rust identity service, registration, site redesign314 },
315 {
Issues and pull requests replace intents and attempts316 name: "list_pull_requests",
API and MCP server, Rust identity service, registration, site redesign317 description:
Issues and pull requests replace intents and attempts318 "Pull requests on a repository, newest first. State open covers drafts and those ready for review; closed covers merged and closed.",
API and MCP server, Rust identity service, registration, site redesign319 input: {
320 type: "object",
Issues and pull requests replace intents and attempts321 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",
API and MCP server, Rust identity service, registration, site redesign341 properties: {
Issues and pull requests replace intents and attempts342 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 },
API and MCP server, Rust identity service, registration, site redesign348 agent: {
349 type: "string",
350 description: 'A label for the agent doing the work, e.g. "claude-code".',
351 },
352 },
Issues and pull requests replace intents and attempts353 required: ["repo"],
API and MCP server, Rust identity service, registration, site redesign354 },
Issues and pull requests replace intents and attempts355 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"),
API and MCP server, Rust identity service, registration, site redesign359 agent: text(input, "agent") || "agent",
360 runtime: "external",
361 });
Issues and pull requests replace intents and attempts362 if (!opened.ok) return opened;
363 const { fork } = opened.value;
API and MCP server, Rust identity service, registration, site redesign364 return ok({
Issues and pull requests replace intents and attempts365 pull: opened.value,
API and MCP server, Rust identity service, registration, site redesign366 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:
Issues and pull requests replace intents and attempts377 "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.",
API and MCP server, Rust identity service, registration, site redesign378 input: {
379 type: "object",
380 properties: {
Issues and pull requests replace intents and attempts381 ...numbered,
API and MCP server, Rust identity service, registration, site redesign382 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 },
Issues and pull requests replace intents and attempts398 required: ["repo", "number", "entries"],
API and MCP server, Rust identity service, registration, site redesign399 },
Issues and pull requests replace intents and attempts400 run: onRepoAs(async (env, path, user, input) => {
API and MCP server, Rust identity service, registration, site redesign401 if (!Array.isArray(input.entries)) {
402 return fail("invalid", "entries must be an array.");
403 }
404 return env.WORK.appendSession(
405 user,
Issues and pull requests replace intents and attempts406 path,
407 Number(input.number),
API and MCP server, Rust identity service, registration, site redesign408 input.entries as NewSessionEntry[],
409 );
410 }),
411 },
412 {
413 name: "read_session",
Issues and pull requests replace intents and attempts414 description: "The recorded session of a pull request, oldest entry first.",
API and MCP server, Rust identity service, registration, site redesign415 input: {
416 type: "object",
417 properties: {
Issues and pull requests replace intents and attempts418 ...numbered,
API and MCP server, Rust identity service, registration, site redesign419 after: { type: "integer", description: "Only entries after this sequence number." },
420 },
Issues and pull requests replace intents and attempts421 required: ["repo", "number"],
API and MCP server, Rust identity service, registration, site redesign422 },
Issues and pull requests replace intents and attempts423 run: onRepo((env, path, viewer, input) =>
424 env.WORK.readSession(path, Number(input.number), viewer, Number(input.after) || 0),
425 ),
API and MCP server, Rust identity service, registration, site redesign426 },
427 {
Issues and pull requests replace intents and attempts428 name: "mark_pull_request_ready",
API and MCP server, Rust identity service, registration, site redesign429 description:
Issues and pull requests replace intents and attempts430 "Mark a draft pull request ready for review. Push your commits first. The summary becomes its description and should say what changed and why.",
API and MCP server, Rust identity service, registration, site redesign431 input: {
432 type: "object",
Issues and pull requests replace intents and attempts433 properties: { ...numbered, summary: { type: "string", description: "Markdown." } },
434 required: ["repo", "number", "summary"],
API and MCP server, Rust identity service, registration, site redesign435 },
Issues and pull requests replace intents and attempts436 run: onRepoAs((env, path, user, input) =>
437 env.WORK.readyPull(user, path, Number(input.number), text(input, "summary")),
API and MCP server, Rust identity service, registration, site redesign438 ),
439 },
440 {
Issues and pull requests replace intents and attempts441 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)),
API and MCP server, Rust identity service, registration, site redesign446 ),
447 },
448 {
Issues and pull requests replace intents and attempts449 name: "get_pull_request_changes",
docs.g1t.sh, generated OpenAPI with an interactive reference, full footer450 description:
Issues and pull requests replace intents and attempts451 "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);
docs.g1t.sh, generated OpenAPI with an interactive reference, full footer455 if (!found.ok) return found;
Issues and pull requests replace intents and attempts456 const { forkRepoId, mergeBase } = found.value.pull;
457 return env.REPOS.compare(forkRepoId, viewer, mergeBase);
458 }),
docs.g1t.sh, generated OpenAPI with an interactive reference, full footer459 },
460 {
Issues and pull requests replace intents and attempts461 name: "merge_pull_request",
Rust repos service with shipping; pull requests kept in the model462 description:
Issues and pull requests replace intents and attempts463 "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.",
Rust repos service with shipping; pull requests kept in the model464 input: {
465 type: "object",
Issues and pull requests replace intents and attempts466 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"],
Rust repos service with shipping; pull requests kept in the model475 },
Issues and pull requests replace intents and attempts476 run: onRepoAs((env, path, user, input) =>
477 env.WORK.mergePull(user, path, Number(input.number), input.keep_issue_open === true),
Rust repos service with shipping; pull requests kept in the model478 ),
479 },
480 {
API and MCP server, Rust identity service, registration, site redesign481 name: "list_events",
482 description:
Issues and pull requests replace intents and attempts483 "The timeline of a repository: pushes, issues, pull requests, comments and session activity, newest first.",
API and MCP server, Rust identity service, registration, site redesign484 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);