pr_01m47d24b0e6n91zwymwxg0vpx/apps/api/src/operations.rs

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