pr_01m47d24b0e6n91zwymwxg0vpx/apps/api/src/openapi.rs

340 lines13,327 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

API and MCP server in Rust; a public index at the API root1//! The OpenAPI document, generated from the same list the routes are.
2
3use serde_json::{Map, Value, json};
4
5use crate::operations::Op;
6use crate::rest::{ROUTES, Route};
7
8/// The section of the API reference an operation is listed under.
9fn tag(op: Op) -> &'static str {
10 let name = op.name();
Webhooks: every event, to your own addresses, signed and retried11 if name.contains("webhook") {
12 "Webhooks"
Automations: rules in .g1t/automations that act when something happens13 } else if name.contains("automation") {
14 "Automations"
GitHub Actions on g1t, part two: running workflows15 } else if name.contains("workflow") || name.contains("actions_") || name == "get_job_logs" {
16 "Actions"
Webhooks: every event, to your own addresses, signed and retried17 } else if name.contains("integration") || name.contains("model_routes") || op == Op::GetContext {
Integrations: your own model provider, alerts that open issues, tickets agents read18 "Integrations"
19 } else if op == Op::Whoami || name.contains("workspace") {
API and MCP server in Rust; a public index at the API root20 "Accounts"
21 } else if name.contains("session") {
22 "Sessions"
23 } else if name.contains("pull_request") {
24 "Pull requests"
25 } else if ["issue", "label", "comment"]
26 .iter()
27 .any(|word| name.contains(word))
28 {
29 "Issues"
30 } else {
31 "Repositories"
32 }
33}
34
35/// A short title from an operation name: `create_issue` is "Create issue".
36fn title(op: Op) -> String {
37 if op == Op::Whoami {
38 return "Get the current user".to_owned();
39 }
40 let words = op.name().replace('_', " ");
41 let mut letters = words.chars();
42 match letters.next() {
43 Some(first) => first.to_uppercase().chain(letters).collect(),
44 None => words,
45 }
46}
47
Agents as a team: lifecycle, merge queue, billing and a new shell48/// `/repos/:owner/:name` as OpenAPI writes it: `/repos/{owner}/{name}`.
API and MCP server in Rust; a public index at the API root49fn openapi_path(route: &Route) -> String {
50 route
51 .path
52 .split('/')
53 .map(|segment| match segment.strip_prefix(':') {
54 Some(name) => format!("{{{name}}}"),
55 None => segment.to_owned(),
56 })
57 .collect::<Vec<_>>()
58 .join("/")
59}
60
61fn error_response(description: &str) -> Value {
62 json!({
63 "description": description,
64 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } },
65 })
66}
67
68fn operation(route: &Route) -> Value {
69 let op = route.op;
70 let path_params: Vec<&str> = route.params().collect();
71 // `owner` and `name` in the path stand for the operation's `repo` input.
72 let covered = |name: &str| name == "repo" || path_params.contains(&name);
73 let mut properties = op.properties();
74 properties.retain(|name, _| !covered(name));
75 let required: Vec<String> = op
76 .required()
77 .into_iter()
78 .filter(|name| !covered(name))
79 .collect();
80
81 let mut parameters: Vec<Value> = path_params
82 .iter()
83 .map(|name| {
84 json!({
85 "name": name,
86 "in": "path",
87 "required": true,
88 "schema": { "type": if *name == "number" { "integer" } else { "string" } },
89 })
90 })
91 .collect();
92 let mut body = Value::Null;
93 if route.method == "GET" {
94 for (name, key) in route.query {
95 parameters.push(json!({
96 "name": name,
97 "in": "query",
98 "required": false,
99 "schema": properties.get(*key).cloned().unwrap_or_else(|| json!({})),
100 }));
101 }
102 } else if !properties.is_empty() {
103 let mut schema = json!({ "type": "object", "properties": properties });
104 if !required.is_empty() {
105 schema["required"] = json!(required);
106 }
107 body = json!({
108 "required": !required.is_empty(),
109 "content": { "application/json": { "schema": schema } },
110 });
111 }
112
Webhooks: every event, to your own addresses, signed and retried113 // An operation reached at a workspace's address as well as a
114 // repository's is documented once for each, with its own id.
GitHub Actions on g1t, part two: running workflows115 // GitHub's alternative addresses for one operation keep GitHub's names.
116 let base = match (route.method, route.path.rsplit('/').next().unwrap_or_default()) {
117 ("PUT", "enable") => "enable_workflow".to_owned(),
118 ("PUT", "disable") => "disable_workflow".to_owned(),
119 ("POST", "rerun-failed-jobs") => "rerun_failed_jobs".to_owned(),
120 ("PATCH", ":setting") => "update_actions_variable".to_owned(),
121 ("GET", "runs") if route.path.contains("/workflows/:workflow/") => "list_runs_of_workflow".to_owned(),
122 _ => op.name().to_owned(),
123 };
Webhooks: every event, to your own addresses, signed and retried124 let id = if route.path.starts_with("/workspaces/") && ROUTES.iter().any(|other| other.op == op && other.path.starts_with("/repos/")) {
GitHub Actions on g1t, part two: running workflows125 format!("{base}_for_workspace")
Webhooks: every event, to your own addresses, signed and retried126 } else {
GitHub Actions on g1t, part two: running workflows127 base
Webhooks: every event, to your own addresses, signed and retried128 };
API and MCP server in Rust; a public index at the API root129 let mut described = json!({
Webhooks: every event, to your own addresses, signed and retried130 "operationId": id,
API and MCP server in Rust; a public index at the API root131 "tags": [tag(op)],
132 "summary": title(op),
133 "description": op.description(),
134 "parameters": parameters,
135 "responses": {
136 "200": {
137 "description": "Success.",
138 "content": { "application/json": { "schema": {} } },
139 },
140 "401": error_response("A token is required, or the one sent is not valid."),
141 "403": error_response("Signed in, but not allowed to do this."),
142 "404": error_response("It does not exist, or you cannot see it."),
143 "409": error_response("The request conflicts with the current state."),
144 "422": error_response("The input is not valid."),
145 },
146 });
147 if !body.is_null() {
148 described["requestBody"] = body;
149 }
150 described
151}
152
153/// Entries for device sign-in, which is not an operation.
154fn onboarding() -> Map<String, Value> {
155 let paths = json!({
Agents as a team: lifecycle, merge queue, billing and a new shell156 "/device/code": {
API and MCP server in Rust; a public index at the API root157 "post": {
158 "operationId": "device_code",
159 "tags": ["Accounts"],
160 "summary": "Start signing in",
Agents as a team: lifecycle, merge queue, billing and a new shell161 "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`.",
API and MCP server in Rust; a public index at the API root162 "security": [],
163 "requestBody": {
164 "content": { "application/json": { "schema": {
165 "type": "object",
166 "properties": {
167 "client_name": {
168 "type": "string",
169 "description": "What is asking, shown to the person approving. For example, Claude Code.",
170 },
171 },
172 } } },
173 },
174 "responses": { "200": {
175 "description": "The codes for this sign-in.",
176 "content": { "application/json": { "schema": {
177 "type": "object",
178 "properties": {
Agents as a team: lifecycle, merge queue, billing and a new shell179 "device_code": { "type": "string", "description": "Secret. Send it to /device/token." },
API and MCP server in Rust; a public index at the API root180 "user_code": { "type": "string", "description": "Shown to the person, like WDJB-MJHT." },
181 "verification_uri": { "type": "string" },
182 "verification_uri_complete": {
183 "type": "string",
184 "description": "The link to give the person; it carries the code.",
185 },
186 "expires_in": { "type": "integer", "description": "Seconds until the codes expire." },
187 "interval": { "type": "integer", "description": "Seconds to wait between polls." },
188 },
189 } } },
190 } },
191 },
192 },
Agents as a team: lifecycle, merge queue, billing and a new shell193 "/device/token": {
API and MCP server in Rust; a public index at the API root194 "post": {
195 "operationId": "device_token",
196 "tags": ["Accounts"],
197 "summary": "Finish signing in",
198 "description": "Asks whether the person has approved. Poll no faster than the interval. The token is returned once.",
199 "security": [],
200 "requestBody": {
201 "required": true,
202 "content": { "application/json": { "schema": {
203 "type": "object",
204 "required": ["device_code"],
205 "properties": { "device_code": { "type": "string" } },
206 } } },
207 },
208 "responses": { "200": {
209 "description": "The state of the sign-in.",
210 "content": { "application/json": { "schema": {
211 "type": "object",
212 "required": ["status"],
213 "properties": {
214 "status": { "type": "string", "enum": ["pending", "approved", "denied", "expired"] },
215 "token": { "type": "string", "description": "Present when approved." },
216 "username": { "type": "string" },
217 "verified": {
218 "type": "boolean",
219 "description": "Whether the account's email is confirmed.",
220 },
221 },
222 } } },
223 } },
224 },
225 },
226 });
227 match paths {
228 Value::Object(paths) => paths,
229 _ => Map::new(),
230 }
231}
232
233pub fn document() -> Value {
234 let mut paths = onboarding();
235 for route in ROUTES {
236 let entry = paths
237 .entry(openapi_path(route))
238 .or_insert_with(|| json!({}));
239 entry[route.method.to_lowercase()] = operation(route);
240 }
241 json!({
242 "openapi": "3.1.0",
243 "info": {
244 "title": "g1t API",
245 "version": "1",
246 "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.",
247 "license": { "name": "MIT", "identifier": "MIT" },
248 },
249 "servers": [{ "url": "https://api.g1t.sh" }],
250 "security": [{ "token": [] }, {}],
251 "tags": [
252 { "name": "Accounts", "description": "Signing in from a tool, and the current user." },
253 { "name": "Repositories" },
254 {
255 "name": "Issues",
256 "description": "What should change in a repository, with labels and comments. Issues and pull requests share one sequence of numbers.",
257 },
258 {
259 "name": "Pull requests",
260 "description": "A proposed change in its own fork or on a branch. Several can be made for one issue; the one merged resolves it.",
261 },
262 { "name": "Sessions", "description": "The record of how a pull request was made." },
263 ],
264 "paths": paths,
265 "components": {
266 "securitySchemes": {
267 "token": {
268 "type": "http",
269 "scheme": "bearer",
270 "description": "An access token, `g1t_…`. Public data needs none.",
271 },
272 },
273 "schemas": {
274 "Error": {
275 "type": "object",
276 "required": ["error"],
277 "properties": {
278 "error": {
279 "type": "object",
280 "required": ["code", "message"],
281 "properties": {
282 "code": {
283 "type": "string",
284 "enum": ["unauthenticated", "forbidden", "not_found", "conflict", "invalid"],
285 },
286 "message": { "type": "string" },
287 },
288 },
289 },
290 },
291 },
292 },
293 })
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299
300 #[test]
301 fn every_route_is_documented_once() {
302 let document = document();
303 let mut ids = Vec::new();
304 for (_, methods) in document["paths"].as_object().unwrap() {
305 for (_, operation) in methods.as_object().unwrap() {
306 ids.push(operation["operationId"].as_str().unwrap().to_owned());
307 }
308 }
309 for op in Op::ALL {
310 assert_eq!(
311 ids.iter().filter(|id| *id == op.name()).count(),
312 1,
313 "{}",
314 op.name()
315 );
316 }
Webhooks: every event, to your own addresses, signed and retried317 let mut unique = ids.clone();
318 unique.sort();
319 unique.dedup();
320 assert_eq!(unique.len(), ids.len(), "operation ids repeat");
API and MCP server in Rust; a public index at the API root321 }
322
323 #[test]
324 fn path_and_query_inputs_are_not_repeated_in_the_body() {
325 let document = document();
Agents as a team: lifecycle, merge queue, billing and a new shell326 let merge = &document["paths"]["/repos/{owner}/{name}/pulls/{number}/merge"]["post"];
API and MCP server in Rust; a public index at the API root327 let body = &merge["requestBody"]["content"]["application/json"]["schema"]["properties"];
328 assert!(body.get("keep_issue_open").is_some());
329 assert!(body.get("repo").is_none() && body.get("number").is_none());
Agents as a team: lifecycle, merge queue, billing and a new shell330 let list = &document["paths"]["/repos"]["get"];
API and MCP server in Rust; a public index at the API root331 assert_eq!(list["parameters"][0]["name"], "q");
332 assert!(list.get("requestBody").is_none());
333 }
334
335 #[test]
336 fn titles_read_as_sentences() {
337 assert_eq!(title(Op::CreateIssue), "Create issue");
338 assert_eq!(title(Op::Whoami), "Get the current user");
339 }
340}