Skip to content
1,127 linesCodeBlameRaw
1//! REST: each route maps an HTTP request onto one operation.
2
3use serde_json::{Map, Value};
4
5use crate::deployments::DeploymentsOp;
6use crate::operations::Op;
7use crate::rules::RulesOp;
8use crate::security::SecurityOp;
9
10pub struct Route {
11 pub method: &'static str,
12 /// Segments starting with `:` are parameters.
13 pub path: &'static str,
14 pub op: Op,
15 /// Query parameters the route reads, as `(name in the URL, input name)`.
16 pub query: &'static [(&'static str, &'static str)],
17}
18
19const fn route(
20 method: &'static str,
21 path: &'static str,
22 op: Op,
23 query: &'static [(&'static str, &'static str)],
24) -> Route {
25 Route {
26 method,
27 path,
28 op,
29 query,
30 }
31}
32
33pub const ROUTES: &[Route] = &[
34 route("GET", "/user", Op::Whoami, &[]),
35 route("POST", "/workspaces", Op::CreateWorkspace, &[]),
36 route("GET", "/workspaces/:workspace", Op::GetWorkspace, &[]),
37 route("DELETE", "/workspaces/:workspace", Op::DeleteWorkspace, &[]),
38 route("GET", "/user/emails", Op::ListEmails, &[]),
39 route("POST", "/user/emails", Op::AddEmail, &[]),
40 route("DELETE", "/user/emails/:email", Op::RemoveEmail, &[]),
41 route("PATCH", "/user/email-settings", Op::UpdateEmailSettings, &[]),
42 route("GET", "/user/invites", Op::ListInvites, &[]),
43 route("POST", "/user/invites", Op::CreateInvite, &[]),
44 route("DELETE", "/user/invites/:id", Op::RevokeInvite, &[]),
45 route("GET", "/workspaces/:workspace/invitations", Op::ListWorkspaceInvites, &[]),
46 route("POST", "/workspaces/:workspace/invitations", Op::InviteMember, &[]),
47 route("DELETE", "/workspaces/:workspace/invitations/:id", Op::RevokeWorkspaceInvite, &[]),
48 // Who has access. GitHub's addresses, but for adding someone, which
49 // takes an email address as well as a username.
50 route("GET", "/repos/:owner/:name/collaborators", Op::ListCollaborators, &[]),
51 route("POST", "/repos/:owner/:name/collaborators", Op::AddCollaborator, &[]),
52 route("PATCH", "/repos/:owner/:name/collaborators/:username", Op::UpdateCollaborator, &[]),
53 route("DELETE", "/repos/:owner/:name/collaborators/:username", Op::RemoveCollaborator, &[]),
54 route(
55 "GET",
56 "/repos/:owner/:name/collaborators/:username/permission",
57 Op::GetCollaboratorPermission,
58 &[],
59 ),
60 route("GET", "/repos/:owner/:name/invitations", Op::ListRepoInvitations, &[]),
61 route("DELETE", "/repos/:owner/:name/invitations/:id", Op::RevokeRepoInvitation, &[]),
62 route("GET", "/user/repository_invitations", Op::ListMyRepoInvitations, &[]),
63 // Your notifications: threads, marking them, and what you subscribe
64 // to and watch. GitHub's addresses, with g1t's saved and snoozed.
65 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")]),
66 route("PUT", "/notifications", Op::MarkNotificationsRead, &[]),
67 route("GET", "/notifications/threads/:id", Op::GetNotificationThread, &[]),
68 route("PATCH", "/notifications/threads/:id", Op::MarkThreadRead, &[]),
69 route("DELETE", "/notifications/threads/:id", Op::MarkThreadDone, &[]),
70 route("PUT", "/notifications/threads/:id/saved", Op::SaveThread, &[]),
71 route("DELETE", "/notifications/threads/:id/saved", Op::SaveThread, &[]),
72 route("PUT", "/notifications/threads/:id/snooze", Op::SnoozeThread, &[]),
73 route("DELETE", "/notifications/threads/:id/snooze", Op::SnoozeThread, &[]),
74 route("GET", "/notifications/threads/:id/subscription", Op::GetThreadSubscription, &[]),
75 route("PUT", "/notifications/threads/:id/subscription", Op::SetThreadSubscription, &[]),
76 route("DELETE", "/notifications/threads/:id/subscription", Op::DeleteThreadSubscription, &[]),
77 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")]),
78 route("PUT", "/repos/:owner/:name/notifications", Op::MarkNotificationsRead, &[]),
79 route("GET", "/repos/:owner/:name/subscription", Op::GetRepoSubscription, &[]),
80 route("PUT", "/repos/:owner/:name/subscription", Op::SetRepoSubscription, &[]),
81 route("DELETE", "/repos/:owner/:name/subscription", Op::DeleteRepoSubscription, &[]),
82 route("GET", "/repos/:owner/:name/issues/:number/subscription", Op::GetThreadSubscription, &[]),
83 route("PUT", "/repos/:owner/:name/issues/:number/subscription", Op::SetThreadSubscription, &[]),
84 route("DELETE", "/repos/:owner/:name/issues/:number/subscription", Op::DeleteThreadSubscription, &[]),
85 route("GET", "/user/subscriptions", Op::ListWatchedRepos, &[]),
86 // Your pinned projects in a workspace, in your order.
87 route("GET", "/user/pinned_projects/:workspace", Op::ListPinnedProjects, &[]),
88 route("PUT", "/user/pinned_projects/:workspace", Op::ReorderPinnedProjects, &[]),
89 route("PUT", "/user/pinned_projects/:workspace/:project", Op::PinProject, &[]),
90 route("DELETE", "/user/pinned_projects/:workspace/:project", Op::UnpinProject, &[]),
91 route("PATCH", "/user/repository_invitations/:id", Op::AcceptRepoInvitation, &[]),
92 route("DELETE", "/user/repository_invitations/:id", Op::DeclineRepoInvitation, &[]),
93 route("PATCH", "/workspaces/:workspace", Op::UpdateWorkspace, &[]),
94 // A workspace's projects: what each is, where it runs, its links.
95 route("GET", "/workspaces/:workspace/projects", Op::ListProjects, &[]),
96 route("GET", "/workspaces/:workspace/projects/:project", Op::GetProject, &[]),
97 route("PATCH", "/workspaces/:workspace/projects/:project", Op::UpdateProject, &[]),
98 route("PUT", "/workspaces/:workspace/base_permission", Op::SetBasePermission, &[]),
99 route(
100 "GET",
101 "/workspaces/:workspace/outside_collaborators",
102 Op::ListOutsideCollaborators,
103 &[],
104 ),
105 // Teams: a workspace's groups of members, with roles on repositories.
106 route("GET", "/workspaces/:workspace/teams", Op::ListTeams, &[("q", "query")]),
107 route("POST", "/workspaces/:workspace/teams", Op::CreateTeam, &[]),
108 route("GET", "/workspaces/:workspace/teams/:team", Op::GetTeam, &[]),
109 route("PATCH", "/workspaces/:workspace/teams/:team", Op::UpdateTeam, &[]),
110 route("DELETE", "/workspaces/:workspace/teams/:team", Op::DeleteTeam, &[]),
111 route(
112 "GET",
113 "/workspaces/:workspace/teams/:team/members",
114 Op::ListTeamMembers,
115 &[("include_child_teams", "include_child_teams")],
116 ),
117 route("PUT", "/workspaces/:workspace/teams/:team/members/:username", Op::SetTeamMember, &[]),
118 route("DELETE", "/workspaces/:workspace/teams/:team/members/:username", Op::RemoveTeamMember, &[]),
119 route("GET", "/workspaces/:workspace/teams/:team/teams", Op::ListChildTeams, &[]),
120 route("GET", "/workspaces/:workspace/teams/:team/repos", Op::ListTeamRepos, &[]),
121 route("PUT", "/workspaces/:workspace/teams/:team/repos/:repo", Op::SetTeamRepo, &[]),
122 route("DELETE", "/workspaces/:workspace/teams/:team/repos/:repo", Op::RemoveTeamRepo, &[]),
123 route(
124 "PUT",
125 "/workspaces/:workspace/teams/:team/review_assignment",
126 Op::SetTeamReviewAssignment,
127 &[],
128 ),
129 route("GET", "/workspaces/:workspace/members/:username/teams", Op::ListUserTeams, &[]),
130 // A workspace's billing: usage, budget, AI credit and invoices.
131 route(
132 "GET",
133 "/workspaces/:workspace/usage",
134 Op::GetUsage,
135 &[("from", "from"), ("until", "until"), ("products", "products"), ("projects", "projects"), ("group_by", "group_by")],
136 ),
137 route("GET", "/workspaces/:workspace/budget", Op::GetBudget, &[]),
138 route("PUT", "/workspaces/:workspace/budget", Op::SetBudget, &[]),
139 route("GET", "/workspaces/:workspace/ai_credit", Op::GetAiCredit, &[]),
140 route("POST", "/workspaces/:workspace/ai_credit/checkout", Op::BuyAiCredit, &[]),
141 route("GET", "/workspaces/:workspace/invoices", Op::ListInvoices, &[]),
142 route("GET", "/workspaces/:workspace/billing_details", Op::GetBillingDetails, &[]),
143 // The AI Gateway's log of a workspace's requests.
144 route("GET", "/workspaces/:workspace/gateway/requests", Op::ListGatewayRequests, &[("limit", "limit"), ("before", "before")]),
145 // Code owners: the CODEOWNERS file, checked.
146 route("GET", "/repos/:owner/:name/codeowners/errors", Op::GetCodeownersErrors, &[("ref", "ref")]),
147 // Security alerts: secrets and vulnerable dependencies.
148 route(
149 "GET",
150 "/repos/:owner/:name/security/alerts",
151 Op::ListSecurityAlerts,
152 &[("state", "state"), ("kind", "kind")],
153 ),
154 route("POST", "/repos/:owner/:name/security/alerts/:id/dismiss", Op::DismissSecurityAlert, &[]),
155 route("POST", "/repos/:owner/:name/security/alerts/:id/reopen", Op::ReopenSecurityAlert, &[]),
156 // The security suite: secret scanning, code scanning, vulnerability
157 // alerts and the supply chain, at the common addresses.
158 route("GET", "/repos/:owner/:name/secret-scanning/alerts", Op::Security(SecurityOp::ListSecretAlerts), &[("state", "state"), ("secret_type", "secret_type"), ("validity", "validity"), ("bypassed", "bypassed")]),
159 route("GET", "/workspaces/:workspace/secret-scanning/alerts", Op::Security(SecurityOp::ListSecretAlerts), &[("state", "state"), ("secret_type", "secret_type"), ("validity", "validity"), ("bypassed", "bypassed")]),
160 route("GET", "/repos/:owner/:name/secret-scanning/alerts/:id", Op::Security(SecurityOp::GetSecretAlert), &[]),
161 route("PATCH", "/repos/:owner/:name/secret-scanning/alerts/:id", Op::Security(SecurityOp::UpdateSecretAlert), &[]),
162 route("GET", "/repos/:owner/:name/secret-scanning/alerts/:id/locations", Op::Security(SecurityOp::ListSecretLocations), &[]),
163 route("POST", "/repos/:owner/:name/secret-scanning/alerts/:id/bypass", Op::Security(SecurityOp::BypassPushProtection), &[]),
164 route("POST", "/repos/:owner/:name/secret-scanning/alerts/:id/validity", Op::Security(SecurityOp::CheckSecretValidity), &[]),
165 route("GET", "/workspaces/:workspace/secret-scanning/bypass-requests", Op::Security(SecurityOp::ListBypassRequests), &[("state", "state"), ("repo", "repo")]),
166 route("PATCH", "/workspaces/:workspace/secret-scanning/bypass-requests/:id", Op::Security(SecurityOp::ReviewBypassRequest), &[]),
167 route("POST", "/repos/:owner/:name/secret-scanning/custom-patterns/dry-run", Op::Security(SecurityOp::DryRunCustomPattern), &[]),
168 route("POST", "/workspaces/:workspace/secret-scanning/custom-patterns/dry-run", Op::Security(SecurityOp::DryRunCustomPattern), &[]),
169 route("GET", "/repos/:owner/:name/secret-scanning/custom-patterns", Op::Security(SecurityOp::ListCustomPatterns), &[]),
170 route("GET", "/workspaces/:workspace/secret-scanning/custom-patterns", Op::Security(SecurityOp::ListCustomPatterns), &[]),
171 route("POST", "/repos/:owner/:name/secret-scanning/custom-patterns", Op::Security(SecurityOp::CreateCustomPattern), &[]),
172 route("POST", "/workspaces/:workspace/secret-scanning/custom-patterns", Op::Security(SecurityOp::CreateCustomPattern), &[]),
173 route("PATCH", "/repos/:owner/:name/secret-scanning/custom-patterns/:id", Op::Security(SecurityOp::UpdateCustomPattern), &[]),
174 route("PATCH", "/workspaces/:workspace/secret-scanning/custom-patterns/:id", Op::Security(SecurityOp::UpdateCustomPattern), &[]),
175 route("DELETE", "/repos/:owner/:name/secret-scanning/custom-patterns/:id", Op::Security(SecurityOp::DeleteCustomPattern), &[]),
176 route("DELETE", "/workspaces/:workspace/secret-scanning/custom-patterns/:id", Op::Security(SecurityOp::DeleteCustomPattern), &[]),
177 route("GET", "/repos/:owner/:name/code-scanning/alerts", Op::Security(SecurityOp::ListCodeAlerts), &[("state", "state"), ("severity", "severity"), ("tool", "tool"), ("rule_id", "rule_id")]),
178 route("GET", "/workspaces/:workspace/code-scanning/alerts", Op::Security(SecurityOp::ListCodeAlerts), &[("state", "state"), ("severity", "severity"), ("tool", "tool"), ("rule_id", "rule_id")]),
179 route("GET", "/repos/:owner/:name/code-scanning/alerts/:number", Op::Security(SecurityOp::GetCodeAlert), &[]),
180 route("PATCH", "/repos/:owner/:name/code-scanning/alerts/:number", Op::Security(SecurityOp::UpdateCodeAlert), &[]),
181 route("GET", "/repos/:owner/:name/code-scanning/analyses", Op::Security(SecurityOp::ListAnalyses), &[]),
182 route("POST", "/repos/:owner/:name/code-scanning/sarifs", Op::Security(SecurityOp::UploadSarif), &[]),
183 route("GET", "/repos/:owner/:name/code-scanning/sarifs/:id", Op::Security(SecurityOp::GetSarifUpload), &[]),
184 route("GET", "/repos/:owner/:name/vulnerability-alerts", Op::Security(SecurityOp::ListVulnerabilityAlerts), &[("state", "state"), ("severity", "severity"), ("ecosystem", "ecosystem"), ("package", "package")]),
185 route("GET", "/workspaces/:workspace/vulnerability-alerts", Op::Security(SecurityOp::ListVulnerabilityAlerts), &[("state", "state"), ("severity", "severity"), ("ecosystem", "ecosystem"), ("package", "package")]),
186 route("GET", "/repos/:owner/:name/vulnerability-alerts/:id", Op::Security(SecurityOp::GetVulnerabilityAlert), &[]),
187 route("PATCH", "/repos/:owner/:name/vulnerability-alerts/:id", Op::Security(SecurityOp::UpdateVulnerabilityAlert), &[]),
188 route("POST", "/repos/:owner/:name/security/alerts/:id/fix", Op::Security(SecurityOp::FixAlert), &[]),
189 route("GET", "/repos/:owner/:name/dependency-graph", Op::Security(SecurityOp::GetDependencyGraph), &[]),
190 route("GET", "/repos/:owner/:name/dependency-graph/sbom", Op::Security(SecurityOp::GetSbom), &[]),
191 route("GET", "/repos/:owner/:name/dependency-graph/compare/:basehead", Op::Security(SecurityOp::CompareDependencies), &[]),
192 route("GET", "/repos/:owner/:name/security/settings", Op::Security(SecurityOp::GetSettings), &[]),
193 route("PATCH", "/repos/:owner/:name/security/settings", Op::Security(SecurityOp::UpdateSettings), &[]),
194 route("GET", "/workspaces/:workspace/security/settings", Op::Security(SecurityOp::GetWorkspaceSettings), &[]),
195 route("PATCH", "/workspaces/:workspace/security/settings", Op::Security(SecurityOp::UpdateWorkspaceSettings), &[]),
196 route("GET", "/workspaces/:workspace/security/overview", Op::Security(SecurityOp::GetOverview), &[("days", "days")]),
197 route("GET", "/repos", Op::ListRepos, &[("q", "query")]),
198 route(
199 "GET",
200 "/search",
201 Op::Search,
202 &[("q", "query"), ("type", "type"), ("page", "page"), ("per_page", "per_page")],
203 ),
204 route("POST", "/repos", Op::CreateRepo, &[]),
205 route("GET", "/repos/:owner/:name", Op::GetRepo, &[]),
206 route("PATCH", "/repos/:owner/:name", Op::UpdateRepo, &[]),
207 route("POST", "/repos/:owner/:name/transfer", Op::TransferRepo, &[]),
208 route("DELETE", "/repos/:owner/:name", Op::DeleteRepo, &[]),
209 route(
210 "GET",
211 "/workspaces/:workspace/repos/deleted",
212 Op::ListDeletedRepos,
213 &[],
214 ),
215 route("POST", "/repos/:owner/:name/restore", Op::RestoreRepo, &[]),
216 route("POST", "/repos/:owner/:name/purge", Op::PurgeRepo, &[]),
217 route("POST", "/repos/:owner/:name/rename", Op::RenameRepo, &[]),
218 route("POST", "/repos/:owner/:name/archive", Op::ArchiveRepo, &[]),
219 route("POST", "/repos/:owner/:name/unarchive", Op::UnarchiveRepo, &[]),
220 route(
221 "POST",
222 "/repos/:owner/:name/visibility",
223 Op::SetRepoVisibility,
224 &[],
225 ),
226 route(
227 "POST",
228 "/repos/:owner/:name/branches/:branch/rename",
229 Op::RenameBranch,
230 &[],
231 ),
232 route(
233 "GET",
234 "/repos/:owner/:name/settings",
235 Op::GetRepoSettings,
236 &[],
237 ),
238 route(
239 "PATCH",
240 "/repos/:owner/:name/settings",
241 Op::UpdateRepoSettings,
242 &[],
243 ),
244 route("GET", "/repos/:owner/:name/check-names", Op::ListCheckNames, &[]),
245 // Rulesets: a repository's, a workspace's, the rules of one branch,
246 // and how they judged pushes and merges.
247 route("GET", "/repos/:owner/:name/rulesets", Op::Rules(RulesOp::ListRepoRulesets), &[("include_parents", "include_parents")]),
248 route("POST", "/repos/:owner/:name/rulesets", Op::Rules(RulesOp::CreateRepoRuleset), &[]),
249 route("GET", "/repos/:owner/:name/rulesets/:id", Op::Rules(RulesOp::GetRepoRuleset), &[]),
250 route("PUT", "/repos/:owner/:name/rulesets/:id", Op::Rules(RulesOp::UpdateRepoRuleset), &[]),
251 route("DELETE", "/repos/:owner/:name/rulesets/:id", Op::Rules(RulesOp::DeleteRepoRuleset), &[]),
252 route("GET", "/repos/:owner/:name/rules/branches/:branch", Op::Rules(RulesOp::GetBranchRules), &[("target", "target")]),
253 route(
254 "GET",
255 "/repos/:owner/:name/rules/evaluations",
256 Op::Rules(RulesOp::ListRuleEvaluations),
257 &[("ruleset_id", "ruleset_id"), ("verdict", "verdict"), ("problems_only", "problems_only"), ("before", "before"), ("limit", "limit")],
258 ),
259 route("GET", "/workspaces/:workspace/rulesets", Op::Rules(RulesOp::ListWorkspaceRulesets), &[]),
260 route("POST", "/workspaces/:workspace/rulesets", Op::Rules(RulesOp::CreateWorkspaceRuleset), &[]),
261 route("GET", "/workspaces/:workspace/rulesets/:id", Op::Rules(RulesOp::GetWorkspaceRuleset), &[]),
262 route("PUT", "/workspaces/:workspace/rulesets/:id", Op::Rules(RulesOp::UpdateWorkspaceRuleset), &[]),
263 route("DELETE", "/workspaces/:workspace/rulesets/:id", Op::Rules(RulesOp::DeleteWorkspaceRuleset), &[]),
264 route(
265 "GET",
266 "/workspaces/:workspace/rules/evaluations",
267 Op::Rules(RulesOp::ListWorkspaceRuleEvaluations),
268 &[("ruleset_id", "ruleset_id"), ("verdict", "verdict"), ("problems_only", "problems_only"), ("before", "before"), ("limit", "limit")],
269 ),
270 // Deployments wherever they run, their statuses, and environments.
271 route(
272 "GET",
273 "/repos/:owner/:name/deployments",
274 Op::Deployments(DeploymentsOp::ListDeployments),
275 &[("environment", "environment"), ("ref", "ref"), ("sha", "sha"), ("task", "task"), ("state", "state"), ("source", "source"), ("creator", "creator"), ("page", "page"), ("per_page", "per_page")],
276 ),
277 route("POST", "/repos/:owner/:name/deployments", Op::Deployments(DeploymentsOp::CreateDeployment), &[]),
278 route("GET", "/repos/:owner/:name/deployments/:id", Op::Deployments(DeploymentsOp::GetDeployment), &[]),
279 route("GET", "/repos/:owner/:name/deployments/:id/statuses", Op::Deployments(DeploymentsOp::ListDeploymentStatuses), &[]),
280 route("POST", "/repos/:owner/:name/deployments/:id/statuses", Op::Deployments(DeploymentsOp::CreateDeploymentStatus), &[]),
281 route("GET", "/repos/:owner/:name/environments", Op::Deployments(DeploymentsOp::ListEnvironments), &[]),
282 route("GET", "/repos/:owner/:name/environments/:environment", Op::Deployments(DeploymentsOp::GetEnvironment), &[]),
283 route("GET", "/repos/:owner/:name/queue", Op::GetMergeQueue, &[]),
284 route(
285 "POST",
286 "/repos/:owner/:name/pulls/:number/messages",
287 Op::MessageAgent,
288 &[],
289 ),
290 route(
291 "POST",
292 "/repos/:owner/:name/pulls/:number/messages/take",
293 Op::TakeMessages,
294 &[],
295 ),
296 route(
297 "POST",
298 "/repos/:owner/:name/messages/:id/answer",
299 Op::AnswerMessage,
300 &[],
301 ),
302 route("POST", "/repos/:owner/:name/memory", Op::Remember, &[]),
303 route(
304 "GET",
305 "/repos/:owner/:name/memory",
306 Op::Recall,
307 &[("q", "query"), ("limit", "limit")],
308 ),
309 route(
310 "GET",
311 "/repos/:owner/:name/events",
312 Op::ListEvents,
313 &[("before", "before")],
314 ),
315 route("GET", "/repos/:owner/:name/labels", Op::ListLabels, &[]),
316 route("POST", "/repos/:owner/:name/labels", Op::CreateLabel, &[]),
317 route("POST", "/repos/:owner/:name/labels/defaults", Op::AddDefaultLabels, &[]),
318 route("PATCH", "/repos/:owner/:name/labels/:label", Op::UpdateLabel, &[]),
319 route("DELETE", "/repos/:owner/:name/labels/:label", Op::DeleteLabel, &[]),
320 route("GET", "/repos/:owner/:name/issues/:number/labels", Op::ListIssueLabels, &[]),
321 route("POST", "/repos/:owner/:name/issues/:number/labels", Op::AddIssueLabels, &[]),
322 route("PUT", "/repos/:owner/:name/issues/:number/labels", Op::SetIssueLabels, &[]),
323 route("DELETE", "/repos/:owner/:name/issues/:number/labels", Op::RemoveIssueLabels, &[]),
324 route("DELETE", "/repos/:owner/:name/issues/:number/labels/:label", Op::RemoveIssueLabels, &[]),
325 route("GET", "/repos/:owner/:name/milestones", Op::ListMilestones, &[("state", "state")]),
326 route("POST", "/repos/:owner/:name/milestones", Op::CreateMilestone, &[]),
327 route("GET", "/repos/:owner/:name/milestones/:milestone", Op::GetMilestone, &[]),
328 route("PATCH", "/repos/:owner/:name/milestones/:milestone", Op::UpdateMilestone, &[]),
329 route("DELETE", "/repos/:owner/:name/milestones/:milestone", Op::DeleteMilestone, &[]),
330 route(
331 "GET",
332 "/repos/:owner/:name/issues",
333 Op::ListIssues,
334 &[("state", "state"), ("label", "label"), ("milestone", "milestone")],
335 ),
336 route("POST", "/repos/:owner/:name/issues", Op::CreateIssue, &[]),
337 route(
338 "GET",
339 "/repos/:owner/:name/issues/:number",
340 Op::GetIssue,
341 &[],
342 ),
343 route(
344 "PATCH",
345 "/repos/:owner/:name/issues/:number",
346 Op::UpdateIssue,
347 &[],
348 ),
349 route(
350 "POST",
351 "/repos/:owner/:name/issues/:number/close",
352 Op::CloseIssue,
353 &[],
354 ),
355 route(
356 "POST",
357 "/repos/:owner/:name/issues/:number/reopen",
358 Op::ReopenIssue,
359 &[],
360 ),
361 route(
362 "POST",
363 "/repos/:owner/:name/issues/:number/assign",
364 Op::AssignIssue,
365 &[],
366 ),
367 route(
368 "POST",
369 "/repos/:owner/:name/issues/import",
370 Op::ImportIssue,
371 &[],
372 ),
373 route(
374 "POST",
375 "/repos/:owner/:name/issues/delegate",
376 Op::Delegate,
377 &[],
378 ),
379 route(
380 "GET",
381 "/repos/:owner/:name/context",
382 Op::GetContext,
383 &[("reference", "reference")],
384 ),
385 route(
386 "GET",
387 "/workspaces/:workspace/context/search",
388 Op::SearchContext,
389 &[("q", "query"), ("project", "project"), ("kinds", "kinds"), ("limit", "limit")],
390 ),
391 route(
392 "GET",
393 "/workspaces/:workspace/context/:kind/:id",
394 Op::GetEntity,
395 &[],
396 ),
397 route(
398 "GET",
399 "/workspaces/:workspace/integrations",
400 Op::ListIntegrations,
401 &[],
402 ),
403 route(
404 "POST",
405 "/workspaces/:workspace/integrations",
406 Op::ConnectIntegration,
407 &[],
408 ),
409 route(
410 "GET",
411 "/repos/:owner/:name/hooks",
412 Op::ListWebhooks,
413 &[],
414 ),
415 route(
416 "POST",
417 "/repos/:owner/:name/hooks",
418 Op::CreateWebhook,
419 &[],
420 ),
421 route(
422 "PATCH",
423 "/repos/:owner/:name/hooks/:id",
424 Op::UpdateWebhook,
425 &[],
426 ),
427 route(
428 "DELETE",
429 "/repos/:owner/:name/hooks/:id",
430 Op::DeleteWebhook,
431 &[],
432 ),
433 route(
434 "POST",
435 "/repos/:owner/:name/hooks/:id/pings",
436 Op::PingWebhook,
437 &[],
438 ),
439 route(
440 "GET",
441 "/repos/:owner/:name/hooks/:id/deliveries",
442 Op::ListWebhookDeliveries,
443 &[],
444 ),
445 route(
446 "POST",
447 "/repos/:owner/:name/hooks/:id/deliveries/:delivery/redeliver",
448 Op::RedeliverWebhook,
449 &[],
450 ),
451 route(
452 "GET",
453 "/workspaces/:workspace/hooks",
454 Op::ListWebhooks,
455 &[],
456 ),
457 route(
458 "POST",
459 "/workspaces/:workspace/hooks",
460 Op::CreateWebhook,
461 &[],
462 ),
463 route(
464 "PATCH",
465 "/workspaces/:workspace/hooks/:id",
466 Op::UpdateWebhook,
467 &[],
468 ),
469 route(
470 "DELETE",
471 "/workspaces/:workspace/hooks/:id",
472 Op::DeleteWebhook,
473 &[],
474 ),
475 route(
476 "POST",
477 "/workspaces/:workspace/hooks/:id/pings",
478 Op::PingWebhook,
479 &[],
480 ),
481 route(
482 "GET",
483 "/workspaces/:workspace/hooks/:id/deliveries",
484 Op::ListWebhookDeliveries,
485 &[],
486 ),
487 route(
488 "POST",
489 "/workspaces/:workspace/hooks/:id/deliveries/:delivery/redeliver",
490 Op::RedeliverWebhook,
491 &[],
492 ),
493 route(
494 "GET",
495 "/workspaces/:workspace/model-routes",
496 Op::GetModelRoutes,
497 &[],
498 ),
499 route(
500 "PUT",
501 "/workspaces/:workspace/model-routes",
502 Op::SetModelRoutes,
503 &[],
504 ),
505 route(
506 "DELETE",
507 "/workspaces/:workspace/integrations/:id",
508 Op::DisconnectIntegration,
509 &[],
510 ),
511 route(
512 "POST",
513 "/workspaces/:workspace/integrations/:id/test",
514 Op::TestIntegration,
515 &[],
516 ),
517 route(
518 "GET",
519 "/repos/:owner/:name/actions/workflows",
520 Op::ListWorkflows,
521 &[],
522 ),
523 route(
524 "GET",
525 "/repos/:owner/:name/actions/workflows/:workflow/runs",
526 Op::ListWorkflowRuns,
527 &[("branch", "branch"), ("event", "event"), ("per_page", "limit")],
528 ),
529 route(
530 "POST",
531 "/repos/:owner/:name/actions/workflows/:workflow/dispatches",
532 Op::DispatchWorkflow,
533 &[],
534 ),
535 route(
536 "PATCH",
537 "/repos/:owner/:name/actions/workflows/:workflow",
538 Op::UpdateWorkflow,
539 &[],
540 ),
541 route(
542 "PUT",
543 "/repos/:owner/:name/actions/workflows/:workflow/enable",
544 Op::UpdateWorkflow,
545 &[],
546 ),
547 route(
548 "PUT",
549 "/repos/:owner/:name/actions/workflows/:workflow/disable",
550 Op::UpdateWorkflow,
551 &[],
552 ),
553 route(
554 "GET",
555 "/repos/:owner/:name/actions/runs",
556 Op::ListWorkflowRuns,
557 &[("workflow", "workflow"), ("branch", "branch"), ("event", "event"), ("pull", "pull"), ("head_sha", "sha"), ("per_page", "limit")],
558 ),
559 route(
560 "GET",
561 "/repos/:owner/:name/actions/runs/:id",
562 Op::GetWorkflowRun,
563 &[],
564 ),
565 route(
566 "POST",
567 "/repos/:owner/:name/actions/runs/:id/cancel",
568 Op::CancelWorkflowRun,
569 &[],
570 ),
571 route(
572 "POST",
573 "/repos/:owner/:name/actions/runs/:id/rerun",
574 Op::RerunWorkflowRun,
575 &[],
576 ),
577 route(
578 "POST",
579 "/repos/:owner/:name/actions/runs/:id/rerun-failed-jobs",
580 Op::RerunWorkflowRun,
581 &[],
582 ),
583 route(
584 "GET",
585 "/repos/:owner/:name/actions/jobs/:job/logs",
586 Op::GetJobLogs,
587 &[("after", "after")],
588 ),
589 route(
590 "GET",
591 "/repos/:owner/:name/actions/secrets",
592 Op::ListActionsSecrets,
593 &[],
594 ),
595 route(
596 "PUT",
597 "/repos/:owner/:name/actions/secrets/:setting",
598 Op::SetActionsSecret,
599 &[],
600 ),
601 route(
602 "DELETE",
603 "/repos/:owner/:name/actions/secrets/:setting",
604 Op::DeleteActionsSecret,
605 &[],
606 ),
607 route(
608 "GET",
609 "/repos/:owner/:name/actions/variables",
610 Op::ListActionsVariables,
611 &[],
612 ),
613 route(
614 "POST",
615 "/repos/:owner/:name/actions/variables",
616 Op::SetActionsVariable,
617 &[],
618 ),
619 route(
620 "PATCH",
621 "/repos/:owner/:name/actions/variables/:setting",
622 Op::SetActionsVariable,
623 &[],
624 ),
625 route(
626 "DELETE",
627 "/repos/:owner/:name/actions/variables/:setting",
628 Op::DeleteActionsVariable,
629 &[],
630 ),
631 route(
632 "GET",
633 "/workspaces/:workspace/actions/secrets",
634 Op::ListActionsSecrets,
635 &[],
636 ),
637 route(
638 "PUT",
639 "/workspaces/:workspace/actions/secrets/:setting",
640 Op::SetActionsSecret,
641 &[],
642 ),
643 route(
644 "DELETE",
645 "/workspaces/:workspace/actions/secrets/:setting",
646 Op::DeleteActionsSecret,
647 &[],
648 ),
649 route(
650 "GET",
651 "/workspaces/:workspace/actions/variables",
652 Op::ListActionsVariables,
653 &[],
654 ),
655 route(
656 "POST",
657 "/workspaces/:workspace/actions/variables",
658 Op::SetActionsVariable,
659 &[],
660 ),
661 route(
662 "PATCH",
663 "/workspaces/:workspace/actions/variables/:setting",
664 Op::SetActionsVariable,
665 &[],
666 ),
667 route(
668 "DELETE",
669 "/workspaces/:workspace/actions/variables/:setting",
670 Op::DeleteActionsVariable,
671 &[],
672 ),
673 // Self-hosted runners: a repository's own, or a workspace's.
674 route("GET", "/repos/:owner/:name/actions/runners", Op::ListRunners, &[]),
675 route("POST", "/repos/:owner/:name/actions/runners/registration-token", Op::CreateRunnerRegistrationToken, &[]),
676 route("DELETE", "/repos/:owner/:name/actions/runners/:id", Op::RemoveRunner, &[]),
677 route("GET", "/repos/:owner/:name/actions/runner-settings", Op::GetRunnerSettings, &[]),
678 route("PATCH", "/repos/:owner/:name/actions/runner-settings", Op::UpdateRunnerSettings, &[]),
679 route("GET", "/workspaces/:workspace/actions/runners", Op::ListRunners, &[]),
680 route("POST", "/workspaces/:workspace/actions/runners/registration-token", Op::CreateRunnerRegistrationToken, &[]),
681 route("DELETE", "/workspaces/:workspace/actions/runners/:id", Op::RemoveRunner, &[]),
682 route("GET", "/workspaces/:workspace/actions/runner-settings", Op::GetRunnerSettings, &[]),
683 route("PATCH", "/workspaces/:workspace/actions/runner-settings", Op::UpdateRunnerSettings, &[]),
684 route("GET", "/workspaces/:workspace/actions/runner-groups", Op::ListRunnerGroups, &[]),
685 route("POST", "/workspaces/:workspace/actions/runner-groups", Op::CreateRunnerGroup, &[]),
686 route("PATCH", "/workspaces/:workspace/actions/runner-groups/:id", Op::UpdateRunnerGroup, &[]),
687 route("DELETE", "/workspaces/:workspace/actions/runner-groups/:id", Op::DeleteRunnerGroup, &[]),
688 route("POST", "/repos/:owner/:name/plans", Op::PlanWork, &[]),
689 route("GET", "/repos/:owner/:name/plans/:plan", Op::GetPlan, &[]),
690 route(
691 "POST",
692 "/repos/:owner/:name/plans/:plan/apply",
693 Op::ApplyPlan,
694 &[],
695 ),
696 route(
697 "POST",
698 "/repos/:owner/:name/issues/:number/comments",
699 Op::AddComment,
700 &[],
701 ),
702 route(
703 "GET",
704 "/repos/:owner/:name/pulls",
705 Op::ListPullRequests,
706 &[("state", "state"), ("label", "label"), ("milestone", "milestone"), ("base", "base")],
707 ),
708 route(
709 "POST",
710 "/repos/:owner/:name/pulls",
711 Op::CreatePullRequest,
712 &[],
713 ),
714 route(
715 "GET",
716 "/repos/:owner/:name/pulls/:number",
717 Op::GetPullRequest,
718 &[],
719 ),
720 route(
721 "PATCH",
722 "/repos/:owner/:name/pulls/:number",
723 Op::UpdatePullRequest,
724 &[],
725 ),
726 route(
727 "GET",
728 "/repos/:owner/:name/pulls/:number/changes",
729 Op::GetPullRequestChanges,
730 &[],
731 ),
732 route(
733 "POST",
734 "/repos/:owner/:name/pulls/:number/reviews",
735 Op::ReviewPullRequest,
736 &[],
737 ),
738 route(
739 "POST",
740 "/repos/:owner/:name/pulls/:number/requested_reviewers",
741 Op::RequestReviewers,
742 &[],
743 ),
744 route(
745 "DELETE",
746 "/repos/:owner/:name/pulls/:number/requested_reviewers",
747 Op::RemoveRequestedReviewers,
748 &[],
749 ),
750 route(
751 "GET",
752 "/repos/:owner/:name/pulls/:number/session",
753 Op::ReadSession,
754 &[("after", "after")],
755 ),
756 route(
757 "POST",
758 "/repos/:owner/:name/pulls/:number/session",
759 Op::RecordSession,
760 &[],
761 ),
762 route(
763 "POST",
764 "/repos/:owner/:name/pulls/:number/ready",
765 Op::MarkPullRequestReady,
766 &[],
767 ),
768 route(
769 "POST",
770 "/repos/:owner/:name/pulls/:number/close",
771 Op::ClosePullRequest,
772 &[],
773 ),
774 route(
775 "POST",
776 "/repos/:owner/:name/pulls/:number/merge",
777 Op::MergePullRequest,
778 &[],
779 ),
780];
781
782impl Route {
783 /// The names of the route's path parameters, in order.
784 pub fn params(&self) -> impl Iterator<Item = &'static str> {
785 self.path
786 .split('/')
787 .filter_map(|segment| segment.strip_prefix(':'))
788 }
789
790 /// The values of the path parameters, if `path` is this route's.
791 fn matches<'a>(&self, path: &'a str) -> Option<Vec<(&'static str, &'a str)>> {
792 let mut values = Vec::new();
793 let mut actual = path.trim_end_matches('/').split('/');
794 for expected in self.path.split('/') {
795 let segment = actual.next()?;
796 match expected.strip_prefix(':') {
797 Some(name) if !segment.is_empty() => values.push((name, segment)),
798 Some(_) => return None,
799 None if expected == segment => {}
800 None => return None,
801 }
802 }
803 actual.next().is_none().then_some(values)
804 }
805}
806
807/// A path segment with its `%XX` escapes decoded; as given when that is not
808/// UTF-8.
809fn percent_decoded(segment: &str) -> String {
810 let bytes = segment.as_bytes();
811 let mut out = Vec::with_capacity(bytes.len());
812 let mut i = 0;
813 while i < bytes.len() {
814 let escaped = (bytes[i] == b'%')
815 .then(|| segment.get(i + 1..i + 3))
816 .flatten()
817 .filter(|hex| hex.bytes().all(|byte| byte.is_ascii_hexdigit()))
818 .and_then(|hex| u8::from_str_radix(hex, 16).ok());
819 match escaped {
820 Some(byte) => {
821 out.push(byte);
822 i += 3;
823 }
824 None => {
825 out.push(bytes[i]);
826 i += 1;
827 }
828 }
829 }
830 String::from_utf8(out).unwrap_or_else(|_| segment.to_owned())
831}
832
833/// The route for a request, and the operation input it describes.
834///
835/// The input is the JSON body, overlaid with the query parameters the route
836/// reads and then with what the path names: `owner` and `name` become
837/// `repo`, as does a team's `repo` with its `workspace`, and `number`
838/// becomes an integer.
839pub fn resolve(
840 method: &str,
841 path: &str,
842 query: &[(String, String)],
843 body: Value,
844) -> Option<(&'static Route, Value)> {
845 let (route, params) = ROUTES
846 .iter()
847 .filter(|route| route.method == method)
848 .find_map(|route| Some((route, route.matches(path)?)))?;
849
850 let mut input = match body {
851 Value::Object(fields) => fields,
852 _ => Map::new(),
853 };
854 for (name, key) in route.query {
855 if let Some((_, value)) = query.iter().find(|(query_name, _)| query_name == name) {
856 input.insert((*key).to_owned(), Value::String(value.clone()));
857 }
858 }
859 let param = |wanted: &str| {
860 params
861 .iter()
862 .find(|(name, _)| *name == wanted)
863 .map(|(_, value)| *value)
864 };
865 if let (Some(owner), Some(name)) = (param("owner"), param("name")) {
866 input.insert("repo".to_owned(), Value::String(format!("{owner}/{name}")));
867 }
868 for key in ["plan", "id", "workspace", "delivery", "workflow", "job", "setting", "username", "team", "basehead"] {
869 if let Some(value) = param(key) {
870 input.insert(key.to_owned(), Value::String(value.to_owned()));
871 }
872 }
873 // A repository of a team's workspace, named by itself.
874 if let (Some(workspace), Some(name)) = (param("workspace"), param("repo")) {
875 input.insert("repo".to_owned(), Value::String(format!("{workspace}/{name}")));
876 }
877 // A branch name may hold slashes, sent URL-encoded as one segment, and
878 // a label's name spaces.
879 if let Some(branch) = param("branch") {
880 input.insert("branch".to_owned(), Value::String(percent_decoded(branch)));
881 }
882 // An environment's name may hold slashes and spaces, URL-encoded.
883 if let Some(environment) = param("environment") {
884 input.insert("environment".to_owned(), Value::String(percent_decoded(environment)));
885 }
886 if let Some(label) = param("label") {
887 input.insert("label".to_owned(), Value::String(percent_decoded(label)));
888 }
889 if let Some(milestone) = param("milestone") {
890 // Not a number: zero, which no milestone has.
891 input.insert("milestone".to_owned(), milestone.parse::<u32>().unwrap_or(0).into());
892 }
893 // GitHub says some things with the path alone.
894 if route.path.ends_with("/enable") || route.path.ends_with("/disable") {
895 input.insert("enabled".to_owned(), Value::Bool(route.path.ends_with("/enable")));
896 }
897 if route.path.ends_with("/rerun-failed-jobs") {
898 input.insert("failed_only".to_owned(), Value::Bool(true));
899 }
900 // Unsaving and waking a thread are a DELETE of what PUT made.
901 if route.method == "DELETE" && route.path.ends_with("/saved") {
902 input.insert("saved".to_owned(), Value::Bool(false));
903 }
904 if route.method == "DELETE" && route.path.ends_with("/snooze") {
905 input.remove("until");
906 }
907 if let Some(number) = param("number") {
908 // Not a number: zero, which no issue or pull request has.
909 input.insert(
910 "number".to_owned(),
911 number.parse::<u32>().unwrap_or(0).into(),
912 );
913 }
914 Some((route, Value::Object(input)))
915}
916
917#[cfg(test)]
918mod tests {
919 use serde_json::json;
920
921 use super::*;
922
923 #[test]
924 fn a_path_resolves_to_its_operation_and_input() {
925 let (route, input) = resolve(
926 "POST",
927 "/repos/flagon-io/hello/pulls/14/merge",
928 &[],
929 json!({ "keep_issue_open": true, "number": 99, "repo": "someone/else" }),
930 )
931 .unwrap();
932 assert_eq!(route.op, Op::MergePullRequest);
933 // What the path names wins over the body.
934 assert_eq!(
935 input,
936 json!({ "keep_issue_open": true, "number": 14, "repo": "flagon-io/hello" })
937 );
938 }
939
940 #[test]
941 fn a_branch_with_slashes_is_one_encoded_segment() {
942 let (route, input) = resolve(
943 "POST",
944 "/repos/flagon-io/hello/branches/feature%2Flogin/rename",
945 &[],
946 json!({ "new_name": "feature/sign-in" }),
947 )
948 .unwrap();
949 assert_eq!(route.op, Op::RenameBranch);
950 assert_eq!(
951 input,
952 json!({ "new_name": "feature/sign-in", "branch": "feature/login", "repo": "flagon-io/hello" })
953 );
954 assert_eq!(percent_decoded("100%"), "100%");
955 assert_eq!(percent_decoded("a%2bb%zz"), "a+b%zz");
956 }
957
958 #[test]
959 fn a_collaborator_is_named_by_username() {
960 let (route, input) = resolve(
961 "PATCH",
962 "/repos/flagon-io/hello/collaborators/ada",
963 &[],
964 json!({ "role": "maintain" }),
965 )
966 .unwrap();
967 assert_eq!(route.op, Op::UpdateCollaborator);
968 assert_eq!(input, json!({ "role": "maintain", "username": "ada", "repo": "flagon-io/hello" }));
969 let (route, input) = resolve("DELETE", "/user/repository_invitations/rin_1", &[], Value::Null).unwrap();
970 assert_eq!(route.op, Op::DeclineRepoInvitation);
971 assert_eq!(input, json!({ "id": "rin_1" }));
972 }
973
974 #[test]
975 fn teams_are_addressed_by_workspace_and_slug() {
976 let query = [("q".to_owned(), "back".to_owned())];
977 let (route, input) = resolve("GET", "/workspaces/acme/teams", &query, Value::Null).unwrap();
978 assert_eq!(route.op, Op::ListTeams);
979 assert_eq!(input, json!({ "query": "back", "workspace": "acme" }));
980 let (route, input) = resolve("PATCH", "/workspaces/acme/teams/backend", &[], json!({ "name": "Back end" })).unwrap();
981 assert_eq!(route.op, Op::UpdateTeam);
982 assert_eq!(input, json!({ "name": "Back end", "workspace": "acme", "team": "backend" }));
983 let (route, input) =
984 resolve("PUT", "/workspaces/acme/teams/backend/members/ana", &[], json!({ "role": "maintainer" })).unwrap();
985 assert_eq!(route.op, Op::SetTeamMember);
986 assert_eq!(input, json!({ "role": "maintainer", "workspace": "acme", "team": "backend", "username": "ana" }));
987 let query = [("include_child_teams".to_owned(), "true".to_owned())];
988 let (route, input) = resolve("GET", "/workspaces/acme/teams/backend/members", &query, Value::Null).unwrap();
989 assert_eq!(route.op, Op::ListTeamMembers);
990 assert_eq!(input, json!({ "include_child_teams": "true", "workspace": "acme", "team": "backend" }));
991 // A repository is named by itself, in the team's workspace.
992 let (route, input) =
993 resolve("PUT", "/workspaces/acme/teams/backend/repos/rocket", &[], json!({ "role": "write" })).unwrap();
994 assert_eq!(route.op, Op::SetTeamRepo);
995 assert_eq!(input, json!({ "role": "write", "workspace": "acme", "team": "backend", "repo": "acme/rocket" }));
996 let op = |method: &str, path: &str| resolve(method, path, &[], Value::Null).unwrap().0.op;
997 assert_eq!(op("DELETE", "/workspaces/acme/teams/backend/repos/rocket"), Op::RemoveTeamRepo);
998 assert_eq!(op("GET", "/workspaces/acme/teams/backend/teams"), Op::ListChildTeams);
999 assert_eq!(op("GET", "/workspaces/acme/teams/backend/repos"), Op::ListTeamRepos);
1000 assert_eq!(op("PUT", "/workspaces/acme/teams/backend/review_assignment"), Op::SetTeamReviewAssignment);
1001 assert_eq!(op("DELETE", "/workspaces/acme/teams/backend"), Op::DeleteTeam);
1002 assert_eq!(op("POST", "/workspaces/acme/teams"), Op::CreateTeam);
1003 assert_eq!(op("GET", "/workspaces/acme/teams/backend"), Op::GetTeam);
1004 assert_eq!(op("DELETE", "/workspaces/acme/teams/backend/members/ana"), Op::RemoveTeamMember);
1005 let (route, input) = resolve("GET", "/workspaces/acme/members/ana/teams", &[], Value::Null).unwrap();
1006 assert_eq!(route.op, Op::ListUserTeams);
1007 assert_eq!(input, json!({ "workspace": "acme", "username": "ana" }));
1008 }
1009
1010 #[test]
1011 fn reviewers_are_requested_and_code_owners_checked_on_a_repository() {
1012 let body = json!({ "reviewers": ["ana"], "team_reviewers": ["backend"] });
1013 let (route, input) = resolve("POST", "/repos/acme/rocket/pulls/7/requested_reviewers", &[], body.clone()).unwrap();
1014 assert_eq!(route.op, Op::RequestReviewers);
1015 assert_eq!(
1016 input,
1017 json!({ "reviewers": ["ana"], "team_reviewers": ["backend"], "repo": "acme/rocket", "number": 7 })
1018 );
1019 let (route, _) = resolve("DELETE", "/repos/acme/rocket/pulls/7/requested_reviewers", &[], body).unwrap();
1020 assert_eq!(route.op, Op::RemoveRequestedReviewers);
1021 let query = [("ref".to_owned(), "main".to_owned())];
1022 let (route, input) = resolve("GET", "/repos/acme/rocket/codeowners/errors", &query, Value::Null).unwrap();
1023 assert_eq!(route.op, Op::GetCodeownersErrors);
1024 assert_eq!(input, json!({ "ref": "main", "repo": "acme/rocket" }));
1025 }
1026
1027 #[test]
1028 fn notifications_are_addressed_as_threads_and_by_issue() {
1029 let (route, input) = resolve("DELETE", "/notifications/threads/ntf_1", &[], Value::Null).unwrap();
1030 assert_eq!(route.op, Op::MarkThreadDone);
1031 assert_eq!(input, json!({ "id": "ntf_1" }));
1032 let (route, input) = resolve("DELETE", "/notifications/threads/ntf_1/saved", &[], Value::Null).unwrap();
1033 assert_eq!(route.op, Op::SaveThread);
1034 assert_eq!(input, json!({ "id": "ntf_1", "saved": false }));
1035 let (route, input) = resolve("DELETE", "/notifications/threads/ntf_1/snooze", &[], json!({ "until": "x" })).unwrap();
1036 assert_eq!(route.op, Op::SnoozeThread);
1037 assert_eq!(input, json!({ "id": "ntf_1" }));
1038 let query = [("all".to_owned(), "true".to_owned()), ("per_page".to_owned(), "50".to_owned())];
1039 let (route, input) = resolve("GET", "/repos/acme/rocket/notifications", &query, Value::Null).unwrap();
1040 assert_eq!(route.op, Op::ListNotifications);
1041 assert_eq!(input, json!({ "all": "true", "per_page": "50", "repo": "acme/rocket" }));
1042 let (route, input) = resolve("PUT", "/repos/acme/rocket/issues/7/subscription", &[], json!({ "ignored": true })).unwrap();
1043 assert_eq!(route.op, Op::SetThreadSubscription);
1044 assert_eq!(input, json!({ "ignored": true, "number": 7, "repo": "acme/rocket" }));
1045 assert_eq!(resolve("GET", "/user/subscriptions", &[], Value::Null).unwrap().0.op, Op::ListWatchedRepos);
1046 }
1047
1048 #[test]
1049 fn labels_and_milestones_are_named_in_the_path() {
1050 let (route, input) = resolve("PATCH", "/repos/acme/web/labels/good%20first%20issue", &[], json!({ "color": "7057ff" })).unwrap();
1051 assert_eq!(route.op, Op::UpdateLabel);
1052 assert_eq!(input, json!({ "color": "7057ff", "label": "good first issue", "repo": "acme/web" }));
1053 let (route, input) = resolve("DELETE", "/repos/acme/web/issues/7/labels/bug", &[], Value::Null).unwrap();
1054 assert_eq!(route.op, Op::RemoveIssueLabels);
1055 assert_eq!(input, json!({ "label": "bug", "number": 7, "repo": "acme/web" }));
1056 let (route, input) = resolve("DELETE", "/repos/acme/web/issues/7/labels", &[], Value::Null).unwrap();
1057 assert_eq!(route.op, Op::RemoveIssueLabels);
1058 assert_eq!(input, json!({ "number": 7, "repo": "acme/web" }));
1059 let (route, _) = resolve("POST", "/repos/acme/web/labels/defaults", &[], Value::Null).unwrap();
1060 assert_eq!(route.op, Op::AddDefaultLabels);
1061 let (route, input) = resolve("PATCH", "/repos/acme/web/milestones/3", &[], json!({ "state": "closed" })).unwrap();
1062 assert_eq!(route.op, Op::UpdateMilestone);
1063 assert_eq!(input, json!({ "state": "closed", "milestone": 3, "repo": "acme/web" }));
1064 let (route, input) = resolve("PATCH", "/repos/acme/web/pulls/9", &[], json!({ "base": "release" })).unwrap();
1065 assert_eq!(route.op, Op::UpdatePullRequest);
1066 assert_eq!(input, json!({ "base": "release", "number": 9, "repo": "acme/web" }));
1067 }
1068
1069 #[test]
1070 fn billing_is_addressed_by_workspace() {
1071 let query = [("from".to_owned(), "2026-10-01".to_owned()), ("products".to_owned(), "agent,sandboxes".to_owned())];
1072 let (route, input) = resolve("GET", "/workspaces/acme/usage", &query, Value::Null).unwrap();
1073 assert_eq!(route.op, Op::GetUsage);
1074 assert_eq!(input, json!({ "from": "2026-10-01", "products": "agent,sandboxes", "workspace": "acme" }));
1075 let (route, input) = resolve("PUT", "/workspaces/acme/budget", &[], json!({ "alerts": [50] })).unwrap();
1076 assert_eq!(route.op, Op::SetBudget);
1077 assert_eq!(input, json!({ "alerts": [50], "workspace": "acme" }));
1078 let op = |method: &str, path: &str| resolve(method, path, &[], Value::Null).unwrap().0.op;
1079 assert_eq!(op("GET", "/workspaces/acme/budget"), Op::GetBudget);
1080 assert_eq!(op("GET", "/workspaces/acme/ai_credit"), Op::GetAiCredit);
1081 assert_eq!(op("POST", "/workspaces/acme/ai_credit/checkout"), Op::BuyAiCredit);
1082 assert_eq!(op("GET", "/workspaces/acme/invoices"), Op::ListInvoices);
1083 assert_eq!(op("GET", "/workspaces/acme/billing_details"), Op::GetBillingDetails);
1084 assert_eq!(op("GET", "/workspaces/acme/gateway/requests"), Op::ListGatewayRequests);
1085 }
1086
1087 #[test]
1088 fn query_parameters_are_renamed() {
1089 let query = [
1090 ("q".to_owned(), "parser".to_owned()),
1091 ("x".to_owned(), "y".to_owned()),
1092 ];
1093 let (route, input) = resolve("GET", "/repos", &query, Value::Null).unwrap();
1094 assert_eq!(route.op, Op::ListRepos);
1095 assert_eq!(input, json!({ "query": "parser" }));
1096 }
1097
1098 #[test]
1099 fn method_and_shape_must_match() {
1100 assert!(resolve("GET", "/repos/a/b/issues/1/close", &[], Value::Null).is_none());
1101 assert!(resolve("GET", "/repos/a", &[], Value::Null).is_none());
1102 assert!(resolve("GET", "/repos/a/b/issues/1/extra", &[], Value::Null).is_none());
1103 assert!(resolve("GET", "/repos/a/b/", &[], Value::Null).is_some());
1104 }
1105
1106 #[test]
1107 fn every_parameter_and_query_name_is_an_input() {
1108 for route in ROUTES {
1109 let properties = route.op.properties();
1110 for (_, key) in route.query {
1111 assert!(properties.contains_key(*key), "{}: {key}", route.path);
1112 }
1113 for name in route.params() {
1114 let covered = matches!(name, "owner" | "name") && properties.contains_key("repo")
1115 || properties.contains_key(name);
1116 assert!(covered, "{}: {name}", route.path);
1117 }
1118 }
1119 }
1120
1121 #[test]
1122 fn every_operation_has_a_route() {
1123 for op in Op::ALL {
1124 assert!(ROUTES.iter().any(|route| route.op == op), "{}", op.name());
1125 }
1126 }
1127}