g1t/apps/api/src/rest.rs

801 lines23,976 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, &[]),
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily61 route("PATCH", "/workspaces/:workspace", Op::UpdateWorkspace, &[]),
62 route("PUT", "/workspaces/:workspace/base_permission", Op::SetBasePermission, &[]),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look63 route(
64 "GET",
65 "/workspaces/:workspace/outside_collaborators",
66 Op::ListOutsideCollaborators,
67 &[],
68 ),
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily69 // Security alerts: secrets and vulnerable dependencies.
70 route(
71 "GET",
72 "/repos/:owner/:name/security/alerts",
73 Op::ListSecurityAlerts,
74 &[("state", "state"), ("kind", "kind")],
75 ),
76 route("POST", "/repos/:owner/:name/security/alerts/:id/dismiss", Op::DismissSecurityAlert, &[]),
77 route("POST", "/repos/:owner/:name/security/alerts/:id/reopen", Op::ReopenSecurityAlert, &[]),
Agents as a team: lifecycle, merge queue, billing and a new shell78 route("GET", "/repos", Op::ListRepos, &[("q", "query")]),
Search across all of g1t, Explore, and a command palette79 route(
80 "GET",
81 "/search",
82 Op::Search,
83 &[("q", "query"), ("type", "type"), ("page", "page"), ("per_page", "per_page")],
84 ),
Agents as a team: lifecycle, merge queue, billing and a new shell85 route("POST", "/repos", Op::CreateRepo, &[]),
86 route("GET", "/repos/:owner/:name", Op::GetRepo, &[]),
87 route("PATCH", "/repos/:owner/:name", Op::UpdateRepo, &[]),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look88 route("POST", "/repos/:owner/:name/transfer", Op::TransferRepo, &[]),
89 route("DELETE", "/repos/:owner/:name", Op::DeleteRepo, &[]),
90 route(
91 "GET",
92 "/workspaces/:workspace/repos/deleted",
93 Op::ListDeletedRepos,
94 &[],
95 ),
96 route("POST", "/repos/:owner/:name/restore", Op::RestoreRepo, &[]),
97 route("POST", "/repos/:owner/:name/purge", Op::PurgeRepo, &[]),
98 route("POST", "/repos/:owner/:name/rename", Op::RenameRepo, &[]),
99 route("POST", "/repos/:owner/:name/archive", Op::ArchiveRepo, &[]),
100 route("POST", "/repos/:owner/:name/unarchive", Op::UnarchiveRepo, &[]),
101 route(
102 "POST",
103 "/repos/:owner/:name/visibility",
104 Op::SetRepoVisibility,
105 &[],
106 ),
107 route(
108 "POST",
109 "/repos/:owner/:name/branches/:branch/rename",
110 Op::RenameBranch,
111 &[],
112 ),
Agents as a team: lifecycle, merge queue, billing and a new shell113 route(
114 "GET",
115 "/repos/:owner/:name/settings",
116 Op::GetRepoSettings,
117 &[],
118 ),
119 route(
120 "PATCH",
121 "/repos/:owner/:name/settings",
122 Op::UpdateRepoSettings,
123 &[],
124 ),
Fast pages, required checks on the branch, self-hosted runners, honest incidents125 route("GET", "/repos/:owner/:name/check-names", Op::ListCheckNames, &[]),
Agents as a team: lifecycle, merge queue, billing and a new shell126 route("GET", "/repos/:owner/:name/queue", Op::GetMergeQueue, &[]),
API and MCP server in Rust; a public index at the API root127 route(
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request128 "POST",
129 "/repos/:owner/:name/pulls/:number/messages",
130 Op::MessageAgent,
131 &[],
132 ),
133 route(
134 "POST",
135 "/repos/:owner/:name/pulls/:number/messages/take",
136 Op::TakeMessages,
137 &[],
138 ),
139 route(
Docs worth reading, and kept that way140 "POST",
141 "/repos/:owner/:name/messages/:id/answer",
142 Op::AnswerMessage,
143 &[],
144 ),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains145 route("POST", "/repos/:owner/:name/memory", Op::Remember, &[]),
146 route(
147 "GET",
148 "/repos/:owner/:name/memory",
149 Op::Recall,
150 &[("q", "query"), ("limit", "limit")],
151 ),
Docs worth reading, and kept that way152 route(
API and MCP server in Rust; a public index at the API root153 "GET",
Agents as a team: lifecycle, merge queue, billing and a new shell154 "/repos/:owner/:name/events",
API and MCP server in Rust; a public index at the API root155 Op::ListEvents,
156 &[("before", "before")],
157 ),
Agents as a team: lifecycle, merge queue, billing and a new shell158 route("GET", "/repos/:owner/:name/labels", Op::ListLabels, &[]),
API and MCP server in Rust; a public index at the API root159 route(
160 "GET",
Agents as a team: lifecycle, merge queue, billing and a new shell161 "/repos/:owner/:name/issues",
API and MCP server in Rust; a public index at the API root162 Op::ListIssues,
163 &[("state", "state"), ("label", "label")],
164 ),
Agents as a team: lifecycle, merge queue, billing and a new shell165 route("POST", "/repos/:owner/:name/issues", Op::CreateIssue, &[]),
API and MCP server in Rust; a public index at the API root166 route(
167 "GET",
Agents as a team: lifecycle, merge queue, billing and a new shell168 "/repos/:owner/:name/issues/:number",
API and MCP server in Rust; a public index at the API root169 Op::GetIssue,
170 &[],
171 ),
172 route(
173 "PATCH",
Agents as a team: lifecycle, merge queue, billing and a new shell174 "/repos/:owner/:name/issues/:number",
API and MCP server in Rust; a public index at the API root175 Op::UpdateIssue,
176 &[],
177 ),
178 route(
179 "POST",
Agents as a team: lifecycle, merge queue, billing and a new shell180 "/repos/:owner/:name/issues/:number/close",
API and MCP server in Rust; a public index at the API root181 Op::CloseIssue,
182 &[],
183 ),
184 route(
185 "POST",
Agents as a team: lifecycle, merge queue, billing and a new shell186 "/repos/:owner/:name/issues/:number/reopen",
API and MCP server in Rust; a public index at the API root187 Op::ReopenIssue,
188 &[],
189 ),
190 route(
191 "POST",
Agents as a team: lifecycle, merge queue, billing and a new shell192 "/repos/:owner/:name/issues/:number/assign",
193 Op::AssignIssue,
194 &[],
195 ),
Integrations: your own model provider, alerts that open issues, tickets agents read196 route(
197 "POST",
198 "/repos/:owner/:name/issues/import",
199 Op::ImportIssue,
200 &[],
201 ),
202 route(
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step203 "POST",
204 "/repos/:owner/:name/issues/delegate",
205 Op::Delegate,
206 &[],
207 ),
208 route(
Integrations: your own model provider, alerts that open issues, tickets agents read209 "GET",
210 "/repos/:owner/:name/context",
211 Op::GetContext,
212 &[("reference", "reference")],
213 ),
214 route(
215 "GET",
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API216 "/workspaces/:workspace/context/search",
217 Op::SearchContext,
218 &[("q", "query"), ("project", "project"), ("kinds", "kinds"), ("limit", "limit")],
219 ),
220 route(
221 "GET",
222 "/workspaces/:workspace/context/:kind/:id",
223 Op::GetEntity,
224 &[],
225 ),
226 route(
227 "GET",
Integrations: your own model provider, alerts that open issues, tickets agents read228 "/workspaces/:workspace/integrations",
229 Op::ListIntegrations,
230 &[],
231 ),
232 route(
233 "POST",
234 "/workspaces/:workspace/integrations",
235 Op::ConnectIntegration,
236 &[],
237 ),
238 route(
Models per workspace: several providers, routed by kind of work239 "GET",
Webhooks: every event, to your own addresses, signed and retried240 "/repos/:owner/:name/hooks",
241 Op::ListWebhooks,
242 &[],
243 ),
244 route(
245 "POST",
246 "/repos/:owner/:name/hooks",
247 Op::CreateWebhook,
248 &[],
249 ),
250 route(
251 "PATCH",
252 "/repos/:owner/:name/hooks/:id",
253 Op::UpdateWebhook,
254 &[],
255 ),
256 route(
257 "DELETE",
258 "/repos/:owner/:name/hooks/:id",
259 Op::DeleteWebhook,
260 &[],
261 ),
262 route(
263 "POST",
264 "/repos/:owner/:name/hooks/:id/pings",
265 Op::PingWebhook,
266 &[],
267 ),
268 route(
269 "GET",
270 "/repos/:owner/:name/hooks/:id/deliveries",
271 Op::ListWebhookDeliveries,
272 &[],
273 ),
274 route(
275 "POST",
276 "/repos/:owner/:name/hooks/:id/deliveries/:delivery/redeliver",
277 Op::RedeliverWebhook,
278 &[],
279 ),
280 route(
281 "GET",
282 "/workspaces/:workspace/hooks",
283 Op::ListWebhooks,
284 &[],
285 ),
286 route(
287 "POST",
288 "/workspaces/:workspace/hooks",
289 Op::CreateWebhook,
290 &[],
291 ),
292 route(
293 "PATCH",
294 "/workspaces/:workspace/hooks/:id",
295 Op::UpdateWebhook,
296 &[],
297 ),
298 route(
299 "DELETE",
300 "/workspaces/:workspace/hooks/:id",
301 Op::DeleteWebhook,
302 &[],
303 ),
304 route(
305 "POST",
306 "/workspaces/:workspace/hooks/:id/pings",
307 Op::PingWebhook,
308 &[],
309 ),
310 route(
311 "GET",
312 "/workspaces/:workspace/hooks/:id/deliveries",
313 Op::ListWebhookDeliveries,
314 &[],
315 ),
316 route(
317 "POST",
318 "/workspaces/:workspace/hooks/:id/deliveries/:delivery/redeliver",
319 Op::RedeliverWebhook,
320 &[],
321 ),
322 route(
323 "GET",
Models per workspace: several providers, routed by kind of work324 "/workspaces/:workspace/model-routes",
325 Op::GetModelRoutes,
326 &[],
327 ),
328 route(
329 "PUT",
330 "/workspaces/:workspace/model-routes",
331 Op::SetModelRoutes,
332 &[],
333 ),
334 route(
Integrations: your own model provider, alerts that open issues, tickets agents read335 "DELETE",
336 "/workspaces/:workspace/integrations/:id",
337 Op::DisconnectIntegration,
338 &[],
339 ),
340 route(
341 "POST",
342 "/workspaces/:workspace/integrations/:id/test",
343 Op::TestIntegration,
344 &[],
345 ),
Automations: rules in .g1t/automations that act when something happens346 route(
347 "GET",
GitHub Actions on g1t, part two: running workflows348 "/repos/:owner/:name/actions/workflows",
349 Op::ListWorkflows,
350 &[],
351 ),
352 route(
353 "GET",
354 "/repos/:owner/:name/actions/workflows/:workflow/runs",
355 Op::ListWorkflowRuns,
356 &[("branch", "branch"), ("event", "event"), ("per_page", "limit")],
357 ),
358 route(
359 "POST",
360 "/repos/:owner/:name/actions/workflows/:workflow/dispatches",
361 Op::DispatchWorkflow,
362 &[],
363 ),
364 route(
365 "PATCH",
366 "/repos/:owner/:name/actions/workflows/:workflow",
367 Op::UpdateWorkflow,
368 &[],
369 ),
370 route(
371 "PUT",
372 "/repos/:owner/:name/actions/workflows/:workflow/enable",
373 Op::UpdateWorkflow,
374 &[],
375 ),
376 route(
377 "PUT",
378 "/repos/:owner/:name/actions/workflows/:workflow/disable",
379 Op::UpdateWorkflow,
380 &[],
381 ),
382 route(
383 "GET",
384 "/repos/:owner/:name/actions/runs",
385 Op::ListWorkflowRuns,
386 &[("workflow", "workflow"), ("branch", "branch"), ("event", "event"), ("pull", "pull"), ("head_sha", "sha"), ("per_page", "limit")],
387 ),
388 route(
389 "GET",
390 "/repos/:owner/:name/actions/runs/:id",
391 Op::GetWorkflowRun,
392 &[],
393 ),
394 route(
395 "POST",
396 "/repos/:owner/:name/actions/runs/:id/cancel",
397 Op::CancelWorkflowRun,
398 &[],
399 ),
400 route(
401 "POST",
402 "/repos/:owner/:name/actions/runs/:id/rerun",
403 Op::RerunWorkflowRun,
404 &[],
405 ),
406 route(
407 "POST",
408 "/repos/:owner/:name/actions/runs/:id/rerun-failed-jobs",
409 Op::RerunWorkflowRun,
410 &[],
411 ),
412 route(
413 "GET",
414 "/repos/:owner/:name/actions/jobs/:job/logs",
415 Op::GetJobLogs,
416 &[("after", "after")],
417 ),
418 route(
419 "GET",
420 "/repos/:owner/:name/actions/secrets",
421 Op::ListActionsSecrets,
422 &[],
423 ),
424 route(
425 "PUT",
426 "/repos/:owner/:name/actions/secrets/:setting",
427 Op::SetActionsSecret,
428 &[],
429 ),
430 route(
431 "DELETE",
432 "/repos/:owner/:name/actions/secrets/:setting",
433 Op::DeleteActionsSecret,
434 &[],
435 ),
436 route(
437 "GET",
438 "/repos/:owner/:name/actions/variables",
439 Op::ListActionsVariables,
440 &[],
441 ),
442 route(
443 "POST",
444 "/repos/:owner/:name/actions/variables",
445 Op::SetActionsVariable,
446 &[],
447 ),
448 route(
449 "PATCH",
450 "/repos/:owner/:name/actions/variables/:setting",
451 Op::SetActionsVariable,
452 &[],
453 ),
454 route(
455 "DELETE",
456 "/repos/:owner/:name/actions/variables/:setting",
457 Op::DeleteActionsVariable,
458 &[],
459 ),
460 route(
461 "GET",
462 "/workspaces/:workspace/actions/secrets",
463 Op::ListActionsSecrets,
464 &[],
465 ),
466 route(
467 "PUT",
468 "/workspaces/:workspace/actions/secrets/:setting",
469 Op::SetActionsSecret,
470 &[],
471 ),
472 route(
473 "DELETE",
474 "/workspaces/:workspace/actions/secrets/:setting",
475 Op::DeleteActionsSecret,
476 &[],
477 ),
478 route(
479 "GET",
480 "/workspaces/:workspace/actions/variables",
481 Op::ListActionsVariables,
482 &[],
483 ),
484 route(
485 "POST",
486 "/workspaces/:workspace/actions/variables",
487 Op::SetActionsVariable,
488 &[],
489 ),
490 route(
491 "PATCH",
492 "/workspaces/:workspace/actions/variables/:setting",
493 Op::SetActionsVariable,
494 &[],
495 ),
496 route(
497 "DELETE",
498 "/workspaces/:workspace/actions/variables/:setting",
499 Op::DeleteActionsVariable,
500 &[],
501 ),
Fast pages, required checks on the branch, self-hosted runners, honest incidents502 // Self-hosted runners: a repository's own, or a workspace's.
503 route("GET", "/repos/:owner/:name/actions/runners", Op::ListRunners, &[]),
504 route("POST", "/repos/:owner/:name/actions/runners/registration-token", Op::CreateRunnerRegistrationToken, &[]),
505 route("DELETE", "/repos/:owner/:name/actions/runners/:id", Op::RemoveRunner, &[]),
506 route("GET", "/repos/:owner/:name/actions/runner-settings", Op::GetRunnerSettings, &[]),
507 route("PATCH", "/repos/:owner/:name/actions/runner-settings", Op::UpdateRunnerSettings, &[]),
508 route("GET", "/workspaces/:workspace/actions/runners", Op::ListRunners, &[]),
509 route("POST", "/workspaces/:workspace/actions/runners/registration-token", Op::CreateRunnerRegistrationToken, &[]),
510 route("DELETE", "/workspaces/:workspace/actions/runners/:id", Op::RemoveRunner, &[]),
511 route("GET", "/workspaces/:workspace/actions/runner-settings", Op::GetRunnerSettings, &[]),
512 route("PATCH", "/workspaces/:workspace/actions/runner-settings", Op::UpdateRunnerSettings, &[]),
513 route("GET", "/workspaces/:workspace/actions/runner-groups", Op::ListRunnerGroups, &[]),
514 route("POST", "/workspaces/:workspace/actions/runner-groups", Op::CreateRunnerGroup, &[]),
515 route("PATCH", "/workspaces/:workspace/actions/runner-groups/:id", Op::UpdateRunnerGroup, &[]),
516 route("DELETE", "/workspaces/:workspace/actions/runner-groups/:id", Op::DeleteRunnerGroup, &[]),
Agents as a team: lifecycle, merge queue, billing and a new shell517 route("POST", "/repos/:owner/:name/plans", Op::PlanWork, &[]),
518 route("GET", "/repos/:owner/:name/plans/:plan", Op::GetPlan, &[]),
519 route(
520 "POST",
521 "/repos/:owner/:name/plans/:plan/apply",
522 Op::ApplyPlan,
523 &[],
524 ),
525 route(
526 "POST",
527 "/repos/:owner/:name/issues/:number/comments",
API and MCP server in Rust; a public index at the API root528 Op::AddComment,
529 &[],
530 ),
531 route(
532 "GET",
Agents as a team: lifecycle, merge queue, billing and a new shell533 "/repos/:owner/:name/pulls",
API and MCP server in Rust; a public index at the API root534 Op::ListPullRequests,
535 &[("state", "state")],
536 ),
537 route(
538 "POST",
Agents as a team: lifecycle, merge queue, billing and a new shell539 "/repos/:owner/:name/pulls",
API and MCP server in Rust; a public index at the API root540 Op::CreatePullRequest,
541 &[],
542 ),
543 route(
544 "GET",
Agents as a team: lifecycle, merge queue, billing and a new shell545 "/repos/:owner/:name/pulls/:number",
API and MCP server in Rust; a public index at the API root546 Op::GetPullRequest,
547 &[],
548 ),
549 route(
550 "GET",
Agents as a team: lifecycle, merge queue, billing and a new shell551 "/repos/:owner/:name/pulls/:number/changes",
API and MCP server in Rust; a public index at the API root552 Op::GetPullRequestChanges,
553 &[],
554 ),
555 route(
Acceptance checks in sandboxes, line comments and review verdicts556 "POST",
Agents as a team: lifecycle, merge queue, billing and a new shell557 "/repos/:owner/:name/pulls/:number/reviews",
Acceptance checks in sandboxes, line comments and review verdicts558 Op::ReviewPullRequest,
559 &[],
560 ),
561 route(
API and MCP server in Rust; a public index at the API root562 "GET",
Agents as a team: lifecycle, merge queue, billing and a new shell563 "/repos/:owner/:name/pulls/:number/session",
API and MCP server in Rust; a public index at the API root564 Op::ReadSession,
565 &[("after", "after")],
566 ),
567 route(
568 "POST",
Agents as a team: lifecycle, merge queue, billing and a new shell569 "/repos/:owner/:name/pulls/:number/session",
API and MCP server in Rust; a public index at the API root570 Op::RecordSession,
571 &[],
572 ),
573 route(
574 "POST",
Agents as a team: lifecycle, merge queue, billing and a new shell575 "/repos/:owner/:name/pulls/:number/ready",
API and MCP server in Rust; a public index at the API root576 Op::MarkPullRequestReady,
577 &[],
578 ),
579 route(
580 "POST",
Agents as a team: lifecycle, merge queue, billing and a new shell581 "/repos/:owner/:name/pulls/:number/close",
API and MCP server in Rust; a public index at the API root582 Op::ClosePullRequest,
583 &[],
584 ),
585 route(
586 "POST",
Agents as a team: lifecycle, merge queue, billing and a new shell587 "/repos/:owner/:name/pulls/:number/merge",
API and MCP server in Rust; a public index at the API root588 Op::MergePullRequest,
589 &[],
590 ),
591];
592
593impl Route {
594 /// The names of the route's path parameters, in order.
595 pub fn params(&self) -> impl Iterator<Item = &'static str> {
596 self.path
597 .split('/')
598 .filter_map(|segment| segment.strip_prefix(':'))
599 }
600
601 /// The values of the path parameters, if `path` is this route's.
602 fn matches<'a>(&self, path: &'a str) -> Option<Vec<(&'static str, &'a str)>> {
603 let mut values = Vec::new();
604 let mut actual = path.trim_end_matches('/').split('/');
605 for expected in self.path.split('/') {
606 let segment = actual.next()?;
607 match expected.strip_prefix(':') {
608 Some(name) if !segment.is_empty() => values.push((name, segment)),
609 Some(_) => return None,
610 None if expected == segment => {}
611 None => return None,
612 }
613 }
614 actual.next().is_none().then_some(values)
615 }
616}
617
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look618/// A path segment with its `%XX` escapes decoded; as given when that is not
619/// UTF-8.
620fn percent_decoded(segment: &str) -> String {
621 let bytes = segment.as_bytes();
622 let mut out = Vec::with_capacity(bytes.len());
623 let mut i = 0;
624 while i < bytes.len() {
625 let escaped = (bytes[i] == b'%')
626 .then(|| segment.get(i + 1..i + 3))
627 .flatten()
628 .filter(|hex| hex.bytes().all(|byte| byte.is_ascii_hexdigit()))
629 .and_then(|hex| u8::from_str_radix(hex, 16).ok());
630 match escaped {
631 Some(byte) => {
632 out.push(byte);
633 i += 3;
634 }
635 None => {
636 out.push(bytes[i]);
637 i += 1;
638 }
639 }
640 }
641 String::from_utf8(out).unwrap_or_else(|_| segment.to_owned())
642}
643
API and MCP server in Rust; a public index at the API root644/// The route for a request, and the operation input it describes.
645///
646/// The input is the JSON body, overlaid with the query parameters the route
647/// reads and then with what the path names: `owner` and `name` become
648/// `repo`, and `number` becomes an integer.
649pub fn resolve(
650 method: &str,
651 path: &str,
652 query: &[(String, String)],
653 body: Value,
654) -> Option<(&'static Route, Value)> {
655 let (route, params) = ROUTES
656 .iter()
657 .filter(|route| route.method == method)
658 .find_map(|route| Some((route, route.matches(path)?)))?;
659
660 let mut input = match body {
661 Value::Object(fields) => fields,
662 _ => Map::new(),
663 };
664 for (name, key) in route.query {
665 if let Some((_, value)) = query.iter().find(|(query_name, _)| query_name == name) {
666 input.insert((*key).to_owned(), Value::String(value.clone()));
667 }
668 }
669 let param = |wanted: &str| {
670 params
671 .iter()
672 .find(|(name, _)| *name == wanted)
673 .map(|(_, value)| *value)
674 };
675 if let (Some(owner), Some(name)) = (param("owner"), param("name")) {
676 input.insert("repo".to_owned(), Value::String(format!("{owner}/{name}")));
677 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look678 for key in ["plan", "id", "workspace", "delivery", "workflow", "job", "setting", "username"] {
Docs worth reading, and kept that way679 if let Some(value) = param(key) {
680 input.insert(key.to_owned(), Value::String(value.to_owned()));
681 }
Agents as a team: lifecycle, merge queue, billing and a new shell682 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look683 // A branch name may hold slashes, sent URL-encoded as one segment.
684 if let Some(branch) = param("branch") {
685 input.insert("branch".to_owned(), Value::String(percent_decoded(branch)));
686 }
GitHub Actions on g1t, part two: running workflows687 // GitHub says some things with the path alone.
688 if route.path.ends_with("/enable") || route.path.ends_with("/disable") {
689 input.insert("enabled".to_owned(), Value::Bool(route.path.ends_with("/enable")));
690 }
691 if route.path.ends_with("/rerun-failed-jobs") {
692 input.insert("failed_only".to_owned(), Value::Bool(true));
693 }
API and MCP server in Rust; a public index at the API root694 if let Some(number) = param("number") {
695 // Not a number: zero, which no issue or pull request has.
696 input.insert(
697 "number".to_owned(),
698 number.parse::<u32>().unwrap_or(0).into(),
699 );
700 }
701 Some((route, Value::Object(input)))
702}
703
704#[cfg(test)]
705mod tests {
706 use serde_json::json;
707
708 use super::*;
709
710 #[test]
711 fn a_path_resolves_to_its_operation_and_input() {
712 let (route, input) = resolve(
713 "POST",
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look714 "/repos/flagon-io/hello/pulls/14/merge",
API and MCP server in Rust; a public index at the API root715 &[],
716 json!({ "keep_issue_open": true, "number": 99, "repo": "someone/else" }),
717 )
718 .unwrap();
719 assert_eq!(route.op, Op::MergePullRequest);
720 // What the path names wins over the body.
721 assert_eq!(
722 input,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look723 json!({ "keep_issue_open": true, "number": 14, "repo": "flagon-io/hello" })
API and MCP server in Rust; a public index at the API root724 );
725 }
726
727 #[test]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look728 fn a_branch_with_slashes_is_one_encoded_segment() {
729 let (route, input) = resolve(
730 "POST",
731 "/repos/flagon-io/hello/branches/feature%2Flogin/rename",
732 &[],
733 json!({ "new_name": "feature/sign-in" }),
734 )
735 .unwrap();
736 assert_eq!(route.op, Op::RenameBranch);
737 assert_eq!(
738 input,
739 json!({ "new_name": "feature/sign-in", "branch": "feature/login", "repo": "flagon-io/hello" })
740 );
741 assert_eq!(percent_decoded("100%"), "100%");
742 assert_eq!(percent_decoded("a%2bb%zz"), "a+b%zz");
743 }
744
745 #[test]
746 fn a_collaborator_is_named_by_username() {
747 let (route, input) = resolve(
748 "PATCH",
749 "/repos/flagon-io/hello/collaborators/ada",
750 &[],
751 json!({ "role": "maintain" }),
752 )
753 .unwrap();
754 assert_eq!(route.op, Op::UpdateCollaborator);
755 assert_eq!(input, json!({ "role": "maintain", "username": "ada", "repo": "flagon-io/hello" }));
756 let (route, input) = resolve("DELETE", "/user/repository_invitations/rin_1", &[], Value::Null).unwrap();
757 assert_eq!(route.op, Op::DeclineRepoInvitation);
758 assert_eq!(input, json!({ "id": "rin_1" }));
759 }
760
761 #[test]
API and MCP server in Rust; a public index at the API root762 fn query_parameters_are_renamed() {
763 let query = [
764 ("q".to_owned(), "parser".to_owned()),
765 ("x".to_owned(), "y".to_owned()),
766 ];
Agents as a team: lifecycle, merge queue, billing and a new shell767 let (route, input) = resolve("GET", "/repos", &query, Value::Null).unwrap();
API and MCP server in Rust; a public index at the API root768 assert_eq!(route.op, Op::ListRepos);
769 assert_eq!(input, json!({ "query": "parser" }));
770 }
771
772 #[test]
773 fn method_and_shape_must_match() {
Agents as a team: lifecycle, merge queue, billing and a new shell774 assert!(resolve("GET", "/repos/a/b/issues/1/close", &[], Value::Null).is_none());
775 assert!(resolve("GET", "/repos/a", &[], Value::Null).is_none());
776 assert!(resolve("GET", "/repos/a/b/issues/1/extra", &[], Value::Null).is_none());
777 assert!(resolve("GET", "/repos/a/b/", &[], Value::Null).is_some());
API and MCP server in Rust; a public index at the API root778 }
779
780 #[test]
781 fn every_parameter_and_query_name_is_an_input() {
782 for route in ROUTES {
783 let properties = route.op.properties();
784 for (_, key) in route.query {
785 assert!(properties.contains_key(*key), "{}: {key}", route.path);
786 }
787 for name in route.params() {
788 let covered = matches!(name, "owner" | "name") && properties.contains_key("repo")
789 || properties.contains_key(name);
790 assert!(covered, "{}: {name}", route.path);
791 }
792 }
793 }
794
795 #[test]
796 fn every_operation_has_a_route() {
797 for op in Op::ALL {
798 assert!(ROUTES.iter().any(|route| route.op == op), "{}", op.name());
799 }
800 }
801}