Skip to content

g1t/apps/api/src/rest.rs

857 lines28,507 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, &[]),
API: notifications over REST and MCP, with notifications scopes59 // Your notifications: threads, marking them, and what you subscribe
60 // to and watch. GitHub's addresses, with g1t's saved and snoozed.
61 route("GET", "/notifications", Op::ListNotifications, &[("all", "all"), ("participating", "participating"), ("view", "view"), ("reason", "reason"), ("severity", "severity"), ("since", "since"), ("before", "before"), ("cursor", "cursor"), ("per_page", "per_page")]),
62 route("PUT", "/notifications", Op::MarkNotificationsRead, &[]),
63 route("GET", "/notifications/threads/:id", Op::GetNotificationThread, &[]),
64 route("PATCH", "/notifications/threads/:id", Op::MarkThreadRead, &[]),
65 route("DELETE", "/notifications/threads/:id", Op::MarkThreadDone, &[]),
66 route("PUT", "/notifications/threads/:id/saved", Op::SaveThread, &[]),
67 route("DELETE", "/notifications/threads/:id/saved", Op::SaveThread, &[]),
68 route("PUT", "/notifications/threads/:id/snooze", Op::SnoozeThread, &[]),
69 route("DELETE", "/notifications/threads/:id/snooze", Op::SnoozeThread, &[]),
70 route("GET", "/notifications/threads/:id/subscription", Op::GetThreadSubscription, &[]),
71 route("PUT", "/notifications/threads/:id/subscription", Op::SetThreadSubscription, &[]),
72 route("DELETE", "/notifications/threads/:id/subscription", Op::DeleteThreadSubscription, &[]),
73 route("GET", "/repos/:owner/:name/notifications", Op::ListNotifications, &[("all", "all"), ("participating", "participating"), ("view", "view"), ("reason", "reason"), ("severity", "severity"), ("since", "since"), ("before", "before"), ("cursor", "cursor"), ("per_page", "per_page")]),
74 route("PUT", "/repos/:owner/:name/notifications", Op::MarkNotificationsRead, &[]),
75 route("GET", "/repos/:owner/:name/subscription", Op::GetRepoSubscription, &[]),
76 route("PUT", "/repos/:owner/:name/subscription", Op::SetRepoSubscription, &[]),
77 route("DELETE", "/repos/:owner/:name/subscription", Op::DeleteRepoSubscription, &[]),
78 route("GET", "/repos/:owner/:name/issues/:number/subscription", Op::GetThreadSubscription, &[]),
79 route("PUT", "/repos/:owner/:name/issues/:number/subscription", Op::SetThreadSubscription, &[]),
80 route("DELETE", "/repos/:owner/:name/issues/:number/subscription", Op::DeleteThreadSubscription, &[]),
81 route("GET", "/user/subscriptions", Op::ListWatchedRepos, &[]),
API: pinned projects over REST and MCP82 // Your pinned projects in a workspace, in your order.
83 route("GET", "/user/pinned_projects/:workspace", Op::ListPinnedProjects, &[]),
84 route("PUT", "/user/pinned_projects/:workspace", Op::ReorderPinnedProjects, &[]),
85 route("PUT", "/user/pinned_projects/:workspace/:project", Op::PinProject, &[]),
86 route("DELETE", "/user/pinned_projects/:workspace/:project", Op::UnpinProject, &[]),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look87 route("PATCH", "/user/repository_invitations/:id", Op::AcceptRepoInvitation, &[]),
88 route("DELETE", "/user/repository_invitations/:id", Op::DeclineRepoInvitation, &[]),
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily89 route("PATCH", "/workspaces/:workspace", Op::UpdateWorkspace, &[]),
90 route("PUT", "/workspaces/:workspace/base_permission", Op::SetBasePermission, &[]),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look91 route(
92 "GET",
93 "/workspaces/:workspace/outside_collaborators",
94 Op::ListOutsideCollaborators,
95 &[],
96 ),
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily97 // Security alerts: secrets and vulnerable dependencies.
98 route(
99 "GET",
100 "/repos/:owner/:name/security/alerts",
101 Op::ListSecurityAlerts,
102 &[("state", "state"), ("kind", "kind")],
103 ),
104 route("POST", "/repos/:owner/:name/security/alerts/:id/dismiss", Op::DismissSecurityAlert, &[]),
105 route("POST", "/repos/:owner/:name/security/alerts/:id/reopen", Op::ReopenSecurityAlert, &[]),
Agents as a team: lifecycle, merge queue, billing and a new shell106 route("GET", "/repos", Op::ListRepos, &[("q", "query")]),
Search across all of g1t, Explore, and a command palette107 route(
108 "GET",
109 "/search",
110 Op::Search,
111 &[("q", "query"), ("type", "type"), ("page", "page"), ("per_page", "per_page")],
112 ),
Agents as a team: lifecycle, merge queue, billing and a new shell113 route("POST", "/repos", Op::CreateRepo, &[]),
114 route("GET", "/repos/:owner/:name", Op::GetRepo, &[]),
115 route("PATCH", "/repos/:owner/:name", Op::UpdateRepo, &[]),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look116 route("POST", "/repos/:owner/:name/transfer", Op::TransferRepo, &[]),
117 route("DELETE", "/repos/:owner/:name", Op::DeleteRepo, &[]),
118 route(
119 "GET",
120 "/workspaces/:workspace/repos/deleted",
121 Op::ListDeletedRepos,
122 &[],
123 ),
124 route("POST", "/repos/:owner/:name/restore", Op::RestoreRepo, &[]),
125 route("POST", "/repos/:owner/:name/purge", Op::PurgeRepo, &[]),
126 route("POST", "/repos/:owner/:name/rename", Op::RenameRepo, &[]),
127 route("POST", "/repos/:owner/:name/archive", Op::ArchiveRepo, &[]),
128 route("POST", "/repos/:owner/:name/unarchive", Op::UnarchiveRepo, &[]),
129 route(
130 "POST",
131 "/repos/:owner/:name/visibility",
132 Op::SetRepoVisibility,
133 &[],
134 ),
135 route(
136 "POST",
137 "/repos/:owner/:name/branches/:branch/rename",
138 Op::RenameBranch,
139 &[],
140 ),
Agents as a team: lifecycle, merge queue, billing and a new shell141 route(
142 "GET",
143 "/repos/:owner/:name/settings",
144 Op::GetRepoSettings,
145 &[],
146 ),
147 route(
148 "PATCH",
149 "/repos/:owner/:name/settings",
150 Op::UpdateRepoSettings,
151 &[],
152 ),
Fast pages, required checks on the branch, self-hosted runners, honest incidents153 route("GET", "/repos/:owner/:name/check-names", Op::ListCheckNames, &[]),
Agents as a team: lifecycle, merge queue, billing and a new shell154 route("GET", "/repos/:owner/:name/queue", Op::GetMergeQueue, &[]),
API and MCP server in Rust; a public index at the API root155 route(
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request156 "POST",
157 "/repos/:owner/:name/pulls/:number/messages",
158 Op::MessageAgent,
159 &[],
160 ),
161 route(
162 "POST",
163 "/repos/:owner/:name/pulls/:number/messages/take",
164 Op::TakeMessages,
165 &[],
166 ),
167 route(
Docs worth reading, and kept that way168 "POST",
169 "/repos/:owner/:name/messages/:id/answer",
170 Op::AnswerMessage,
171 &[],
172 ),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains173 route("POST", "/repos/:owner/:name/memory", Op::Remember, &[]),
174 route(
175 "GET",
176 "/repos/:owner/:name/memory",
177 Op::Recall,
178 &[("q", "query"), ("limit", "limit")],
179 ),
Docs worth reading, and kept that way180 route(
API and MCP server in Rust; a public index at the API root181 "GET",
Agents as a team: lifecycle, merge queue, billing and a new shell182 "/repos/:owner/:name/events",
API and MCP server in Rust; a public index at the API root183 Op::ListEvents,
184 &[("before", "before")],
185 ),
Agents as a team: lifecycle, merge queue, billing and a new shell186 route("GET", "/repos/:owner/:name/labels", Op::ListLabels, &[]),
API and MCP server in Rust; a public index at the API root187 route(
188 "GET",
Agents as a team: lifecycle, merge queue, billing and a new shell189 "/repos/:owner/:name/issues",
API and MCP server in Rust; a public index at the API root190 Op::ListIssues,
191 &[("state", "state"), ("label", "label")],
192 ),
Agents as a team: lifecycle, merge queue, billing and a new shell193 route("POST", "/repos/:owner/:name/issues", Op::CreateIssue, &[]),
API and MCP server in Rust; a public index at the API root194 route(
195 "GET",
Agents as a team: lifecycle, merge queue, billing and a new shell196 "/repos/:owner/:name/issues/:number",
API and MCP server in Rust; a public index at the API root197 Op::GetIssue,
198 &[],
199 ),
200 route(
201 "PATCH",
Agents as a team: lifecycle, merge queue, billing and a new shell202 "/repos/:owner/:name/issues/:number",
API and MCP server in Rust; a public index at the API root203 Op::UpdateIssue,
204 &[],
205 ),
206 route(
207 "POST",
Agents as a team: lifecycle, merge queue, billing and a new shell208 "/repos/:owner/:name/issues/:number/close",
API and MCP server in Rust; a public index at the API root209 Op::CloseIssue,
210 &[],
211 ),
212 route(
213 "POST",
Agents as a team: lifecycle, merge queue, billing and a new shell214 "/repos/:owner/:name/issues/:number/reopen",
API and MCP server in Rust; a public index at the API root215 Op::ReopenIssue,
216 &[],
217 ),
218 route(
219 "POST",
Agents as a team: lifecycle, merge queue, billing and a new shell220 "/repos/:owner/:name/issues/:number/assign",
221 Op::AssignIssue,
222 &[],
223 ),
Integrations: your own model provider, alerts that open issues, tickets agents read224 route(
225 "POST",
226 "/repos/:owner/:name/issues/import",
227 Op::ImportIssue,
228 &[],
229 ),
230 route(
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step231 "POST",
232 "/repos/:owner/:name/issues/delegate",
233 Op::Delegate,
234 &[],
235 ),
236 route(
Integrations: your own model provider, alerts that open issues, tickets agents read237 "GET",
238 "/repos/:owner/:name/context",
239 Op::GetContext,
240 &[("reference", "reference")],
241 ),
242 route(
243 "GET",
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API244 "/workspaces/:workspace/context/search",
245 Op::SearchContext,
246 &[("q", "query"), ("project", "project"), ("kinds", "kinds"), ("limit", "limit")],
247 ),
248 route(
249 "GET",
250 "/workspaces/:workspace/context/:kind/:id",
251 Op::GetEntity,
252 &[],
253 ),
254 route(
255 "GET",
Integrations: your own model provider, alerts that open issues, tickets agents read256 "/workspaces/:workspace/integrations",
257 Op::ListIntegrations,
258 &[],
259 ),
260 route(
261 "POST",
262 "/workspaces/:workspace/integrations",
263 Op::ConnectIntegration,
264 &[],
265 ),
266 route(
Models per workspace: several providers, routed by kind of work267 "GET",
Webhooks: every event, to your own addresses, signed and retried268 "/repos/:owner/:name/hooks",
269 Op::ListWebhooks,
270 &[],
271 ),
272 route(
273 "POST",
274 "/repos/:owner/:name/hooks",
275 Op::CreateWebhook,
276 &[],
277 ),
278 route(
279 "PATCH",
280 "/repos/:owner/:name/hooks/:id",
281 Op::UpdateWebhook,
282 &[],
283 ),
284 route(
285 "DELETE",
286 "/repos/:owner/:name/hooks/:id",
287 Op::DeleteWebhook,
288 &[],
289 ),
290 route(
291 "POST",
292 "/repos/:owner/:name/hooks/:id/pings",
293 Op::PingWebhook,
294 &[],
295 ),
296 route(
297 "GET",
298 "/repos/:owner/:name/hooks/:id/deliveries",
299 Op::ListWebhookDeliveries,
300 &[],
301 ),
302 route(
303 "POST",
304 "/repos/:owner/:name/hooks/:id/deliveries/:delivery/redeliver",
305 Op::RedeliverWebhook,
306 &[],
307 ),
308 route(
309 "GET",
310 "/workspaces/:workspace/hooks",
311 Op::ListWebhooks,
312 &[],
313 ),
314 route(
315 "POST",
316 "/workspaces/:workspace/hooks",
317 Op::CreateWebhook,
318 &[],
319 ),
320 route(
321 "PATCH",
322 "/workspaces/:workspace/hooks/:id",
323 Op::UpdateWebhook,
324 &[],
325 ),
326 route(
327 "DELETE",
328 "/workspaces/:workspace/hooks/:id",
329 Op::DeleteWebhook,
330 &[],
331 ),
332 route(
333 "POST",
334 "/workspaces/:workspace/hooks/:id/pings",
335 Op::PingWebhook,
336 &[],
337 ),
338 route(
339 "GET",
340 "/workspaces/:workspace/hooks/:id/deliveries",
341 Op::ListWebhookDeliveries,
342 &[],
343 ),
344 route(
345 "POST",
346 "/workspaces/:workspace/hooks/:id/deliveries/:delivery/redeliver",
347 Op::RedeliverWebhook,
348 &[],
349 ),
350 route(
351 "GET",
Models per workspace: several providers, routed by kind of work352 "/workspaces/:workspace/model-routes",
353 Op::GetModelRoutes,
354 &[],
355 ),
356 route(
357 "PUT",
358 "/workspaces/:workspace/model-routes",
359 Op::SetModelRoutes,
360 &[],
361 ),
362 route(
Integrations: your own model provider, alerts that open issues, tickets agents read363 "DELETE",
364 "/workspaces/:workspace/integrations/:id",
365 Op::DisconnectIntegration,
366 &[],
367 ),
368 route(
369 "POST",
370 "/workspaces/:workspace/integrations/:id/test",
371 Op::TestIntegration,
372 &[],
373 ),
Automations: rules in .g1t/automations that act when something happens374 route(
375 "GET",
GitHub Actions on g1t, part two: running workflows376 "/repos/:owner/:name/actions/workflows",
377 Op::ListWorkflows,
378 &[],
379 ),
380 route(
381 "GET",
382 "/repos/:owner/:name/actions/workflows/:workflow/runs",
383 Op::ListWorkflowRuns,
384 &[("branch", "branch"), ("event", "event"), ("per_page", "limit")],
385 ),
386 route(
387 "POST",
388 "/repos/:owner/:name/actions/workflows/:workflow/dispatches",
389 Op::DispatchWorkflow,
390 &[],
391 ),
392 route(
393 "PATCH",
394 "/repos/:owner/:name/actions/workflows/:workflow",
395 Op::UpdateWorkflow,
396 &[],
397 ),
398 route(
399 "PUT",
400 "/repos/:owner/:name/actions/workflows/:workflow/enable",
401 Op::UpdateWorkflow,
402 &[],
403 ),
404 route(
405 "PUT",
406 "/repos/:owner/:name/actions/workflows/:workflow/disable",
407 Op::UpdateWorkflow,
408 &[],
409 ),
410 route(
411 "GET",
412 "/repos/:owner/:name/actions/runs",
413 Op::ListWorkflowRuns,
414 &[("workflow", "workflow"), ("branch", "branch"), ("event", "event"), ("pull", "pull"), ("head_sha", "sha"), ("per_page", "limit")],
415 ),
416 route(
417 "GET",
418 "/repos/:owner/:name/actions/runs/:id",
419 Op::GetWorkflowRun,
420 &[],
421 ),
422 route(
423 "POST",
424 "/repos/:owner/:name/actions/runs/:id/cancel",
425 Op::CancelWorkflowRun,
426 &[],
427 ),
428 route(
429 "POST",
430 "/repos/:owner/:name/actions/runs/:id/rerun",
431 Op::RerunWorkflowRun,
432 &[],
433 ),
434 route(
435 "POST",
436 "/repos/:owner/:name/actions/runs/:id/rerun-failed-jobs",
437 Op::RerunWorkflowRun,
438 &[],
439 ),
440 route(
441 "GET",
442 "/repos/:owner/:name/actions/jobs/:job/logs",
443 Op::GetJobLogs,
444 &[("after", "after")],
445 ),
446 route(
447 "GET",
448 "/repos/:owner/:name/actions/secrets",
449 Op::ListActionsSecrets,
450 &[],
451 ),
452 route(
453 "PUT",
454 "/repos/:owner/:name/actions/secrets/:setting",
455 Op::SetActionsSecret,
456 &[],
457 ),
458 route(
459 "DELETE",
460 "/repos/:owner/:name/actions/secrets/:setting",
461 Op::DeleteActionsSecret,
462 &[],
463 ),
464 route(
465 "GET",
466 "/repos/:owner/:name/actions/variables",
467 Op::ListActionsVariables,
468 &[],
469 ),
470 route(
471 "POST",
472 "/repos/:owner/:name/actions/variables",
473 Op::SetActionsVariable,
474 &[],
475 ),
476 route(
477 "PATCH",
478 "/repos/:owner/:name/actions/variables/:setting",
479 Op::SetActionsVariable,
480 &[],
481 ),
482 route(
483 "DELETE",
484 "/repos/:owner/:name/actions/variables/:setting",
485 Op::DeleteActionsVariable,
486 &[],
487 ),
488 route(
489 "GET",
490 "/workspaces/:workspace/actions/secrets",
491 Op::ListActionsSecrets,
492 &[],
493 ),
494 route(
495 "PUT",
496 "/workspaces/:workspace/actions/secrets/:setting",
497 Op::SetActionsSecret,
498 &[],
499 ),
500 route(
501 "DELETE",
502 "/workspaces/:workspace/actions/secrets/:setting",
503 Op::DeleteActionsSecret,
504 &[],
505 ),
506 route(
507 "GET",
508 "/workspaces/:workspace/actions/variables",
509 Op::ListActionsVariables,
510 &[],
511 ),
512 route(
513 "POST",
514 "/workspaces/:workspace/actions/variables",
515 Op::SetActionsVariable,
516 &[],
517 ),
518 route(
519 "PATCH",
520 "/workspaces/:workspace/actions/variables/:setting",
521 Op::SetActionsVariable,
522 &[],
523 ),
524 route(
525 "DELETE",
526 "/workspaces/:workspace/actions/variables/:setting",
527 Op::DeleteActionsVariable,
528 &[],
529 ),
Fast pages, required checks on the branch, self-hosted runners, honest incidents530 // Self-hosted runners: a repository's own, or a workspace's.
531 route("GET", "/repos/:owner/:name/actions/runners", Op::ListRunners, &[]),
532 route("POST", "/repos/:owner/:name/actions/runners/registration-token", Op::CreateRunnerRegistrationToken, &[]),
533 route("DELETE", "/repos/:owner/:name/actions/runners/:id", Op::RemoveRunner, &[]),
534 route("GET", "/repos/:owner/:name/actions/runner-settings", Op::GetRunnerSettings, &[]),
535 route("PATCH", "/repos/:owner/:name/actions/runner-settings", Op::UpdateRunnerSettings, &[]),
536 route("GET", "/workspaces/:workspace/actions/runners", Op::ListRunners, &[]),
537 route("POST", "/workspaces/:workspace/actions/runners/registration-token", Op::CreateRunnerRegistrationToken, &[]),
538 route("DELETE", "/workspaces/:workspace/actions/runners/:id", Op::RemoveRunner, &[]),
539 route("GET", "/workspaces/:workspace/actions/runner-settings", Op::GetRunnerSettings, &[]),
540 route("PATCH", "/workspaces/:workspace/actions/runner-settings", Op::UpdateRunnerSettings, &[]),
541 route("GET", "/workspaces/:workspace/actions/runner-groups", Op::ListRunnerGroups, &[]),
542 route("POST", "/workspaces/:workspace/actions/runner-groups", Op::CreateRunnerGroup, &[]),
543 route("PATCH", "/workspaces/:workspace/actions/runner-groups/:id", Op::UpdateRunnerGroup, &[]),
544 route("DELETE", "/workspaces/:workspace/actions/runner-groups/:id", Op::DeleteRunnerGroup, &[]),
Agents as a team: lifecycle, merge queue, billing and a new shell545 route("POST", "/repos/:owner/:name/plans", Op::PlanWork, &[]),
546 route("GET", "/repos/:owner/:name/plans/:plan", Op::GetPlan, &[]),
547 route(
548 "POST",
549 "/repos/:owner/:name/plans/:plan/apply",
550 Op::ApplyPlan,
551 &[],
552 ),
553 route(
554 "POST",
555 "/repos/:owner/:name/issues/:number/comments",
API and MCP server in Rust; a public index at the API root556 Op::AddComment,
557 &[],
558 ),
559 route(
560 "GET",
Agents as a team: lifecycle, merge queue, billing and a new shell561 "/repos/:owner/:name/pulls",
API and MCP server in Rust; a public index at the API root562 Op::ListPullRequests,
563 &[("state", "state")],
564 ),
565 route(
566 "POST",
Agents as a team: lifecycle, merge queue, billing and a new shell567 "/repos/:owner/:name/pulls",
API and MCP server in Rust; a public index at the API root568 Op::CreatePullRequest,
569 &[],
570 ),
571 route(
572 "GET",
Agents as a team: lifecycle, merge queue, billing and a new shell573 "/repos/:owner/:name/pulls/:number",
API and MCP server in Rust; a public index at the API root574 Op::GetPullRequest,
575 &[],
576 ),
577 route(
578 "GET",
Agents as a team: lifecycle, merge queue, billing and a new shell579 "/repos/:owner/:name/pulls/:number/changes",
API and MCP server in Rust; a public index at the API root580 Op::GetPullRequestChanges,
581 &[],
582 ),
583 route(
Acceptance checks in sandboxes, line comments and review verdicts584 "POST",
Agents as a team: lifecycle, merge queue, billing and a new shell585 "/repos/:owner/:name/pulls/:number/reviews",
Acceptance checks in sandboxes, line comments and review verdicts586 Op::ReviewPullRequest,
587 &[],
588 ),
589 route(
API and MCP server in Rust; a public index at the API root590 "GET",
Agents as a team: lifecycle, merge queue, billing and a new shell591 "/repos/:owner/:name/pulls/:number/session",
API and MCP server in Rust; a public index at the API root592 Op::ReadSession,
593 &[("after", "after")],
594 ),
595 route(
596 "POST",
Agents as a team: lifecycle, merge queue, billing and a new shell597 "/repos/:owner/:name/pulls/:number/session",
API and MCP server in Rust; a public index at the API root598 Op::RecordSession,
599 &[],
600 ),
601 route(
602 "POST",
Agents as a team: lifecycle, merge queue, billing and a new shell603 "/repos/:owner/:name/pulls/:number/ready",
API and MCP server in Rust; a public index at the API root604 Op::MarkPullRequestReady,
605 &[],
606 ),
607 route(
608 "POST",
Agents as a team: lifecycle, merge queue, billing and a new shell609 "/repos/:owner/:name/pulls/:number/close",
API and MCP server in Rust; a public index at the API root610 Op::ClosePullRequest,
611 &[],
612 ),
613 route(
614 "POST",
Agents as a team: lifecycle, merge queue, billing and a new shell615 "/repos/:owner/:name/pulls/:number/merge",
API and MCP server in Rust; a public index at the API root616 Op::MergePullRequest,
617 &[],
618 ),
619];
620
621impl Route {
622 /// The names of the route's path parameters, in order.
623 pub fn params(&self) -> impl Iterator<Item = &'static str> {
624 self.path
625 .split('/')
626 .filter_map(|segment| segment.strip_prefix(':'))
627 }
628
629 /// The values of the path parameters, if `path` is this route's.
630 fn matches<'a>(&self, path: &'a str) -> Option<Vec<(&'static str, &'a str)>> {
631 let mut values = Vec::new();
632 let mut actual = path.trim_end_matches('/').split('/');
633 for expected in self.path.split('/') {
634 let segment = actual.next()?;
635 match expected.strip_prefix(':') {
636 Some(name) if !segment.is_empty() => values.push((name, segment)),
637 Some(_) => return None,
638 None if expected == segment => {}
639 None => return None,
640 }
641 }
642 actual.next().is_none().then_some(values)
643 }
644}
645
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look646/// A path segment with its `%XX` escapes decoded; as given when that is not
647/// UTF-8.
648fn percent_decoded(segment: &str) -> String {
649 let bytes = segment.as_bytes();
650 let mut out = Vec::with_capacity(bytes.len());
651 let mut i = 0;
652 while i < bytes.len() {
653 let escaped = (bytes[i] == b'%')
654 .then(|| segment.get(i + 1..i + 3))
655 .flatten()
656 .filter(|hex| hex.bytes().all(|byte| byte.is_ascii_hexdigit()))
657 .and_then(|hex| u8::from_str_radix(hex, 16).ok());
658 match escaped {
659 Some(byte) => {
660 out.push(byte);
661 i += 3;
662 }
663 None => {
664 out.push(bytes[i]);
665 i += 1;
666 }
667 }
668 }
669 String::from_utf8(out).unwrap_or_else(|_| segment.to_owned())
670}
671
API and MCP server in Rust; a public index at the API root672/// The route for a request, and the operation input it describes.
673///
674/// The input is the JSON body, overlaid with the query parameters the route
675/// reads and then with what the path names: `owner` and `name` become
676/// `repo`, and `number` becomes an integer.
677pub fn resolve(
678 method: &str,
679 path: &str,
680 query: &[(String, String)],
681 body: Value,
682) -> Option<(&'static Route, Value)> {
683 let (route, params) = ROUTES
684 .iter()
685 .filter(|route| route.method == method)
686 .find_map(|route| Some((route, route.matches(path)?)))?;
687
688 let mut input = match body {
689 Value::Object(fields) => fields,
690 _ => Map::new(),
691 };
692 for (name, key) in route.query {
693 if let Some((_, value)) = query.iter().find(|(query_name, _)| query_name == name) {
694 input.insert((*key).to_owned(), Value::String(value.clone()));
695 }
696 }
697 let param = |wanted: &str| {
698 params
699 .iter()
700 .find(|(name, _)| *name == wanted)
701 .map(|(_, value)| *value)
702 };
703 if let (Some(owner), Some(name)) = (param("owner"), param("name")) {
704 input.insert("repo".to_owned(), Value::String(format!("{owner}/{name}")));
705 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look706 for key in ["plan", "id", "workspace", "delivery", "workflow", "job", "setting", "username"] {
Docs worth reading, and kept that way707 if let Some(value) = param(key) {
708 input.insert(key.to_owned(), Value::String(value.to_owned()));
709 }
Agents as a team: lifecycle, merge queue, billing and a new shell710 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look711 // A branch name may hold slashes, sent URL-encoded as one segment.
712 if let Some(branch) = param("branch") {
713 input.insert("branch".to_owned(), Value::String(percent_decoded(branch)));
714 }
GitHub Actions on g1t, part two: running workflows715 // GitHub says some things with the path alone.
716 if route.path.ends_with("/enable") || route.path.ends_with("/disable") {
717 input.insert("enabled".to_owned(), Value::Bool(route.path.ends_with("/enable")));
718 }
719 if route.path.ends_with("/rerun-failed-jobs") {
720 input.insert("failed_only".to_owned(), Value::Bool(true));
721 }
API: notifications over REST and MCP, with notifications scopes722 // Unsaving and waking a thread are a DELETE of what PUT made.
723 if route.method == "DELETE" && route.path.ends_with("/saved") {
724 input.insert("saved".to_owned(), Value::Bool(false));
725 }
726 if route.method == "DELETE" && route.path.ends_with("/snooze") {
727 input.remove("until");
728 }
API and MCP server in Rust; a public index at the API root729 if let Some(number) = param("number") {
730 // Not a number: zero, which no issue or pull request has.
731 input.insert(
732 "number".to_owned(),
733 number.parse::<u32>().unwrap_or(0).into(),
734 );
735 }
736 Some((route, Value::Object(input)))
737}
738
739#[cfg(test)]
740mod tests {
741 use serde_json::json;
742
743 use super::*;
744
745 #[test]
746 fn a_path_resolves_to_its_operation_and_input() {
747 let (route, input) = resolve(
748 "POST",
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look749 "/repos/flagon-io/hello/pulls/14/merge",
API and MCP server in Rust; a public index at the API root750 &[],
751 json!({ "keep_issue_open": true, "number": 99, "repo": "someone/else" }),
752 )
753 .unwrap();
754 assert_eq!(route.op, Op::MergePullRequest);
755 // What the path names wins over the body.
756 assert_eq!(
757 input,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look758 json!({ "keep_issue_open": true, "number": 14, "repo": "flagon-io/hello" })
API and MCP server in Rust; a public index at the API root759 );
760 }
761
762 #[test]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look763 fn a_branch_with_slashes_is_one_encoded_segment() {
764 let (route, input) = resolve(
765 "POST",
766 "/repos/flagon-io/hello/branches/feature%2Flogin/rename",
767 &[],
768 json!({ "new_name": "feature/sign-in" }),
769 )
770 .unwrap();
771 assert_eq!(route.op, Op::RenameBranch);
772 assert_eq!(
773 input,
774 json!({ "new_name": "feature/sign-in", "branch": "feature/login", "repo": "flagon-io/hello" })
775 );
776 assert_eq!(percent_decoded("100%"), "100%");
777 assert_eq!(percent_decoded("a%2bb%zz"), "a+b%zz");
778 }
779
780 #[test]
781 fn a_collaborator_is_named_by_username() {
782 let (route, input) = resolve(
783 "PATCH",
784 "/repos/flagon-io/hello/collaborators/ada",
785 &[],
786 json!({ "role": "maintain" }),
787 )
788 .unwrap();
789 assert_eq!(route.op, Op::UpdateCollaborator);
790 assert_eq!(input, json!({ "role": "maintain", "username": "ada", "repo": "flagon-io/hello" }));
791 let (route, input) = resolve("DELETE", "/user/repository_invitations/rin_1", &[], Value::Null).unwrap();
792 assert_eq!(route.op, Op::DeclineRepoInvitation);
793 assert_eq!(input, json!({ "id": "rin_1" }));
794 }
795
796 #[test]
API: notifications over REST and MCP, with notifications scopes797 fn notifications_are_addressed_as_threads_and_by_issue() {
798 let (route, input) = resolve("DELETE", "/notifications/threads/ntf_1", &[], Value::Null).unwrap();
799 assert_eq!(route.op, Op::MarkThreadDone);
800 assert_eq!(input, json!({ "id": "ntf_1" }));
801 let (route, input) = resolve("DELETE", "/notifications/threads/ntf_1/saved", &[], Value::Null).unwrap();
802 assert_eq!(route.op, Op::SaveThread);
803 assert_eq!(input, json!({ "id": "ntf_1", "saved": false }));
804 let (route, input) = resolve("DELETE", "/notifications/threads/ntf_1/snooze", &[], json!({ "until": "x" })).unwrap();
805 assert_eq!(route.op, Op::SnoozeThread);
806 assert_eq!(input, json!({ "id": "ntf_1" }));
807 let query = [("all".to_owned(), "true".to_owned()), ("per_page".to_owned(), "50".to_owned())];
808 let (route, input) = resolve("GET", "/repos/acme/rocket/notifications", &query, Value::Null).unwrap();
809 assert_eq!(route.op, Op::ListNotifications);
810 assert_eq!(input, json!({ "all": "true", "per_page": "50", "repo": "acme/rocket" }));
811 let (route, input) = resolve("PUT", "/repos/acme/rocket/issues/7/subscription", &[], json!({ "ignored": true })).unwrap();
812 assert_eq!(route.op, Op::SetThreadSubscription);
813 assert_eq!(input, json!({ "ignored": true, "number": 7, "repo": "acme/rocket" }));
814 assert_eq!(resolve("GET", "/user/subscriptions", &[], Value::Null).unwrap().0.op, Op::ListWatchedRepos);
815 }
816
817 #[test]
API and MCP server in Rust; a public index at the API root818 fn query_parameters_are_renamed() {
819 let query = [
820 ("q".to_owned(), "parser".to_owned()),
821 ("x".to_owned(), "y".to_owned()),
822 ];
Agents as a team: lifecycle, merge queue, billing and a new shell823 let (route, input) = resolve("GET", "/repos", &query, Value::Null).unwrap();
API and MCP server in Rust; a public index at the API root824 assert_eq!(route.op, Op::ListRepos);
825 assert_eq!(input, json!({ "query": "parser" }));
826 }
827
828 #[test]
829 fn method_and_shape_must_match() {
Agents as a team: lifecycle, merge queue, billing and a new shell830 assert!(resolve("GET", "/repos/a/b/issues/1/close", &[], Value::Null).is_none());
831 assert!(resolve("GET", "/repos/a", &[], Value::Null).is_none());
832 assert!(resolve("GET", "/repos/a/b/issues/1/extra", &[], Value::Null).is_none());
833 assert!(resolve("GET", "/repos/a/b/", &[], Value::Null).is_some());
API and MCP server in Rust; a public index at the API root834 }
835
836 #[test]
837 fn every_parameter_and_query_name_is_an_input() {
838 for route in ROUTES {
839 let properties = route.op.properties();
840 for (_, key) in route.query {
841 assert!(properties.contains_key(*key), "{}: {key}", route.path);
842 }
843 for name in route.params() {
844 let covered = matches!(name, "owner" | "name") && properties.contains_key("repo")
845 || properties.contains_key(name);
846 assert!(covered, "{}: {name}", route.path);
847 }
848 }
849 }
850
851 #[test]
852 fn every_operation_has_a_route() {
853 for op in Op::ALL {
854 assert!(ROUTES.iter().any(|route| route.op == op), "{}", op.name());
855 }
856 }
857}