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.
| Compare diffs from where the two branches last agreed, as a pull request would | 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: 'flagon-io', name: 'hello', workspace: 'flagon-io' }; | |
| 26 | ||
| 27 | const 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).'], | |
| The docs describe g1t as the place your coding agents work, not as an agent of its own. The guides to g1t's hosted agents are gone: agents, their abilities, access, budgets, memory, routines, sessions and skills, talking to agents, working with g1t, Customize, model providers, the AI Gateway, the context hub, spend, plans and the chat app's agent pages. Each old address redirects to the page that now covers what is left of it. | 30 | 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.'], |
| Compare diffs from where the two branches last agreed, as a pull request would | 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 operation'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((name, i) => [name, i]))); | |
| 66 | return found.sort( | |
| 67 | (a, b) => | |
| 68 | tags.indexOf(a.tag) - tags.indexOf(b.tag) || | |
| 69 | (order.get(a['x-operation']) ?? -1) - (order.get(b['x-operation']) ?? -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 operation'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 name = operation['x-operation']; | |
| 200 | const tool = operation['x-mcp-tool']; | |
| 201 | const action = operation['x-mcp-action']; | |
| 202 | const scope = operation['x-scope']; | |
| 203 | const security = operation.security ?? [{ token: [] }]; | |
| 204 | const auth = | |
| 205 | security.length === 0 | |
| 206 | ? 'None.' | |
| 207 | : security.some((entry) => Object.keys(entry).length === 0) | |
| 208 | ? 'Optional. Public data can be read without a token; send one to see what is private.' | |
| 209 | : 'Required. Send an [access token](/reference/api/#authentication) as `Authorization: Bearer`.'; | |
| 210 | const siblings = all.filter((other) => name && other['x-operation'] === name && other !== operation); | |
| 211 | ||
| 212 | const out = []; | |
| 213 | out.push('---'); | |
| 214 | out.push(`title: ${JSON.stringify(operation.summary)}`); | |
| 215 | out.push(`description: ${JSON.stringify(lead.replace(/`/g, ''))}`); | |
| 216 | out.push('editUrl: false'); | |
| 217 | out.push('lastUpdated: false'); | |
| 218 | out.push('---'); | |
| 219 | out.push(''); | |
| 220 | out.push( | |
| 221 | `<div class="g1t-endpoint"><span class="g1t-method" data-method="${operation.method.toLowerCase()}">${operation.method}</span><code>${operation.path}</code></div>`, | |
| 222 | ); | |
| 223 | out.push(''); | |
| 224 | if (rest) { | |
| 225 | out.push(linkTools(prose(rest), name)); | |
| 226 | out.push(''); | |
| 227 | } | |
| 228 | const facts = [['Authentication', auth]]; | |
| 229 | facts.push([ | |
| 230 | 'MCP tool', | |
| 231 | tool | |
| 232 | ? `[\`${tool}\`](/reference/mcp/#${tool}) with \`action\` \`${action}\`, and the same inputs` | |
| 233 | : 'None. Signing in is on the REST API only.', | |
| 234 | ]); | |
| 235 | if (tool) { | |
| 236 | facts.push([ | |
| 237 | 'Scope', | |
| 238 | scope | |
| 239 | ? `An access token needs [\`${scope}\`](/guides/authentication/#scopes).` | |
| 240 | : 'None. Any access token may use it.', | |
| 241 | ]); | |
| 242 | } | |
| 243 | if (siblings.length) { | |
| 244 | facts.push([ | |
| 245 | 'Also at', | |
| 246 | siblings.map((other) => `[\`${other.method} ${other.path}\`](/${other.slug}/)`).join(', '), | |
| 247 | ]); | |
| 248 | } | |
| 249 | out.push(...facts.map(([name, value]) => `- **${name}:** ${value}`)); | |
| 250 | out.push(''); | |
| 251 | ||
| 252 | const path = (operation.parameters ?? []).filter((p) => p.in === 'path'); | |
| 253 | const query = (operation.parameters ?? []).filter((p) => p.in === 'query'); | |
| 254 | const parameterRows = (list) => | |
| 255 | list.map((p) => [`\`${p.name}\``, typeName(p.schema), p.required ? 'Yes' : 'No', describe({ ...p.schema, description: p.description })]); | |
| 256 | if (path.length) { | |
| 257 | out.push('## Path parameters', '', table(['Name', 'Type', 'Required', 'Description'], parameterRows(path)), ''); | |
| 258 | } | |
| 259 | if (query.length) { | |
| 260 | out.push('## Query parameters', '', table(['Name', 'Type', 'Required', 'Description'], parameterRows(query)), ''); | |
| 261 | } | |
| 262 | const body = operation.requestBody?.content?.['application/json']?.schema; | |
| 263 | if (body?.properties && Object.keys(body.properties).length) { | |
| 264 | out.push( | |
| 265 | '## Body parameters', | |
| 266 | '', | |
| 267 | 'Send a JSON object. Names are `snake_case`, as in responses; the `camelCase` spelling is accepted too.', | |
| 268 | '', | |
| 269 | table(['Name', 'Type', 'Required', 'Description'], propertyRows(body)), | |
| 270 | '', | |
| 271 | ); | |
| 272 | } | |
| 273 | ||
| 274 | out.push('## Example request', '', '```sh', curl(operation), '```', ''); | |
| 275 | ||
| 276 | const ok = operation.responses?.['200']; | |
| 277 | const example = ok?.content?.['application/json']?.example; | |
| 278 | out.push('## Example response', ''); | |
| 279 | if (example !== undefined) { | |
| 280 | out.push(`A successful request answers \`200\` with:`, '', '```json', JSON.stringify(example, null, 2), '```', ''); | |
| 281 | } else { | |
| 282 | out.push('A successful request answers `200`.', ''); | |
| 283 | } | |
| 284 | ||
| 285 | const errors = Object.keys(operation.responses ?? {}) | |
| 286 | .filter((status) => status in STATUS) | |
| 287 | .map((status) => [status, `\`${STATUS[status][0]}\``, STATUS[status][1]]); | |
| 288 | if (errors.length) { | |
| 289 | out.push( | |
| 290 | '## Errors', | |
| 291 | '', | |
| 292 | '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).', | |
| 293 | '', | |
| 294 | table(['Status', 'Code', 'When'], errors), | |
| 295 | '', | |
| 296 | ); | |
| 297 | } | |
| 298 | return out.join('\n'); | |
| 299 | } | |
| 300 | ||
| 301 | /** | |
| 302 | * Writes every operation's page and returns the sidebar groups for them, | |
| 303 | * one per section of the API. | |
| 304 | */ | |
| 305 | export function generateApiReference() { | |
| 306 | const document = loadDocument(); | |
| 307 | const all = operations(document); | |
| 308 | pages.clear(); | |
| 309 | for (const operation of all) { | |
| 310 | const name = operation['x-operation']; | |
| 311 | // An operation's name links to its first address: the repository's. | |
| 312 | if (name && !pages.has(name)) pages.set(name, operation.slug); | |
| 313 | } | |
| 314 | ||
| 315 | rmSync(OUT, { recursive: true, force: true }); | |
| 316 | for (const operation of all) { | |
| 317 | const file = join(root, 'src/content/docs', `${operation.slug}.md`); | |
| 318 | mkdirSync(dirname(file), { recursive: true }); | |
| 319 | writeFileSync(file, page(operation, all)); | |
| 320 | } | |
| 321 | ||
| 322 | return document.tags | |
| 323 | .map((tag) => ({ | |
| 324 | label: tag.name, | |
| 325 | items: all | |
| 326 | .filter((operation) => operation.tag === tag.name) | |
| 327 | .map((operation) => ({ | |
| 328 | label: operation.summary, | |
| 329 | slug: operation.slug, | |
| 330 | badge: { text: operation.method, class: `g1t-method g1t-method-${operation.method.toLowerCase()}` }, | |
| 331 | })), | |
| 332 | })) | |
| 333 | .filter((group) => group.items.length); | |
| 334 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.