g1t/apps/api/src/operations.rs

1,280 lines53,378 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,
Agents ask each other, hand each other work, and answer56 AnswerMessage,
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request57 TakeMessages,
API and MCP server in Rust; a public index at the API root58 ListIssues,
59 GetIssue,
60 CreateIssue,
61 UpdateIssue,
62 CloseIssue,
63 ReopenIssue,
Agents as a team: lifecycle, merge queue, billing and a new shell64 AssignIssue,
65 PlanWork,
66 GetPlan,
67 ApplyPlan,
API and MCP server in Rust; a public index at the API root68 ListLabels,
69 AddComment,
Acceptance checks in sandboxes, line comments and review verdicts70 ReviewPullRequest,
API and MCP server in Rust; a public index at the API root71 ListPullRequests,
72 GetPullRequest,
73 CreatePullRequest,
74 RecordSession,
75 ReadSession,
76 MarkPullRequestReady,
77 ClosePullRequest,
78 GetPullRequestChanges,
79 MergePullRequest,
80 ListEvents,
81}
82
83fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> {
84 Ok(Outcome::fail(code, message))
85}
86
87fn ok<T: Serialize>(value: &T) -> Result<Outcome<Value>> {
88 Ok(Outcome::Ok(serde_json::to_value(value)?))
89}
90
91/// Calls a method that returns an `Outcome`, decoding its value as `T`.
92async fn call<A: Serialize, T: DeserializeOwned>(
93 service: &Fetcher,
94 method: &str,
95 args: &A,
96) -> Result<Outcome<T>> {
97 g1t_kit::call(service, method, args).await
98}
99
100/// Calls a method that returns an `Outcome`, passing its value through.
101async fn pass<A: Serialize>(service: &Fetcher, method: &str, args: &A) -> Result<Outcome<Value>> {
102 call(service, method, args).await
103}
104
105fn text(input: &Value, key: &str) -> String {
106 input[key].as_str().unwrap_or_default().to_owned()
107}
108
109fn optional_text(input: &Value, key: &str) -> Option<String> {
110 input[key]
111 .as_str()
112 .filter(|value| !value.is_empty())
113 .map(str::to_owned)
114}
115
116/// A whole number given as a number or as digits.
117fn integer(input: &Value, key: &str) -> Option<u32> {
118 match &input[key] {
119 Value::Number(number) => number.as_u64().and_then(|n| u32::try_from(n).ok()),
120 Value::String(digits) => digits.parse().ok(),
121 _ => None,
122 }
123}
124
125fn strings(input: &Value, key: &str) -> Option<Vec<String>> {
126 input[key].as_array().map(|items| {
127 items
128 .iter()
129 .map(|item| match item {
130 Value::String(text) => text.clone(),
131 other => other.to_string(),
132 })
133 .collect()
134 })
135}
136
137fn state(input: &Value) -> Option<State> {
138 match input["state"].as_str() {
139 Some("open") => Some(State::Open),
140 Some("closed") => Some(State::Closed),
141 _ => None,
142 }
143}
144
145/// The repository named by `repo`, written `owner/name`.
146fn repo_path(input: &Value) -> Option<RepoPath> {
147 let mut parts = input["repo"].as_str()?.split('/');
148 match (parts.next(), parts.next(), parts.next()) {
149 (Some(namespace), Some(name), None) if !namespace.is_empty() && !name.is_empty() => {
150 Some(RepoPath {
151 namespace: namespace.to_owned(),
152 name: name.to_owned(),
153 })
154 }
155 _ => None,
156 }
157}
158
159/// An object schema. `required` names the properties that must be given.
160fn object(properties: Value, required: &[&str]) -> Value {
161 let mut schema = json!({ "type": "object", "properties": properties });
162 if !required.is_empty() {
163 schema["required"] = json!(required);
164 }
165 schema
166}
167
168/// The properties naming an issue or pull request, with `more` added.
169fn numbered(more: Value) -> Value {
170 let mut properties = json!({
171 "repo": repo_schema(),
172 "number": {
173 "type": "integer",
174 "description": "The number shown after the #. Issues and pull requests share one sequence.",
175 },
176 });
177 if let (Some(all), Value::Object(more)) = (properties.as_object_mut(), more) {
178 all.extend(more);
179 }
180 properties
181}
182
183fn repo_schema() -> Value {
184 json!({
185 "type": "string",
186 "description": "Repository as \"owner/name\", e.g. \"syntaqx/hello\".",
187 })
188}
189
190impl Op {
Agents ask each other, hand each other work, and answer191 pub const ALL: [Op; 35] = [
API and MCP server in Rust; a public index at the API root192 Op::Whoami,
193 Op::CreateWorkspace,
194 Op::ListRepos,
195 Op::GetRepo,
196 Op::CreateRepo,
Agents as a team: lifecycle, merge queue, billing and a new shell197 Op::UpdateRepo,
198 Op::GetRepoSettings,
199 Op::UpdateRepoSettings,
200 Op::GetMergeQueue,
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request201 Op::MessageAgent,
Agents ask each other, hand each other work, and answer202 Op::AnswerMessage,
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request203 Op::TakeMessages,
API and MCP server in Rust; a public index at the API root204 Op::ListIssues,
205 Op::GetIssue,
206 Op::CreateIssue,
207 Op::UpdateIssue,
208 Op::CloseIssue,
209 Op::ReopenIssue,
Agents as a team: lifecycle, merge queue, billing and a new shell210 Op::AssignIssue,
211 Op::PlanWork,
212 Op::GetPlan,
213 Op::ApplyPlan,
API and MCP server in Rust; a public index at the API root214 Op::ListLabels,
215 Op::AddComment,
Acceptance checks in sandboxes, line comments and review verdicts216 Op::ReviewPullRequest,
API and MCP server in Rust; a public index at the API root217 Op::ListPullRequests,
218 Op::GetPullRequest,
219 Op::CreatePullRequest,
220 Op::RecordSession,
221 Op::ReadSession,
222 Op::MarkPullRequestReady,
223 Op::ClosePullRequest,
224 Op::GetPullRequestChanges,
225 Op::MergePullRequest,
226 Op::ListEvents,
227 ];
228
229 pub fn by_name(name: &str) -> Option<Op> {
230 Op::ALL.into_iter().find(|op| op.name() == name)
231 }
232
233 /// The operation's name: its MCP tool name and OpenAPI operation id.
234 pub fn name(self) -> &'static str {
235 match self {
236 Op::Whoami => "whoami",
237 Op::CreateWorkspace => "create_workspace",
238 Op::ListRepos => "list_repos",
239 Op::GetRepo => "get_repo",
240 Op::CreateRepo => "create_repo",
Agents as a team: lifecycle, merge queue, billing and a new shell241 Op::UpdateRepo => "update_repo",
242 Op::GetRepoSettings => "get_repo_settings",
243 Op::GetMergeQueue => "get_merge_queue",
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request244 Op::MessageAgent => "message_agent",
Agents ask each other, hand each other work, and answer245 Op::AnswerMessage => "answer_message",
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request246 Op::TakeMessages => "take_messages",
Agents as a team: lifecycle, merge queue, billing and a new shell247 Op::UpdateRepoSettings => "update_repo_settings",
API and MCP server in Rust; a public index at the API root248 Op::ListIssues => "list_issues",
249 Op::GetIssue => "get_issue",
250 Op::CreateIssue => "create_issue",
251 Op::UpdateIssue => "update_issue",
252 Op::CloseIssue => "close_issue",
253 Op::ReopenIssue => "reopen_issue",
Agents as a team: lifecycle, merge queue, billing and a new shell254 Op::AssignIssue => "assign_issue",
255 Op::PlanWork => "plan_work",
256 Op::GetPlan => "get_plan",
257 Op::ApplyPlan => "apply_plan",
API and MCP server in Rust; a public index at the API root258 Op::ListLabels => "list_labels",
259 Op::AddComment => "add_comment",
Acceptance checks in sandboxes, line comments and review verdicts260 Op::ReviewPullRequest => "review_pull_request",
API and MCP server in Rust; a public index at the API root261 Op::ListPullRequests => "list_pull_requests",
262 Op::GetPullRequest => "get_pull_request",
263 Op::CreatePullRequest => "create_pull_request",
264 Op::RecordSession => "record_session",
265 Op::ReadSession => "read_session",
266 Op::MarkPullRequestReady => "mark_pull_request_ready",
267 Op::ClosePullRequest => "close_pull_request",
268 Op::GetPullRequestChanges => "get_pull_request_changes",
269 Op::MergePullRequest => "merge_pull_request",
270 Op::ListEvents => "list_events",
271 }
272 }
273
274 pub fn description(self) -> &'static str {
275 match self {
Agents as a team: lifecycle, merge queue, billing and a new shell276 Op::Whoami => {
277 "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."
278 }
API and MCP server in Rust; a public index at the API root279 Op::CreateWorkspace => {
280 "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."
281 }
282 Op::ListRepos => "Repositories you can see, optionally filtered by a search query.",
283 Op::GetRepo => "One repository's details.",
Agents as a team: lifecycle, merge queue, billing and a new shell284 Op::UpdateRepo => {
285 "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."
286 }
287 Op::GetRepoSettings => {
288 "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."
289 }
290 Op::UpdateRepoSettings => {
291 "Change how a repository handles pull requests. Only the fields given are changed. Members of its workspace only."
292 }
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request293 Op::MessageAgent => {
Agents ask each other, hand each other work, and answer294 "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. An agent uses it to ask the agent on another pull request a question (kind: question) or hand it work that belongs there (kind: handoff), giving its own pull request as from_number; the answer comes back to it at its next step."
295 }
296 Op::AnswerMessage => {
297 "Answer a question or a handoff another agent sent you, by the message's id. For a handoff, set decline to say it is not yours to take. The answer reaches the asking agent at its next step."
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request298 }
299 Op::TakeMessages => {
300 "For a g1t agent at work: the messages people have sent it that it has not seen yet. Each is returned once."
301 }
Agents as a team: lifecycle, merge queue, billing and a new shell302 Op::GetMergeQueue => {
303 "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."
304 }
305 Op::CreateRepo => {
306 "Create a repository in one of your workspaces, empty or as a copy of a public git repository elsewhere."
307 }
API and MCP server in Rust; a public index at the API root308 Op::ListIssues => {
309 "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."
310 }
311 Op::GetIssue => {
312 "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."
313 }
314 Op::CreateIssue => "Open an issue on a repository.",
315 Op::UpdateIssue => {
Agents as a team: lifecycle, merge queue, billing and a new shell316 "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 root317 }
318 Op::CloseIssue => {
319 "Close an issue without a pull request. Merging a pull request made for an issue closes it for you."
320 }
321 Op::ReopenIssue => "Reopen a closed issue.",
Agents as a team: lifecycle, merge queue, billing and a new shell322 Op::PlanWork => {
323 "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."
324 }
325 Op::GetPlan => {
326 "A plan: the outcome asked for, its status (planning, ready, failed or applied), and the issues it proposes with their dependencies."
327 }
328 Op::ApplyPlan => {
329 "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."
330 }
331 Op::AssignIssue => {
332 "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."
333 }
API and MCP server in Rust; a public index at the API root334 Op::ListLabels => "The labels available on a repository's issues.",
Acceptance checks in sandboxes, line comments and review verdicts335 Op::AddComment => {
336 "Comment on an issue or a pull request. On a pull request, give path and line to comment on one line of the change."
337 }
338 Op::ReviewPullRequest => {
339 "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."
340 }
API and MCP server in Rust; a public index at the API root341 Op::ListPullRequests => {
342 "Pull requests on a repository, newest first. State open covers drafts and those ready for review; closed covers merged and closed."
343 }
344 Op::GetPullRequest => {
Agents as a team: lifecycle, merge queue, billing and a new shell345 "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 root346 }
347 Op::CreatePullRequest => {
348 "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."
349 }
350 Op::RecordSession => {
351 "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."
352 }
353 Op::ReadSession => "The recorded session of a pull request, oldest entry first.",
354 Op::MarkPullRequestReady => {
355 "Mark a draft pull request ready for review. Push your commits first. The summary becomes its description and should say what changed and why."
356 }
357 Op::ClosePullRequest => "Close a pull request without merging it.",
358 Op::GetPullRequestChanges => {
359 "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."
360 }
361 Op::MergePullRequest => {
Acceptance checks in sandboxes, line comments and review verdicts362 "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 root363 }
364 Op::ListEvents => {
365 "The timeline of a repository: pushes, issues, pull requests, comments and session activity, newest first."
366 }
367 }
368 }
369
370 /// The JSON Schema of the operation's input.
371 pub fn input(self) -> Value {
372 let repo_only = || object(json!({ "repo": repo_schema() }), &["repo"]);
373 let just_numbered = || object(numbered(json!({})), &["repo", "number"]);
374 let states = json!({ "type": "string", "enum": ["open", "closed"] });
375 match self {
376 Op::Whoami => object(json!({}), &[]),
377 Op::CreateWorkspace => object(
378 json!({
379 "slug": {
380 "type": "string",
381 "description": "Its name in URLs: lowercase letters, digits and single hyphens.",
382 },
383 "name": { "type": "string", "description": "A display name." },
384 }),
385 &["slug"],
386 ),
387 Op::ListRepos => object(
388 json!({
389 "query": { "type": "string", "description": "Matches name or description." },
390 }),
391 &[],
392 ),
393 Op::GetRepo | Op::ListLabels => repo_only(),
Agents as a team: lifecycle, merge queue, billing and a new shell394 Op::UpdateRepo => object(
395 json!({
396 "repo": repo_schema(),
397 "description": { "type": "string", "description": "An empty string clears it." },
398 "private": { "type": "boolean" },
399 "protected": {
400 "type": "boolean",
401 "description": "Refuse pushes to the default branch, so that it changes only by merging a pull request.",
402 },
403 }),
404 &["repo"],
405 ),
406 Op::GetRepoSettings => object(json!({ "repo": repo_schema() }), &["repo"]),
407 Op::GetMergeQueue => object(json!({ "repo": repo_schema() }), &["repo"]),
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request408 Op::MessageAgent => object(
409 numbered(json!({
410 "body": { "type": "string", "description": "What to tell the agent." },
Agents ask each other, hand each other work, and answer411 "kind": {
412 "type": "string",
413 "enum": ["question", "handoff"],
414 "description": "For an agent: a question, or work handed over.",
415 },
416 "from_number": {
417 "type": "integer",
418 "description": "For an agent: the pull request you are working on, where the answer goes.",
419 },
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request420 })),
421 &["repo", "number", "body"],
422 ),
Agents ask each other, hand each other work, and answer423 Op::AnswerMessage => object(
424 json!({
425 "repo": repo_schema(),
426 "id": { "type": "string", "description": "The message's id, as it was given to you." },
427 "body": { "type": "string", "description": "Your answer." },
428 "decline": { "type": "boolean", "description": "For a handoff: it is not yours to take." },
429 }),
430 &["repo", "id", "body"],
431 ),
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request432 Op::TakeMessages => object(numbered(json!({})), &["repo", "number"]),
Agents as a team: lifecycle, merge queue, billing and a new shell433 Op::UpdateRepoSettings => object(
434 json!({
435 "repo": repo_schema(),
436 "auto_merge": {
437 "type": "boolean",
438 "description": "Land a g1t agent's pull request without a person once every rule is met.",
439 },
440 "require_up_to_date": {
441 "type": "boolean",
442 "description": "Refuse to merge a pull request that is behind the default branch. When false, merging brings it up to date first.",
443 },
444 "required_approvals": {
445 "type": "integer",
446 "description": "How many approving reviews a merge needs.",
447 },
448 "count_agent_approvals": {
449 "type": "boolean",
450 "description": "Whether a g1t agent's approval counts towards required_approvals.",
451 },
452 "allow_ignoring_checks": {
453 "type": "boolean",
454 "description": "Whether a member may merge although the acceptance checks did not pass.",
455 },
456 "agent_review": {
457 "type": "boolean",
458 "description": "Whether a second agent reviews a g1t agent's pull request unasked.",
459 },
460 "merge_queue": {
461 "type": "boolean",
462 "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.",
463 },
464 "max_revisions": {
465 "type": "integer",
466 "description": "How many times a g1t agent is sent back before a person is asked.",
467 },
468 }),
469 &["repo"],
470 ),
API and MCP server in Rust; a public index at the API root471 Op::CreateRepo => object(
472 json!({
473 "workspace": {
474 "type": "string",
475 "description": "The workspace to create it in. May be left out if you belong to exactly one.",
476 },
477 "name": { "type": "string" },
478 "description": { "type": "string" },
479 "private": { "type": "boolean" },
Agents as a team: lifecycle, merge queue, billing and a new shell480 "import_url": {
481 "type": "string",
482 "description": "Copy the default branch of a public git repository at this https address, e.g. https://github.com/owner/repo.",
483 },
API and MCP server in Rust; a public index at the API root484 }),
485 &["name"],
486 ),
487 Op::ListIssues => object(
488 json!({
489 "repo": repo_schema(),
490 "state": states,
491 "label": { "type": "string", "description": "Only issues carrying this label." },
492 }),
493 &["repo"],
494 ),
495 Op::GetIssue
496 | Op::ReopenIssue
497 | Op::GetPullRequest
498 | Op::ClosePullRequest
499 | Op::GetPullRequestChanges => just_numbered(),
500 Op::CreateIssue => object(
501 json!({
502 "repo": repo_schema(),
503 "title": { "type": "string", "description": "The problem or goal in one line." },
504 "body": {
505 "type": "string",
506 "description": "Markdown. What an agent or a person needs to do the work: what is wrong or wanted, constraints, context.",
507 },
508 "labels": {
509 "type": "array",
510 "items": { "type": "string" },
511 "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.",
512 },
513 "checks": {
514 "type": "array",
515 "items": { "type": "string" },
516 "description": "Commands that must pass for a pull request to be accepted.",
517 },
518 }),
519 &["repo", "title"],
520 ),
521 Op::UpdateIssue => object(
522 numbered(json!({
523 "title": { "type": "string" },
524 "body": { "type": "string" },
525 "labels": { "type": "array", "items": { "type": "string" } },
Agents as a team: lifecycle, merge queue, billing and a new shell526 "assignees": {
527 "type": "array",
528 "items": { "type": "string" },
529 "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.",
530 },
531 })),
532 &["repo", "number"],
533 ),
534 Op::PlanWork => object(
535 json!({
536 "repo": repo_schema(),
537 "brief": {
538 "type": "string",
539 "description": "What should be true when the work is done, in plain words. Say what you want, not how to split it.",
540 },
541 }),
542 &["repo", "brief"],
543 ),
544 Op::GetPlan => object(
545 json!({
546 "repo": repo_schema(),
547 "plan": { "type": "string", "description": "The plan's id." },
548 }),
549 &["repo", "plan"],
550 ),
551 Op::ApplyPlan => object(
552 json!({
553 "repo": repo_schema(),
554 "plan": { "type": "string", "description": "The plan's id." },
555 "assign": {
556 "type": "boolean",
557 "description": "Put g1t agents on the issues, in dependency order.",
558 },
559 "keep": {
560 "type": "array",
561 "items": { "type": "integer" },
562 "description": "Positions, counting from 1, of the proposed issues to open. All of them if left out.",
563 },
564 }),
565 &["repo", "plan"],
566 ),
567 Op::AssignIssue => object(
568 numbered(json!({
569 "instructions": {
570 "type": "string",
571 "description": "Extra guidance for this run, on top of the issue's description.",
572 },
API and MCP server in Rust; a public index at the API root573 })),
574 &["repo", "number"],
575 ),
576 Op::CloseIssue => object(
577 numbered(json!({
578 "reason": {
579 "type": "string",
580 "enum": ["completed", "not_planned"],
581 "description": "Defaults to completed.",
582 },
583 })),
584 &["repo", "number"],
585 ),
586 Op::AddComment => object(
Acceptance checks in sandboxes, line comments and review verdicts587 numbered(json!({
588 "body": { "type": "string", "description": "Markdown." },
589 "path": {
590 "type": "string",
591 "description": "On a pull request: the file to comment on.",
592 },
593 "line": {
594 "type": "integer",
595 "description": "The line of that file, as numbered after the change.",
596 },
597 })),
API and MCP server in Rust; a public index at the API root598 &["repo", "number", "body"],
599 ),
Acceptance checks in sandboxes, line comments and review verdicts600 Op::ReviewPullRequest => object(
601 numbered(json!({
602 "verdict": { "type": "string", "enum": ["approve", "request_changes"] },
603 "body": {
604 "type": "string",
605 "description": "Markdown. Required when requesting changes.",
606 },
607 })),
608 &["repo", "number", "verdict"],
609 ),
API and MCP server in Rust; a public index at the API root610 Op::ListPullRequests => {
611 object(json!({ "repo": repo_schema(), "state": states }), &["repo"])
612 }
613 Op::CreatePullRequest => object(
614 json!({
615 "repo": repo_schema(),
616 "issue": { "type": "integer", "description": "The number of the issue this is for." },
617 "title": {
618 "type": "string",
619 "description": "Defaults to the issue's title. Required when there is no issue.",
620 },
621 "branch": {
622 "type": "string",
623 "description": "A branch already pushed to the repository that holds the change. Leave out to get a fork.",
624 },
625 "body": {
626 "type": "string",
627 "description": "Markdown: what changed and why. Mainly for pull requests from a branch.",
628 },
629 "agent": {
630 "type": "string",
631 "description": "A label for the agent doing the work, e.g. \"claude-code\".",
632 },
633 }),
634 &["repo"],
635 ),
636 Op::RecordSession => object(
637 numbered(json!({
638 "entries": {
639 "type": "array",
640 "items": {
641 "type": "object",
642 "properties": {
643 "kind": {
644 "type": "string",
645 "enum": ["prompt", "message", "tool_call", "tool_result", "note"],
646 },
647 "text": { "type": "string" },
648 "tool": { "type": "string", "description": "Tool name, for tool entries." },
649 },
650 "required": ["kind", "text"],
651 },
652 },
653 })),
654 &["repo", "number", "entries"],
655 ),
656 Op::ReadSession => object(
657 numbered(json!({
658 "after": { "type": "integer", "description": "Only entries after this sequence number." },
659 })),
660 &["repo", "number"],
661 ),
662 Op::MarkPullRequestReady => object(
663 numbered(json!({ "summary": { "type": "string", "description": "Markdown." } })),
664 &["repo", "number", "summary"],
665 ),
666 Op::MergePullRequest => object(
667 numbered(json!({
668 "keep_issue_open": {
669 "type": "boolean",
670 "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.",
671 },
Acceptance checks in sandboxes, line comments and review verdicts672 "ignore_checks": {
673 "type": "boolean",
674 "description": "Merge although the acceptance checks have not passed.",
675 },
API and MCP server in Rust; a public index at the API root676 })),
677 &["repo", "number"],
678 ),
679 Op::ListEvents => object(
680 json!({
681 "repo": repo_schema(),
682 "before": { "type": "string", "description": "Event id to page back from." },
683 }),
684 &["repo"],
685 ),
686 }
687 }
688
689 /// Whether the operation refuses an anonymous caller outright.
690 fn needs_user(self) -> bool {
691 !matches!(
692 self,
693 Op::ListRepos
694 | Op::GetRepo
695 | Op::ListIssues
696 | Op::GetIssue
697 | Op::ListLabels
698 | Op::ListPullRequests
699 | Op::GetPullRequest
700 | Op::ReadSession
701 | Op::GetPullRequestChanges
702 | Op::ListEvents
Agents as a team: lifecycle, merge queue, billing and a new shell703 | Op::GetRepoSettings
704 | Op::GetMergeQueue
API and MCP server in Rust; a public index at the API root705 )
706 }
707
Agents as a team: lifecycle, merge queue, billing and a new shell708 /// Whether an agent's token with `scope` may use the operation.
709 pub fn allowed_by(self, scope: &AgentScope) -> bool {
710 scope.operations.iter().any(|name| name == self.name())
711 }
712
API and MCP server in Rust; a public index at the API root713 /// Whether the operation is about one repository, named by `repo`.
714 fn needs_repo(self) -> bool {
715 !matches!(
716 self,
717 Op::Whoami | Op::CreateWorkspace | Op::ListRepos | Op::CreateRepo
718 )
719 }
720
721 pub async fn run(
722 self,
723 services: &Services,
724 viewer: &Viewer,
725 input: &Value,
726 ) -> Result<Outcome<Value>> {
727 if self.needs_user() && viewer.is_none() {
728 return failed(
729 FailureCode::Unauthenticated,
730 "This needs a g1t access token.",
731 );
732 }
Agents as a team: lifecycle, merge queue, billing and a new shell733 // An agent's token does only what its scope lists, in its repository.
734 if let Some(scope) = &services.scope {
735 if !self.allowed_by(scope) {
736 return failed(
737 FailureCode::Forbidden,
738 &format!("A g1t agent's token cannot use {}.", self.name()),
739 );
740 }
741 let asked = repo_path(input);
742 if self.needs_repo()
743 && !asked.is_some_and(|asked| {
744 asked.namespace.eq_ignore_ascii_case(&scope.repo.namespace)
745 && asked.name.eq_ignore_ascii_case(&scope.repo.name)
746 })
747 {
748 return failed(
749 FailureCode::Forbidden,
750 &format!(
751 "A g1t agent's token works in {}/{} only.",
752 scope.repo.namespace, scope.repo.name
753 ),
754 );
755 }
756 }
API and MCP server in Rust; a public index at the API root757 // Checked above for every operation that uses it.
758 let actor = || viewer.clone().unwrap_or_default();
759 let repo = match repo_path(input) {
760 Some(repo) => repo,
761 None if self.needs_repo() => {
762 return failed(
763 FailureCode::Invalid,
764 "Give the repository as \"owner/name\".",
765 );
766 }
767 None => RepoPath {
768 namespace: String::new(),
769 name: String::new(),
770 },
771 };
772 let number = integer(input, "number").unwrap_or_default();
773 let view = || ViewArgs {
774 repo: repo.clone(),
775 number,
776 viewer: viewer.clone(),
777 after_seq: integer(input, "after").unwrap_or_default(),
778 };
779 let pull_action = || PullActionArgs {
780 actor: actor(),
781 repo: repo.clone(),
782 number,
783 summary: text(input, "summary"),
784 keep_issue_open: input["keep_issue_open"].as_bool() == Some(true),
Acceptance checks in sandboxes, line comments and review verdicts785 ignore_checks: input["ignore_checks"].as_bool() == Some(true),
API and MCP server in Rust; a public index at the API root786 };
787 let Services {
788 identity,
789 repos,
790 work,
791 events,
Agents as a team: lifecycle, merge queue, billing and a new shell792 runner,
793 ..
API and MCP server in Rust; a public index at the API root794 } = services;
795
796 match self {
797 Op::Whoami => ok(&actor()),
798 Op::CreateWorkspace => {
799 pass(
800 identity,
801 "create_workspace",
802 &CreateWorkspaceArgs {
803 user: actor(),
804 slug: text(input, "slug"),
805 name: text(input, "name"),
806 },
807 )
808 .await
809 }
810 Op::ListRepos => {
811 let found: Vec<Repo> = g1t_kit::call(
812 repos,
813 "list",
814 &ListReposArgs {
815 viewer: viewer.clone(),
816 query: optional_text(input, "query"),
817 namespace: None,
818 member_only: false,
819 },
820 )
821 .await?;
822 ok(&found)
823 }
824 Op::GetRepo => {
825 pass(
826 repos,
827 "get",
828 &GetArgs {
829 path: repo,
830 viewer: viewer.clone(),
831 },
832 )
833 .await
834 }
Agents as a team: lifecycle, merge queue, billing and a new shell835 Op::UpdateRepo => {
836 pass(
837 repos,
838 "update",
839 &json!({
840 "actor": actor(),
841 "path": repo,
842 "description": input["description"].as_str(),
843 "isPrivate": input["private"].as_bool(),
844 "protected": input["protected"].as_bool(),
845 }),
846 )
847 .await
848 }
849 Op::GetRepoSettings => {
850 pass(
851 work,
852 "get_settings",
853 &json!({ "repo": repo, "viewer": viewer }),
854 )
855 .await
856 }
857 Op::GetMergeQueue => {
858 pass(work, "queue", &json!({ "repo": repo, "viewer": viewer })).await
859 }
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request860 Op::MessageAgent => {
861 pass(
862 work,
863 "message_agent",
Agents ask each other, hand each other work, and answer864 &json!({
865 "actor": actor(),
866 "repo": repo,
867 "number": number,
868 "body": text(input, "body"),
869 "kind": input["kind"].as_str(),
870 "from_number": integer(input, "from_number"),
871 }),
872 )
873 .await
874 }
875 Op::AnswerMessage => {
876 pass(
877 work,
878 "answer_message",
879 &json!({
880 "actor": actor(),
881 "repo": repo,
882 "id": text(input, "id"),
883 "body": text(input, "body"),
884 "decline": input["decline"].as_bool() == Some(true),
885 }),
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request886 )
887 .await
888 }
889 Op::TakeMessages => {
890 pass(
891 work,
892 "take_messages",
893 &json!({ "actor": actor(), "repo": repo, "number": number }),
894 )
895 .await
896 }
Agents as a team: lifecycle, merge queue, billing and a new shell897 Op::UpdateRepoSettings => {
898 // What is not given stays as it is.
899 let current: Outcome<RepoSettings> = g1t_kit::call(
900 work,
901 "get_settings",
902 &json!({ "repo": repo, "viewer": viewer }),
903 )
904 .await?;
905 let current = match current {
906 Outcome::Ok(settings) => settings,
907 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
908 };
909 let flag = |key: &str, now: bool| input[key].as_bool().unwrap_or(now);
910 let settings = RepoSettings {
911 auto_merge: flag("auto_merge", current.auto_merge),
912 require_up_to_date: flag("require_up_to_date", current.require_up_to_date),
913 required_approvals: integer(input, "required_approvals")
914 .unwrap_or(current.required_approvals),
915 count_agent_approvals: flag(
916 "count_agent_approvals",
917 current.count_agent_approvals,
918 ),
919 allow_ignoring_checks: flag(
920 "allow_ignoring_checks",
921 current.allow_ignoring_checks,
922 ),
923 agent_review: flag("agent_review", current.agent_review),
924 max_revisions: integer(input, "max_revisions").unwrap_or(current.max_revisions),
925 merge_queue: flag("merge_queue", current.merge_queue),
926 ..current
927 };
928 pass(
929 work,
930 "update_settings",
931 &UpdateSettingsArgs {
932 actor: actor(),
933 repo,
934 settings,
935 },
936 )
937 .await
938 }
API and MCP server in Rust; a public index at the API root939 Op::CreateRepo => {
940 let owner = actor();
941 // Someone in exactly one workspace need not name it.
942 let namespace = optional_text(input, "workspace").unwrap_or_else(|| {
943 match owner.workspaces.as_slice() {
944 [only] => only.slug.clone(),
945 _ => String::new(),
946 }
947 });
948 pass(
949 repos,
950 "create",
951 &CreateArgs {
952 owner,
953 namespace,
954 name: text(input, "name"),
955 description: optional_text(input, "description"),
956 is_private: input["private"].as_bool() == Some(true),
Agents as a team: lifecycle, merge queue, billing and a new shell957 import_url: optional_text(input, "import_url"),
API and MCP server in Rust; a public index at the API root958 },
959 )
960 .await
961 }
962 Op::ListIssues => {
963 pass(
964 work,
965 "list_issues",
966 &ListIssuesArgs {
967 repo,
968 viewer: viewer.clone(),
969 state: state(input),
970 label: optional_text(input, "label"),
971 },
972 )
973 .await
974 }
975 Op::GetIssue => pass(work, "get_issue", &view()).await,
976 Op::CreateIssue => {
977 pass(
978 work,
979 "open_issue",
980 &OpenIssueArgs {
981 actor: actor(),
982 repo,
983 title: text(input, "title"),
984 body: text(input, "body"),
985 labels: strings(input, "labels").unwrap_or_default(),
986 checks: strings(input, "checks").unwrap_or_default(),
987 },
988 )
989 .await
990 }
991 Op::UpdateIssue => {
992 pass(
993 work,
994 "update_issue",
995 &UpdateIssueArgs {
996 actor: actor(),
997 repo,
998 number,
999 title: input["title"].as_str().map(str::to_owned),
1000 body: input["body"].as_str().map(str::to_owned),
1001 labels: strings(input, "labels"),
Agents as a team: lifecycle, merge queue, billing and a new shell1002 assignees: strings(input, "assignees"),
API and MCP server in Rust; a public index at the API root1003 },
1004 )
1005 .await
1006 }
Agents as a team: lifecycle, merge queue, billing and a new shell1007 Op::PlanWork => {
1008 pass(
1009 runner,
1010 "plan",
1011 &json!({ "actor": actor(), "repo": repo, "brief": text(input, "brief") }),
1012 )
1013 .await
1014 }
1015 Op::GetPlan => {
1016 pass(
1017 work,
1018 "get_plan",
1019 &PlanArgs {
1020 repo,
1021 viewer: viewer.clone(),
1022 id: text(input, "plan"),
1023 },
1024 )
1025 .await
1026 }
1027 Op::ApplyPlan => {
1028 pass(
1029 runner,
1030 "apply_plan",
1031 &json!({
1032 "actor": actor(),
1033 "repo": repo,
1034 "planId": text(input, "plan"),
1035 "assign": input["assign"].as_bool() == Some(true),
1036 "keep": input["keep"].as_array(),
1037 }),
1038 )
1039 .await
1040 }
1041 Op::AssignIssue => {
1042 pass(
1043 runner,
1044 "run",
1045 &json!({
1046 "actor": actor(),
1047 "repo": repo,
1048 "issue": number,
1049 "instructions": text(input, "instructions"),
1050 }),
1051 )
1052 .await
1053 }
API and MCP server in Rust; a public index at the API root1054 Op::CloseIssue | Op::ReopenIssue => {
1055 let reason = match input["reason"].as_str() {
1056 Some("not_planned") => IssueReason::NotPlanned,
1057 _ => IssueReason::Completed,
1058 };
1059 let method = if self == Op::CloseIssue {
1060 "close_issue"
1061 } else {
1062 "reopen_issue"
1063 };
1064 pass(
1065 work,
1066 method,
1067 &IssueActionArgs {
1068 actor: actor(),
1069 repo,
1070 number,
1071 reason: Some(reason),
1072 },
1073 )
1074 .await
1075 }
1076 Op::ListLabels => pass(work, "list_labels", &view()).await,
Acceptance checks in sandboxes, line comments and review verdicts1077 Op::AddComment | Op::ReviewPullRequest => {
1078 let verdict = match (self, input["verdict"].as_str()) {
1079 (Op::AddComment, _) => None,
1080 (_, Some("approve")) => Some(Verdict::Approve),
1081 (_, Some("request_changes")) => Some(Verdict::RequestChanges),
1082 _ => {
1083 return failed(
1084 FailureCode::Invalid,
1085 "verdict must be approve or request_changes.",
1086 );
1087 }
1088 };
API and MCP server in Rust; a public index at the API root1089 pass(
1090 work,
1091 "add_comment",
1092 &AddCommentArgs {
1093 actor: actor(),
1094 repo,
1095 number,
1096 body: text(input, "body"),
Acceptance checks in sandboxes, line comments and review verdicts1097 path: optional_text(input, "path"),
1098 line: integer(input, "line"),
1099 verdict,
API and MCP server in Rust; a public index at the API root1100 },
1101 )
1102 .await
1103 }
1104 Op::ListPullRequests => {
1105 pass(
1106 work,
1107 "list_pulls",
1108 &ListPullsArgs {
1109 repo,
1110 viewer: viewer.clone(),
1111 state: state(input),
1112 },
1113 )
1114 .await
1115 }
1116 Op::GetPullRequest => pass(work, "get_pull", &view()).await,
1117 Op::CreatePullRequest => {
1118 let user = actor();
1119 let opened: Outcome<Pull> = call(
1120 work,
1121 "open_pull",
1122 &OpenPullArgs {
1123 actor: user.clone(),
1124 repo: repo.clone(),
1125 issue: integer(input, "issue"),
1126 title: text(input, "title"),
1127 body: text(input, "body"),
1128 branch: optional_text(input, "branch"),
1129 agent: optional_text(input, "agent").unwrap_or_else(|| "agent".into()),
1130 runtime: Runtime::External,
1131 },
1132 )
1133 .await?;
1134 let pull = match opened {
1135 Outcome::Ok(pull) => pull,
1136 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
1137 };
1138 // Where to push. A pull request from a branch has no fork:
1139 // push to that branch of the repository.
1140 let source = pull.fork.as_ref().unwrap_or(&repo);
1141 let remote = format!("https://g1t.sh/{}/{}.git", source.namespace, source.name);
1142 ok(&json!({
1143 "pull": pull,
1144 "git": {
1145 "remote": remote,
1146 "username": user.username,
1147 "password": "your g1t access token",
1148 },
1149 }))
1150 }
1151 Op::RecordSession => {
1152 let Ok(entries) = serde_json::from_value(input["entries"].clone()) else {
1153 return failed(
1154 FailureCode::Invalid,
1155 "entries must be a list of objects with a kind and a text.",
1156 );
1157 };
1158 pass(
1159 work,
1160 "append_session",
1161 &AppendSessionArgs {
1162 actor: actor(),
1163 repo,
1164 number,
1165 entries,
1166 },
1167 )
1168 .await
1169 }
1170 Op::ReadSession => pass(work, "read_session", &view()).await,
1171 Op::MarkPullRequestReady => pass(work, "ready_pull", &pull_action()).await,
1172 Op::ClosePullRequest => pass(work, "close_pull", &pull_action()).await,
1173 Op::MergePullRequest => pass(work, "merge_pull", &pull_action()).await,
1174 Op::GetPullRequestChanges => {
1175 let found: Outcome<PullDetail> = call(work, "get_pull", &view()).await?;
1176 match found {
1177 Outcome::Ok(detail) => {
Agents as a team: lifecycle, merge queue, billing and a new shell1178 pass(repos, "compare", &detail.pull.comparison(viewer)).await
API and MCP server in Rust; a public index at the API root1179 }
1180 Outcome::Fail(failure) => Ok(Outcome::Fail(failure)),
1181 }
1182 }
1183 Op::ListEvents => {
1184 let found: Outcome<Repo> = call(
1185 repos,
1186 "get",
1187 &GetArgs {
1188 path: repo,
1189 viewer: viewer.clone(),
1190 },
1191 )
1192 .await?;
1193 let repo = match found {
1194 Outcome::Ok(repo) => repo,
1195 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
1196 };
1197 let timeline: Vec<Event> = g1t_kit::call(
1198 events,
1199 "list",
1200 &ListEventsArgs {
1201 repo_id: Some(repo.id),
1202 before: optional_text(input, "before"),
1203 ..ListEventsArgs::default()
1204 },
1205 )
1206 .await?;
1207 ok(&timeline)
1208 }
1209 }
1210 }
1211}
1212
1213impl Op {
1214 /// The properties of the operation's input schema.
1215 pub fn properties(self) -> Map<String, Value> {
1216 match self.input() {
1217 Value::Object(mut schema) => match schema.remove("properties") {
1218 Some(Value::Object(properties)) => properties,
1219 _ => Map::new(),
1220 },
1221 _ => Map::new(),
1222 }
1223 }
1224
1225 /// The names of the properties that must be given.
1226 pub fn required(self) -> Vec<String> {
1227 self.input()["required"]
1228 .as_array()
1229 .map(|names| {
1230 names
1231 .iter()
1232 .filter_map(|name| name.as_str().map(str::to_owned))
1233 .collect()
1234 })
1235 .unwrap_or_default()
1236 }
1237}
1238
1239#[cfg(test)]
1240mod tests {
1241 use super::*;
1242
1243 #[test]
1244 fn names_are_unique_and_found_again() {
1245 for op in Op::ALL {
1246 assert_eq!(Op::by_name(op.name()), Some(op));
1247 }
1248 assert_eq!(Op::by_name("start_attempt"), None);
1249 }
1250
1251 #[test]
1252 fn required_properties_exist() {
1253 for op in Op::ALL {
1254 let properties = op.properties();
1255 for name in op.required() {
1256 assert!(properties.contains_key(&name), "{}: {name}", op.name());
1257 }
1258 }
1259 }
1260
1261 #[test]
1262 fn a_repository_is_owner_slash_name() {
1263 let path = repo_path(&json!({ "repo": "syntaqx/hello" })).unwrap();
1264 assert_eq!(
1265 (path.namespace.as_str(), path.name.as_str()),
1266 ("syntaqx", "hello")
1267 );
1268 for bad in ["syntaqx", "a/b/c", "/hello", "syntaqx/", ""] {
1269 assert!(repo_path(&json!({ "repo": bad })).is_none(), "{bad}");
1270 }
1271 }
1272
1273 #[test]
1274 fn numbers_are_read_from_numbers_and_digits() {
1275 assert_eq!(integer(&json!({ "number": 12 }), "number"), Some(12));
1276 assert_eq!(integer(&json!({ "number": "12" }), "number"), Some(12));
1277 assert_eq!(integer(&json!({ "number": "x" }), "number"), None);
1278 assert_eq!(integer(&json!({}), "number"), None);
1279 }
1280}