Skip to content

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

332 linesCodeBlameRaw
1// @ts-check
2/**
3 * The REST API reference: a page for every operation in the OpenAPI
4 * document, written as Markdown into src/content/docs/reference/api/ so
5 * Starlight renders, indexes and searches it like any other page.
6 *
7 * The document is src/data/openapi.json, the API's own description of
8 * itself. It is generated from apps/api, not fetched, so building the docs
9 * needs no network. After changing an operation, refresh it with
10 * `G1T_WRITE_OPENAPI=1 cargo test -p g1t-api openapi`.
11 *
12 * astro.config.mjs calls this when it loads, so `astro dev` and
13 * `astro build` both see current pages. The pages are not committed.
14 */
15import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
16import { dirname, join } from "node:path";
17import { fileURLToPath } from "node:url";
18
19const root = join(dirname(fileURLToPath(import.meta.url)), "..");
20const OUT = join(root, "src/content/docs/reference/api");
21const BASE = "https://api.g1t.sh";
22const METHODS = ["get", "post", "put", "patch", "delete"];
23
24/** Values for path parameters an example does not give. */
25const DEFAULT_PARAMS = { owner: "flagon-io", name: "hello", workspace: "flagon-io" };
26
27const STATUS = {
28 401: ["unauthenticated", "A token is required, or the one sent is not valid."],
29 402: [
30 "payment_required",
31 "The workspace cannot start this work: it needs the g1t plan or a card check, or it is at a limit. See [usage and billing](/guides/usage-and-billing/#when-work-is-stopped).",
32 ],
33 403: ["forbidden", "The token is valid but not allowed to do this, such as a member-only change, or a token without the scope it needs."],
34 404: ["not_found", "It does not exist, or you cannot see it."],
35 409: ["conflict", "The request conflicts with the current state."],
36 422: ["invalid", "The input is not valid. `message` says which field and why."],
37};
38
39/** `create_issue` as a URL segment: `create-issue`. */
40const slugify = (text) =>
41 text
42 .toLowerCase()
43 .replace(/[^a-z0-9]+/g, "-")
44 .replace(/^-|-$/g, "");
45
46export function loadDocument() {
47 return JSON.parse(readFileSync(join(root, "src/data/openapi.json"), "utf8"));
48}
49
50/** Every operation in the document, in the order the reference lists them. */
51export function operations(document = loadDocument()) {
52 const tags = document.tags.map((tag) => tag.name);
53 const found = [];
54 let position = 0;
55 for (const [path, methods] of Object.entries(document.paths)) {
56 for (const method of METHODS) {
57 const operation = methods[method];
58 if (!operation) continue;
59 const tag = operation.tags?.[0] ?? "Repositories";
60 found.push({
61 ...operation,
62 method: method.toUpperCase(),
63 path,
64 tag,
65 position: position++,
66 slug: `reference/api/${slugify(tag)}/${slugify(operation.operationId)}`,
67 });
68 }
69 }
70 // By section, then by the section's own reading order (the operation's
71 // place in it), then a repository's address before a workspace's.
72 const order = new Map(document.tags.flatMap((tag) => (tag["x-tools"] ?? []).map((name, i) => [name, i])));
73 return found.sort(
74 (a, b) =>
75 tags.indexOf(a.tag) - tags.indexOf(b.tag) ||
76 (order.get(a["x-operation"]) ?? -1) - (order.get(b["x-operation"]) ?? -1) ||
77 Number(a.operationId.endsWith("_for_workspace")) - Number(b.operationId.endsWith("_for_workspace")) ||
78 a.position - b.position,
79 );
80}
81
82/** A table cell's text: one line, with pipes escaped. */
83const cell = (text) =>
84 String(text ?? "")
85 .replace(/\r?\n+/g, " ")
86 .replace(/\|/g, "\\|");
87
88/** Markdown text, made safe for a page: no raw HTML from the document. */
89const prose = (text) => String(text ?? "").replace(/</g, "&lt;");
90
91/** A schema's type, the way a person says it. */
92function typeName(schema = {}) {
93 if (schema.$ref) return "object";
94 const types = [schema.type ?? "any"].flat();
95 return types
96 .map((type) => {
97 if (type === "array") {
98 const items = schema.items ?? {};
99 const of = typeName(items);
100 return `array of ${of === "object" ? "objects" : `${of}s`}`;
101 }
102 return type;
103 })
104 .join(" or ");
105}
106
107function describe(schema = {}) {
108 const parts = [];
109 if (schema.description) parts.push(linkTools(prose(schema.description)));
110 const values = schema.enum ?? schema.items?.enum;
111 if (values && values.length <= 20) parts.push(`One of ${values.map((value) => `\`${value}\``).join(", ")}.`);
112 else if (values) parts.push(`One of the ${values.length} [webhook event types](/guides/webhooks/#events).`);
113 return parts.join(" ");
114}
115
116/** Rows for an object's properties, with nested objects and lists of objects after their parent. */
117function propertyRows(schema, prefix = "") {
118 const rows = [];
119 const required = new Set(schema.required ?? []);
120 for (const [name, property] of Object.entries(schema.properties ?? {})) {
121 const full = `${prefix}${name}`;
122 rows.push([`\`${full}\``, typeName(property), required.has(name) ? "Yes" : "No", describe(property)]);
123 if (property.type === "object" && property.properties) rows.push(...propertyRows(property, `${full}.`));
124 if (property.type === "array" && property.items?.properties) rows.push(...propertyRows(property.items, `${full}[].`));
125 }
126 return rows;
127}
128
129function table(head, rows) {
130 return [`| ${head.join(" | ")} |`, `| ${head.map(() => "---").join(" | ")} |`, ...rows.map((row) => `| ${row.map(cell).join(" | ")} |`)].join("\n");
131}
132
133/** A value for a parameter an example does not give. */
134function sample(name, schema = {}) {
135 if (schema.enum) return schema.enum[0];
136 if (name === "number") return 12;
137 const type = [schema.type].flat()[0];
138 if (type === "integer") return 1;
139 if (type === "boolean") return true;
140 return `${name}`;
141}
142
143/** A body for the example when the reference has none: the required fields. */
144function sampleBody(schema) {
145 const body = {};
146 for (const name of schema.required ?? []) body[name] = sample(name, schema.properties?.[name]);
147 return body;
148}
149
150function curl(operation) {
151 const params = { ...DEFAULT_PARAMS, ...(operation["x-example-params"] ?? {}) };
152 let url =
153 BASE +
154 operation.path.replace(/\{(\w+)\}/g, (_, name) => {
155 if (name in params) return encodeURIComponent(String(params[name]));
156 if (name === "number") return operation.path.includes("/pulls/") ? "14" : "12";
157 const schema = (operation.parameters ?? []).find((p) => p.name === name)?.schema;
158 return encodeURIComponent(String(sample(name, schema)));
159 });
160 const query = operation["x-example-query"];
161 if (query && Object.keys(query).length) {
162 url += "?" + new URLSearchParams(Object.entries(query).map(([k, v]) => [k, String(v)])).toString();
163 }
164 const lines = [];
165 const quoted = url.includes("?") ? `"${url}"` : url;
166 lines.push(operation.method === "GET" ? `curl ${quoted}` : `curl -X ${operation.method} ${quoted}`);
167 const anonymous = Array.isArray(operation.security) && operation.security.length === 0;
168 if (!anonymous) lines.push(`-H "Authorization: Bearer $G1T_TOKEN"`);
169 const content = operation.requestBody?.content?.["application/json"];
170 if (content) {
171 const body = content.example ?? sampleBody(content.schema ?? {});
172 if (Object.keys(body).length) {
173 lines.push('-H "Content-Type: application/json"');
174 const json = JSON.stringify(body, null, 2).replace(/'/g, `'\\''`).replace(/\n/g, "\n ");
175 lines.push(`-d '${json}'`);
176 }
177 }
178 return lines.join(" \\\n ");
179}
180
181/** The first sentence, for the page's summary line, and the rest. */
182function split(description) {
183 const text = String(description ?? "").trim();
184 const paragraphs = text.split(/\n\n/);
185 const first = paragraphs[0];
186 // A sentence ends at ". " followed by a capital letter or a backtick.
187 const match = first.match(/^(.+?[.!?])\s+(?=[A-Z`])/s);
188 if (!match) return { lead: first, rest: paragraphs.slice(1).join("\n\n") };
189 return { lead: match[1], rest: [first.slice(match[0].length), ...paragraphs.slice(1)].join("\n\n") };
190}
191
192/** Each operation's page: the first of its addresses, a repository's. */
193const pages = new Map();
194
195/** Names of other operations in running text, linked to their pages. */
196function linkTools(text, self) {
197 return text.replace(/(^|[\s(])([a-z]+(?:_[a-z]+)+)(?=[\s.,;:)]|$)/g, (whole, before, name) => {
198 const page = pages.get(name);
199 if (!page || name === self) return whole;
200 return `${before}[\`${name}\`](/${page}/)`;
201 });
202}
203
204/** The page for one operation. */
205function page(operation, all) {
206 const { lead, rest } = split(operation.description);
207 const name = operation["x-operation"];
208 const tool = operation["x-mcp-tool"];
209 const action = operation["x-mcp-action"];
210 const scope = operation["x-scope"];
211 const security = operation.security ?? [{ token: [] }];
212 const auth =
213 security.length === 0
214 ? "None."
215 : security.some((entry) => Object.keys(entry).length === 0)
216 ? "Optional. Public data can be read without a token; send one to see what is private."
217 : "Required. Send an [access token](/reference/api/#authentication) as `Authorization: Bearer`.";
218 const siblings = all.filter((other) => name && other["x-operation"] === name && other !== operation);
219
220 const out = [];
221 out.push("---");
222 out.push(`title: ${JSON.stringify(operation.summary)}`);
223 out.push(`description: ${JSON.stringify(lead.replace(/`/g, ""))}`);
224 out.push("editUrl: false");
225 out.push("lastUpdated: false");
226 out.push("---");
227 out.push("");
228 out.push(
229 `<div class="g1t-endpoint"><span class="g1t-method" data-method="${operation.method.toLowerCase()}">${operation.method}</span><code>${operation.path}</code></div>`,
230 );
231 out.push("");
232 if (rest) {
233 out.push(linkTools(prose(rest), name));
234 out.push("");
235 }
236 const facts = [["Authentication", auth]];
237 facts.push([
238 "MCP tool",
239 tool ? `[\`${tool}\`](/reference/mcp/#${tool}) with \`action\` \`${action}\`, and the same inputs` : "None. Signing in is on the REST API only.",
240 ]);
241 if (tool) {
242 facts.push(["Scope", scope ? `An access token needs [\`${scope}\`](/guides/authentication/#scopes).` : "None. Any access token may use it."]);
243 }
244 if (siblings.length) {
245 facts.push(["Also at", siblings.map((other) => `[\`${other.method} ${other.path}\`](/${other.slug}/)`).join(", ")]);
246 }
247 out.push(...facts.map(([name, value]) => `- **${name}:** ${value}`));
248 out.push("");
249
250 const path = (operation.parameters ?? []).filter((p) => p.in === "path");
251 const query = (operation.parameters ?? []).filter((p) => p.in === "query");
252 const parameterRows = (list) =>
253 list.map((p) => [`\`${p.name}\``, typeName(p.schema), p.required ? "Yes" : "No", describe({ ...p.schema, description: p.description })]);
254 if (path.length) {
255 out.push("## Path parameters", "", table(["Name", "Type", "Required", "Description"], parameterRows(path)), "");
256 }
257 if (query.length) {
258 out.push("## Query parameters", "", table(["Name", "Type", "Required", "Description"], parameterRows(query)), "");
259 }
260 const body = operation.requestBody?.content?.["application/json"]?.schema;
261 if (body?.properties && Object.keys(body.properties).length) {
262 out.push(
263 "## Body parameters",
264 "",
265 "Send a JSON object. Names are `snake_case`, as in responses; the `camelCase` spelling is accepted too.",
266 "",
267 table(["Name", "Type", "Required", "Description"], propertyRows(body)),
268 "",
269 );
270 }
271
272 out.push("## Example request", "", "```sh", curl(operation), "```", "");
273
274 const ok = operation.responses?.["200"];
275 const example = ok?.content?.["application/json"]?.example;
276 out.push("## Example response", "");
277 if (example !== undefined) {
278 out.push(`A successful request answers \`200\` with:`, "", "```json", JSON.stringify(example, null, 2), "```", "");
279 } else {
280 out.push("A successful request answers `200`.", "");
281 }
282
283 const errors = Object.keys(operation.responses ?? {})
284 .filter((status) => status in STATUS)
285 .map((status) => [status, `\`${STATUS[status][0]}\``, STATUS[status][1]]);
286 if (errors.length) {
287 out.push(
288 "## Errors",
289 "",
290 'A failed request answers with one of these statuses and a body like `{"error": {"code": "not_found", "message": "Repository not found."}}`. See [errors](/reference/api/#errors).',
291 "",
292 table(["Status", "Code", "When"], errors),
293 "",
294 );
295 }
296 return out.join("\n");
297}
298
299/**
300 * Writes every operation's page and returns the sidebar groups for them,
301 * one per section of the API.
302 */
303export function generateApiReference() {
304 const document = loadDocument();
305 const all = operations(document);
306 pages.clear();
307 for (const operation of all) {
308 const name = operation["x-operation"];
309 // An operation's name links to its first address: the repository's.
310 if (name && !pages.has(name)) pages.set(name, operation.slug);
311 }
312
313 rmSync(OUT, { recursive: true, force: true });
314 for (const operation of all) {
315 const file = join(root, "src/content/docs", `${operation.slug}.md`);
316 mkdirSync(dirname(file), { recursive: true });
317 writeFileSync(file, page(operation, all));
318 }
319
320 return document.tags
321 .map((tag) => ({
322 label: tag.name,
323 items: all
324 .filter((operation) => operation.tag === tag.name)
325 .map((operation) => ({
326 label: operation.summary,
327 slug: operation.slug,
328 badge: { text: operation.method, class: `g1t-method g1t-method-${operation.method.toLowerCase()}` },
329 })),
330 }))
331 .filter((group) => group.items.length);
332}