pr_01m47d24b0e6n91zwymwxg0vpx/apps/api/src/operations.rs

1,235 lines51,121 bytesCodeBlame
1//! 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::identity::AgentScope;
8use g1t_contracts::events::{Event, ListArgs as ListEventsArgs};
9use g1t_contracts::identity::CreateWorkspaceArgs;
10use g1t_contracts::repos::{CreateArgs, GetArgs, ListArgs as ListReposArgs, Repo, RepoPath};
11use 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,
24 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>,
28}
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")?,
37 runner: env.service("RUNNER")?,
38 billing: env.service("BILLING")?,
39 scope: None,
40 })
41 }
42}
43
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45pub enum Op {
46 Whoami,
47 CreateWorkspace,
48 ListRepos,
49 GetRepo,
50 CreateRepo,
51 UpdateRepo,
52 GetRepoSettings,
53 UpdateRepoSettings,
54 GetMergeQueue,
55 MessageAgent,
56 TakeMessages,
57 ListIssues,
58 GetIssue,
59 CreateIssue,
60 UpdateIssue,
61 CloseIssue,
62 ReopenIssue,
63 AssignIssue,
64 PlanWork,
65 GetPlan,
66 ApplyPlan,
67 ListLabels,
68 AddComment,
69 ReviewPullRequest,
70 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 {
190 pub const ALL: [Op; 34] = [
191 Op::Whoami,
192 Op::CreateWorkspace,
193 Op::ListRepos,
194 Op::GetRepo,
195 Op::CreateRepo,
196 Op::UpdateRepo,
197 Op::GetRepoSettings,
198 Op::UpdateRepoSettings,
199 Op::GetMergeQueue,
200 Op::MessageAgent,
201 Op::TakeMessages,
202 Op::ListIssues,
203 Op::GetIssue,
204 Op::CreateIssue,
205 Op::UpdateIssue,
206 Op::CloseIssue,
207 Op::ReopenIssue,
208 Op::AssignIssue,
209 Op::PlanWork,
210 Op::GetPlan,
211 Op::ApplyPlan,
212 Op::ListLabels,
213 Op::AddComment,
214 Op::ReviewPullRequest,
215 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",
239 Op::UpdateRepo => "update_repo",
240 Op::GetRepoSettings => "get_repo_settings",
241 Op::GetMergeQueue => "get_merge_queue",
242 Op::MessageAgent => "message_agent",
243 Op::TakeMessages => "take_messages",
244 Op::UpdateRepoSettings => "update_repo_settings",
245 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",
251 Op::AssignIssue => "assign_issue",
252 Op::PlanWork => "plan_work",
253 Op::GetPlan => "get_plan",
254 Op::ApplyPlan => "apply_plan",
255 Op::ListLabels => "list_labels",
256 Op::AddComment => "add_comment",
257 Op::ReviewPullRequest => "review_pull_request",
258 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 {
273 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 }
276 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.",
281 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 }
290 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 }
296 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 }
302 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 => {
310 "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."
311 }
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.",
316 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 }
328 Op::ListLabels => "The labels available on a repository's issues.",
329 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 }
335 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 => {
339 "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."
340 }
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 => {
356 "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."
357 }
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(),
388 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"]),
402 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"]),
409 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 ),
447 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" },
456 "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 },
460 }),
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" } },
502 "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 },
549 })),
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(
563 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 })),
574 &["repo", "number", "body"],
575 ),
576 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 ),
586 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 },
648 "ignore_checks": {
649 "type": "boolean",
650 "description": "Merge although the acceptance checks have not passed.",
651 },
652 })),
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
679 | Op::GetRepoSettings
680 | Op::GetMergeQueue
681 )
682 }
683
684 /// 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
689 /// 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 }
709 // 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 }
733 // 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),
761 ignore_checks: input["ignore_checks"].as_bool() == Some(true),
762 };
763 let Services {
764 identity,
765 repos,
766 work,
767 events,
768 runner,
769 ..
770 } = 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 }
811 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 }
836 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 }
852 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 }
894 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),
912 import_url: optional_text(input, "import_url"),
913 },
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"),
957 assignees: strings(input, "assignees"),
958 },
959 )
960 .await
961 }
962 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 }
1009 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,
1032 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 };
1044 pass(
1045 work,
1046 "add_comment",
1047 &AddCommentArgs {
1048 actor: actor(),
1049 repo,
1050 number,
1051 body: text(input, "body"),
1052 path: optional_text(input, "path"),
1053 line: integer(input, "line"),
1054 verdict,
1055 },
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) => {
1133 pass(repos, "compare", &detail.pull.comparison(viewer)).await
1134 }
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}