pr_01m47d24b0e6n91zwymwxg0vpx/apps/api/src/operations.rs

1,235 lines51,121 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 in Rust; a public index at the API root1//! Everything a client can do through the API.
2//!
3//! REST routes, MCP tools and the OpenAPI document are all generated from
4//! [`Op`], so the surfaces cannot drift apart: adding a variant without
5//! describing it or running it does not compile.
6
Agents as a team: lifecycle, merge queue, billing and a new shell7use g1t_contracts::identity::AgentScope;
API and MCP server in Rust; a public index at the API root8use g1t_contracts::events::{Event, ListArgs as ListEventsArgs};
9use g1t_contracts::identity::CreateWorkspaceArgs;
Agents as a team: lifecycle, merge queue, billing and a new shell10use g1t_contracts::repos::{CreateArgs, GetArgs, ListArgs as ListReposArgs, Repo, RepoPath};
API and MCP server in Rust; a public index at the API root11use g1t_contracts::work::*;
12use g1t_contracts::{FailureCode, Outcome, Viewer};
13use serde::Serialize;
14use serde::de::DeserializeOwned;
15use serde_json::{Map, Value, json};
16use worker::{Env, Fetcher, Result};
17
18/// The services the API is a front for.
19pub struct Services {
20 pub identity: Fetcher,
21 pub repos: Fetcher,
22 pub work: Fetcher,
23 pub events: Fetcher,
Agents as a team: lifecycle, merge queue, billing and a new shell24 pub runner: Fetcher,
25 pub billing: Fetcher,
26 /// Set for a request made with an agent's token: all it may do.
27 pub scope: Option<AgentScope>,
API and MCP server in Rust; a public index at the API root28}
29
30impl Services {
31 pub fn new(env: &Env) -> Result<Self> {
32 Ok(Services {
33 identity: env.service("IDENTITY")?,
34 repos: env.service("REPOS")?,
35 work: env.service("WORK")?,
36 events: env.service("EVENTS")?,
Agents as a team: lifecycle, merge queue, billing and a new shell37 runner: env.service("RUNNER")?,
38 billing: env.service("BILLING")?,
39 scope: None,
API and MCP server in Rust; a public index at the API root40 })
41 }
42}
43
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45pub enum Op {
46 Whoami,
47 CreateWorkspace,
48 ListRepos,
49 GetRepo,
50 CreateRepo,
Agents as a team: lifecycle, merge queue, billing and a new shell51 UpdateRepo,
52 GetRepoSettings,
53 UpdateRepoSettings,
54 GetMergeQueue,
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request55 MessageAgent,
56 TakeMessages,
API and MCP server in Rust; a public index at the API root57 ListIssues,
58 GetIssue,
59 CreateIssue,
60 UpdateIssue,
61 CloseIssue,
62 ReopenIssue,
Agents as a team: lifecycle, merge queue, billing and a new shell63 AssignIssue,
64 PlanWork,
65 GetPlan,
66 ApplyPlan,
API and MCP server in Rust; a public index at the API root67 ListLabels,
68 AddComment,
Acceptance checks in sandboxes, line comments and review verdicts69 ReviewPullRequest,
API and MCP server in Rust; a public index at the API root70 ListPullRequests,
71 GetPullRequest,
72 CreatePullRequest,
73 RecordSession,
74 ReadSession,
75 MarkPullRequestReady,
76 ClosePullRequest,
77 GetPullRequestChanges,
78 MergePullRequest,
79 ListEvents,
80}
81
82fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> {
83 Ok(Outcome::fail(code, message))
84}
85
86fn ok<T: Serialize>(value: &T) -> Result<Outcome<Value>> {
87 Ok(Outcome::Ok(serde_json::to_value(value)?))
88}
89
90/// Calls a method that returns an `Outcome`, decoding its value as `T`.
91async fn call<A: Serialize, T: DeserializeOwned>(
92 service: &Fetcher,
93 method: &str,
94 args: &A,
95) -> Result<Outcome<T>> {
96 g1t_kit::call(service, method, args).await
97}
98
99/// Calls a method that returns an `Outcome`, passing its value through.
100async fn pass<A: Serialize>(service: &Fetcher, method: &str, args: &A) -> Result<Outcome<Value>> {
101 call(service, method, args).await
102}
103
104fn text(input: &Value, key: &str) -> String {
105 input[key].as_str().unwrap_or_default().to_owned()
106}
107
108fn optional_text(input: &Value, key: &str) -> Option<String> {
109 input[key]
110 .as_str()
111 .filter(|value| !value.is_empty())
112 .map(str::to_owned)
113}
114
115/// A whole number given as a number or as digits.
116fn integer(input: &Value, key: &str) -> Option<u32> {
117 match &input[key] {
118 Value::Number(number) => number.as_u64().and_then(|n| u32::try_from(n).ok()),
119 Value::String(digits) => digits.parse().ok(),
120 _ => None,
121 }
122}
123
124fn strings(input: &Value, key: &str) -> Option<Vec<String>> {
125 input[key].as_array().map(|items| {
126 items
127 .iter()
128 .map(|item| match item {
129 Value::String(text) => text.clone(),
130 other => other.to_string(),
131 })
132 .collect()
133 })
134}
135
136fn state(input: &Value) -> Option<State> {
137 match input["state"].as_str() {
138 Some("open") => Some(State::Open),
139 Some("closed") => Some(State::Closed),
140 _ => None,
141 }
142}
143
144/// The repository named by `repo`, written `owner/name`.
145fn repo_path(input: &Value) -> Option<RepoPath> {
146 let mut parts = input["repo"].as_str()?.split('/');
147 match (parts.next(), parts.next(), parts.next()) {
148 (Some(namespace), Some(name), None) if !namespace.is_empty() && !name.is_empty() => {
149 Some(RepoPath {
150 namespace: namespace.to_owned(),
151 name: name.to_owned(),
152 })
153 }
154 _ => None,
155 }
156}
157
158/// An object schema. `required` names the properties that must be given.
159fn object(properties: Value, required: &[&str]) -> Value {
160 let mut schema = json!({ "type": "object", "properties": properties });
161 if !required.is_empty() {
162 schema["required"] = json!(required);
163 }
164 schema
165}
166
167/// The properties naming an issue or pull request, with `more` added.
168fn numbered(more: Value) -> Value {
169 let mut properties = json!({
170 "repo": repo_schema(),
171 "number": {
172 "type": "integer",
173 "description": "The number shown after the #. Issues and pull requests share one sequence.",
174 },
175 });
176 if let (Some(all), Value::Object(more)) = (properties.as_object_mut(), more) {
177 all.extend(more);
178 }
179 properties
180}
181
182fn repo_schema() -> Value {
183 json!({
184 "type": "string",
185 "description": "Repository as \"owner/name\", e.g. \"syntaqx/hello\".",
186 })
187}
188
189impl Op {
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request190 pub const ALL: [Op; 34] = [
API and MCP server in Rust; a public index at the API root191 Op::Whoami,
192 Op::CreateWorkspace,
193 Op::ListRepos,
194 Op::GetRepo,
195 Op::CreateRepo,
Agents as a team: lifecycle, merge queue, billing and a new shell196 Op::UpdateRepo,
197 Op::GetRepoSettings,
198 Op::UpdateRepoSettings,
199 Op::GetMergeQueue,
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request200 Op::MessageAgent,
201 Op::TakeMessages,
API and MCP server in Rust; a public index at the API root202 Op::ListIssues,
203 Op::GetIssue,
204 Op::CreateIssue,
205 Op::UpdateIssue,
206 Op::CloseIssue,
207 Op::ReopenIssue,
Agents as a team: lifecycle, merge queue, billing and a new shell208 Op::AssignIssue,
209 Op::PlanWork,
210 Op::GetPlan,
211 Op::ApplyPlan,
API and MCP server in Rust; a public index at the API root212 Op::ListLabels,
213 Op::AddComment,
Acceptance checks in sandboxes, line comments and review verdicts214 Op::ReviewPullRequest,
API and MCP server in Rust; a public index at the API root215 Op::ListPullRequests,
216 Op::GetPullRequest,
217 Op::CreatePullRequest,
218 Op::RecordSession,
219 Op::ReadSession,
220 Op::MarkPullRequestReady,
221 Op::ClosePullRequest,
222 Op::GetPullRequestChanges,
223 Op::MergePullRequest,
224 Op::ListEvents,
225 ];
226
227 pub fn by_name(name: &str) -> Option<Op> {
228 Op::ALL.into_iter().find(|op| op.name() == name)
229 }
230
231 /// The operation's name: its MCP tool name and OpenAPI operation id.
232 pub fn name(self) -> &'static str {
233 match self {
234 Op::Whoami => "whoami",
235 Op::CreateWorkspace => "create_workspace",
236 Op::ListRepos => "list_repos",
237 Op::GetRepo => "get_repo",
238 Op::CreateRepo => "create_repo",
Agents as a team: lifecycle, merge queue, billing and a new shell239 Op::UpdateRepo => "update_repo",
240 Op::GetRepoSettings => "get_repo_settings",
241 Op::GetMergeQueue => "get_merge_queue",
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request242 Op::MessageAgent => "message_agent",
243 Op::TakeMessages => "take_messages",
Agents as a team: lifecycle, merge queue, billing and a new shell244 Op::UpdateRepoSettings => "update_repo_settings",
API and MCP server in Rust; a public index at the API root245 Op::ListIssues => "list_issues",
246 Op::GetIssue => "get_issue",
247 Op::CreateIssue => "create_issue",
248 Op::UpdateIssue => "update_issue",
249 Op::CloseIssue => "close_issue",
250 Op::ReopenIssue => "reopen_issue",
Agents as a team: lifecycle, merge queue, billing and a new shell251 Op::AssignIssue => "assign_issue",
252 Op::PlanWork => "plan_work",
253 Op::GetPlan => "get_plan",
254 Op::ApplyPlan => "apply_plan",
API and MCP server in Rust; a public index at the API root255 Op::ListLabels => "list_labels",
256 Op::AddComment => "add_comment",
Acceptance checks in sandboxes, line comments and review verdicts257 Op::ReviewPullRequest => "review_pull_request",
API and MCP server in Rust; a public index at the API root258 Op::ListPullRequests => "list_pull_requests",
259 Op::GetPullRequest => "get_pull_request",
260 Op::CreatePullRequest => "create_pull_request",
261 Op::RecordSession => "record_session",
262 Op::ReadSession => "read_session",
263 Op::MarkPullRequestReady => "mark_pull_request_ready",
264 Op::ClosePullRequest => "close_pull_request",
265 Op::GetPullRequestChanges => "get_pull_request_changes",
266 Op::MergePullRequest => "merge_pull_request",
267 Op::ListEvents => "list_events",
268 }
269 }
270
271 pub fn description(self) -> &'static str {
272 match self {
Agents as a team: lifecycle, merge queue, billing and a new shell273 Op::Whoami => {
274 "Who the access token acts as, and the workspaces it can work in. `kind` is `user` for a person's token and `workspace` for a token that belongs to a workspace."
275 }
API and MCP server in Rust; a public index at the API root276 Op::CreateWorkspace => {
277 "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."
278 }
279 Op::ListRepos => "Repositories you can see, optionally filtered by a search query.",
280 Op::GetRepo => "One repository's details.",
Agents as a team: lifecycle, merge queue, billing and a new shell281 Op::UpdateRepo => {
282 "Change a repository's description, whether it is private, and whether its default branch is protected. A protected branch refuses pushes and changes only by merging a pull request. Only the fields given are changed. Members of its workspace only."
283 }
284 Op::GetRepoSettings => {
285 "How a repository handles pull requests: the approvals a merge needs, whether failed checks can be overridden, whether a pull request must be up to date, and how g1t's agents are reviewed, revised and merged."
286 }
287 Op::UpdateRepoSettings => {
288 "Change how a repository handles pull requests. Only the fields given are changed. Members of its workspace only."
289 }
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request290 Op::MessageAgent => {
291 "Send the agent working on a pull request a message: a correction, a hint, a change of plan. It receives it at its next step, and it is recorded in the pull request's session. The pull request's author and members of its workspace only."
292 }
293 Op::TakeMessages => {
294 "For a g1t agent at work: the messages people have sent it that it has not seen yet. Each is returned once."
295 }
Agents as a team: lifecycle, merge queue, billing and a new shell296 Op::GetMergeQueue => {
297 "A repository's merge queue: the pull requests waiting to land, in order, each with the state it is being tested in (the default branch with the pull requests ahead of it merged in) and how that went; then those that recently landed or left. With the queue on, merging a pull request adds it here."
298 }
299 Op::CreateRepo => {
300 "Create a repository in one of your workspaces, empty or as a copy of a public git repository elsewhere."
301 }
API and MCP server in Rust; a public index at the API root302 Op::ListIssues => {
303 "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."
304 }
305 Op::GetIssue => {
306 "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."
307 }
308 Op::CreateIssue => "Open an issue on a repository.",
309 Op::UpdateIssue => {
Agents as a team: lifecycle, merge queue, billing and a new shell310 "Change an issue's title, body, labels or the people it is assigned to. Only the fields given are changed; labels and assignees each replace the whole set."
API and MCP server in Rust; a public index at the API root311 }
312 Op::CloseIssue => {
313 "Close an issue without a pull request. Merging a pull request made for an issue closes it for you."
314 }
315 Op::ReopenIssue => "Reopen a closed issue.",
Agents as a team: lifecycle, merge queue, billing and a new shell316 Op::PlanWork => {
317 "Turn an outcome into a plan. An agent reads the repository and proposes the issues that would get there: what each changes, the checks it must pass, the files it will touch, and which must merge before which. Returns the plan's id at once; the plan takes a minute or two to write, so read it with get_plan until its status is ready. Nothing is opened until apply_plan. Members of the repository's workspace only."
318 }
319 Op::GetPlan => {
320 "A plan: the outcome asked for, its status (planning, ready, failed or applied), and the issues it proposes with their dependencies."
321 }
322 Op::ApplyPlan => {
323 "Open a plan's issues, each blocked by the ones it depends on. With assign, g1t agents start at once on every issue that depends on nothing, working in parallel, and on the others as what they depend on merges. keep limits it to some of the proposed issues, by their positions counting from 1. A plan is applied once."
324 }
325 Op::AssignIssue => {
326 "Assign an issue to the g1t agent. It opens a pull request for the issue in a sandbox of its own and sees it through: the issue's acceptance checks, a review by a second agent, revision if either finds something, and catching up when main moves. Returns the pull request at once; follow its progress with get_pull_request. There is no model or agent count to choose. To put many agents to work, assign many issues. In preview: only for accounts g1t agents are enabled for."
327 }
API and MCP server in Rust; a public index at the API root328 Op::ListLabels => "The labels available on a repository's issues.",
Acceptance checks in sandboxes, line comments and review verdicts329 Op::AddComment => {
330 "Comment on an issue or a pull request. On a pull request, give path and line to comment on one line of the change."
331 }
332 Op::ReviewPullRequest => {
333 "Give a verdict on a pull request: approve it, or request changes and say what. Read get_pull_request_changes first. You cannot review a pull request you opened."
334 }
API and MCP server in Rust; a public index at the API root335 Op::ListPullRequests => {
336 "Pull requests on a repository, newest first. State open covers drafts and those ready for review; closed covers merged and closed."
337 }
338 Op::GetPullRequest => {
Agents as a team: lifecycle, merge queue, billing and a new shell339 "A pull request's status, head commit, comments and reviews, the issue it is for, the latest run of that issue's acceptance checks with each command's output, whether it is behind the branch it would merge into, and overlaps: other pull requests in progress that change the same files. An overlap with a pull request for a different issue means the two will conflict; say so, or keep clear of those files."
API and MCP server in Rust; a public index at the API root340 }
341 Op::CreatePullRequest => {
342 "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."
343 }
344 Op::RecordSession => {
345 "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."
346 }
347 Op::ReadSession => "The recorded session of a pull request, oldest entry first.",
348 Op::MarkPullRequestReady => {
349 "Mark a draft pull request ready for review. Push your commits first. The summary becomes its description and should say what changed and why."
350 }
351 Op::ClosePullRequest => "Close a pull request without merging it.",
352 Op::GetPullRequestChanges => {
353 "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."
354 }
355 Op::MergePullRequest => {
Acceptance checks in sandboxes, line comments and review verdicts356 "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 and its acceptance checks have passed. 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 or branch and push, then merge again."
API and MCP server in Rust; a public index at the API root357 }
358 Op::ListEvents => {
359 "The timeline of a repository: pushes, issues, pull requests, comments and session activity, newest first."
360 }
361 }
362 }
363
364 /// The JSON Schema of the operation's input.
365 pub fn input(self) -> Value {
366 let repo_only = || object(json!({ "repo": repo_schema() }), &["repo"]);
367 let just_numbered = || object(numbered(json!({})), &["repo", "number"]);
368 let states = json!({ "type": "string", "enum": ["open", "closed"] });
369 match self {
370 Op::Whoami => object(json!({}), &[]),
371 Op::CreateWorkspace => object(
372 json!({
373 "slug": {
374 "type": "string",
375 "description": "Its name in URLs: lowercase letters, digits and single hyphens.",
376 },
377 "name": { "type": "string", "description": "A display name." },
378 }),
379 &["slug"],
380 ),
381 Op::ListRepos => object(
382 json!({
383 "query": { "type": "string", "description": "Matches name or description." },
384 }),
385 &[],
386 ),
387 Op::GetRepo | Op::ListLabels => repo_only(),
Agents as a team: lifecycle, merge queue, billing and a new shell388 Op::UpdateRepo => object(
389 json!({
390 "repo": repo_schema(),
391 "description": { "type": "string", "description": "An empty string clears it." },
392 "private": { "type": "boolean" },
393 "protected": {
394 "type": "boolean",
395 "description": "Refuse pushes to the default branch, so that it changes only by merging a pull request.",
396 },
397 }),
398 &["repo"],
399 ),
400 Op::GetRepoSettings => object(json!({ "repo": repo_schema() }), &["repo"]),
401 Op::GetMergeQueue => object(json!({ "repo": repo_schema() }), &["repo"]),
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request402 Op::MessageAgent => object(
403 numbered(json!({
404 "body": { "type": "string", "description": "What to tell the agent." },
405 })),
406 &["repo", "number", "body"],
407 ),
408 Op::TakeMessages => object(numbered(json!({})), &["repo", "number"]),
Agents as a team: lifecycle, merge queue, billing and a new shell409 Op::UpdateRepoSettings => object(
410 json!({
411 "repo": repo_schema(),
412 "auto_merge": {
413 "type": "boolean",
414 "description": "Land a g1t agent's pull request without a person once every rule is met.",
415 },
416 "require_up_to_date": {
417 "type": "boolean",
418 "description": "Refuse to merge a pull request that is behind the default branch. When false, merging brings it up to date first.",
419 },
420 "required_approvals": {
421 "type": "integer",
422 "description": "How many approving reviews a merge needs.",
423 },
424 "count_agent_approvals": {
425 "type": "boolean",
426 "description": "Whether a g1t agent's approval counts towards required_approvals.",
427 },
428 "allow_ignoring_checks": {
429 "type": "boolean",
430 "description": "Whether a member may merge although the acceptance checks did not pass.",
431 },
432 "agent_review": {
433 "type": "boolean",
434 "description": "Whether a second agent reviews a g1t agent's pull request unasked.",
435 },
436 "merge_queue": {
437 "type": "boolean",
438 "description": "Merge through a queue: each pull request is tested together with those ahead of it, and only a combination that passed reaches the default branch.",
439 },
440 "max_revisions": {
441 "type": "integer",
442 "description": "How many times a g1t agent is sent back before a person is asked.",
443 },
444 }),
445 &["repo"],
446 ),
API and MCP server in Rust; a public index at the API root447 Op::CreateRepo => object(
448 json!({
449 "workspace": {
450 "type": "string",
451 "description": "The workspace to create it in. May be left out if you belong to exactly one.",
452 },
453 "name": { "type": "string" },
454 "description": { "type": "string" },
455 "private": { "type": "boolean" },
Agents as a team: lifecycle, merge queue, billing and a new shell456 "import_url": {
457 "type": "string",
458 "description": "Copy the default branch of a public git repository at this https address, e.g. https://github.com/owner/repo.",
459 },
API and MCP server in Rust; a public index at the API root460 }),
461 &["name"],
462 ),
463 Op::ListIssues => object(
464 json!({
465 "repo": repo_schema(),
466 "state": states,
467 "label": { "type": "string", "description": "Only issues carrying this label." },
468 }),
469 &["repo"],
470 ),
471 Op::GetIssue
472 | Op::ReopenIssue
473 | Op::GetPullRequest
474 | Op::ClosePullRequest
475 | Op::GetPullRequestChanges => just_numbered(),
476 Op::CreateIssue => object(
477 json!({
478 "repo": repo_schema(),
479 "title": { "type": "string", "description": "The problem or goal in one line." },
480 "body": {
481 "type": "string",
482 "description": "Markdown. What an agent or a person needs to do the work: what is wrong or wanted, constraints, context.",
483 },
484 "labels": {
485 "type": "array",
486 "items": { "type": "string" },
487 "description": "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.",
488 },
489 "checks": {
490 "type": "array",
491 "items": { "type": "string" },
492 "description": "Commands that must pass for a pull request to be accepted.",
493 },
494 }),
495 &["repo", "title"],
496 ),
497 Op::UpdateIssue => object(
498 numbered(json!({
499 "title": { "type": "string" },
500 "body": { "type": "string" },
501 "labels": { "type": "array", "items": { "type": "string" } },
Agents as a team: lifecycle, merge queue, billing and a new shell502 "assignees": {
503 "type": "array",
504 "items": { "type": "string" },
505 "description": "Usernames of the people it is assigned to. Replaces the whole set; an empty list unassigns everyone. To assign it to the g1t agent, use assign_issue.",
506 },
507 })),
508 &["repo", "number"],
509 ),
510 Op::PlanWork => object(
511 json!({
512 "repo": repo_schema(),
513 "brief": {
514 "type": "string",
515 "description": "What should be true when the work is done, in plain words. Say what you want, not how to split it.",
516 },
517 }),
518 &["repo", "brief"],
519 ),
520 Op::GetPlan => object(
521 json!({
522 "repo": repo_schema(),
523 "plan": { "type": "string", "description": "The plan's id." },
524 }),
525 &["repo", "plan"],
526 ),
527 Op::ApplyPlan => object(
528 json!({
529 "repo": repo_schema(),
530 "plan": { "type": "string", "description": "The plan's id." },
531 "assign": {
532 "type": "boolean",
533 "description": "Put g1t agents on the issues, in dependency order.",
534 },
535 "keep": {
536 "type": "array",
537 "items": { "type": "integer" },
538 "description": "Positions, counting from 1, of the proposed issues to open. All of them if left out.",
539 },
540 }),
541 &["repo", "plan"],
542 ),
543 Op::AssignIssue => object(
544 numbered(json!({
545 "instructions": {
546 "type": "string",
547 "description": "Extra guidance for this run, on top of the issue's description.",
548 },
API and MCP server in Rust; a public index at the API root549 })),
550 &["repo", "number"],
551 ),
552 Op::CloseIssue => object(
553 numbered(json!({
554 "reason": {
555 "type": "string",
556 "enum": ["completed", "not_planned"],
557 "description": "Defaults to completed.",
558 },
559 })),
560 &["repo", "number"],
561 ),
562 Op::AddComment => object(
Acceptance checks in sandboxes, line comments and review verdicts563 numbered(json!({
564 "body": { "type": "string", "description": "Markdown." },
565 "path": {
566 "type": "string",
567 "description": "On a pull request: the file to comment on.",
568 },
569 "line": {
570 "type": "integer",
571 "description": "The line of that file, as numbered after the change.",
572 },
573 })),
API and MCP server in Rust; a public index at the API root574 &["repo", "number", "body"],
575 ),
Acceptance checks in sandboxes, line comments and review verdicts576 Op::ReviewPullRequest => object(
577 numbered(json!({
578 "verdict": { "type": "string", "enum": ["approve", "request_changes"] },
579 "body": {
580 "type": "string",
581 "description": "Markdown. Required when requesting changes.",
582 },
583 })),
584 &["repo", "number", "verdict"],
585 ),
API and MCP server in Rust; a public index at the API root586 Op::ListPullRequests => {
587 object(json!({ "repo": repo_schema(), "state": states }), &["repo"])
588 }
589 Op::CreatePullRequest => object(
590 json!({
591 "repo": repo_schema(),
592 "issue": { "type": "integer", "description": "The number of the issue this is for." },
593 "title": {
594 "type": "string",
595 "description": "Defaults to the issue's title. Required when there is no issue.",
596 },
597 "branch": {
598 "type": "string",
599 "description": "A branch already pushed to the repository that holds the change. Leave out to get a fork.",
600 },
601 "body": {
602 "type": "string",
603 "description": "Markdown: what changed and why. Mainly for pull requests from a branch.",
604 },
605 "agent": {
606 "type": "string",
607 "description": "A label for the agent doing the work, e.g. \"claude-code\".",
608 },
609 }),
610 &["repo"],
611 ),
612 Op::RecordSession => object(
613 numbered(json!({
614 "entries": {
615 "type": "array",
616 "items": {
617 "type": "object",
618 "properties": {
619 "kind": {
620 "type": "string",
621 "enum": ["prompt", "message", "tool_call", "tool_result", "note"],
622 },
623 "text": { "type": "string" },
624 "tool": { "type": "string", "description": "Tool name, for tool entries." },
625 },
626 "required": ["kind", "text"],
627 },
628 },
629 })),
630 &["repo", "number", "entries"],
631 ),
632 Op::ReadSession => object(
633 numbered(json!({
634 "after": { "type": "integer", "description": "Only entries after this sequence number." },
635 })),
636 &["repo", "number"],
637 ),
638 Op::MarkPullRequestReady => object(
639 numbered(json!({ "summary": { "type": "string", "description": "Markdown." } })),
640 &["repo", "number", "summary"],
641 ),
642 Op::MergePullRequest => object(
643 numbered(json!({
644 "keep_issue_open": {
645 "type": "boolean",
646 "description": "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.",
647 },
Acceptance checks in sandboxes, line comments and review verdicts648 "ignore_checks": {
649 "type": "boolean",
650 "description": "Merge although the acceptance checks have not passed.",
651 },
API and MCP server in Rust; a public index at the API root652 })),
653 &["repo", "number"],
654 ),
655 Op::ListEvents => object(
656 json!({
657 "repo": repo_schema(),
658 "before": { "type": "string", "description": "Event id to page back from." },
659 }),
660 &["repo"],
661 ),
662 }
663 }
664
665 /// Whether the operation refuses an anonymous caller outright.
666 fn needs_user(self) -> bool {
667 !matches!(
668 self,
669 Op::ListRepos
670 | Op::GetRepo
671 | Op::ListIssues
672 | Op::GetIssue
673 | Op::ListLabels
674 | Op::ListPullRequests
675 | Op::GetPullRequest
676 | Op::ReadSession
677 | Op::GetPullRequestChanges
678 | Op::ListEvents
Agents as a team: lifecycle, merge queue, billing and a new shell679 | Op::GetRepoSettings
680 | Op::GetMergeQueue
API and MCP server in Rust; a public index at the API root681 )
682 }
683
Agents as a team: lifecycle, merge queue, billing and a new shell684 /// Whether an agent's token with `scope` may use the operation.
685 pub fn allowed_by(self, scope: &AgentScope) -> bool {
686 scope.operations.iter().any(|name| name == self.name())
687 }
688
API and MCP server in Rust; a public index at the API root689 /// Whether the operation is about one repository, named by `repo`.
690 fn needs_repo(self) -> bool {
691 !matches!(
692 self,
693 Op::Whoami | Op::CreateWorkspace | Op::ListRepos | Op::CreateRepo
694 )
695 }
696
697 pub async fn run(
698 self,
699 services: &Services,
700 viewer: &Viewer,
701 input: &Value,
702 ) -> Result<Outcome<Value>> {
703 if self.needs_user() && viewer.is_none() {
704 return failed(
705 FailureCode::Unauthenticated,
706 "This needs a g1t access token.",
707 );
708 }
Agents as a team: lifecycle, merge queue, billing and a new shell709 // An agent's token does only what its scope lists, in its repository.
710 if let Some(scope) = &services.scope {
711 if !self.allowed_by(scope) {
712 return failed(
713 FailureCode::Forbidden,
714 &format!("A g1t agent's token cannot use {}.", self.name()),
715 );
716 }
717 let asked = repo_path(input);
718 if self.needs_repo()
719 && !asked.is_some_and(|asked| {
720 asked.namespace.eq_ignore_ascii_case(&scope.repo.namespace)
721 && asked.name.eq_ignore_ascii_case(&scope.repo.name)
722 })
723 {
724 return failed(
725 FailureCode::Forbidden,
726 &format!(
727 "A g1t agent's token works in {}/{} only.",
728 scope.repo.namespace, scope.repo.name
729 ),
730 );
731 }
732 }
API and MCP server in Rust; a public index at the API root733 // Checked above for every operation that uses it.
734 let actor = || viewer.clone().unwrap_or_default();
735 let repo = match repo_path(input) {
736 Some(repo) => repo,
737 None if self.needs_repo() => {
738 return failed(
739 FailureCode::Invalid,
740 "Give the repository as \"owner/name\".",
741 );
742 }
743 None => RepoPath {
744 namespace: String::new(),
745 name: String::new(),
746 },
747 };
748 let number = integer(input, "number").unwrap_or_default();
749 let view = || ViewArgs {
750 repo: repo.clone(),
751 number,
752 viewer: viewer.clone(),
753 after_seq: integer(input, "after").unwrap_or_default(),
754 };
755 let pull_action = || PullActionArgs {
756 actor: actor(),
757 repo: repo.clone(),
758 number,
759 summary: text(input, "summary"),
760 keep_issue_open: input["keep_issue_open"].as_bool() == Some(true),
Acceptance checks in sandboxes, line comments and review verdicts761 ignore_checks: input["ignore_checks"].as_bool() == Some(true),
API and MCP server in Rust; a public index at the API root762 };
763 let Services {
764 identity,
765 repos,
766 work,
767 events,
Agents as a team: lifecycle, merge queue, billing and a new shell768 runner,
769 ..
API and MCP server in Rust; a public index at the API root770 } = services;
771
772 match self {
773 Op::Whoami => ok(&actor()),
774 Op::CreateWorkspace => {
775 pass(
776 identity,
777 "create_workspace",
778 &CreateWorkspaceArgs {
779 user: actor(),
780 slug: text(input, "slug"),
781 name: text(input, "name"),
782 },
783 )
784 .await
785 }
786 Op::ListRepos => {
787 let found: Vec<Repo> = g1t_kit::call(
788 repos,
789 "list",
790 &ListReposArgs {
791 viewer: viewer.clone(),
792 query: optional_text(input, "query"),
793 namespace: None,
794 member_only: false,
795 },
796 )
797 .await?;
798 ok(&found)
799 }
800 Op::GetRepo => {
801 pass(
802 repos,
803 "get",
804 &GetArgs {
805 path: repo,
806 viewer: viewer.clone(),
807 },
808 )
809 .await
810 }
Agents as a team: lifecycle, merge queue, billing and a new shell811 Op::UpdateRepo => {
812 pass(
813 repos,
814 "update",
815 &json!({
816 "actor": actor(),
817 "path": repo,
818 "description": input["description"].as_str(),
819 "isPrivate": input["private"].as_bool(),
820 "protected": input["protected"].as_bool(),
821 }),
822 )
823 .await
824 }
825 Op::GetRepoSettings => {
826 pass(
827 work,
828 "get_settings",
829 &json!({ "repo": repo, "viewer": viewer }),
830 )
831 .await
832 }
833 Op::GetMergeQueue => {
834 pass(work, "queue", &json!({ "repo": repo, "viewer": viewer })).await
835 }
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request836 Op::MessageAgent => {
837 pass(
838 work,
839 "message_agent",
840 &json!({ "actor": actor(), "repo": repo, "number": number, "body": text(input, "body") }),
841 )
842 .await
843 }
844 Op::TakeMessages => {
845 pass(
846 work,
847 "take_messages",
848 &json!({ "actor": actor(), "repo": repo, "number": number }),
849 )
850 .await
851 }
Agents as a team: lifecycle, merge queue, billing and a new shell852 Op::UpdateRepoSettings => {
853 // What is not given stays as it is.
854 let current: Outcome<RepoSettings> = g1t_kit::call(
855 work,
856 "get_settings",
857 &json!({ "repo": repo, "viewer": viewer }),
858 )
859 .await?;
860 let current = match current {
861 Outcome::Ok(settings) => settings,
862 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
863 };
864 let flag = |key: &str, now: bool| input[key].as_bool().unwrap_or(now);
865 let settings = RepoSettings {
866 auto_merge: flag("auto_merge", current.auto_merge),
867 require_up_to_date: flag("require_up_to_date", current.require_up_to_date),
868 required_approvals: integer(input, "required_approvals")
869 .unwrap_or(current.required_approvals),
870 count_agent_approvals: flag(
871 "count_agent_approvals",
872 current.count_agent_approvals,
873 ),
874 allow_ignoring_checks: flag(
875 "allow_ignoring_checks",
876 current.allow_ignoring_checks,
877 ),
878 agent_review: flag("agent_review", current.agent_review),
879 max_revisions: integer(input, "max_revisions").unwrap_or(current.max_revisions),
880 merge_queue: flag("merge_queue", current.merge_queue),
881 ..current
882 };
883 pass(
884 work,
885 "update_settings",
886 &UpdateSettingsArgs {
887 actor: actor(),
888 repo,
889 settings,
890 },
891 )
892 .await
893 }
API and MCP server in Rust; a public index at the API root894 Op::CreateRepo => {
895 let owner = actor();
896 // Someone in exactly one workspace need not name it.
897 let namespace = optional_text(input, "workspace").unwrap_or_else(|| {
898 match owner.workspaces.as_slice() {
899 [only] => only.slug.clone(),
900 _ => String::new(),
901 }
902 });
903 pass(
904 repos,
905 "create",
906 &CreateArgs {
907 owner,
908 namespace,
909 name: text(input, "name"),
910 description: optional_text(input, "description"),
911 is_private: input["private"].as_bool() == Some(true),
Agents as a team: lifecycle, merge queue, billing and a new shell912 import_url: optional_text(input, "import_url"),
API and MCP server in Rust; a public index at the API root913 },
914 )
915 .await
916 }
917 Op::ListIssues => {
918 pass(
919 work,
920 "list_issues",
921 &ListIssuesArgs {
922 repo,
923 viewer: viewer.clone(),
924 state: state(input),
925 label: optional_text(input, "label"),
926 },
927 )
928 .await
929 }
930 Op::GetIssue => pass(work, "get_issue", &view()).await,
931 Op::CreateIssue => {
932 pass(
933 work,
934 "open_issue",
935 &OpenIssueArgs {
936 actor: actor(),
937 repo,
938 title: text(input, "title"),
939 body: text(input, "body"),
940 labels: strings(input, "labels").unwrap_or_default(),
941 checks: strings(input, "checks").unwrap_or_default(),
942 },
943 )
944 .await
945 }
946 Op::UpdateIssue => {
947 pass(
948 work,
949 "update_issue",
950 &UpdateIssueArgs {
951 actor: actor(),
952 repo,
953 number,
954 title: input["title"].as_str().map(str::to_owned),
955 body: input["body"].as_str().map(str::to_owned),
956 labels: strings(input, "labels"),
Agents as a team: lifecycle, merge queue, billing and a new shell957 assignees: strings(input, "assignees"),
API and MCP server in Rust; a public index at the API root958 },
959 )
960 .await
961 }
Agents as a team: lifecycle, merge queue, billing and a new shell962 Op::PlanWork => {
963 pass(
964 runner,
965 "plan",
966 &json!({ "actor": actor(), "repo": repo, "brief": text(input, "brief") }),
967 )
968 .await
969 }
970 Op::GetPlan => {
971 pass(
972 work,
973 "get_plan",
974 &PlanArgs {
975 repo,
976 viewer: viewer.clone(),
977 id: text(input, "plan"),
978 },
979 )
980 .await
981 }
982 Op::ApplyPlan => {
983 pass(
984 runner,
985 "apply_plan",
986 &json!({
987 "actor": actor(),
988 "repo": repo,
989 "planId": text(input, "plan"),
990 "assign": input["assign"].as_bool() == Some(true),
991 "keep": input["keep"].as_array(),
992 }),
993 )
994 .await
995 }
996 Op::AssignIssue => {
997 pass(
998 runner,
999 "run",
1000 &json!({
1001 "actor": actor(),
1002 "repo": repo,
1003 "issue": number,
1004 "instructions": text(input, "instructions"),
1005 }),
1006 )
1007 .await
1008 }
API and MCP server in Rust; a public index at the API root1009 Op::CloseIssue | Op::ReopenIssue => {
1010 let reason = match input["reason"].as_str() {
1011 Some("not_planned") => IssueReason::NotPlanned,
1012 _ => IssueReason::Completed,
1013 };
1014 let method = if self == Op::CloseIssue {
1015 "close_issue"
1016 } else {
1017 "reopen_issue"
1018 };
1019 pass(
1020 work,
1021 method,
1022 &IssueActionArgs {
1023 actor: actor(),
1024 repo,
1025 number,
1026 reason: Some(reason),
1027 },
1028 )
1029 .await
1030 }
1031 Op::ListLabels => pass(work, "list_labels", &view()).await,
Acceptance checks in sandboxes, line comments and review verdicts1032 Op::AddComment | Op::ReviewPullRequest => {
1033 let verdict = match (self, input["verdict"].as_str()) {
1034 (Op::AddComment, _) => None,
1035 (_, Some("approve")) => Some(Verdict::Approve),
1036 (_, Some("request_changes")) => Some(Verdict::RequestChanges),
1037 _ => {
1038 return failed(
1039 FailureCode::Invalid,
1040 "verdict must be approve or request_changes.",
1041 );
1042 }
1043 };
API and MCP server in Rust; a public index at the API root1044 pass(
1045 work,
1046 "add_comment",
1047 &AddCommentArgs {
1048 actor: actor(),
1049 repo,
1050 number,
1051 body: text(input, "body"),
Acceptance checks in sandboxes, line comments and review verdicts1052 path: optional_text(input, "path"),
1053 line: integer(input, "line"),
1054 verdict,
API and MCP server in Rust; a public index at the API root1055 },
1056 )
1057 .await
1058 }
1059 Op::ListPullRequests => {
1060 pass(
1061 work,
1062 "list_pulls",
1063 &ListPullsArgs {
1064 repo,
1065 viewer: viewer.clone(),
1066 state: state(input),
1067 },
1068 )
1069 .await
1070 }
1071 Op::GetPullRequest => pass(work, "get_pull", &view()).await,
1072 Op::CreatePullRequest => {
1073 let user = actor();
1074 let opened: Outcome<Pull> = call(
1075 work,
1076 "open_pull",
1077 &OpenPullArgs {
1078 actor: user.clone(),
1079 repo: repo.clone(),
1080 issue: integer(input, "issue"),
1081 title: text(input, "title"),
1082 body: text(input, "body"),
1083 branch: optional_text(input, "branch"),
1084 agent: optional_text(input, "agent").unwrap_or_else(|| "agent".into()),
1085 runtime: Runtime::External,
1086 },
1087 )
1088 .await?;
1089 let pull = match opened {
1090 Outcome::Ok(pull) => pull,
1091 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
1092 };
1093 // Where to push. A pull request from a branch has no fork:
1094 // push to that branch of the repository.
1095 let source = pull.fork.as_ref().unwrap_or(&repo);
1096 let remote = format!("https://g1t.sh/{}/{}.git", source.namespace, source.name);
1097 ok(&json!({
1098 "pull": pull,
1099 "git": {
1100 "remote": remote,
1101 "username": user.username,
1102 "password": "your g1t access token",
1103 },
1104 }))
1105 }
1106 Op::RecordSession => {
1107 let Ok(entries) = serde_json::from_value(input["entries"].clone()) else {
1108 return failed(
1109 FailureCode::Invalid,
1110 "entries must be a list of objects with a kind and a text.",
1111 );
1112 };
1113 pass(
1114 work,
1115 "append_session",
1116 &AppendSessionArgs {
1117 actor: actor(),
1118 repo,
1119 number,
1120 entries,
1121 },
1122 )
1123 .await
1124 }
1125 Op::ReadSession => pass(work, "read_session", &view()).await,
1126 Op::MarkPullRequestReady => pass(work, "ready_pull", &pull_action()).await,
1127 Op::ClosePullRequest => pass(work, "close_pull", &pull_action()).await,
1128 Op::MergePullRequest => pass(work, "merge_pull", &pull_action()).await,
1129 Op::GetPullRequestChanges => {
1130 let found: Outcome<PullDetail> = call(work, "get_pull", &view()).await?;
1131 match found {
1132 Outcome::Ok(detail) => {
Agents as a team: lifecycle, merge queue, billing and a new shell1133 pass(repos, "compare", &detail.pull.comparison(viewer)).await
API and MCP server in Rust; a public index at the API root1134 }
1135 Outcome::Fail(failure) => Ok(Outcome::Fail(failure)),
1136 }
1137 }
1138 Op::ListEvents => {
1139 let found: Outcome<Repo> = call(
1140 repos,
1141 "get",
1142 &GetArgs {
1143 path: repo,
1144 viewer: viewer.clone(),
1145 },
1146 )
1147 .await?;
1148 let repo = match found {
1149 Outcome::Ok(repo) => repo,
1150 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
1151 };
1152 let timeline: Vec<Event> = g1t_kit::call(
1153 events,
1154 "list",
1155 &ListEventsArgs {
1156 repo_id: Some(repo.id),
1157 before: optional_text(input, "before"),
1158 ..ListEventsArgs::default()
1159 },
1160 )
1161 .await?;
1162 ok(&timeline)
1163 }
1164 }
1165 }
1166}
1167
1168impl Op {
1169 /// The properties of the operation's input schema.
1170 pub fn properties(self) -> Map<String, Value> {
1171 match self.input() {
1172 Value::Object(mut schema) => match schema.remove("properties") {
1173 Some(Value::Object(properties)) => properties,
1174 _ => Map::new(),
1175 },
1176 _ => Map::new(),
1177 }
1178 }
1179
1180 /// The names of the properties that must be given.
1181 pub fn required(self) -> Vec<String> {
1182 self.input()["required"]
1183 .as_array()
1184 .map(|names| {
1185 names
1186 .iter()
1187 .filter_map(|name| name.as_str().map(str::to_owned))
1188 .collect()
1189 })
1190 .unwrap_or_default()
1191 }
1192}
1193
1194#[cfg(test)]
1195mod tests {
1196 use super::*;
1197
1198 #[test]
1199 fn names_are_unique_and_found_again() {
1200 for op in Op::ALL {
1201 assert_eq!(Op::by_name(op.name()), Some(op));
1202 }
1203 assert_eq!(Op::by_name("start_attempt"), None);
1204 }
1205
1206 #[test]
1207 fn required_properties_exist() {
1208 for op in Op::ALL {
1209 let properties = op.properties();
1210 for name in op.required() {
1211 assert!(properties.contains_key(&name), "{}: {name}", op.name());
1212 }
1213 }
1214 }
1215
1216 #[test]
1217 fn a_repository_is_owner_slash_name() {
1218 let path = repo_path(&json!({ "repo": "syntaqx/hello" })).unwrap();
1219 assert_eq!(
1220 (path.namespace.as_str(), path.name.as_str()),
1221 ("syntaqx", "hello")
1222 );
1223 for bad in ["syntaqx", "a/b/c", "/hello", "syntaqx/", ""] {
1224 assert!(repo_path(&json!({ "repo": bad })).is_none(), "{bad}");
1225 }
1226 }
1227
1228 #[test]
1229 fn numbers_are_read_from_numbers_and_digits() {
1230 assert_eq!(integer(&json!({ "number": 12 }), "number"), Some(12));
1231 assert_eq!(integer(&json!({ "number": "12" }), "number"), Some(12));
1232 assert_eq!(integer(&json!({ "number": "x" }), "number"), None);
1233 assert_eq!(integer(&json!({}), "number"), None);
1234 }
1235}