g1t/apps/docs/scripts/api-reference.mjs
| 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 | */ |
| 15 | import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; |
| 16 | import { dirname, join } from 'node:path'; |
| 17 | import { fileURLToPath } from 'node:url'; |
| 18 | |
| 19 | const root = join(dirname(fileURLToPath(import.meta.url)), '..'); |
| 20 | const OUT = join(root, 'src/content/docs/reference/api'); |
| 21 | const BASE = 'https://api.g1t.sh'; |
| 22 | const METHODS = ['get', 'post', 'put', 'patch', 'delete']; |
| 23 | |
| 24 | /** Values for path parameters an example does not give. */ |
| 25 | const DEFAULT_PARAMS = { owner: 'syntaqx', name: 'hello', workspace: 'syntaqx' }; |
| 26 | |
| 27 | const STATUS = { |
| 28 | 401: ['unauthenticated', 'A token is required, or the one sent is not valid.'], |
| 29 | 402: ['payment_required', 'The workspace has no agent credit. See [usage and billing](/guides/usage-and-billing/#when-credit-runs-out).'], |
| 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`. */ |
| 37 | const slugify = (text) => text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); |
| 38 | |
| 39 | export 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. */ |
| 44 | export 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. */ |
| 76 | const 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. */ |
| 79 | const prose = (text) => String(text ?? '').replace(/</g, '<'); |
| 80 | |
| 81 | /** A schema's type, the way a person says it. */ |
| 82 | function 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 | |
| 97 | function 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. */ |
| 107 | function 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 | |
| 119 | function 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. */ |
| 128 | function 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. */ |
| 138 | function sampleBody(schema) { |
| 139 | const body = {}; |
| 140 | for (const name of schema.required ?? []) body[name] = sample(name, schema.properties?.[name]); |
| 141 | return body; |
| 142 | } |
| 143 | |
| 144 | function 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. */ |
| 174 | function 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. */ |
| 185 | const pages = new Map(); |
| 186 | |
| 187 | /** Names of other operations in running text, linked to their pages. */ |
| 188 | function 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. */ |
| 197 | function 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 | */ |
| 292 | export 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 | } |