Skip to content

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.sh
334 linesCodeBlameRaw

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 would1// @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).'],
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 would31 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 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. */
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 operation'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 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 */
305export 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.