g1t/apps/docs/scripts/api-reference.mjs
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.
| Merge branch 'worktree-agent-ab2e39e11a6493412' | 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. */ | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 25 | const DEFAULT_PARAMS = { owner: 'flagon-io', name: 'hello', workspace: 'flagon-io' }; |
| Merge branch 'worktree-agent-ab2e39e11a6493412' | 26 | |
| 27 | const STATUS = { | |
| 28 | 401: ['unauthenticated', 'A token is required, or the one sent is not valid.'], | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 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).'], |
| Merge branch 'worktree-agent-ab2e39e11a6493412' | 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 | '', | |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 254 | 'Send a JSON object. Names are `snake_case`, as in responses; the `camelCase` spelling is accepted too.', |
| Merge branch 'worktree-agent-ab2e39e11a6493412' | 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 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.