Skip to content

g1t/apps/api/src/openapi.rs

946 lines39,765 bytesCodeBlame
1//! The OpenAPI document, generated from the same list the routes are.
2//!
3//! The docs site builds its API reference from a copy of this document,
4//! `apps/docs/src/data/openapi.json`. A test keeps the copy current: run
5//! `G1T_WRITE_OPENAPI=1 cargo test -p g1t-api openapi` to rewrite it.
6
7use g1t_contracts::scopes::scope_for;
8use serde_json::{Map, Value, json};
9
10use crate::operations::Op;
11use crate::rest::{ROUTES, Route};
12
13/// The sections of the API reference: a name, what it covers, and its
14/// operations in the order a reader meets them.
15const SECTIONS: &[(&str, &str, &[Op])] = &[
16 (
17 "Accounts",
18 "Signing in from a tool, who a token acts as, and your email addresses.",
19 &[Op::Whoami, Op::ListEmails, Op::AddEmail, Op::RemoveEmail, Op::UpdateEmailSettings],
20 ),
21 (
22 "Notifications",
23 "Your inbox: a thread for each thing you were told about (an issue, a pull request, a workflow on a branch, a deployment), why you were told, and what you subscribe to and watch. Your own: personal tokens and sessions only.",
24 &[
25 Op::ListNotifications,
26 Op::MarkNotificationsRead,
27 Op::GetNotificationThread,
28 Op::MarkThreadRead,
29 Op::MarkThreadDone,
30 Op::SaveThread,
31 Op::SnoozeThread,
32 Op::GetThreadSubscription,
33 Op::SetThreadSubscription,
34 Op::DeleteThreadSubscription,
35 Op::GetRepoSubscription,
36 Op::SetRepoSubscription,
37 Op::DeleteRepoSubscription,
38 Op::ListWatchedRepos,
39 ],
40 ),
41 (
42 "Pinned projects",
43 "The projects you keep at the top of a workspace's sidebar, in your order, up to eight a workspace. Your own: personal tokens and sessions only.",
44 &[Op::ListPinnedProjects, Op::PinProject, Op::UnpinProject, Op::ReorderPinnedProjects],
45 ),
46 (
47 "Workspaces",
48 "A workspace owns repositories and is the first part of their address. People and agents work in workspaces.",
49 &[Op::CreateWorkspace, Op::UpdateWorkspace, Op::DeleteWorkspace],
50 ),
51 (
52 "Invites",
53 "While g1t is invite-only, every new account needs an invite. Your invites, and inviting people into a workspace by email.",
54 &[
55 Op::ListInvites,
56 Op::CreateInvite,
57 Op::RevokeInvite,
58 Op::ListWorkspaceInvites,
59 Op::InviteMember,
60 Op::RevokeWorkspaceInvite,
61 ],
62 ),
63 (
64 "Repositories",
65 "A repository, how it handles pull requests, and its timeline: renaming, archiving, moving and deleting it.",
66 &[
67 Op::ListRepos,
68 Op::CreateRepo,
69 Op::GetRepo,
70 Op::UpdateRepo,
71 Op::RenameRepo,
72 Op::RenameBranch,
73 Op::SetRepoVisibility,
74 Op::ArchiveRepo,
75 Op::UnarchiveRepo,
76 Op::TransferRepo,
77 Op::DeleteRepo,
78 Op::ListDeletedRepos,
79 Op::RestoreRepo,
80 Op::PurgeRepo,
81 Op::GetRepoSettings,
82 Op::UpdateRepoSettings,
83 Op::ListCheckNames,
84 Op::ListEvents,
85 ],
86 ),
87 (
88 "Access",
89 "Who can do what in a repository: repository roles, people given a role on one repository (outside collaborators when they are not members), invitations, and a workspace's base permission.",
90 &[
91 Op::ListCollaborators,
92 Op::AddCollaborator,
93 Op::UpdateCollaborator,
94 Op::RemoveCollaborator,
95 Op::GetCollaboratorPermission,
96 Op::ListRepoInvitations,
97 Op::RevokeRepoInvitation,
98 Op::ListMyRepoInvitations,
99 Op::AcceptRepoInvitation,
100 Op::DeclineRepoInvitation,
101 Op::SetBasePermission,
102 Op::ListOutsideCollaborators,
103 ],
104 ),
105 (
106 "Security",
107 "Secrets found in what is pushed and in a repository's history, and dependencies with known vulnerabilities: listing the alerts, and dismissing or reopening them.",
108 &[Op::ListSecurityAlerts, Op::DismissSecurityAlert, Op::ReopenSecurityAlert],
109 ),
110 (
111 "Issues",
112 "What should change in a repository, with labels and comments. Issues and pull requests share one sequence of numbers.",
113 &[
114 Op::ListIssues,
115 Op::CreateIssue,
116 Op::GetIssue,
117 Op::UpdateIssue,
118 Op::CloseIssue,
119 Op::ReopenIssue,
120 Op::AssignIssue,
121 Op::Delegate,
122 Op::AddComment,
123 Op::ListLabels,
124 ],
125 ),
126 (
127 "Plans",
128 "An outcome turned into the issues that would get there, with the order they must merge in.",
129 &[Op::PlanWork, Op::GetPlan, Op::ApplyPlan],
130 ),
131 (
132 "Pull requests",
133 "A proposed change in its own fork or on a branch. Several can be made for one issue; the one merged resolves it.",
134 &[
135 Op::ListPullRequests,
136 Op::CreatePullRequest,
137 Op::GetPullRequest,
138 Op::GetPullRequestChanges,
139 Op::MarkPullRequestReady,
140 Op::ReviewPullRequest,
141 Op::MergePullRequest,
142 Op::ClosePullRequest,
143 Op::GetMergeQueue,
144 Op::MessageAgent,
145 Op::AnswerMessage,
146 Op::TakeMessages,
147 ],
148 ),
149 (
150 "Sessions",
151 "The record of how a pull request was made: prompts, reasoning and the tools that ran.",
152 &[Op::ReadSession, Op::RecordSession],
153 ),
154 (
155 "Memory",
156 "What agents and people learned that the next agent should know, for one project or across a workspace. Members and g1t's agents only; never a secret.",
157 &[Op::Remember, Op::Recall],
158 ),
159 (
160 "Search",
161 "One search across all of g1t: repositories, code, issues, pull requests, people and workspaces. Public content for everyone, and private content in workspaces you belong to.",
162 &[Op::Search],
163 ),
164 (
165 "Context",
166 "A workspace's context hub: a catalog of what it builds and runs, built from its repositories, deployments and integrations, and one search across the catalog, docs, issues, pull requests and memory.",
167 &[Op::SearchContext, Op::GetEntity],
168 ),
169 (
170 "Actions",
171 "GitHub Actions workflows in .g1t/workflows, their runs, and their jobs' logs.",
172 &[
173 Op::ListWorkflows,
174 Op::ListWorkflowRuns,
175 Op::GetWorkflowRun,
176 Op::GetJobLogs,
177 Op::DispatchWorkflow,
178 Op::CancelWorkflowRun,
179 Op::RerunWorkflowRun,
180 Op::UpdateWorkflow,
181 ],
182 ),
183 (
184 "Secrets and variables",
185 "Values that workflows and deployments read, per repository or for a whole workspace, with a row per environment.",
186 &[
187 Op::ListActionsSecrets,
188 Op::SetActionsSecret,
189 Op::DeleteActionsSecret,
190 Op::ListActionsVariables,
191 Op::SetActionsVariable,
192 Op::DeleteActionsVariable,
193 ],
194 ),
195 (
196 "Runners",
197 "Self-hosted runners: your own machines, which run your workflow jobs (and, if you choose, your agents' work) for $0 of g1t compute. They register with a short-lived token and only ever connect out.",
198 &[
199 Op::ListRunners,
200 Op::CreateRunnerRegistrationToken,
201 Op::RemoveRunner,
202 Op::ListRunnerGroups,
203 Op::CreateRunnerGroup,
204 Op::UpdateRunnerGroup,
205 Op::DeleteRunnerGroup,
206 Op::GetRunnerSettings,
207 Op::UpdateRunnerSettings,
208 ],
209 ),
210 (
211 "Webhooks",
212 "Signed HTTPS requests sent to your own address as things happen, for a repository or a whole workspace.",
213 &[
214 Op::ListWebhooks,
215 Op::CreateWebhook,
216 Op::UpdateWebhook,
217 Op::DeleteWebhook,
218 Op::PingWebhook,
219 Op::ListWebhookDeliveries,
220 Op::RedeliverWebhook,
221 ],
222 ),
223 (
224 "Integrations",
225 "A workspace's connections to outside systems: model providers, alert sources and issue trackers.",
226 &[
227 Op::ListIntegrations,
228 Op::ConnectIntegration,
229 Op::DisconnectIntegration,
230 Op::TestIntegration,
231 Op::GetModelRoutes,
232 Op::SetModelRoutes,
233 Op::GetContext,
234 Op::ImportIssue,
235 ],
236 ),
237];
238
239/// The section of the API reference an operation is listed under.
240fn tag(op: Op) -> &'static str {
241 SECTIONS
242 .iter()
243 .find(|(_, _, ops)| ops.contains(&op))
244 .map_or("Repositories", |(name, _, _)| name)
245}
246
247/// What an operation's page is called, as a short sentence.
248fn title(op: Op) -> &'static str {
249 match op {
250 Op::Whoami => "Get the current user",
251 Op::CreateWorkspace => "Create a workspace",
252 Op::DeleteWorkspace => "Delete a workspace",
253 Op::UpdateWorkspace => "Update a workspace",
254 Op::ListEmails => "List your email addresses",
255 Op::AddEmail => "Add an email address",
256 Op::RemoveEmail => "Remove an email address",
257 Op::UpdateEmailSettings => "Change your email settings",
258 Op::ListInvites => "List your invites",
259 Op::CreateInvite => "Create an invite",
260 Op::RevokeInvite => "Revoke an invite",
261 Op::ListWorkspaceInvites => "List a workspace's invites",
262 Op::InviteMember => "Invite someone to a workspace",
263 Op::RevokeWorkspaceInvite => "Revoke a workspace's invite",
264 Op::TransferRepo => "Transfer a repository",
265 Op::RenameRepo => "Rename a repository",
266 Op::RenameBranch => "Rename a branch",
267 Op::ArchiveRepo => "Archive a repository",
268 Op::UnarchiveRepo => "Unarchive a repository",
269 Op::SetRepoVisibility => "Change a repository's visibility",
270 Op::DeleteRepo => "Delete a repository",
271 Op::ListDeletedRepos => "List recently deleted repositories",
272 Op::RestoreRepo => "Restore a deleted repository",
273 Op::PurgeRepo => "Purge a deleted repository",
274 Op::ListRepos => "List repositories",
275 Op::GetRepo => "Get a repository",
276 Op::CreateRepo => "Create a repository",
277 Op::UpdateRepo => "Update a repository",
278 Op::GetRepoSettings => "Get repository settings",
279 Op::UpdateRepoSettings => "Update repository settings",
280 Op::ListCheckNames => "List check names",
281 Op::GetMergeQueue => "Get the merge queue",
282 Op::MessageAgent => "Message an agent",
283 Op::AnswerMessage => "Answer a message",
284 Op::TakeMessages => "Take new messages",
285 Op::Remember => "Remember something",
286 Op::Recall => "Recall memory",
287 Op::SearchContext => "Search the context hub",
288 Op::GetEntity => "Get a catalog entry",
289 Op::Search => "Search g1t",
290 Op::ListIssues => "List issues",
291 Op::GetIssue => "Get an issue",
292 Op::CreateIssue => "Create an issue",
293 Op::UpdateIssue => "Update an issue",
294 Op::CloseIssue => "Close an issue",
295 Op::ReopenIssue => "Reopen an issue",
296 Op::AssignIssue => "Assign an issue to g1t",
297 Op::Delegate => "Put an agent on it",
298 Op::PlanWork => "Plan work",
299 Op::GetPlan => "Get a plan",
300 Op::ApplyPlan => "Apply a plan",
301 Op::ListLabels => "List labels",
302 Op::AddComment => "Add a comment",
303 Op::ReviewPullRequest => "Review a pull request",
304 Op::ListPullRequests => "List pull requests",
305 Op::GetPullRequest => "Get a pull request",
306 Op::CreatePullRequest => "Create a pull request",
307 Op::RecordSession => "Record session entries",
308 Op::ReadSession => "Read a session",
309 Op::MarkPullRequestReady => "Mark a pull request ready",
310 Op::ClosePullRequest => "Close a pull request",
311 Op::GetPullRequestChanges => "Get a pull request's changes",
312 Op::MergePullRequest => "Merge a pull request",
313 Op::ListEvents => "List repository events",
314 Op::ListIntegrations => "List integrations",
315 Op::ConnectIntegration => "Connect an integration",
316 Op::DisconnectIntegration => "Disconnect an integration",
317 Op::TestIntegration => "Test an integration",
318 Op::GetContext => "Look up a ticket",
319 Op::ImportIssue => "Import an issue",
320 Op::GetModelRoutes => "Get model routes",
321 Op::SetModelRoutes => "Set model routes",
322 Op::ListWebhooks => "List webhooks",
323 Op::CreateWebhook => "Create a webhook",
324 Op::UpdateWebhook => "Update a webhook",
325 Op::DeleteWebhook => "Delete a webhook",
326 Op::PingWebhook => "Ping a webhook",
327 Op::ListWebhookDeliveries => "List webhook deliveries",
328 Op::RedeliverWebhook => "Redeliver a webhook delivery",
329 Op::ListWorkflows => "List workflows",
330 Op::ListWorkflowRuns => "List workflow runs",
331 Op::GetWorkflowRun => "Get a workflow run",
332 Op::GetJobLogs => "Get a job's log",
333 Op::DispatchWorkflow => "Run a workflow",
334 Op::CancelWorkflowRun => "Cancel a workflow run",
335 Op::RerunWorkflowRun => "Re-run a workflow run",
336 Op::UpdateWorkflow => "Turn a workflow on or off",
337 Op::ListActionsSecrets => "List secrets",
338 Op::SetActionsSecret => "Set a secret",
339 Op::DeleteActionsSecret => "Delete a secret",
340 Op::ListActionsVariables => "List variables",
341 Op::SetActionsVariable => "Set a variable",
342 Op::DeleteActionsVariable => "Delete a variable",
343 Op::ListRunners => "List self-hosted runners",
344 Op::ListRunnerGroups => "List runner groups",
345 Op::GetRunnerSettings => "Get runner settings",
346 Op::CreateRunnerRegistrationToken => "Create a runner registration token",
347 Op::RemoveRunner => "Remove a self-hosted runner",
348 Op::CreateRunnerGroup => "Create a runner group",
349 Op::UpdateRunnerGroup => "Change a runner group",
350 Op::DeleteRunnerGroup => "Delete a runner group",
351 Op::UpdateRunnerSettings => "Change runner settings",
352 Op::ListCollaborators => "List who has access",
353 Op::AddCollaborator => "Add a collaborator",
354 Op::UpdateCollaborator => "Change a collaborator's role",
355 Op::RemoveCollaborator => "Remove a collaborator",
356 Op::GetCollaboratorPermission => "Get someone's permission",
357 Op::ListRepoInvitations => "List a repository's invitations",
358 Op::RevokeRepoInvitation => "Revoke a repository invitation",
359 Op::ListMyRepoInvitations => "List your repository invitations",
360 Op::AcceptRepoInvitation => "Accept a repository invitation",
361 Op::DeclineRepoInvitation => "Decline a repository invitation",
362 Op::SetBasePermission => "Set the base permission",
363 Op::ListOutsideCollaborators => "List outside collaborators",
364 Op::ListSecurityAlerts => "List security alerts",
365 Op::DismissSecurityAlert => "Dismiss a security alert",
366 Op::ReopenSecurityAlert => "Reopen a security alert",
367 Op::ListNotifications => "List notifications",
368 Op::MarkNotificationsRead => "Mark notifications read",
369 Op::GetNotificationThread => "Get a thread",
370 Op::MarkThreadRead => "Mark a thread read",
371 Op::MarkThreadDone => "Mark a thread done",
372 Op::SaveThread => "Save a thread",
373 Op::SnoozeThread => "Snooze a thread",
374 Op::GetThreadSubscription => "Get a thread subscription",
375 Op::SetThreadSubscription => "Set a thread subscription",
376 Op::DeleteThreadSubscription => "Unsubscribe from a thread",
377 Op::GetRepoSubscription => "Get how you watch a repository",
378 Op::SetRepoSubscription => "Watch a repository",
379 Op::DeleteRepoSubscription => "Stop watching a repository",
380 Op::ListWatchedRepos => "List repositories you watch",
381 Op::ListPinnedProjects => "List your pinned projects",
382 Op::PinProject => "Pin a project",
383 Op::UnpinProject => "Unpin a project",
384 Op::ReorderPinnedProjects => "Reorder your pinned projects",
385 }
386}
387
388/// Why an operation can be refused with `402 payment_required`, if it
389/// can: the ones that start an agent, when the workspace has no credit,
390/// and the ones that make a repository private in a workspace, when a free
391/// workspace's private storage has no room for it.
392fn may_need_payment(op: Op) -> Option<&'static str> {
393 match op {
394 Op::AssignIssue | Op::PlanWork | Op::ApplyPlan => Some("The workspace has no agent credit."),
395 Op::UpdateRepo | Op::SetRepoVisibility | Op::TransferRepo => Some(
396 "A free workspace's private storage has no room for this private repository.",
397 ),
398 _ => None,
399 }
400}
401
402/// What the reference says beyond each operation's own description, keyed
403/// by operation id, written by hand from what the services return: `notes`
404/// (Markdown, added to the description) and example `params` (path),
405/// `query`, `request` (body) and `response`.
406const REFERENCE: &str = include_str!("reference.json");
407
408fn examples() -> Map<String, Value> {
409 match serde_json::from_str(REFERENCE) {
410 Ok(Value::Object(examples)) => examples,
411 _ => Map::new(),
412 }
413}
414
415/// `/repos/:owner/:name` as OpenAPI writes it: `/repos/{owner}/{name}`.
416fn openapi_path(route: &Route) -> String {
417 route
418 .path
419 .split('/')
420 .map(|segment| match segment.strip_prefix(':') {
421 Some(name) => format!("{{{name}}}"),
422 None => segment.to_owned(),
423 })
424 .collect::<Vec<_>>()
425 .join("/")
426}
427
428fn error_response(description: &str) -> Value {
429 json!({
430 "description": description,
431 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } },
432 })
433}
434
435/// A parameter in the path or the query, described by the operation's
436/// input schema where it has the same name.
437fn parameter(name: &str, place: &str, required: bool, schema: Option<&Value>) -> Value {
438 let mut schema = schema.cloned().unwrap_or_else(|| json!({ "type": "string" }));
439 let description = match name {
440 "owner" => Some(Value::from("The workspace that owns the repository.")),
441 "name" => Some(Value::from("The repository's name.")),
442 _ => schema.as_object_mut().and_then(|schema| schema.remove("description")),
443 };
444 let mut parameter = json!({
445 "name": name,
446 "in": place,
447 "required": required,
448 "schema": schema,
449 });
450 if let Some(description) = description {
451 parameter["description"] = description;
452 }
453 parameter
454}
455
456/// The operation id of a route. An operation reached at a workspace's
457/// address as well as a repository's is documented once for each, with its
458/// own id; GitHub's alternative addresses for one operation keep GitHub's
459/// names.
460fn operation_id(route: &Route) -> String {
461 let op = route.op;
462 let base = match (route.method, route.path.rsplit('/').next().unwrap_or_default()) {
463 ("PUT", "enable") => "enable_workflow".to_owned(),
464 ("PUT", "disable") => "disable_workflow".to_owned(),
465 ("POST", "rerun-failed-jobs") => "rerun_failed_jobs".to_owned(),
466 ("PATCH", ":setting") => "update_actions_variable".to_owned(),
467 ("GET", "runs") if route.path.contains("/workflows/:workflow/") => "list_runs_of_workflow".to_owned(),
468 // One repository's notifications, and an issue's subscription by
469 // its number rather than a thread's id.
470 (_, "notifications") if route.path.starts_with("/repos/") => match op {
471 Op::ListNotifications => "list_repo_notifications".to_owned(),
472 _ => "mark_repo_notifications_read".to_owned(),
473 },
474 (method, "subscription") if route.path.contains("/issues/:number/") => match method {
475 "GET" => "get_issue_subscription".to_owned(),
476 "PUT" => "set_issue_subscription".to_owned(),
477 _ => "delete_issue_subscription".to_owned(),
478 },
479 ("DELETE", "saved") => "unsave_thread".to_owned(),
480 ("DELETE", "snooze") => "unsnooze_thread".to_owned(),
481 _ => op.name().to_owned(),
482 };
483 if route.path.starts_with("/workspaces/") && ROUTES.iter().any(|other| other.op == op && other.path.starts_with("/repos/")) {
484 format!("{base}_for_workspace")
485 } else {
486 base
487 }
488}
489
490/// The summary of a route: its operation's title, or for one of GitHub's
491/// alternative addresses, what that address does.
492fn summary(route: &Route, id: &str) -> String {
493 let base = match id.trim_end_matches("_for_workspace") {
494 "enable_workflow" => "Turn a workflow on",
495 "disable_workflow" => "Turn a workflow off",
496 "rerun_failed_jobs" => "Re-run failed jobs",
497 "update_actions_variable" => "Update a variable",
498 "list_runs_of_workflow" => "List a workflow's runs",
499 "list_repo_notifications" => "List a repository's notifications",
500 "mark_repo_notifications_read" => "Mark a repository's notifications read",
501 "get_issue_subscription" => "Get your subscription to an issue",
502 "set_issue_subscription" => "Subscribe to an issue",
503 "delete_issue_subscription" => "Unsubscribe from an issue",
504 "unsave_thread" => "Unsave a thread",
505 "unsnooze_thread" => "Bring a snoozed thread back",
506 _ => title(route.op),
507 };
508 if id.ends_with("_for_workspace") {
509 format!("{base} for a workspace")
510 } else {
511 base.to_owned()
512 }
513}
514
515fn operation(route: &Route) -> Value {
516 let op = route.op;
517 let path_params: Vec<&str> = route.params().collect();
518 // `owner` and `name` in the path stand for the operation's `repo` input.
519 let covered = |name: &str| name == "repo" || path_params.contains(&name);
520 let all_properties = op.properties();
521 let mut properties = all_properties.clone();
522 properties.retain(|name, _| !covered(name));
523 let required: Vec<String> = op
524 .required()
525 .into_iter()
526 .filter(|name| !covered(name))
527 .collect();
528
529 let mut parameters: Vec<Value> = path_params
530 .iter()
531 .map(|name| parameter(name, "path", true, all_properties.get(*name)))
532 .collect();
533 let mut body = Value::Null;
534 if route.method == "GET" {
535 for (name, key) in route.query {
536 parameters.push(parameter(
537 name,
538 "query",
539 required.iter().any(|required| required == key),
540 properties.get(*key),
541 ));
542 }
543 } else if !properties.is_empty() {
544 let mut schema = json!({ "type": "object", "properties": properties });
545 if !required.is_empty() {
546 schema["required"] = json!(required);
547 }
548 body = json!({
549 "required": !required.is_empty(),
550 "content": { "application/json": { "schema": schema } },
551 });
552 }
553
554 let id = operation_id(route);
555 let mut responses = Map::new();
556 responses.insert(
557 "200".into(),
558 json!({
559 "description": "Success.",
560 "content": { "application/json": { "schema": {} } },
561 }),
562 );
563 responses.insert(
564 "401".into(),
565 error_response("A token is required, or the one sent is not valid."),
566 );
567 if let Some(reason) = may_need_payment(op) {
568 responses.insert("402".into(), error_response(reason));
569 }
570 responses.insert(
571 "403".into(),
572 error_response("Signed in, but not allowed to do this: the role you have is not enough, or the token lacks the scope it needs, which `needed_scope` names."),
573 );
574 if !matches!(op, Op::Whoami | Op::ListRepos | Op::Search) {
575 responses.insert("404".into(), error_response("It does not exist, or you cannot see it."));
576 }
577 if route.method != "GET" {
578 responses.insert(
579 "409".into(),
580 error_response("The request conflicts with the current state."),
581 );
582 }
583 if op != Op::Whoami {
584 responses.insert("422".into(), error_response("The input is not valid."));
585 }
586 // Public data can be read without a token; everything else needs one.
587 let scope: Vec<&str> = scope_for(op.name()).map(|scope| scope.as_str()).into_iter().collect();
588 let security = if op.needs_user() {
589 json!([{ "token": scope }])
590 } else {
591 json!([{ "token": scope }, {}])
592 };
593 let (tool, action) = crate::tools::TOOLS
594 .iter()
595 .find_map(|tool| {
596 tool.actions
597 .iter()
598 .find(|action| action.op == op)
599 .map(|action| (tool.name, action.name))
600 })
601 .unwrap_or_default();
602 let mut described = json!({
603 "operationId": id,
604 "tags": [tag(op)],
605 "summary": summary(route, &id),
606 "description": op.description(),
607 "x-operation": op.name(),
608 "x-mcp-tool": tool,
609 "x-mcp-action": action,
610 "x-scope": scope.first().copied(),
611 "security": security,
612 "parameters": parameters,
613 "responses": responses,
614 });
615 if !body.is_null() {
616 described["requestBody"] = body;
617 }
618 described
619}
620
621/// Entries for device sign-in, which is not an operation.
622fn onboarding() -> Map<String, Value> {
623 let paths = json!({
624 "/device/code": {
625 "post": {
626 "operationId": "device_code",
627 "tags": ["Accounts"],
628 "summary": "Start signing in",
629 "description": "Begins a device sign-in. Show the person `verification_uri_complete` and have them open it in a browser, where they sign in or register and approve the code. Then poll `/device/token`.",
630 "security": [],
631 "requestBody": {
632 "content": { "application/json": { "schema": {
633 "type": "object",
634 "properties": {
635 "client_name": {
636 "type": "string",
637 "description": "What is asking, shown to the person approving. For example, Claude Code.",
638 },
639 },
640 } } },
641 },
642 "responses": { "200": {
643 "description": "The codes for this sign-in.",
644 "content": { "application/json": { "schema": {
645 "type": "object",
646 "properties": {
647 "device_code": { "type": "string", "description": "Secret. Send it to /device/token." },
648 "user_code": { "type": "string", "description": "Shown to the person, like WDJB-MJHT." },
649 "verification_uri": { "type": "string" },
650 "verification_uri_complete": {
651 "type": "string",
652 "description": "The link to give the person; it carries the code.",
653 },
654 "expires_in": { "type": "integer", "description": "Seconds until the codes expire." },
655 "interval": { "type": "integer", "description": "Seconds to wait between polls." },
656 },
657 } } },
658 } },
659 },
660 },
661 "/device/token": {
662 "post": {
663 "operationId": "device_token",
664 "tags": ["Accounts"],
665 "summary": "Finish signing in",
666 "description": "Asks whether the person has approved. Poll no faster than the interval. The token is returned once.",
667 "security": [],
668 "requestBody": {
669 "required": true,
670 "content": { "application/json": { "schema": {
671 "type": "object",
672 "required": ["device_code"],
673 "properties": { "device_code": { "type": "string" } },
674 } } },
675 },
676 "responses": { "200": {
677 "description": "The state of the sign-in.",
678 "content": { "application/json": { "schema": {
679 "type": "object",
680 "required": ["status"],
681 "properties": {
682 "status": { "type": "string", "enum": ["pending", "approved", "denied", "expired"] },
683 "token": { "type": "string", "description": "Present when approved." },
684 "username": { "type": "string" },
685 "verified": {
686 "type": "boolean",
687 "description": "Whether the account's email is confirmed.",
688 },
689 },
690 } } },
691 } },
692 },
693 },
694 });
695 match paths {
696 Value::Object(paths) => paths,
697 _ => Map::new(),
698 }
699}
700
701
702/// Puts each operation's examples, where it has them, into its request
703/// and response. Path and query values go under `x-example-params` and
704/// `x-example-query`, which tools that build a request can use.
705fn attach_examples(paths: &mut Map<String, Value>) {
706 let examples = examples();
707 for methods in paths.values_mut() {
708 let Some(methods) = methods.as_object_mut() else { continue };
709 for operation in methods.values_mut() {
710 let id = operation["operationId"].as_str().unwrap_or_default().to_owned();
711 let name = operation["x-operation"].as_str().unwrap_or_default().to_owned();
712 let Some(example) = examples.get(&id).or_else(|| examples.get(&name)) else {
713 continue;
714 };
715 if let Some(notes) = example.get("notes").and_then(Value::as_str) {
716 let description = operation["description"].as_str().unwrap_or_default();
717 operation["description"] = json!(format!("{description}\n\n{notes}"));
718 }
719 if let Some(response) = example.get("response") {
720 let content = &mut operation["responses"]["200"]["content"]["application/json"];
721 if content.is_object() {
722 content["example"] = response.clone();
723 }
724 }
725 if let Some(request) = example.get("request") {
726 let content = &mut operation["requestBody"]["content"]["application/json"];
727 if content.is_object() {
728 content["example"] = request.clone();
729 }
730 }
731 for (key, extension) in [("params", "x-example-params"), ("query", "x-example-query")] {
732 if let Some(values) = example.get(key) {
733 operation[extension] = values.clone();
734 }
735 }
736 }
737 }
738}
739
740pub fn document() -> Value {
741 let mut paths = onboarding();
742 for route in ROUTES {
743 let entry = paths
744 .entry(openapi_path(route))
745 .or_insert_with(|| json!({}));
746 entry[route.method.to_lowercase()] = operation(route);
747 }
748 attach_examples(&mut paths);
749 let tags: Vec<Value> = SECTIONS
750 .iter()
751 .map(|(name, description, ops)| {
752 json!({
753 "name": name,
754 "description": description,
755 // The section's operations in reading order, by MCP tool name.
756 "x-tools": ops.iter().map(|op| op.name()).collect::<Vec<_>>(),
757 })
758 })
759 .collect();
760 let codes = ["unauthenticated", "payment_required", "forbidden", "not_found", "conflict", "invalid"];
761 json!({
762 "openapi": "3.1.0",
763 "info": {
764 "title": "g1t API",
765 "version": "1",
766 "description": "The REST API for g1t, a git forge built for agents. The same operations are available to agents as MCP tools at https://mcp.g1t.sh. Every name in a request or response body is `snake_case`; names you chose, such as a workflow's inputs or a secret's name, are returned as you wrote them.",
767 "license": { "name": "MIT", "identifier": "MIT" },
768 },
769 "servers": [{ "url": "https://api.g1t.sh" }],
770 "security": [{ "token": [] }, {}],
771 "tags": tags,
772 "paths": paths,
773 "components": {
774 "securitySchemes": {
775 "token": {
776 "type": "http",
777 "scheme": "bearer",
778 "description": "An access token, `g1t_…`. Public data needs none. Each operation names the scope a token needs for it; see https://docs.g1t.sh/guides/authentication/#scopes.",
779 },
780 },
781 "schemas": {
782 "Error": {
783 "type": "object",
784 "required": ["error"],
785 "properties": {
786 "error": {
787 "type": "object",
788 "required": ["code", "message"],
789 "properties": {
790 "code": { "type": "string", "enum": codes },
791 "message": { "type": "string" },
792 "needed_scope": {
793 "type": "string",
794 "description": "On a 403 for an access token without the scope the call needs: that scope, such as `issues:write`.",
795 },
796 },
797 },
798 },
799 },
800 },
801 },
802 })
803}
804
805#[cfg(test)]
806mod tests {
807 use super::*;
808
809 #[test]
810 fn every_route_is_documented_once() {
811 let document = document();
812 let mut ids = Vec::new();
813 for (_, methods) in document["paths"].as_object().unwrap() {
814 for (_, operation) in methods.as_object().unwrap() {
815 ids.push(operation["operationId"].as_str().unwrap().to_owned());
816 }
817 }
818 for op in Op::ALL {
819 assert_eq!(
820 ids.iter().filter(|id| *id == op.name()).count(),
821 1,
822 "{}",
823 op.name()
824 );
825 }
826 let mut unique = ids.clone();
827 unique.sort();
828 unique.dedup();
829 assert_eq!(unique.len(), ids.len(), "operation ids repeat");
830 }
831
832 #[test]
833 fn path_and_query_inputs_are_not_repeated_in_the_body() {
834 let document = document();
835 let merge = &document["paths"]["/repos/{owner}/{name}/pulls/{number}/merge"]["post"];
836 let body = &merge["requestBody"]["content"]["application/json"]["schema"]["properties"];
837 assert!(body.get("keep_issue_open").is_some());
838 assert!(body.get("repo").is_none() && body.get("number").is_none());
839 let list = &document["paths"]["/repos"]["get"];
840 assert_eq!(list["parameters"][0]["name"], "q");
841 assert!(list.get("requestBody").is_none());
842 }
843
844 #[test]
845 fn every_operation_is_in_one_section() {
846 for op in Op::ALL {
847 let sections = SECTIONS
848 .iter()
849 .filter(|(_, _, ops)| ops.contains(&op))
850 .count();
851 assert_eq!(sections, 1, "{}", op.name());
852 }
853 }
854
855 #[test]
856 fn titles_read_as_sentences() {
857 assert_eq!(title(Op::CreateIssue), "Create an issue");
858 assert_eq!(title(Op::Whoami), "Get the current user");
859 }
860
861 #[test]
862 fn every_operation_has_an_example_response() {
863 let examples = examples();
864 assert!(!examples.is_empty(), "reference.json does not parse");
865 let document = document();
866 let mut known = Vec::new();
867 for (path, methods) in document["paths"].as_object().unwrap() {
868 for (method, operation) in methods.as_object().unwrap() {
869 known.push(operation["operationId"].as_str().unwrap().to_owned());
870 let example = &operation["responses"]["200"]["content"]["application/json"]["example"];
871 assert!(!example.is_null(), "{method} {path} has no example response");
872 }
873 }
874 for id in examples.keys() {
875 assert!(known.contains(id), "reference.json names {id}, which is not an operation");
876 }
877 }
878
879 #[test]
880 fn example_requests_send_only_what_the_body_takes() {
881 let document = document();
882 for (path, methods) in document["paths"].as_object().unwrap() {
883 for (method, operation) in methods.as_object().unwrap() {
884 let content = &operation["requestBody"]["content"]["application/json"];
885 let Some(example) = content["example"].as_object() else { continue };
886 let properties = &content["schema"]["properties"];
887 for key in example.keys() {
888 assert!(!properties[key].is_null(), "{method} {path}: {key} is not in the body");
889 }
890 }
891 }
892 }
893
894 /// The docs site's copy of the document. Run with `G1T_WRITE_OPENAPI=1`
895 /// to rewrite it after changing an operation.
896 #[test]
897 fn the_docs_copy_is_current() {
898 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../docs/src/data/openapi.json");
899 let current = serde_json::to_string_pretty(&document()).unwrap() + "\n";
900 if std::env::var_os("G1T_WRITE_OPENAPI").is_some() {
901 std::fs::write(path, &current).unwrap();
902 return;
903 }
904 let copy = std::fs::read_to_string(path).unwrap_or_default().replace("\r\n", "\n");
905 assert!(
906 copy == current,
907 "apps/docs/src/data/openapi.json is out of date: run G1T_WRITE_OPENAPI=1 cargo test -p g1t-api openapi"
908 );
909 }
910
911 /// The reference shows responses as they are sent: `snake_case`.
912 #[test]
913 fn example_responses_are_snake_case() {
914 let document = document();
915 for (path, methods) in document["paths"].as_object().unwrap() {
916 for (method, operation) in methods.as_object().unwrap() {
917 let example = &operation["responses"]["200"]["content"]["application/json"]["example"];
918 let leaked = g1t_kit::wire::camel_case_keys(example);
919 assert!(leaked.is_empty(), "{method} {path} shows {leaked:?}");
920 }
921 }
922 }
923
924 /// Examples never hold anything that reads as a real credential, which
925 /// secret scanners rightly flag in a public repository: they end in `…`
926 /// after the prefix, as `whsec_…` and `g1t_…` do.
927 #[test]
928 fn examples_hold_no_real_looking_secrets() {
929 let prefixes = ["whsec_", "g1t_", "g1tr_", "g1trt_", "sk_live_", "sk_test_", "ghp_", "github_pat_", "xoxb-", "AKIA"];
930 for (line, text) in REFERENCE.lines().enumerate() {
931 for prefix in prefixes {
932 let mut rest = text;
933 while let Some(at) = rest.find(prefix) {
934 let after = &rest[at + prefix.len()..];
935 let run = after.chars().take_while(|c| c.is_ascii_alphanumeric()).count();
936 assert!(
937 run < 12,
938 "reference.json line {}: `{prefix}` followed by {run} characters reads as a real secret; write `{prefix}…`",
939 line + 1
940 );
941 rest = after;
942 }
943 }
944 }
945 }
946}