| 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: [ |
| 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`. */ |
| 40 | const slugify = (text) => |
| 41 | text |
| 42 | .toLowerCase() |
| 43 | .replace(/[^a-z0-9]+/g, "-") |
| 44 | .replace(/^-|-$/g, ""); |
| 45 | |
| 46 | export 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. */ |
| 51 | export 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. */ |
| 83 | const 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. */ |
| 89 | const prose = (text) => String(text ?? "").replace(/</g, "<"); |
| 90 | |
| 91 | /** A schema's type, the way a person says it. */ |
| 92 | function 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 | |
| 107 | function 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. */ |
| 117 | function 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 | |
| 129 | function 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. */ |
| 134 | function 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. */ |
| 144 | function sampleBody(schema) { |
| 145 | const body = {}; |
| 146 | for (const name of schema.required ?? []) body[name] = sample(name, schema.properties?.[name]); |
| 147 | return body; |
| 148 | } |
| 149 | |
| 150 | function 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. */ |
| 182 | function 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. */ |
| 193 | const pages = new Map(); |
| 194 | |
| 195 | /** Names of other operations in running text, linked to their pages. */ |
| 196 | function 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. */ |
| 205 | function 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 | */ |
| 303 | export 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 | } |