flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/apps/api/src/openapi.rs

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