g1t/apps/api/src/operations.ts

525 lines17,982 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,
Pull requests from branches13 pullComparison,
API and MCP server, Rust identity service, registration, site redesign14} from "@g1t/contracts";
15
16export interface ApiEnv {
Rust repos service with shipping; pull requests kept in the model17 IDENTITY: IdentityApi;
API and MCP server, Rust identity service, registration, site redesign18 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
Issues and pull requests replace intents and attempts61const 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
API and MCP server, Rust identity service, registration, site redesign97/** 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",
Workspaces own repositories108 description: "The account the access token belongs to, and its workspaces.",
API and MCP server, Rust identity service, registration, site redesign109 input: { type: "object", properties: {} },
110 run: authed(async (_env, user) => ok(user)),
111 },
112 {
Workspaces own repositories113 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 {
API and MCP server, Rust identity service, registration, site redesign132 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",
Workspaces own repositories152 description: "Create a repository in one of your workspaces.",
API and MCP server, Rust identity service, registration, site redesign153 input: {
154 type: "object",
155 properties: {
Workspaces own repositories156 workspace: {
157 type: "string",
158 description:
159 "The workspace to create it in. May be left out if you belong to exactly one.",
160 },
API and MCP server, Rust identity service, registration, site redesign161 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, {
Workspaces own repositories169 namespace:
170 text(input, "workspace") ||
171 (user.workspaces?.length === 1 ? user.workspaces[0].slug : ""),
API and MCP server, Rust identity service, registration, site redesign172 name: text(input, "name"),
173 description: text(input, "description"),
174 isPrivate: input.private === true,
175 }),
176 ),
177 },
178 {
Issues and pull requests replace intents and attempts179 name: "list_issues",
API and MCP server, Rust identity service, registration, site redesign180 description:
Issues and pull requests replace intents and attempts181 "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 redesign182 input: {
183 type: "object",
184 properties: {
185 repo: REPO,
Issues and pull requests replace intents and attempts186 state: { type: "string", enum: ["open", "closed"] },
187 label: { type: "string", description: "Only issues carrying this label." },
API and MCP server, Rust identity service, registration, site redesign188 },
189 required: ["repo"],
190 },
Issues and pull requests replace intents and attempts191 run: onRepo((env, path, viewer, input) =>
192 env.WORK.listIssues(path, viewer, {
193 state: state(input),
194 label: text(input, "label") || undefined,
195 }),
196 ),
API and MCP server, Rust identity service, registration, site redesign197 },
198 {
Issues and pull requests replace intents and attempts199 name: "get_issue",
API and MCP server, Rust identity service, registration, site redesign200 description:
Issues and pull requests replace intents and attempts201 "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 ),
API and MCP server, Rust identity service, registration, site redesign206 },
207 {
Issues and pull requests replace intents and attempts208 name: "create_issue",
209 description: "Open an issue on a repository.",
API and MCP server, Rust identity service, registration, site redesign210 input: {
211 type: "object",
212 properties: {
213 repo: REPO,
Issues and pull requests replace intents and attempts214 title: { type: "string", description: "The problem or goal in one line." },
215 body: {
API and MCP server, Rust identity service, registration, site redesign216 type: "string",
Issues and pull requests replace intents and attempts217 description:
218 "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 redesign219 },
Issues and pull requests replace intents and attempts220 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 },
API and MCP server, Rust identity service, registration, site redesign226 checks: {
227 type: "array",
228 items: { type: "string" },
Issues and pull requests replace intents and attempts229 description: "Commands that must pass for a pull request to be accepted.",
API and MCP server, Rust identity service, registration, site redesign230 },
231 },
Issues and pull requests replace intents and attempts232 required: ["repo", "title"],
API and MCP server, Rust identity service, registration, site redesign233 },
Issues and pull requests replace intents and attempts234 run: onRepoAs((env, path, user, input) =>
235 env.WORK.openIssue(user, path, {
API and MCP server, Rust identity service, registration, site redesign236 title: text(input, "title"),
Issues and pull requests replace intents and attempts237 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 ),
API and MCP server, Rust identity service, registration, site redesign315 },
316 {
Issues and pull requests replace intents and attempts317 name: "list_pull_requests",
API and MCP server, Rust identity service, registration, site redesign318 description:
Issues and pull requests replace intents and attempts319 "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 redesign320 input: {
321 type: "object",
Issues and pull requests replace intents and attempts322 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:
Pull requests from branches339 "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.",
Issues and pull requests replace intents and attempts340 input: {
341 type: "object",
API and MCP server, Rust identity service, registration, site redesign342 properties: {
Issues and pull requests replace intents and attempts343 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 },
Pull requests from branches349 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 },
API and MCP server, Rust identity service, registration, site redesign358 agent: {
359 type: "string",
360 description: 'A label for the agent doing the work, e.g. "claude-code".',
361 },
362 },
Issues and pull requests replace intents and attempts363 required: ["repo"],
API and MCP server, Rust identity service, registration, site redesign364 },
Issues and pull requests replace intents and attempts365 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"),
Pull requests from branches369 body: text(input, "body"),
370 branch: text(input, "branch") || undefined,
API and MCP server, Rust identity service, registration, site redesign371 agent: text(input, "agent") || "agent",
372 runtime: "external",
373 });
Issues and pull requests replace intents and attempts374 if (!opened.ok) return opened;
375 const { fork } = opened.value;
API and MCP server, Rust identity service, registration, site redesign376 return ok({
Issues and pull requests replace intents and attempts377 pull: opened.value,
Pull requests from branches378 // Where to push. A pull request from a branch has no fork: push to
379 // that branch of the repository.
API and MCP server, Rust identity service, registration, site redesign380 git: {
Pull requests from branches381 remote: fork
382 ? `https://g1t.sh/${fork.namespace}/${fork.name}.git`
383 : `https://g1t.sh/${path.namespace}/${path.name}.git`,
API and MCP server, Rust identity service, registration, site redesign384 username: user.username,
385 password: "your g1t access token",
386 },
387 });
388 }),
389 },
390 {
391 name: "record_session",
392 description:
Issues and pull requests replace intents and attempts393 "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 redesign394 input: {
395 type: "object",
396 properties: {
Issues and pull requests replace intents and attempts397 ...numbered,
API and MCP server, Rust identity service, registration, site redesign398 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 },
Issues and pull requests replace intents and attempts414 required: ["repo", "number", "entries"],
API and MCP server, Rust identity service, registration, site redesign415 },
Issues and pull requests replace intents and attempts416 run: onRepoAs(async (env, path, user, input) => {
API and MCP server, Rust identity service, registration, site redesign417 if (!Array.isArray(input.entries)) {
418 return fail("invalid", "entries must be an array.");
419 }
420 return env.WORK.appendSession(
421 user,
Issues and pull requests replace intents and attempts422 path,
423 Number(input.number),
API and MCP server, Rust identity service, registration, site redesign424 input.entries as NewSessionEntry[],
425 );
426 }),
427 },
428 {
429 name: "read_session",
Issues and pull requests replace intents and attempts430 description: "The recorded session of a pull request, oldest entry first.",
API and MCP server, Rust identity service, registration, site redesign431 input: {
432 type: "object",
433 properties: {
Issues and pull requests replace intents and attempts434 ...numbered,
API and MCP server, Rust identity service, registration, site redesign435 after: { type: "integer", description: "Only entries after this sequence number." },
436 },
Issues and pull requests replace intents and attempts437 required: ["repo", "number"],
API and MCP server, Rust identity service, registration, site redesign438 },
Issues and pull requests replace intents and attempts439 run: onRepo((env, path, viewer, input) =>
440 env.WORK.readSession(path, Number(input.number), viewer, Number(input.after) || 0),
441 ),
API and MCP server, Rust identity service, registration, site redesign442 },
443 {
Issues and pull requests replace intents and attempts444 name: "mark_pull_request_ready",
API and MCP server, Rust identity service, registration, site redesign445 description:
Issues and pull requests replace intents and attempts446 "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 redesign447 input: {
448 type: "object",
Issues and pull requests replace intents and attempts449 properties: { ...numbered, summary: { type: "string", description: "Markdown." } },
450 required: ["repo", "number", "summary"],
API and MCP server, Rust identity service, registration, site redesign451 },
Issues and pull requests replace intents and attempts452 run: onRepoAs((env, path, user, input) =>
453 env.WORK.readyPull(user, path, Number(input.number), text(input, "summary")),
API and MCP server, Rust identity service, registration, site redesign454 ),
455 },
456 {
Issues and pull requests replace intents and attempts457 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)),
API and MCP server, Rust identity service, registration, site redesign462 ),
463 },
464 {
Issues and pull requests replace intents and attempts465 name: "get_pull_request_changes",
docs.g1t.sh, generated OpenAPI with an interactive reference, full footer466 description:
Issues and pull requests replace intents and attempts467 "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);
docs.g1t.sh, generated OpenAPI with an interactive reference, full footer471 if (!found.ok) return found;
Pull requests from branches472 const { repoId, base, head } = pullComparison(found.value.pull);
473 return env.REPOS.compare(repoId, viewer, base, head);
Issues and pull requests replace intents and attempts474 }),
docs.g1t.sh, generated OpenAPI with an interactive reference, full footer475 },
476 {
Issues and pull requests replace intents and attempts477 name: "merge_pull_request",
Rust repos service with shipping; pull requests kept in the model478 description:
Issues and pull requests replace intents and attempts479 "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 model480 input: {
481 type: "object",
Issues and pull requests replace intents and attempts482 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"],
Rust repos service with shipping; pull requests kept in the model491 },
Issues and pull requests replace intents and attempts492 run: onRepoAs((env, path, user, input) =>
493 env.WORK.mergePull(user, path, Number(input.number), input.keep_issue_open === true),
Rust repos service with shipping; pull requests kept in the model494 ),
495 },
496 {
API and MCP server, Rust identity service, registration, site redesign497 name: "list_events",
498 description:
Issues and pull requests replace intents and attempts499 "The timeline of a repository: pushes, issues, pull requests, comments and session activity, newest first.",
API and MCP server, Rust identity service, registration, site redesign500 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);