g1t/apps/api/src/rest.rs

775 lines21,972 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

API and MCP server in Rust; a public index at the API root1//! REST: each route maps an HTTP request onto one operation.
2
3use serde_json::{Map, Value};
4
5use crate::operations::Op;
6
7pub struct Route {
8 pub method: &'static str,
9 /// Segments starting with `:` are parameters.
10 pub path: &'static str,
11 pub op: Op,
12 /// Query parameters the route reads, as `(name in the URL, input name)`.
13 pub query: &'static [(&'static str, &'static str)],
14}
15
16const fn route(
17 method: &'static str,
18 path: &'static str,
19 op: Op,
20 query: &'static [(&'static str, &'static str)],
21) -> Route {
22 Route {
23 method,
24 path,
25 op,
26 query,
27 }
28}
29
30pub const ROUTES: &[Route] = &[
Agents as a team: lifecycle, merge queue, billing and a new shell31 route("GET", "/user", Op::Whoami, &[]),
32 route("POST", "/workspaces", Op::CreateWorkspace, &[]),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look33 route("DELETE", "/workspaces/:workspace", Op::DeleteWorkspace, &[]),
34 route("GET", "/user/emails", Op::ListEmails, &[]),
35 route("POST", "/user/emails", Op::AddEmail, &[]),
36 route("DELETE", "/user/emails/:email", Op::RemoveEmail, &[]),
37 route("PATCH", "/user/email-settings", Op::UpdateEmailSettings, &[]),
38 route("GET", "/user/invites", Op::ListInvites, &[]),
39 route("POST", "/user/invites", Op::CreateInvite, &[]),
40 route("DELETE", "/user/invites/:id", Op::RevokeInvite, &[]),
41 route("GET", "/workspaces/:workspace/invitations", Op::ListWorkspaceInvites, &[]),
42 route("POST", "/workspaces/:workspace/invitations", Op::InviteMember, &[]),
43 route("DELETE", "/workspaces/:workspace/invitations/:id", Op::RevokeWorkspaceInvite, &[]),
44 // Who has access. GitHub's addresses, but for adding someone, which
45 // takes an email address as well as a username.
46 route("GET", "/repos/:owner/:name/collaborators", Op::ListCollaborators, &[]),
47 route("POST", "/repos/:owner/:name/collaborators", Op::AddCollaborator, &[]),
48 route("PATCH", "/repos/:owner/:name/collaborators/:username", Op::UpdateCollaborator, &[]),
49 route("DELETE", "/repos/:owner/:name/collaborators/:username", Op::RemoveCollaborator, &[]),
50 route(
51 "GET",
52 "/repos/:owner/:name/collaborators/:username/permission",
53 Op::GetCollaboratorPermission,
54 &[],
55 ),
56 route("GET", "/repos/:owner/:name/invitations", Op::ListRepoInvitations, &[]),
57 route("DELETE", "/repos/:owner/:name/invitations/:id", Op::RevokeRepoInvitation, &[]),
58 route("GET", "/user/repository_invitations", Op::ListMyRepoInvitations, &[]),
59 route("PATCH", "/user/repository_invitations/:id", Op::AcceptRepoInvitation, &[]),
60 route("DELETE", "/user/repository_invitations/:id", Op::DeclineRepoInvitation, &[]),
61 route("PATCH", "/workspaces/:workspace", Op::SetBasePermission, &[]),
62 route(
63 "GET",
64 "/workspaces/:workspace/outside_collaborators",
65 Op::ListOutsideCollaborators,
66 &[],
67 ),
Agents as a team: lifecycle, merge queue, billing and a new shell68 route("GET", "/repos", Op::ListRepos, &[("q", "query")]),
Search across all of g1t, Explore, and a command palette69 route(
70 "GET",
71 "/search",
72 Op::Search,
73 &[("q", "query"), ("type", "type"), ("page", "page"), ("per_page", "per_page")],
74 ),
Agents as a team: lifecycle, merge queue, billing and a new shell75 route("POST", "/repos", Op::CreateRepo, &[]),
76 route("GET", "/repos/:owner/:name", Op::GetRepo, &[]),
77 route("PATCH", "/repos/:owner/:name", Op::UpdateRepo, &[]),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look78 route("POST", "/repos/:owner/:name/transfer", Op::TransferRepo, &[]),
79 route("DELETE", "/repos/:owner/:name", Op::DeleteRepo, &[]),
80 route(
81 "GET",
82 "/workspaces/:workspace/repos/deleted",
83 Op::ListDeletedRepos,
84 &[],
85 ),
86 route("POST", "/repos/:owner/:name/restore", Op::RestoreRepo, &[]),
87 route("POST", "/repos/:owner/:name/purge", Op::PurgeRepo, &[]),
88 route("POST", "/repos/:owner/:name/rename", Op::RenameRepo, &[]),
89 route("POST", "/repos/:owner/:name/archive", Op::ArchiveRepo, &[]),
90 route("POST", "/repos/:owner/:name/unarchive", Op::UnarchiveRepo, &[]),
91 route(
92 "POST",
93 "/repos/:owner/:name/visibility",
94 Op::SetRepoVisibility,
95 &[],
96 ),
97 route(
98 "POST",
99 "/repos/:owner/:name/branches/:branch/rename",
100 Op::RenameBranch,
101 &[],
102 ),
Agents as a team: lifecycle, merge queue, billing and a new shell103 route(
104 "GET",
105 "/repos/:owner/:name/settings",
106 Op::GetRepoSettings,
107 &[],
108 ),
109 route(
110 "PATCH",
111 "/repos/:owner/:name/settings",
112 Op::UpdateRepoSettings,
113 &[],
114 ),
115 route("GET", "/repos/:owner/:name/queue", Op::GetMergeQueue, &[]),
API and MCP server in Rust; a public index at the API root116 route(
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request117 "POST",
118 "/repos/:owner/:name/pulls/:number/messages",
119 Op::MessageAgent,
120 &[],
121 ),
122 route(
123 "POST",
124 "/repos/:owner/:name/pulls/:number/messages/take",
125 Op::TakeMessages,
126 &[],
127 ),
128 route(
Docs worth reading, and kept that way129 "POST",
130 "/repos/:owner/:name/messages/:id/answer",
131 Op::AnswerMessage,
132 &[],
133 ),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains134 route("POST", "/repos/:owner/:name/memory", Op::Remember, &[]),
135 route(
136 "GET",
137 "/repos/:owner/:name/memory",
138 Op::Recall,
139 &[("q", "query"), ("limit", "limit")],
140 ),
Docs worth reading, and kept that way141 route(
API and MCP server in Rust; a public index at the API root142 "GET",
Agents as a team: lifecycle, merge queue, billing and a new shell143 "/repos/:owner/:name/events",
API and MCP server in Rust; a public index at the API root144 Op::ListEvents,
145 &[("before", "before")],
146 ),
Agents as a team: lifecycle, merge queue, billing and a new shell147 route("GET", "/repos/:owner/:name/labels", Op::ListLabels, &[]),
API and MCP server in Rust; a public index at the API root148 route(
149 "GET",
Agents as a team: lifecycle, merge queue, billing and a new shell150 "/repos/:owner/:name/issues",
API and MCP server in Rust; a public index at the API root151 Op::ListIssues,
152 &[("state", "state"), ("label", "label")],
153 ),
Agents as a team: lifecycle, merge queue, billing and a new shell154 route("POST", "/repos/:owner/:name/issues", Op::CreateIssue, &[]),
API and MCP server in Rust; a public index at the API root155 route(
156 "GET",
Agents as a team: lifecycle, merge queue, billing and a new shell157 "/repos/:owner/:name/issues/:number",
API and MCP server in Rust; a public index at the API root158 Op::GetIssue,
159 &[],
160 ),
161 route(
162 "PATCH",
Agents as a team: lifecycle, merge queue, billing and a new shell163 "/repos/:owner/:name/issues/:number",
API and MCP server in Rust; a public index at the API root164 Op::UpdateIssue,
165 &[],
166 ),
167 route(
168 "POST",
Agents as a team: lifecycle, merge queue, billing and a new shell169 "/repos/:owner/:name/issues/:number/close",
API and MCP server in Rust; a public index at the API root170 Op::CloseIssue,
171 &[],
172 ),
173 route(
174 "POST",
Agents as a team: lifecycle, merge queue, billing and a new shell175 "/repos/:owner/:name/issues/:number/reopen",
API and MCP server in Rust; a public index at the API root176 Op::ReopenIssue,
177 &[],
178 ),
179 route(
180 "POST",
Agents as a team: lifecycle, merge queue, billing and a new shell181 "/repos/:owner/:name/issues/:number/assign",
182 Op::AssignIssue,
183 &[],
184 ),
Integrations: your own model provider, alerts that open issues, tickets agents read185 route(
186 "POST",
187 "/repos/:owner/:name/issues/import",
188 Op::ImportIssue,
189 &[],
190 ),
191 route(
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step192 "POST",
193 "/repos/:owner/:name/issues/delegate",
194 Op::Delegate,
195 &[],
196 ),
197 route(
Integrations: your own model provider, alerts that open issues, tickets agents read198 "GET",
199 "/repos/:owner/:name/context",
200 Op::GetContext,
201 &[("reference", "reference")],
202 ),
203 route(
204 "GET",
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API205 "/workspaces/:workspace/context/search",
206 Op::SearchContext,
207 &[("q", "query"), ("project", "project"), ("kinds", "kinds"), ("limit", "limit")],
208 ),
209 route(
210 "GET",
211 "/workspaces/:workspace/context/:kind/:id",
212 Op::GetEntity,
213 &[],
214 ),
215 route(
216 "GET",
Integrations: your own model provider, alerts that open issues, tickets agents read217 "/workspaces/:workspace/integrations",
218 Op::ListIntegrations,
219 &[],
220 ),
221 route(
222 "POST",
223 "/workspaces/:workspace/integrations",
224 Op::ConnectIntegration,
225 &[],
226 ),
227 route(
Models per workspace: several providers, routed by kind of work228 "GET",
Webhooks: every event, to your own addresses, signed and retried229 "/repos/:owner/:name/hooks",
230 Op::ListWebhooks,
231 &[],
232 ),
233 route(
234 "POST",
235 "/repos/:owner/:name/hooks",
236 Op::CreateWebhook,
237 &[],
238 ),
239 route(
240 "PATCH",
241 "/repos/:owner/:name/hooks/:id",
242 Op::UpdateWebhook,
243 &[],
244 ),
245 route(
246 "DELETE",
247 "/repos/:owner/:name/hooks/:id",
248 Op::DeleteWebhook,
249 &[],
250 ),
251 route(
252 "POST",
253 "/repos/:owner/:name/hooks/:id/pings",
254 Op::PingWebhook,
255 &[],
256 ),
257 route(
258 "GET",
259 "/repos/:owner/:name/hooks/:id/deliveries",
260 Op::ListWebhookDeliveries,
261 &[],
262 ),
263 route(
264 "POST",
265 "/repos/:owner/:name/hooks/:id/deliveries/:delivery/redeliver",
266 Op::RedeliverWebhook,
267 &[],
268 ),
269 route(
270 "GET",
271 "/workspaces/:workspace/hooks",
272 Op::ListWebhooks,
273 &[],
274 ),
275 route(
276 "POST",
277 "/workspaces/:workspace/hooks",
278 Op::CreateWebhook,
279 &[],
280 ),
281 route(
282 "PATCH",
283 "/workspaces/:workspace/hooks/:id",
284 Op::UpdateWebhook,
285 &[],
286 ),
287 route(
288 "DELETE",
289 "/workspaces/:workspace/hooks/:id",
290 Op::DeleteWebhook,
291 &[],
292 ),
293 route(
294 "POST",
295 "/workspaces/:workspace/hooks/:id/pings",
296 Op::PingWebhook,
297 &[],
298 ),
299 route(
300 "GET",
301 "/workspaces/:workspace/hooks/:id/deliveries",
302 Op::ListWebhookDeliveries,
303 &[],
304 ),
305 route(
306 "POST",
307 "/workspaces/:workspace/hooks/:id/deliveries/:delivery/redeliver",
308 Op::RedeliverWebhook,
309 &[],
310 ),
311 route(
312 "GET",
Models per workspace: several providers, routed by kind of work313 "/workspaces/:workspace/model-routes",
314 Op::GetModelRoutes,
315 &[],
316 ),
317 route(
318 "PUT",
319 "/workspaces/:workspace/model-routes",
320 Op::SetModelRoutes,
321 &[],
322 ),
323 route(
Integrations: your own model provider, alerts that open issues, tickets agents read324 "DELETE",
325 "/workspaces/:workspace/integrations/:id",
326 Op::DisconnectIntegration,
327 &[],
328 ),
329 route(
330 "POST",
331 "/workspaces/:workspace/integrations/:id/test",
332 Op::TestIntegration,
333 &[],
334 ),
Automations: rules in .g1t/automations that act when something happens335 route(
336 "GET",
GitHub Actions on g1t, part two: running workflows337 "/repos/:owner/:name/actions/workflows",
338 Op::ListWorkflows,
339 &[],
340 ),
341 route(
342 "GET",
343 "/repos/:owner/:name/actions/workflows/:workflow/runs",
344 Op::ListWorkflowRuns,
345 &[("branch", "branch"), ("event", "event"), ("per_page", "limit")],
346 ),
347 route(
348 "POST",
349 "/repos/:owner/:name/actions/workflows/:workflow/dispatches",
350 Op::DispatchWorkflow,
351 &[],
352 ),
353 route(
354 "PATCH",
355 "/repos/:owner/:name/actions/workflows/:workflow",
356 Op::UpdateWorkflow,
357 &[],
358 ),
359 route(
360 "PUT",
361 "/repos/:owner/:name/actions/workflows/:workflow/enable",
362 Op::UpdateWorkflow,
363 &[],
364 ),
365 route(
366 "PUT",
367 "/repos/:owner/:name/actions/workflows/:workflow/disable",
368 Op::UpdateWorkflow,
369 &[],
370 ),
371 route(
372 "GET",
373 "/repos/:owner/:name/actions/runs",
374 Op::ListWorkflowRuns,
375 &[("workflow", "workflow"), ("branch", "branch"), ("event", "event"), ("pull", "pull"), ("head_sha", "sha"), ("per_page", "limit")],
376 ),
377 route(
378 "GET",
379 "/repos/:owner/:name/actions/runs/:id",
380 Op::GetWorkflowRun,
381 &[],
382 ),
383 route(
384 "POST",
385 "/repos/:owner/:name/actions/runs/:id/cancel",
386 Op::CancelWorkflowRun,
387 &[],
388 ),
389 route(
390 "POST",
391 "/repos/:owner/:name/actions/runs/:id/rerun",
392 Op::RerunWorkflowRun,
393 &[],
394 ),
395 route(
396 "POST",
397 "/repos/:owner/:name/actions/runs/:id/rerun-failed-jobs",
398 Op::RerunWorkflowRun,
399 &[],
400 ),
401 route(
402 "GET",
403 "/repos/:owner/:name/actions/jobs/:job/logs",
404 Op::GetJobLogs,
405 &[("after", "after")],
406 ),
407 route(
408 "GET",
409 "/repos/:owner/:name/actions/secrets",
410 Op::ListActionsSecrets,
411 &[],
412 ),
413 route(
414 "PUT",
415 "/repos/:owner/:name/actions/secrets/:setting",
416 Op::SetActionsSecret,
417 &[],
418 ),
419 route(
420 "DELETE",
421 "/repos/:owner/:name/actions/secrets/:setting",
422 Op::DeleteActionsSecret,
423 &[],
424 ),
425 route(
426 "GET",
427 "/repos/:owner/:name/actions/variables",
428 Op::ListActionsVariables,
429 &[],
430 ),
431 route(
432 "POST",
433 "/repos/:owner/:name/actions/variables",
434 Op::SetActionsVariable,
435 &[],
436 ),
437 route(
438 "PATCH",
439 "/repos/:owner/:name/actions/variables/:setting",
440 Op::SetActionsVariable,
441 &[],
442 ),
443 route(
444 "DELETE",
445 "/repos/:owner/:name/actions/variables/:setting",
446 Op::DeleteActionsVariable,
447 &[],
448 ),
449 route(
450 "GET",
451 "/workspaces/:workspace/actions/secrets",
452 Op::ListActionsSecrets,
453 &[],
454 ),
455 route(
456 "PUT",
457 "/workspaces/:workspace/actions/secrets/:setting",
458 Op::SetActionsSecret,
459 &[],
460 ),
461 route(
462 "DELETE",
463 "/workspaces/:workspace/actions/secrets/:setting",
464 Op::DeleteActionsSecret,
465 &[],
466 ),
467 route(
468 "GET",
469 "/workspaces/:workspace/actions/variables",
470 Op::ListActionsVariables,
471 &[],
472 ),
473 route(
474 "POST",
475 "/workspaces/:workspace/actions/variables",
476 Op::SetActionsVariable,
477 &[],
478 ),
479 route(
480 "PATCH",
481 "/workspaces/:workspace/actions/variables/:setting",
482 Op::SetActionsVariable,
483 &[],
484 ),
485 route(
486 "DELETE",
487 "/workspaces/:workspace/actions/variables/:setting",
488 Op::DeleteActionsVariable,
489 &[],
490 ),
Agents as a team: lifecycle, merge queue, billing and a new shell491 route("POST", "/repos/:owner/:name/plans", Op::PlanWork, &[]),
492 route("GET", "/repos/:owner/:name/plans/:plan", Op::GetPlan, &[]),
493 route(
494 "POST",
495 "/repos/:owner/:name/plans/:plan/apply",
496 Op::ApplyPlan,
497 &[],
498 ),
499 route(
500 "POST",
501 "/repos/:owner/:name/issues/:number/comments",
API and MCP server in Rust; a public index at the API root502 Op::AddComment,
503 &[],
504 ),
505 route(
506 "GET",
Agents as a team: lifecycle, merge queue, billing and a new shell507 "/repos/:owner/:name/pulls",
API and MCP server in Rust; a public index at the API root508 Op::ListPullRequests,
509 &[("state", "state")],
510 ),
511 route(
512 "POST",
Agents as a team: lifecycle, merge queue, billing and a new shell513 "/repos/:owner/:name/pulls",
API and MCP server in Rust; a public index at the API root514 Op::CreatePullRequest,
515 &[],
516 ),
517 route(
518 "GET",
Agents as a team: lifecycle, merge queue, billing and a new shell519 "/repos/:owner/:name/pulls/:number",
API and MCP server in Rust; a public index at the API root520 Op::GetPullRequest,
521 &[],
522 ),
523 route(
524 "GET",
Agents as a team: lifecycle, merge queue, billing and a new shell525 "/repos/:owner/:name/pulls/:number/changes",
API and MCP server in Rust; a public index at the API root526 Op::GetPullRequestChanges,
527 &[],
528 ),
529 route(
Acceptance checks in sandboxes, line comments and review verdicts530 "POST",
Agents as a team: lifecycle, merge queue, billing and a new shell531 "/repos/:owner/:name/pulls/:number/reviews",
Acceptance checks in sandboxes, line comments and review verdicts532 Op::ReviewPullRequest,
533 &[],
534 ),
535 route(
API and MCP server in Rust; a public index at the API root536 "GET",
Agents as a team: lifecycle, merge queue, billing and a new shell537 "/repos/:owner/:name/pulls/:number/session",
API and MCP server in Rust; a public index at the API root538 Op::ReadSession,
539 &[("after", "after")],
540 ),
541 route(
542 "POST",
Agents as a team: lifecycle, merge queue, billing and a new shell543 "/repos/:owner/:name/pulls/:number/session",
API and MCP server in Rust; a public index at the API root544 Op::RecordSession,
545 &[],
546 ),
547 route(
548 "POST",
Agents as a team: lifecycle, merge queue, billing and a new shell549 "/repos/:owner/:name/pulls/:number/ready",
API and MCP server in Rust; a public index at the API root550 Op::MarkPullRequestReady,
551 &[],
552 ),
553 route(
554 "POST",
Agents as a team: lifecycle, merge queue, billing and a new shell555 "/repos/:owner/:name/pulls/:number/close",
API and MCP server in Rust; a public index at the API root556 Op::ClosePullRequest,
557 &[],
558 ),
559 route(
560 "POST",
Agents as a team: lifecycle, merge queue, billing and a new shell561 "/repos/:owner/:name/pulls/:number/merge",
API and MCP server in Rust; a public index at the API root562 Op::MergePullRequest,
563 &[],
564 ),
565];
566
567impl Route {
568 /// The names of the route's path parameters, in order.
569 pub fn params(&self) -> impl Iterator<Item = &'static str> {
570 self.path
571 .split('/')
572 .filter_map(|segment| segment.strip_prefix(':'))
573 }
574
575 /// The values of the path parameters, if `path` is this route's.
576 fn matches<'a>(&self, path: &'a str) -> Option<Vec<(&'static str, &'a str)>> {
577 let mut values = Vec::new();
578 let mut actual = path.trim_end_matches('/').split('/');
579 for expected in self.path.split('/') {
580 let segment = actual.next()?;
581 match expected.strip_prefix(':') {
582 Some(name) if !segment.is_empty() => values.push((name, segment)),
583 Some(_) => return None,
584 None if expected == segment => {}
585 None => return None,
586 }
587 }
588 actual.next().is_none().then_some(values)
589 }
590}
591
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look592/// A path segment with its `%XX` escapes decoded; as given when that is not
593/// UTF-8.
594fn percent_decoded(segment: &str) -> String {
595 let bytes = segment.as_bytes();
596 let mut out = Vec::with_capacity(bytes.len());
597 let mut i = 0;
598 while i < bytes.len() {
599 let escaped = (bytes[i] == b'%')
600 .then(|| segment.get(i + 1..i + 3))
601 .flatten()
602 .filter(|hex| hex.bytes().all(|byte| byte.is_ascii_hexdigit()))
603 .and_then(|hex| u8::from_str_radix(hex, 16).ok());
604 match escaped {
605 Some(byte) => {
606 out.push(byte);
607 i += 3;
608 }
609 None => {
610 out.push(bytes[i]);
611 i += 1;
612 }
613 }
614 }
615 String::from_utf8(out).unwrap_or_else(|_| segment.to_owned())
616}
617
API and MCP server in Rust; a public index at the API root618/// The route for a request, and the operation input it describes.
619///
620/// The input is the JSON body, overlaid with the query parameters the route
621/// reads and then with what the path names: `owner` and `name` become
622/// `repo`, and `number` becomes an integer.
623pub fn resolve(
624 method: &str,
625 path: &str,
626 query: &[(String, String)],
627 body: Value,
628) -> Option<(&'static Route, Value)> {
629 let (route, params) = ROUTES
630 .iter()
631 .filter(|route| route.method == method)
632 .find_map(|route| Some((route, route.matches(path)?)))?;
633
634 let mut input = match body {
635 Value::Object(fields) => fields,
636 _ => Map::new(),
637 };
638 for (name, key) in route.query {
639 if let Some((_, value)) = query.iter().find(|(query_name, _)| query_name == name) {
640 input.insert((*key).to_owned(), Value::String(value.clone()));
641 }
642 }
643 let param = |wanted: &str| {
644 params
645 .iter()
646 .find(|(name, _)| *name == wanted)
647 .map(|(_, value)| *value)
648 };
649 if let (Some(owner), Some(name)) = (param("owner"), param("name")) {
650 input.insert("repo".to_owned(), Value::String(format!("{owner}/{name}")));
651 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look652 for key in ["plan", "id", "workspace", "delivery", "workflow", "job", "setting", "username"] {
Docs worth reading, and kept that way653 if let Some(value) = param(key) {
654 input.insert(key.to_owned(), Value::String(value.to_owned()));
655 }
Agents as a team: lifecycle, merge queue, billing and a new shell656 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look657 // A branch name may hold slashes, sent URL-encoded as one segment.
658 if let Some(branch) = param("branch") {
659 input.insert("branch".to_owned(), Value::String(percent_decoded(branch)));
660 }
GitHub Actions on g1t, part two: running workflows661 // GitHub says some things with the path alone.
662 if route.path.ends_with("/enable") || route.path.ends_with("/disable") {
663 input.insert("enabled".to_owned(), Value::Bool(route.path.ends_with("/enable")));
664 }
665 if route.path.ends_with("/rerun-failed-jobs") {
666 input.insert("failed_only".to_owned(), Value::Bool(true));
667 }
API and MCP server in Rust; a public index at the API root668 if let Some(number) = param("number") {
669 // Not a number: zero, which no issue or pull request has.
670 input.insert(
671 "number".to_owned(),
672 number.parse::<u32>().unwrap_or(0).into(),
673 );
674 }
675 Some((route, Value::Object(input)))
676}
677
678#[cfg(test)]
679mod tests {
680 use serde_json::json;
681
682 use super::*;
683
684 #[test]
685 fn a_path_resolves_to_its_operation_and_input() {
686 let (route, input) = resolve(
687 "POST",
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look688 "/repos/flagon-io/hello/pulls/14/merge",
API and MCP server in Rust; a public index at the API root689 &[],
690 json!({ "keep_issue_open": true, "number": 99, "repo": "someone/else" }),
691 )
692 .unwrap();
693 assert_eq!(route.op, Op::MergePullRequest);
694 // What the path names wins over the body.
695 assert_eq!(
696 input,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look697 json!({ "keep_issue_open": true, "number": 14, "repo": "flagon-io/hello" })
API and MCP server in Rust; a public index at the API root698 );
699 }
700
701 #[test]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look702 fn a_branch_with_slashes_is_one_encoded_segment() {
703 let (route, input) = resolve(
704 "POST",
705 "/repos/flagon-io/hello/branches/feature%2Flogin/rename",
706 &[],
707 json!({ "new_name": "feature/sign-in" }),
708 )
709 .unwrap();
710 assert_eq!(route.op, Op::RenameBranch);
711 assert_eq!(
712 input,
713 json!({ "new_name": "feature/sign-in", "branch": "feature/login", "repo": "flagon-io/hello" })
714 );
715 assert_eq!(percent_decoded("100%"), "100%");
716 assert_eq!(percent_decoded("a%2bb%zz"), "a+b%zz");
717 }
718
719 #[test]
720 fn a_collaborator_is_named_by_username() {
721 let (route, input) = resolve(
722 "PATCH",
723 "/repos/flagon-io/hello/collaborators/ada",
724 &[],
725 json!({ "role": "maintain" }),
726 )
727 .unwrap();
728 assert_eq!(route.op, Op::UpdateCollaborator);
729 assert_eq!(input, json!({ "role": "maintain", "username": "ada", "repo": "flagon-io/hello" }));
730 let (route, input) = resolve("DELETE", "/user/repository_invitations/rin_1", &[], Value::Null).unwrap();
731 assert_eq!(route.op, Op::DeclineRepoInvitation);
732 assert_eq!(input, json!({ "id": "rin_1" }));
733 }
734
735 #[test]
API and MCP server in Rust; a public index at the API root736 fn query_parameters_are_renamed() {
737 let query = [
738 ("q".to_owned(), "parser".to_owned()),
739 ("x".to_owned(), "y".to_owned()),
740 ];
Agents as a team: lifecycle, merge queue, billing and a new shell741 let (route, input) = resolve("GET", "/repos", &query, Value::Null).unwrap();
API and MCP server in Rust; a public index at the API root742 assert_eq!(route.op, Op::ListRepos);
743 assert_eq!(input, json!({ "query": "parser" }));
744 }
745
746 #[test]
747 fn method_and_shape_must_match() {
Agents as a team: lifecycle, merge queue, billing and a new shell748 assert!(resolve("GET", "/repos/a/b/issues/1/close", &[], Value::Null).is_none());
749 assert!(resolve("GET", "/repos/a", &[], Value::Null).is_none());
750 assert!(resolve("GET", "/repos/a/b/issues/1/extra", &[], Value::Null).is_none());
751 assert!(resolve("GET", "/repos/a/b/", &[], Value::Null).is_some());
API and MCP server in Rust; a public index at the API root752 }
753
754 #[test]
755 fn every_parameter_and_query_name_is_an_input() {
756 for route in ROUTES {
757 let properties = route.op.properties();
758 for (_, key) in route.query {
759 assert!(properties.contains_key(*key), "{}: {key}", route.path);
760 }
761 for name in route.params() {
762 let covered = matches!(name, "owner" | "name") && properties.contains_key("repo")
763 || properties.contains_key(name);
764 assert!(covered, "{}: {name}", route.path);
765 }
766 }
767 }
768
769 #[test]
770 fn every_operation_has_a_route() {
771 for op in Op::ALL {
772 assert!(ROUTES.iter().any(|route| route.op == op), "{}", op.name());
773 }
774 }
775}