g1t/apps/api/src/operations.rs

912 lines35,293 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
7use g1t_contracts::events::{Event, ListArgs as ListEventsArgs};
8use g1t_contracts::identity::CreateWorkspaceArgs;
9use g1t_contracts::repos::{
10 CompareArgs, CreateArgs, GetArgs, ListArgs as ListReposArgs, Repo, RepoPath,
11};
12use g1t_contracts::work::*;
13use g1t_contracts::{FailureCode, Outcome, Viewer};
14use serde::Serialize;
15use serde::de::DeserializeOwned;
16use serde_json::{Map, Value, json};
17use worker::{Env, Fetcher, Result};
18
19/// The services the API is a front for.
20pub struct Services {
21 pub identity: Fetcher,
22 pub repos: Fetcher,
23 pub work: Fetcher,
24 pub events: Fetcher,
25}
26
27impl Services {
28 pub fn new(env: &Env) -> Result<Self> {
29 Ok(Services {
30 identity: env.service("IDENTITY")?,
31 repos: env.service("REPOS")?,
32 work: env.service("WORK")?,
33 events: env.service("EVENTS")?,
34 })
35 }
36}
37
38#[derive(Clone, Copy, Debug, PartialEq, Eq)]
39pub enum Op {
40 Whoami,
41 CreateWorkspace,
42 ListRepos,
43 GetRepo,
44 CreateRepo,
45 ListIssues,
46 GetIssue,
47 CreateIssue,
48 UpdateIssue,
49 CloseIssue,
50 ReopenIssue,
51 ListLabels,
52 AddComment,
Acceptance checks in sandboxes, line comments and review verdicts53 ReviewPullRequest,
API and MCP server in Rust; a public index at the API root54 ListPullRequests,
55 GetPullRequest,
56 CreatePullRequest,
57 RecordSession,
58 ReadSession,
59 MarkPullRequestReady,
60 ClosePullRequest,
61 GetPullRequestChanges,
62 MergePullRequest,
63 ListEvents,
64}
65
66fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> {
67 Ok(Outcome::fail(code, message))
68}
69
70fn ok<T: Serialize>(value: &T) -> Result<Outcome<Value>> {
71 Ok(Outcome::Ok(serde_json::to_value(value)?))
72}
73
74/// Calls a method that returns an `Outcome`, decoding its value as `T`.
75async fn call<A: Serialize, T: DeserializeOwned>(
76 service: &Fetcher,
77 method: &str,
78 args: &A,
79) -> Result<Outcome<T>> {
80 g1t_kit::call(service, method, args).await
81}
82
83/// Calls a method that returns an `Outcome`, passing its value through.
84async fn pass<A: Serialize>(service: &Fetcher, method: &str, args: &A) -> Result<Outcome<Value>> {
85 call(service, method, args).await
86}
87
88fn text(input: &Value, key: &str) -> String {
89 input[key].as_str().unwrap_or_default().to_owned()
90}
91
92fn optional_text(input: &Value, key: &str) -> Option<String> {
93 input[key]
94 .as_str()
95 .filter(|value| !value.is_empty())
96 .map(str::to_owned)
97}
98
99/// A whole number given as a number or as digits.
100fn integer(input: &Value, key: &str) -> Option<u32> {
101 match &input[key] {
102 Value::Number(number) => number.as_u64().and_then(|n| u32::try_from(n).ok()),
103 Value::String(digits) => digits.parse().ok(),
104 _ => None,
105 }
106}
107
108fn strings(input: &Value, key: &str) -> Option<Vec<String>> {
109 input[key].as_array().map(|items| {
110 items
111 .iter()
112 .map(|item| match item {
113 Value::String(text) => text.clone(),
114 other => other.to_string(),
115 })
116 .collect()
117 })
118}
119
120fn state(input: &Value) -> Option<State> {
121 match input["state"].as_str() {
122 Some("open") => Some(State::Open),
123 Some("closed") => Some(State::Closed),
124 _ => None,
125 }
126}
127
128/// The repository named by `repo`, written `owner/name`.
129fn repo_path(input: &Value) -> Option<RepoPath> {
130 let mut parts = input["repo"].as_str()?.split('/');
131 match (parts.next(), parts.next(), parts.next()) {
132 (Some(namespace), Some(name), None) if !namespace.is_empty() && !name.is_empty() => {
133 Some(RepoPath {
134 namespace: namespace.to_owned(),
135 name: name.to_owned(),
136 })
137 }
138 _ => None,
139 }
140}
141
142/// An object schema. `required` names the properties that must be given.
143fn object(properties: Value, required: &[&str]) -> Value {
144 let mut schema = json!({ "type": "object", "properties": properties });
145 if !required.is_empty() {
146 schema["required"] = json!(required);
147 }
148 schema
149}
150
151/// The properties naming an issue or pull request, with `more` added.
152fn numbered(more: Value) -> Value {
153 let mut properties = json!({
154 "repo": repo_schema(),
155 "number": {
156 "type": "integer",
157 "description": "The number shown after the #. Issues and pull requests share one sequence.",
158 },
159 });
160 if let (Some(all), Value::Object(more)) = (properties.as_object_mut(), more) {
161 all.extend(more);
162 }
163 properties
164}
165
166fn repo_schema() -> Value {
167 json!({
168 "type": "string",
169 "description": "Repository as \"owner/name\", e.g. \"syntaqx/hello\".",
170 })
171}
172
173/// What `ReposApi.compare` needs to show what a pull request changes.
174///
175/// A fork is compared as a whole. A branch is compared by name while the
176/// pull request is open, and by the commit it was merged or closed at
177/// afterwards, so later pushes to the branch do not change the record.
178fn comparison(pull: Pull, viewer: &Viewer) -> CompareArgs {
179 let settled = matches!(pull.status, PullStatus::Merged | PullStatus::Closed);
180 let (repo_id, head) = match pull.fork_repo_id {
181 Some(fork) => (fork, None),
182 None => (
183 pull.repo_id,
184 pull.head_commit.filter(|_| settled).or(pull.branch),
185 ),
186 };
187 CompareArgs {
188 repo_id,
189 viewer: viewer.clone(),
190 base: pull.merge_base,
191 head,
192 }
193}
194
195impl Op {
Acceptance checks in sandboxes, line comments and review verdicts196 pub const ALL: [Op; 24] = [
API and MCP server in Rust; a public index at the API root197 Op::Whoami,
198 Op::CreateWorkspace,
199 Op::ListRepos,
200 Op::GetRepo,
201 Op::CreateRepo,
202 Op::ListIssues,
203 Op::GetIssue,
204 Op::CreateIssue,
205 Op::UpdateIssue,
206 Op::CloseIssue,
207 Op::ReopenIssue,
208 Op::ListLabels,
209 Op::AddComment,
Acceptance checks in sandboxes, line comments and review verdicts210 Op::ReviewPullRequest,
API and MCP server in Rust; a public index at the API root211 Op::ListPullRequests,
212 Op::GetPullRequest,
213 Op::CreatePullRequest,
214 Op::RecordSession,
215 Op::ReadSession,
216 Op::MarkPullRequestReady,
217 Op::ClosePullRequest,
218 Op::GetPullRequestChanges,
219 Op::MergePullRequest,
220 Op::ListEvents,
221 ];
222
223 pub fn by_name(name: &str) -> Option<Op> {
224 Op::ALL.into_iter().find(|op| op.name() == name)
225 }
226
227 /// The operation's name: its MCP tool name and OpenAPI operation id.
228 pub fn name(self) -> &'static str {
229 match self {
230 Op::Whoami => "whoami",
231 Op::CreateWorkspace => "create_workspace",
232 Op::ListRepos => "list_repos",
233 Op::GetRepo => "get_repo",
234 Op::CreateRepo => "create_repo",
235 Op::ListIssues => "list_issues",
236 Op::GetIssue => "get_issue",
237 Op::CreateIssue => "create_issue",
238 Op::UpdateIssue => "update_issue",
239 Op::CloseIssue => "close_issue",
240 Op::ReopenIssue => "reopen_issue",
241 Op::ListLabels => "list_labels",
242 Op::AddComment => "add_comment",
Acceptance checks in sandboxes, line comments and review verdicts243 Op::ReviewPullRequest => "review_pull_request",
API and MCP server in Rust; a public index at the API root244 Op::ListPullRequests => "list_pull_requests",
245 Op::GetPullRequest => "get_pull_request",
246 Op::CreatePullRequest => "create_pull_request",
247 Op::RecordSession => "record_session",
248 Op::ReadSession => "read_session",
249 Op::MarkPullRequestReady => "mark_pull_request_ready",
250 Op::ClosePullRequest => "close_pull_request",
251 Op::GetPullRequestChanges => "get_pull_request_changes",
252 Op::MergePullRequest => "merge_pull_request",
253 Op::ListEvents => "list_events",
254 }
255 }
256
257 pub fn description(self) -> &'static str {
258 match self {
259 Op::Whoami => "The account the access token belongs to, and its workspaces.",
260 Op::CreateWorkspace => {
261 "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."
262 }
263 Op::ListRepos => "Repositories you can see, optionally filtered by a search query.",
264 Op::GetRepo => "One repository's details.",
265 Op::CreateRepo => "Create a repository in one of your workspaces.",
266 Op::ListIssues => {
267 "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."
268 }
269 Op::GetIssue => {
270 "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."
271 }
272 Op::CreateIssue => "Open an issue on a repository.",
273 Op::UpdateIssue => {
274 "Change an issue's title, body or labels. Only the fields given are changed; labels replaces the whole set."
275 }
276 Op::CloseIssue => {
277 "Close an issue without a pull request. Merging a pull request made for an issue closes it for you."
278 }
279 Op::ReopenIssue => "Reopen a closed issue.",
280 Op::ListLabels => "The labels available on a repository's issues.",
Acceptance checks in sandboxes, line comments and review verdicts281 Op::AddComment => {
282 "Comment on an issue or a pull request. On a pull request, give path and line to comment on one line of the change."
283 }
284 Op::ReviewPullRequest => {
285 "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."
286 }
API and MCP server in Rust; a public index at the API root287 Op::ListPullRequests => {
288 "Pull requests on a repository, newest first. State open covers drafts and those ready for review; closed covers merged and closed."
289 }
290 Op::GetPullRequest => {
Acceptance checks in sandboxes, line comments and review verdicts291 "A pull request's status, head commit, comments and reviews, the issue it is for, and the latest run of that issue's acceptance checks with each command's output."
API and MCP server in Rust; a public index at the API root292 }
293 Op::CreatePullRequest => {
294 "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."
295 }
296 Op::RecordSession => {
297 "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."
298 }
299 Op::ReadSession => "The recorded session of a pull request, oldest entry first.",
300 Op::MarkPullRequestReady => {
301 "Mark a draft pull request ready for review. Push your commits first. The summary becomes its description and should say what changed and why."
302 }
303 Op::ClosePullRequest => "Close a pull request without merging it.",
304 Op::GetPullRequestChanges => {
305 "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."
306 }
307 Op::MergePullRequest => {
Acceptance checks in sandboxes, line comments and review verdicts308 "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 root309 }
310 Op::ListEvents => {
311 "The timeline of a repository: pushes, issues, pull requests, comments and session activity, newest first."
312 }
313 }
314 }
315
316 /// The JSON Schema of the operation's input.
317 pub fn input(self) -> Value {
318 let repo_only = || object(json!({ "repo": repo_schema() }), &["repo"]);
319 let just_numbered = || object(numbered(json!({})), &["repo", "number"]);
320 let states = json!({ "type": "string", "enum": ["open", "closed"] });
321 match self {
322 Op::Whoami => object(json!({}), &[]),
323 Op::CreateWorkspace => object(
324 json!({
325 "slug": {
326 "type": "string",
327 "description": "Its name in URLs: lowercase letters, digits and single hyphens.",
328 },
329 "name": { "type": "string", "description": "A display name." },
330 }),
331 &["slug"],
332 ),
333 Op::ListRepos => object(
334 json!({
335 "query": { "type": "string", "description": "Matches name or description." },
336 }),
337 &[],
338 ),
339 Op::GetRepo | Op::ListLabels => repo_only(),
340 Op::CreateRepo => object(
341 json!({
342 "workspace": {
343 "type": "string",
344 "description": "The workspace to create it in. May be left out if you belong to exactly one.",
345 },
346 "name": { "type": "string" },
347 "description": { "type": "string" },
348 "private": { "type": "boolean" },
349 }),
350 &["name"],
351 ),
352 Op::ListIssues => object(
353 json!({
354 "repo": repo_schema(),
355 "state": states,
356 "label": { "type": "string", "description": "Only issues carrying this label." },
357 }),
358 &["repo"],
359 ),
360 Op::GetIssue
361 | Op::ReopenIssue
362 | Op::GetPullRequest
363 | Op::ClosePullRequest
364 | Op::GetPullRequestChanges => just_numbered(),
365 Op::CreateIssue => object(
366 json!({
367 "repo": repo_schema(),
368 "title": { "type": "string", "description": "The problem or goal in one line." },
369 "body": {
370 "type": "string",
371 "description": "Markdown. What an agent or a person needs to do the work: what is wrong or wanted, constraints, context.",
372 },
373 "labels": {
374 "type": "array",
375 "items": { "type": "string" },
376 "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.",
377 },
378 "checks": {
379 "type": "array",
380 "items": { "type": "string" },
381 "description": "Commands that must pass for a pull request to be accepted.",
382 },
383 }),
384 &["repo", "title"],
385 ),
386 Op::UpdateIssue => object(
387 numbered(json!({
388 "title": { "type": "string" },
389 "body": { "type": "string" },
390 "labels": { "type": "array", "items": { "type": "string" } },
391 })),
392 &["repo", "number"],
393 ),
394 Op::CloseIssue => object(
395 numbered(json!({
396 "reason": {
397 "type": "string",
398 "enum": ["completed", "not_planned"],
399 "description": "Defaults to completed.",
400 },
401 })),
402 &["repo", "number"],
403 ),
404 Op::AddComment => object(
Acceptance checks in sandboxes, line comments and review verdicts405 numbered(json!({
406 "body": { "type": "string", "description": "Markdown." },
407 "path": {
408 "type": "string",
409 "description": "On a pull request: the file to comment on.",
410 },
411 "line": {
412 "type": "integer",
413 "description": "The line of that file, as numbered after the change.",
414 },
415 })),
API and MCP server in Rust; a public index at the API root416 &["repo", "number", "body"],
417 ),
Acceptance checks in sandboxes, line comments and review verdicts418 Op::ReviewPullRequest => object(
419 numbered(json!({
420 "verdict": { "type": "string", "enum": ["approve", "request_changes"] },
421 "body": {
422 "type": "string",
423 "description": "Markdown. Required when requesting changes.",
424 },
425 })),
426 &["repo", "number", "verdict"],
427 ),
API and MCP server in Rust; a public index at the API root428 Op::ListPullRequests => {
429 object(json!({ "repo": repo_schema(), "state": states }), &["repo"])
430 }
431 Op::CreatePullRequest => object(
432 json!({
433 "repo": repo_schema(),
434 "issue": { "type": "integer", "description": "The number of the issue this is for." },
435 "title": {
436 "type": "string",
437 "description": "Defaults to the issue's title. Required when there is no issue.",
438 },
439 "branch": {
440 "type": "string",
441 "description": "A branch already pushed to the repository that holds the change. Leave out to get a fork.",
442 },
443 "body": {
444 "type": "string",
445 "description": "Markdown: what changed and why. Mainly for pull requests from a branch.",
446 },
447 "agent": {
448 "type": "string",
449 "description": "A label for the agent doing the work, e.g. \"claude-code\".",
450 },
451 }),
452 &["repo"],
453 ),
454 Op::RecordSession => object(
455 numbered(json!({
456 "entries": {
457 "type": "array",
458 "items": {
459 "type": "object",
460 "properties": {
461 "kind": {
462 "type": "string",
463 "enum": ["prompt", "message", "tool_call", "tool_result", "note"],
464 },
465 "text": { "type": "string" },
466 "tool": { "type": "string", "description": "Tool name, for tool entries." },
467 },
468 "required": ["kind", "text"],
469 },
470 },
471 })),
472 &["repo", "number", "entries"],
473 ),
474 Op::ReadSession => object(
475 numbered(json!({
476 "after": { "type": "integer", "description": "Only entries after this sequence number." },
477 })),
478 &["repo", "number"],
479 ),
480 Op::MarkPullRequestReady => object(
481 numbered(json!({ "summary": { "type": "string", "description": "Markdown." } })),
482 &["repo", "number", "summary"],
483 ),
484 Op::MergePullRequest => object(
485 numbered(json!({
486 "keep_issue_open": {
487 "type": "boolean",
488 "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.",
489 },
Acceptance checks in sandboxes, line comments and review verdicts490 "ignore_checks": {
491 "type": "boolean",
492 "description": "Merge although the acceptance checks have not passed.",
493 },
API and MCP server in Rust; a public index at the API root494 })),
495 &["repo", "number"],
496 ),
497 Op::ListEvents => object(
498 json!({
499 "repo": repo_schema(),
500 "before": { "type": "string", "description": "Event id to page back from." },
501 }),
502 &["repo"],
503 ),
504 }
505 }
506
507 /// Whether the operation refuses an anonymous caller outright.
508 fn needs_user(self) -> bool {
509 !matches!(
510 self,
511 Op::ListRepos
512 | Op::GetRepo
513 | Op::ListIssues
514 | Op::GetIssue
515 | Op::ListLabels
516 | Op::ListPullRequests
517 | Op::GetPullRequest
518 | Op::ReadSession
519 | Op::GetPullRequestChanges
520 | Op::ListEvents
521 )
522 }
523
524 /// Whether the operation is about one repository, named by `repo`.
525 fn needs_repo(self) -> bool {
526 !matches!(
527 self,
528 Op::Whoami | Op::CreateWorkspace | Op::ListRepos | Op::CreateRepo
529 )
530 }
531
532 pub async fn run(
533 self,
534 services: &Services,
535 viewer: &Viewer,
536 input: &Value,
537 ) -> Result<Outcome<Value>> {
538 if self.needs_user() && viewer.is_none() {
539 return failed(
540 FailureCode::Unauthenticated,
541 "This needs a g1t access token.",
542 );
543 }
544 // Checked above for every operation that uses it.
545 let actor = || viewer.clone().unwrap_or_default();
546 let repo = match repo_path(input) {
547 Some(repo) => repo,
548 None if self.needs_repo() => {
549 return failed(
550 FailureCode::Invalid,
551 "Give the repository as \"owner/name\".",
552 );
553 }
554 None => RepoPath {
555 namespace: String::new(),
556 name: String::new(),
557 },
558 };
559 let number = integer(input, "number").unwrap_or_default();
560 let view = || ViewArgs {
561 repo: repo.clone(),
562 number,
563 viewer: viewer.clone(),
564 after_seq: integer(input, "after").unwrap_or_default(),
565 };
566 let pull_action = || PullActionArgs {
567 actor: actor(),
568 repo: repo.clone(),
569 number,
570 summary: text(input, "summary"),
571 keep_issue_open: input["keep_issue_open"].as_bool() == Some(true),
Acceptance checks in sandboxes, line comments and review verdicts572 ignore_checks: input["ignore_checks"].as_bool() == Some(true),
API and MCP server in Rust; a public index at the API root573 };
574 let Services {
575 identity,
576 repos,
577 work,
578 events,
579 } = services;
580
581 match self {
582 Op::Whoami => ok(&actor()),
583 Op::CreateWorkspace => {
584 pass(
585 identity,
586 "create_workspace",
587 &CreateWorkspaceArgs {
588 user: actor(),
589 slug: text(input, "slug"),
590 name: text(input, "name"),
591 },
592 )
593 .await
594 }
595 Op::ListRepos => {
596 let found: Vec<Repo> = g1t_kit::call(
597 repos,
598 "list",
599 &ListReposArgs {
600 viewer: viewer.clone(),
601 query: optional_text(input, "query"),
602 namespace: None,
603 member_only: false,
604 },
605 )
606 .await?;
607 ok(&found)
608 }
609 Op::GetRepo => {
610 pass(
611 repos,
612 "get",
613 &GetArgs {
614 path: repo,
615 viewer: viewer.clone(),
616 },
617 )
618 .await
619 }
620 Op::CreateRepo => {
621 let owner = actor();
622 // Someone in exactly one workspace need not name it.
623 let namespace = optional_text(input, "workspace").unwrap_or_else(|| {
624 match owner.workspaces.as_slice() {
625 [only] => only.slug.clone(),
626 _ => String::new(),
627 }
628 });
629 pass(
630 repos,
631 "create",
632 &CreateArgs {
633 owner,
634 namespace,
635 name: text(input, "name"),
636 description: optional_text(input, "description"),
637 is_private: input["private"].as_bool() == Some(true),
638 },
639 )
640 .await
641 }
642 Op::ListIssues => {
643 pass(
644 work,
645 "list_issues",
646 &ListIssuesArgs {
647 repo,
648 viewer: viewer.clone(),
649 state: state(input),
650 label: optional_text(input, "label"),
651 },
652 )
653 .await
654 }
655 Op::GetIssue => pass(work, "get_issue", &view()).await,
656 Op::CreateIssue => {
657 pass(
658 work,
659 "open_issue",
660 &OpenIssueArgs {
661 actor: actor(),
662 repo,
663 title: text(input, "title"),
664 body: text(input, "body"),
665 labels: strings(input, "labels").unwrap_or_default(),
666 checks: strings(input, "checks").unwrap_or_default(),
667 },
668 )
669 .await
670 }
671 Op::UpdateIssue => {
672 pass(
673 work,
674 "update_issue",
675 &UpdateIssueArgs {
676 actor: actor(),
677 repo,
678 number,
679 title: input["title"].as_str().map(str::to_owned),
680 body: input["body"].as_str().map(str::to_owned),
681 labels: strings(input, "labels"),
682 },
683 )
684 .await
685 }
686 Op::CloseIssue | Op::ReopenIssue => {
687 let reason = match input["reason"].as_str() {
688 Some("not_planned") => IssueReason::NotPlanned,
689 _ => IssueReason::Completed,
690 };
691 let method = if self == Op::CloseIssue {
692 "close_issue"
693 } else {
694 "reopen_issue"
695 };
696 pass(
697 work,
698 method,
699 &IssueActionArgs {
700 actor: actor(),
701 repo,
702 number,
703 reason: Some(reason),
704 },
705 )
706 .await
707 }
708 Op::ListLabels => pass(work, "list_labels", &view()).await,
Acceptance checks in sandboxes, line comments and review verdicts709 Op::AddComment | Op::ReviewPullRequest => {
710 let verdict = match (self, input["verdict"].as_str()) {
711 (Op::AddComment, _) => None,
712 (_, Some("approve")) => Some(Verdict::Approve),
713 (_, Some("request_changes")) => Some(Verdict::RequestChanges),
714 _ => {
715 return failed(
716 FailureCode::Invalid,
717 "verdict must be approve or request_changes.",
718 );
719 }
720 };
API and MCP server in Rust; a public index at the API root721 pass(
722 work,
723 "add_comment",
724 &AddCommentArgs {
725 actor: actor(),
726 repo,
727 number,
728 body: text(input, "body"),
Acceptance checks in sandboxes, line comments and review verdicts729 path: optional_text(input, "path"),
730 line: integer(input, "line"),
731 verdict,
API and MCP server in Rust; a public index at the API root732 },
733 )
734 .await
735 }
736 Op::ListPullRequests => {
737 pass(
738 work,
739 "list_pulls",
740 &ListPullsArgs {
741 repo,
742 viewer: viewer.clone(),
743 state: state(input),
744 },
745 )
746 .await
747 }
748 Op::GetPullRequest => pass(work, "get_pull", &view()).await,
749 Op::CreatePullRequest => {
750 let user = actor();
751 let opened: Outcome<Pull> = call(
752 work,
753 "open_pull",
754 &OpenPullArgs {
755 actor: user.clone(),
756 repo: repo.clone(),
757 issue: integer(input, "issue"),
758 title: text(input, "title"),
759 body: text(input, "body"),
760 branch: optional_text(input, "branch"),
761 agent: optional_text(input, "agent").unwrap_or_else(|| "agent".into()),
762 runtime: Runtime::External,
763 },
764 )
765 .await?;
766 let pull = match opened {
767 Outcome::Ok(pull) => pull,
768 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
769 };
770 // Where to push. A pull request from a branch has no fork:
771 // push to that branch of the repository.
772 let source = pull.fork.as_ref().unwrap_or(&repo);
773 let remote = format!("https://g1t.sh/{}/{}.git", source.namespace, source.name);
774 ok(&json!({
775 "pull": pull,
776 "git": {
777 "remote": remote,
778 "username": user.username,
779 "password": "your g1t access token",
780 },
781 }))
782 }
783 Op::RecordSession => {
784 let Ok(entries) = serde_json::from_value(input["entries"].clone()) else {
785 return failed(
786 FailureCode::Invalid,
787 "entries must be a list of objects with a kind and a text.",
788 );
789 };
790 pass(
791 work,
792 "append_session",
793 &AppendSessionArgs {
794 actor: actor(),
795 repo,
796 number,
797 entries,
798 },
799 )
800 .await
801 }
802 Op::ReadSession => pass(work, "read_session", &view()).await,
803 Op::MarkPullRequestReady => pass(work, "ready_pull", &pull_action()).await,
804 Op::ClosePullRequest => pass(work, "close_pull", &pull_action()).await,
805 Op::MergePullRequest => pass(work, "merge_pull", &pull_action()).await,
806 Op::GetPullRequestChanges => {
807 let found: Outcome<PullDetail> = call(work, "get_pull", &view()).await?;
808 match found {
809 Outcome::Ok(detail) => {
810 pass(repos, "compare", &comparison(detail.pull, viewer)).await
811 }
812 Outcome::Fail(failure) => Ok(Outcome::Fail(failure)),
813 }
814 }
815 Op::ListEvents => {
816 let found: Outcome<Repo> = call(
817 repos,
818 "get",
819 &GetArgs {
820 path: repo,
821 viewer: viewer.clone(),
822 },
823 )
824 .await?;
825 let repo = match found {
826 Outcome::Ok(repo) => repo,
827 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
828 };
829 let timeline: Vec<Event> = g1t_kit::call(
830 events,
831 "list",
832 &ListEventsArgs {
833 repo_id: Some(repo.id),
834 before: optional_text(input, "before"),
835 ..ListEventsArgs::default()
836 },
837 )
838 .await?;
839 ok(&timeline)
840 }
841 }
842 }
843}
844
845impl Op {
846 /// The properties of the operation's input schema.
847 pub fn properties(self) -> Map<String, Value> {
848 match self.input() {
849 Value::Object(mut schema) => match schema.remove("properties") {
850 Some(Value::Object(properties)) => properties,
851 _ => Map::new(),
852 },
853 _ => Map::new(),
854 }
855 }
856
857 /// The names of the properties that must be given.
858 pub fn required(self) -> Vec<String> {
859 self.input()["required"]
860 .as_array()
861 .map(|names| {
862 names
863 .iter()
864 .filter_map(|name| name.as_str().map(str::to_owned))
865 .collect()
866 })
867 .unwrap_or_default()
868 }
869}
870
871#[cfg(test)]
872mod tests {
873 use super::*;
874
875 #[test]
876 fn names_are_unique_and_found_again() {
877 for op in Op::ALL {
878 assert_eq!(Op::by_name(op.name()), Some(op));
879 }
880 assert_eq!(Op::by_name("start_attempt"), None);
881 }
882
883 #[test]
884 fn required_properties_exist() {
885 for op in Op::ALL {
886 let properties = op.properties();
887 for name in op.required() {
888 assert!(properties.contains_key(&name), "{}: {name}", op.name());
889 }
890 }
891 }
892
893 #[test]
894 fn a_repository_is_owner_slash_name() {
895 let path = repo_path(&json!({ "repo": "syntaqx/hello" })).unwrap();
896 assert_eq!(
897 (path.namespace.as_str(), path.name.as_str()),
898 ("syntaqx", "hello")
899 );
900 for bad in ["syntaqx", "a/b/c", "/hello", "syntaqx/", ""] {
901 assert!(repo_path(&json!({ "repo": bad })).is_none(), "{bad}");
902 }
903 }
904
905 #[test]
906 fn numbers_are_read_from_numbers_and_digits() {
907 assert_eq!(integer(&json!({ "number": 12 }), "number"), Some(12));
908 assert_eq!(integer(&json!({ "number": "12" }), "number"), Some(12));
909 assert_eq!(integer(&json!({ "number": "x" }), "number"), None);
910 assert_eq!(integer(&json!({}), "number"), None);
911 }
912}