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/docs/scripts/api-reference.mjs

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