g1t/apps/api/src/operations.rs

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